egaki 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/CHANGELOG.md +145 -9
  2. package/dist/cli/cache-utils.d.ts +33 -0
  3. package/dist/cli/cache-utils.d.ts.map +1 -0
  4. package/dist/cli/cache-utils.js +157 -0
  5. package/dist/cli/cached-generate.d.ts +105 -0
  6. package/dist/cli/cached-generate.d.ts.map +1 -0
  7. package/dist/cli/cached-generate.js +208 -0
  8. package/dist/cli/cached-generate.test.d.ts +11 -0
  9. package/dist/cli/cached-generate.test.d.ts.map +1 -0
  10. package/dist/cli/cached-generate.test.js +431 -0
  11. package/dist/cli/cartesia-provider.d.ts +16 -0
  12. package/dist/cli/cartesia-provider.d.ts.map +1 -0
  13. package/dist/cli/cartesia-provider.js +184 -0
  14. package/dist/cli/chatgpt-auth.d.ts.map +1 -0
  15. package/dist/cli/cli.d.ts +4 -0
  16. package/dist/cli/cli.d.ts.map +1 -0
  17. package/dist/{cli.js → cli/cli.js} +626 -600
  18. package/dist/cli/clone-generate.d.ts +26 -0
  19. package/dist/cli/clone-generate.d.ts.map +1 -0
  20. package/dist/cli/clone-generate.js +148 -0
  21. package/dist/cli/credentials.d.ts.map +1 -0
  22. package/dist/{credentials.js → cli/credentials.js} +20 -0
  23. package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
  24. package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
  25. package/dist/cli/elevenlabs-speech-provider.js +199 -0
  26. package/dist/cli/generate.d.ts +140 -0
  27. package/dist/cli/generate.d.ts.map +1 -0
  28. package/dist/cli/generate.js +604 -0
  29. package/dist/cli/generate.test.d.ts +2 -0
  30. package/dist/cli/generate.test.d.ts.map +1 -0
  31. package/dist/cli/generate.test.js +131 -0
  32. package/dist/cli/login.d.ts.map +1 -0
  33. package/dist/cli/main.d.ts +3 -0
  34. package/dist/cli/main.d.ts.map +1 -0
  35. package/dist/cli/main.js +7 -0
  36. package/dist/{model-catalog.d.ts → cli/model-catalog.d.ts} +6 -0
  37. package/dist/cli/model-catalog.d.ts.map +1 -0
  38. package/dist/{model-catalog.js → cli/model-catalog.js} +714 -4
  39. package/dist/{models.d.ts → cli/models.d.ts} +12 -5
  40. package/dist/cli/models.d.ts.map +1 -0
  41. package/dist/{models.js → cli/models.js} +58 -57
  42. package/dist/cli/open-browser.d.ts.map +1 -0
  43. package/dist/cli/openai-speech-provider.d.ts +3 -0
  44. package/dist/cli/openai-speech-provider.d.ts.map +1 -0
  45. package/dist/cli/openai-speech-provider.js +28 -0
  46. package/dist/cli/separation-generate.d.ts +34 -0
  47. package/dist/cli/separation-generate.d.ts.map +1 -0
  48. package/dist/cli/separation-generate.js +117 -0
  49. package/dist/cli/speech-catalog.d.ts +35 -0
  50. package/dist/cli/speech-catalog.d.ts.map +1 -0
  51. package/dist/cli/speech-catalog.js +195 -0
  52. package/dist/cli/speech-generate.d.ts +86 -0
  53. package/dist/cli/speech-generate.d.ts.map +1 -0
  54. package/dist/cli/speech-generate.js +156 -0
  55. package/dist/cli/speech-models.d.ts +8 -0
  56. package/dist/cli/speech-models.d.ts.map +1 -0
  57. package/dist/cli/speech-models.js +109 -0
  58. package/dist/cli/speech-provider.test.d.ts +2 -0
  59. package/dist/cli/speech-provider.test.d.ts.map +1 -0
  60. package/dist/cli/speech-provider.test.js +197 -0
  61. package/dist/cli/subscription.d.ts.map +1 -0
  62. package/dist/cli/transcription-catalog.d.ts +35 -0
  63. package/dist/cli/transcription-catalog.d.ts.map +1 -0
  64. package/dist/cli/transcription-catalog.js +192 -0
  65. package/dist/cli/transcription-generate.d.ts +62 -0
  66. package/dist/cli/transcription-generate.d.ts.map +1 -0
  67. package/dist/cli/transcription-generate.js +144 -0
  68. package/dist/cli/transcription-models.d.ts +9 -0
  69. package/dist/cli/transcription-models.d.ts.map +1 -0
  70. package/dist/cli/transcription-models.js +100 -0
  71. package/dist/cli/transcription-providers.d.ts +22 -0
  72. package/dist/cli/transcription-providers.d.ts.map +1 -0
  73. package/dist/cli/transcription-providers.js +290 -0
  74. package/dist/cli/video-model-catalog.d.ts.map +1 -0
  75. package/dist/cli/xai-auth.d.ts.map +1 -0
  76. package/dist/{xai-auth.js → cli/xai-auth.js} +1 -1
  77. package/dist/vite/angled-screen.d.ts +57 -0
  78. package/dist/vite/angled-screen.d.ts.map +1 -0
  79. package/dist/vite/angled-screen.js +115 -0
  80. package/dist/vite/app.d.ts +51 -0
  81. package/dist/vite/app.d.ts.map +1 -0
  82. package/dist/vite/app.js +150 -0
  83. package/dist/vite/bands-shader.d.ts +47 -0
  84. package/dist/vite/bands-shader.d.ts.map +1 -0
  85. package/dist/vite/bands-shader.js +267 -0
  86. package/dist/vite/code-block.d.ts +107 -0
  87. package/dist/vite/code-block.d.ts.map +1 -0
  88. package/dist/vite/code-block.js +641 -0
  89. package/dist/vite/components.d.ts +36 -0
  90. package/dist/vite/components.d.ts.map +1 -0
  91. package/dist/vite/components.js +113 -0
  92. package/dist/vite/dispersion-rings-shader.d.ts +44 -0
  93. package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
  94. package/dist/vite/dispersion-rings-shader.js +282 -0
  95. package/dist/vite/easing-curves.d.ts +129 -0
  96. package/dist/vite/easing-curves.d.ts.map +1 -0
  97. package/dist/vite/easing-curves.js +584 -0
  98. package/dist/vite/easing-curves.test.d.ts +2 -0
  99. package/dist/vite/easing-curves.test.d.ts.map +1 -0
  100. package/dist/vite/easing-curves.test.js +200 -0
  101. package/dist/vite/generated-media-client.d.ts +35 -0
  102. package/dist/vite/generated-media-client.d.ts.map +1 -0
  103. package/dist/vite/generated-media-client.js +76 -0
  104. package/dist/vite/keyframes.d.ts +129 -0
  105. package/dist/vite/keyframes.d.ts.map +1 -0
  106. package/dist/vite/keyframes.js +194 -0
  107. package/dist/vite/layout-editor.d.ts +45 -0
  108. package/dist/vite/layout-editor.d.ts.map +1 -0
  109. package/dist/vite/layout-editor.js +587 -0
  110. package/dist/vite/layout-transition.d.ts +136 -0
  111. package/dist/vite/layout-transition.d.ts.map +1 -0
  112. package/dist/vite/layout-transition.js +581 -0
  113. package/dist/vite/liquid-gradient-shader.d.ts +62 -0
  114. package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
  115. package/dist/vite/liquid-gradient-shader.js +413 -0
  116. package/dist/vite/mdx-client.d.ts +15 -0
  117. package/dist/vite/mdx-client.d.ts.map +1 -0
  118. package/dist/vite/mdx-client.js +265 -0
  119. package/dist/vite/mdx-parse.d.ts +102 -0
  120. package/dist/vite/mdx-parse.d.ts.map +1 -0
  121. package/dist/vite/mdx-parse.js +251 -0
  122. package/dist/vite/mdx-provided-components.d.ts +18 -0
  123. package/dist/vite/mdx-provided-components.d.ts.map +1 -0
  124. package/dist/vite/mdx-provided-components.js +14 -0
  125. package/dist/vite/mdx-video.d.ts +327 -0
  126. package/dist/vite/mdx-video.d.ts.map +1 -0
  127. package/dist/vite/mdx-video.js +596 -0
  128. package/dist/vite/mdx-video.test.d.ts +9 -0
  129. package/dist/vite/mdx-video.test.d.ts.map +1 -0
  130. package/dist/vite/mdx-video.test.js +1945 -0
  131. package/dist/vite/media-components.d.ts +45 -0
  132. package/dist/vite/media-components.d.ts.map +1 -0
  133. package/dist/vite/media-components.js +355 -0
  134. package/dist/vite/media-duration-store.d.ts +66 -0
  135. package/dist/vite/media-duration-store.d.ts.map +1 -0
  136. package/dist/vite/media-duration-store.js +204 -0
  137. package/dist/vite/player-page.d.ts +35 -0
  138. package/dist/vite/player-page.d.ts.map +1 -0
  139. package/dist/vite/player-page.js +720 -0
  140. package/dist/vite/render-client.d.ts +18 -0
  141. package/dist/vite/render-client.d.ts.map +1 -0
  142. package/dist/vite/render-client.js +67 -0
  143. package/dist/vite/sdk.d.ts +221 -0
  144. package/dist/vite/sdk.d.ts.map +1 -0
  145. package/dist/vite/sdk.js +355 -0
  146. package/dist/vite/server-components.d.ts +55 -0
  147. package/dist/vite/server-components.d.ts.map +1 -0
  148. package/dist/vite/server-components.js +156 -0
  149. package/dist/vite/server-mdx.d.ts +74 -0
  150. package/dist/vite/server-mdx.d.ts.map +1 -0
  151. package/dist/vite/server-mdx.js +259 -0
  152. package/dist/vite/shader-renderer.d.ts +48 -0
  153. package/dist/vite/shader-renderer.d.ts.map +1 -0
  154. package/dist/vite/shader-renderer.js +411 -0
  155. package/dist/vite/store-hooks.d.ts +12 -0
  156. package/dist/vite/store-hooks.d.ts.map +1 -0
  157. package/dist/vite/store-hooks.js +93 -0
  158. package/dist/vite/store.d.ts +75 -0
  159. package/dist/vite/store.d.ts.map +1 -0
  160. package/dist/vite/store.js +52 -0
  161. package/dist/vite/store.test.d.ts +6 -0
  162. package/dist/vite/store.test.d.ts.map +1 -0
  163. package/dist/vite/store.test.js +172 -0
  164. package/dist/vite/tweakpane-hook.d.ts +94 -0
  165. package/dist/vite/tweakpane-hook.d.ts.map +1 -0
  166. package/dist/vite/tweakpane-hook.js +450 -0
  167. package/dist/vite/vite-plugin.d.ts +28 -0
  168. package/dist/vite/vite-plugin.d.ts.map +1 -0
  169. package/dist/vite/vite-plugin.js +539 -0
  170. package/dist/vite/wave-gradient-shader.d.ts +41 -0
  171. package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
  172. package/dist/vite/wave-gradient-shader.js +207 -0
  173. package/package.json +101 -24
  174. package/skills/egaki/SKILL.md +162 -1
  175. package/src/cli/cache-utils.ts +169 -0
  176. package/src/cli/cached-generate.test.ts +531 -0
  177. package/src/cli/cached-generate.ts +348 -0
  178. package/src/cli/cartesia-provider.ts +249 -0
  179. package/src/{cli.ts → cli/cli.ts} +836 -884
  180. package/src/cli/clone-generate.ts +202 -0
  181. package/src/{credentials.ts → cli/credentials.ts} +20 -0
  182. package/src/cli/elevenlabs-speech-provider.ts +243 -0
  183. package/src/cli/generate.test.ts +166 -0
  184. package/src/cli/generate.ts +912 -0
  185. package/src/cli/main.ts +8 -0
  186. package/src/{model-catalog.ts → cli/model-catalog.ts} +787 -58
  187. package/src/{models.ts → cli/models.ts} +65 -86
  188. package/src/cli/openai-speech-provider.ts +34 -0
  189. package/src/cli/separation-generate.ts +187 -0
  190. package/src/cli/speech-catalog.ts +253 -0
  191. package/src/cli/speech-generate.ts +275 -0
  192. package/src/cli/speech-models.ts +146 -0
  193. package/src/cli/speech-provider.test.ts +226 -0
  194. package/src/cli/transcription-catalog.ts +250 -0
  195. package/src/cli/transcription-generate.ts +214 -0
  196. package/src/cli/transcription-models.ts +136 -0
  197. package/src/cli/transcription-providers.ts +427 -0
  198. package/src/{xai-auth.ts → cli/xai-auth.ts} +1 -1
  199. package/src/vite/angled-screen.tsx +214 -0
  200. package/src/vite/app.tsx +197 -0
  201. package/src/vite/bands-shader.tsx +319 -0
  202. package/src/vite/code-block.tsx +939 -0
  203. package/src/vite/components.tsx +278 -0
  204. package/src/vite/dispersion-rings-shader.tsx +331 -0
  205. package/src/vite/easing-curves.test.ts +232 -0
  206. package/src/vite/easing-curves.ts +743 -0
  207. package/src/vite/generated-media-client.tsx +121 -0
  208. package/src/vite/keyframes.tsx +347 -0
  209. package/src/vite/layout-editor.tsx +709 -0
  210. package/src/vite/layout-transition.tsx +706 -0
  211. package/src/vite/liquid-gradient-shader.tsx +474 -0
  212. package/src/vite/mdx-client.tsx +420 -0
  213. package/src/vite/mdx-parse.ts +343 -0
  214. package/src/vite/mdx-provided-components.ts +19 -0
  215. package/src/vite/mdx-video.test.tsx +2219 -0
  216. package/src/vite/mdx-video.tsx +976 -0
  217. package/src/vite/media-components.tsx +473 -0
  218. package/src/vite/media-duration-store.ts +225 -0
  219. package/src/vite/motion-timing.ts +194 -0
  220. package/src/vite/player-page.tsx +1107 -0
  221. package/src/vite/render-client.ts +84 -0
  222. package/src/vite/sdk.ts +598 -0
  223. package/src/vite/server-components.tsx +241 -0
  224. package/src/vite/server-mdx.ts +285 -0
  225. package/src/vite/shader-renderer.tsx +535 -0
  226. package/src/vite/store-hooks.ts +117 -0
  227. package/src/vite/store.test.ts +202 -0
  228. package/src/vite/store.ts +115 -0
  229. package/src/vite/styles.css +21 -0
  230. package/src/vite/tweakpane-hook.tsx +593 -0
  231. package/src/vite/virtual-modules.d.ts +30 -0
  232. package/src/vite/vite-plugin.ts +585 -0
  233. package/src/vite/wave-gradient-shader.tsx +253 -0
  234. package/README.md +0 -370
  235. package/dist/antigravity-auth.d.ts +0 -15
  236. package/dist/antigravity-auth.d.ts.map +0 -1
  237. package/dist/antigravity-auth.js +0 -544
  238. package/dist/antigravity-image.d.ts +0 -11
  239. package/dist/antigravity-image.d.ts.map +0 -1
  240. package/dist/antigravity-image.js +0 -285
  241. package/dist/chatgpt-auth.d.ts.map +0 -1
  242. package/dist/cli.d.ts +0 -3
  243. package/dist/cli.d.ts.map +0 -1
  244. package/dist/credentials.d.ts.map +0 -1
  245. package/dist/gateway-model-candidates.generated.d.ts +0 -11
  246. package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
  247. package/dist/gateway-model-candidates.generated.js +0 -64
  248. package/dist/login.d.ts.map +0 -1
  249. package/dist/model-catalog.d.ts.map +0 -1
  250. package/dist/models.d.ts.map +0 -1
  251. package/dist/open-browser.d.ts.map +0 -1
  252. package/dist/subscription.d.ts.map +0 -1
  253. package/dist/video-model-catalog.d.ts.map +0 -1
  254. package/dist/xai-auth.d.ts.map +0 -1
  255. /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
  256. /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
  257. /package/dist/{credentials.d.ts → cli/credentials.d.ts} +0 -0
  258. /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
  259. /package/dist/{login.js → cli/login.js} +0 -0
  260. /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
  261. /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
  262. /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
  263. /package/dist/{subscription.js → cli/subscription.js} +0 -0
  264. /package/dist/{video-model-catalog.d.ts → cli/video-model-catalog.d.ts} +0 -0
  265. /package/dist/{video-model-catalog.js → cli/video-model-catalog.js} +0 -0
  266. /package/dist/{xai-auth.d.ts → cli/xai-auth.d.ts} +0 -0
  267. /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
  268. /package/src/{login.ts → cli/login.ts} +0 -0
  269. /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
  270. /package/src/{subscription.ts → cli/subscription.ts} +0 -0
  271. /package/src/{video-model-catalog.ts → cli/video-model-catalog.ts} +0 -0
@@ -0,0 +1,202 @@
1
+ // Voice cloning via Cartesia and ElevenLabs APIs.
2
+ // Both are simple multipart/form-data HTTP calls, no SDK needed.
3
+ // Returns Error | Result (errore style) instead of throwing.
4
+ //
5
+ // Cartesia: POST https://api.cartesia.ai/voices/clone
6
+ // Instant clone, up to 10s of audio, free.
7
+ // Docs: https://docs.cartesia.ai/api-reference/voices/clone
8
+ // Best practices: https://docs.cartesia.ai/build-with-cartesia/capability-guides/clone-voices
9
+ //
10
+ // ElevenLabs: POST https://api.elevenlabs.io/v1/voices/add
11
+ // Instant voice clone (IVC), 1-3 min recommended.
12
+ // Docs: https://elevenlabs.io/docs/api-reference/voices/ivc/create
13
+ import { injectCredentialsToEnv } from './credentials.js'
14
+
15
+ // ─── types ──────────────────────────────────────────────────────────────────
16
+
17
+ export type CloneProvider = 'cartesia' | 'elevenlabs'
18
+
19
+ export interface CloneVoiceOptions {
20
+ /** Raw audio bytes of the clip to clone from. */
21
+ audio: Uint8Array
22
+ /** Name for the cloned voice. */
23
+ name: string
24
+ /** Which provider to use. */
25
+ provider: CloneProvider
26
+ /** Cartesia only: ISO 639-1 language code. Default: 'en'. Ignored by ElevenLabs. */
27
+ language?: string
28
+ /** Optional description for the voice. */
29
+ description?: string
30
+ /** Original filename for MIME type inference. */
31
+ filename?: string
32
+ /** Cartesia: optional base voice ID to derive from. */
33
+ baseVoiceId?: string
34
+ /** ElevenLabs: apply AI noise removal to the clip. */
35
+ removeBackgroundNoise?: boolean
36
+ }
37
+
38
+ export interface CloneVoiceResult {
39
+ voiceId: string
40
+ name: string
41
+ provider: CloneProvider
42
+ }
43
+
44
+ // ─── validation ─────────────────────────────────────────────────────────────
45
+
46
+ const CARTESIA_LANGUAGES = new Set([
47
+ 'en', 'fr', 'de', 'es', 'pt', 'zh', 'ja', 'hi', 'it', 'ko', 'nl', 'pl',
48
+ 'ru', 'sv', 'tr', 'tl', 'bg', 'ro', 'ar', 'cs', 'el', 'fi', 'hr', 'ms',
49
+ 'sk', 'da', 'ta', 'uk', 'hu', 'no', 'vi', 'bn', 'th', 'he', 'ka', 'id',
50
+ 'te', 'gu', 'kn', 'ml', 'mr', 'pa',
51
+ ])
52
+
53
+ function validateOptions(opts: CloneVoiceOptions): Error | void {
54
+ if (opts.audio.length === 0) {
55
+ return new Error('No audio data provided. Pass an audio file or use --stdin.')
56
+ }
57
+ if (!opts.name || opts.name.trim().length === 0) {
58
+ return new Error('Voice name is required. Use --name "My Voice".')
59
+ }
60
+ if (opts.provider === 'cartesia' && opts.language && !CARTESIA_LANGUAGES.has(opts.language)) {
61
+ return new Error(
62
+ `Unsupported language "${opts.language}" for Cartesia. ` +
63
+ `Supported: ${[...CARTESIA_LANGUAGES].join(', ')}`,
64
+ )
65
+ }
66
+ }
67
+
68
+ // ─── MIME type helper ───────────────────────────────────────────────────────
69
+
70
+ function mimeTypeFromFilename(filename?: string): string {
71
+ if (!filename) return 'audio/mpeg'
72
+ const ext = filename.split('.').pop()?.toLowerCase()
73
+ const map: Record<string, string> = {
74
+ mp3: 'audio/mpeg',
75
+ wav: 'audio/wav',
76
+ flac: 'audio/flac',
77
+ ogg: 'audio/ogg',
78
+ oga: 'audio/ogg',
79
+ webm: 'audio/webm',
80
+ mpga: 'audio/mpeg',
81
+ m4a: 'audio/mp4',
82
+ }
83
+ return map[ext ?? ''] ?? 'audio/mpeg'
84
+ }
85
+
86
+ // ─── Cartesia clone ─────────────────────────────────────────────────────────
87
+
88
+ const CARTESIA_API_BASE = 'https://api.cartesia.ai'
89
+ const CARTESIA_API_VERSION = '2026-03-01'
90
+
91
+ async function cloneVoiceCartesia(opts: CloneVoiceOptions): Promise<Error | CloneVoiceResult> {
92
+ const apiKey = process.env['CARTESIA_API_KEY']
93
+ if (!apiKey) {
94
+ return new Error(
95
+ 'CARTESIA_API_KEY not found. Run: egaki login --provider cartesia\n' +
96
+ 'Get your key at https://play.cartesia.ai/keys',
97
+ )
98
+ }
99
+
100
+ const language = opts.language ?? 'en'
101
+ const mime = mimeTypeFromFilename(opts.filename)
102
+ const ext = opts.filename?.split('.').pop() ?? 'mp3'
103
+
104
+ const formData = new FormData()
105
+ const blob = new Blob([Buffer.from(opts.audio)], { type: mime })
106
+ formData.append('clip', new File([blob], `clip.${ext}`, { type: mime }))
107
+ formData.append('name', opts.name.trim())
108
+ formData.append('language', language)
109
+ if (opts.description) formData.append('description', opts.description)
110
+ if (opts.baseVoiceId) formData.append('base_voice_id', opts.baseVoiceId)
111
+
112
+ const response = await fetch(`${CARTESIA_API_BASE}/voices/clone`, {
113
+ method: 'POST',
114
+ headers: {
115
+ 'Cartesia-Version': CARTESIA_API_VERSION,
116
+ 'Authorization': `Bearer ${apiKey}`,
117
+ },
118
+ body: formData,
119
+ }).catch((cause) => new Error('Cartesia clone request failed', { cause }))
120
+ if (response instanceof Error) return response
121
+
122
+ if (!response.ok) {
123
+ const errorText = await response.text().catch(() => '')
124
+ return new Error(`Cartesia clone API error ${response.status}: ${errorText || response.statusText}`)
125
+ }
126
+
127
+ const json = await response.json().catch(
128
+ (cause) => new Error('Cartesia clone API returned invalid JSON', { cause }),
129
+ ) as { id?: string; name?: string } | Error
130
+ if (json instanceof Error) return json
131
+ if (!json.id) return new Error('Cartesia clone API response did not include a voice id')
132
+ return {
133
+ voiceId: json.id,
134
+ name: json.name ?? opts.name.trim(),
135
+ provider: 'cartesia',
136
+ }
137
+ }
138
+
139
+ // ─── ElevenLabs clone ───────────────────────────────────────────────────────
140
+
141
+ const ELEVENLABS_API_BASE = 'https://api.elevenlabs.io'
142
+
143
+ async function cloneVoiceElevenLabs(opts: CloneVoiceOptions): Promise<Error | CloneVoiceResult> {
144
+ const apiKey = process.env['ELEVENLABS_API_KEY']
145
+ if (!apiKey) {
146
+ return new Error(
147
+ 'ELEVENLABS_API_KEY not found. Run: egaki login --provider elevenlabs\n' +
148
+ 'Get your key at https://elevenlabs.io/app/settings/api-keys',
149
+ )
150
+ }
151
+
152
+ const mime = mimeTypeFromFilename(opts.filename)
153
+ const ext = opts.filename?.split('.').pop() ?? 'mp3'
154
+
155
+ const formData = new FormData()
156
+ formData.append('name', opts.name.trim())
157
+ const blob = new Blob([Buffer.from(opts.audio)], { type: mime })
158
+ formData.append('files', new File([blob], `clip.${ext}`, { type: mime }))
159
+ if (opts.description) formData.append('description', opts.description)
160
+ if (opts.removeBackgroundNoise) formData.append('remove_background_noise', 'true')
161
+
162
+ const response = await fetch(`${ELEVENLABS_API_BASE}/v1/voices/add`, {
163
+ method: 'POST',
164
+ headers: {
165
+ 'xi-api-key': apiKey,
166
+ },
167
+ body: formData,
168
+ }).catch((cause) => new Error('ElevenLabs clone request failed', { cause }))
169
+ if (response instanceof Error) return response
170
+
171
+ if (!response.ok) {
172
+ const errorText = await response.text().catch(() => '')
173
+ return new Error(`ElevenLabs clone API error ${response.status}: ${errorText || response.statusText}`)
174
+ }
175
+
176
+ const json = await response.json().catch(
177
+ (cause) => new Error('ElevenLabs clone API returned invalid JSON', { cause }),
178
+ ) as { voice_id?: string; requires_verification?: boolean } | Error
179
+ if (json instanceof Error) return json
180
+ if (!json.voice_id) return new Error('ElevenLabs clone API response did not include a voice_id')
181
+ return {
182
+ voiceId: json.voice_id,
183
+ name: opts.name.trim(),
184
+ provider: 'elevenlabs',
185
+ }
186
+ }
187
+
188
+ // ─── main entry point ───────────────────────────────────────────────────────
189
+
190
+ export async function cloneVoiceUncached(
191
+ opts: CloneVoiceOptions,
192
+ ): Promise<Error | CloneVoiceResult> {
193
+ injectCredentialsToEnv()
194
+
195
+ const validationError = validateOptions(opts)
196
+ if (validationError) return validationError
197
+
198
+ if (opts.provider === 'elevenlabs') {
199
+ return cloneVoiceElevenLabs(opts)
200
+ }
201
+ return cloneVoiceCartesia(opts)
202
+ }
@@ -69,6 +69,26 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
69
69
  hint: 'Sign in with your xAI / Grok account via browser',
70
70
  oauth: true,
71
71
  },
72
+ elevenlabs: {
73
+ envVar: 'ELEVENLABS_API_KEY',
74
+ label: 'ElevenLabs (speech)',
75
+ hint: 'Get your key at https://elevenlabs.io/app/settings/api-keys',
76
+ },
77
+ cartesia: {
78
+ envVar: 'CARTESIA_API_KEY',
79
+ label: 'Cartesia (speech)',
80
+ hint: 'Get your key at https://play.cartesia.ai/keys',
81
+ },
82
+ deepgram: {
83
+ envVar: 'DEEPGRAM_API_KEY',
84
+ label: 'Deepgram (transcription)',
85
+ hint: 'Get your key at https://console.deepgram.com/api-keys',
86
+ },
87
+ groq: {
88
+ envVar: 'GROQ_API_KEY',
89
+ label: 'Groq (fast transcription)',
90
+ hint: 'Get your key at https://console.groq.com/keys',
91
+ },
72
92
  }
73
93
 
74
94
  export const EGAKI_GATEWAY_URL = 'https://egaki.org/v3/ai'
@@ -0,0 +1,243 @@
1
+ // ElevenLabs speech provider for egaki.
2
+ // Uses the streaming endpoint /v1/text-to-speech/{voiceId}/stream/with-timestamps
3
+ // to get audio chunks + character timestamps in a single streamed response.
4
+ // Character-level alignment is grouped into word-level timestamps.
5
+ //
6
+ // API docs: https://elevenlabs.io/docs/api-reference/text-to-speech/stream-with-timestamps
7
+ //
8
+ // The streaming response is NDJSON (newline-delimited JSON), NOT SSE.
9
+ // Each line is a JSON object: { audio_base64, alignment?, normalized_alignment? }
10
+ // Alignment data (character-level timing) is included in the first chunk only.
11
+ // We use `normalized_alignment` (timing of what was actually spoken).
12
+
13
+ import type { SpeechProvider, SpeechProviderOptions, SpeechProviderResult } from './speech-generate.js'
14
+ import type { WordTimestamp } from './transcription-generate.js'
15
+
16
+ const ELEVENLABS_API_BASE = 'https://api.elevenlabs.io'
17
+ const DEFAULT_VOICE_ID = '21m00Tcm4TlvDq8ikWAM' // Rachel
18
+
19
+ // ─── format mapping ──────────────────────────────────────────────────────────
20
+
21
+ const FORMAT_MAP: Record<string, string> = {
22
+ mp3: 'mp3_44100_128',
23
+ mp3_32: 'mp3_44100_32',
24
+ mp3_64: 'mp3_44100_64',
25
+ mp3_96: 'mp3_44100_96',
26
+ mp3_128: 'mp3_44100_128',
27
+ mp3_192: 'mp3_44100_192',
28
+ pcm: 'pcm_44100',
29
+ pcm_16000: 'pcm_16000',
30
+ pcm_22050: 'pcm_22050',
31
+ pcm_24000: 'pcm_24000',
32
+ pcm_44100: 'pcm_44100',
33
+ ulaw: 'ulaw_8000',
34
+ }
35
+
36
+ function resolveOutputFormat(format?: string): string {
37
+ if (!format) return 'mp3_44100_128'
38
+ return FORMAT_MAP[format] || format
39
+ }
40
+
41
+ function formatToMediaType(format: string): string {
42
+ if (format.startsWith('mp3')) return 'audio/mpeg'
43
+ if (format.startsWith('pcm')) return 'audio/pcm'
44
+ if (format.startsWith('ulaw')) return 'audio/basic'
45
+ if (format.startsWith('opus')) return 'audio/opus'
46
+ if (format.startsWith('wav')) return 'audio/wav'
47
+ return 'audio/mpeg'
48
+ }
49
+
50
+ // ─── character-to-word grouping ──────────────────────────────────────────────
51
+
52
+ interface CharacterAlignment {
53
+ characters: string[]
54
+ character_start_times_seconds: number[]
55
+ character_end_times_seconds: number[]
56
+ }
57
+
58
+ /**
59
+ * Group character-level timestamps into word-level timestamps.
60
+ * Characters are grouped by splitting on whitespace characters.
61
+ * Each word's start time is the start of its first character,
62
+ * and its end time is the end of its last character.
63
+ */
64
+ export function groupCharactersToWords(alignment: CharacterAlignment): WordTimestamp[] {
65
+ const words: WordTimestamp[] = []
66
+ let currentWord = ''
67
+ let wordStart = -1
68
+ let wordEnd = -1
69
+
70
+ for (let i = 0; i < alignment.characters.length; i++) {
71
+ const char = alignment.characters[i]!
72
+ const charStart = alignment.character_start_times_seconds[i]!
73
+ const charEnd = alignment.character_end_times_seconds[i]!
74
+
75
+ if (/\s/.test(char)) {
76
+ if (currentWord) {
77
+ words.push({ word: currentWord, startSecond: wordStart, endSecond: wordEnd })
78
+ currentWord = ''
79
+ wordStart = -1
80
+ wordEnd = -1
81
+ }
82
+ continue
83
+ }
84
+
85
+ currentWord += char
86
+ if (wordStart < 0) wordStart = charStart
87
+ wordEnd = charEnd
88
+ }
89
+
90
+ if (currentWord) {
91
+ words.push({ word: currentWord, startSecond: wordStart, endSecond: wordEnd })
92
+ }
93
+
94
+ return words
95
+ }
96
+
97
+ // ─── NDJSON stream types ─────────────────────────────────────────────────────
98
+
99
+ interface ElevenLabsStreamChunk {
100
+ audio_base64: string
101
+ alignment?: CharacterAlignment | null
102
+ normalized_alignment?: CharacterAlignment | null
103
+ }
104
+
105
+ // ─── NDJSON stream parser ────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Parse an ElevenLabs NDJSON stream into audio chunks and character alignment.
109
+ * Each line is a JSON object with audio_base64 and optional alignment data.
110
+ * Alignment is typically only in the first chunk.
111
+ */
112
+ export async function parseElevenLabsStream(
113
+ body: ReadableStream<Uint8Array>,
114
+ ): Promise<{ audioChunks: Uint8Array[]; alignment?: CharacterAlignment }> {
115
+ const audioChunks: Uint8Array[] = []
116
+ let alignment: CharacterAlignment | undefined
117
+
118
+ const reader = body.pipeThrough(new TextDecoderStream() as any).getReader()
119
+ let buffer = ''
120
+
121
+ while (true) {
122
+ const { done, value } = await reader.read()
123
+ if (done) break
124
+ buffer += value
125
+
126
+ // Process complete lines
127
+ let newlineIdx: number
128
+ while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
129
+ const line = buffer.slice(0, newlineIdx).trim()
130
+ buffer = buffer.slice(newlineIdx + 1)
131
+ if (!line) continue
132
+
133
+ let chunk: ElevenLabsStreamChunk
134
+ try {
135
+ chunk = JSON.parse(line)
136
+ } catch {
137
+ continue
138
+ }
139
+
140
+ if (chunk.audio_base64) {
141
+ audioChunks.push(new Uint8Array(Buffer.from(chunk.audio_base64, 'base64')))
142
+ }
143
+
144
+ // Capture alignment from first chunk that has it
145
+ if (!alignment) {
146
+ const align = chunk.normalized_alignment || chunk.alignment
147
+ if (align) alignment = align
148
+ }
149
+ }
150
+ }
151
+
152
+ // Process any remaining data in buffer (last line without trailing newline)
153
+ const remaining = buffer.trim()
154
+ if (remaining) {
155
+ try {
156
+ const chunk: ElevenLabsStreamChunk = JSON.parse(remaining)
157
+ if (chunk.audio_base64) {
158
+ audioChunks.push(new Uint8Array(Buffer.from(chunk.audio_base64, 'base64')))
159
+ }
160
+ if (!alignment) {
161
+ const align = chunk.normalized_alignment || chunk.alignment
162
+ if (align) alignment = align
163
+ }
164
+ } catch { /* ignore malformed trailing data */ }
165
+ }
166
+
167
+ return { audioChunks, alignment }
168
+ }
169
+
170
+ /** Concatenate multiple Uint8Array chunks into one. */
171
+ function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
172
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
173
+ const result = new Uint8Array(totalLength)
174
+ let offset = 0
175
+ for (const chunk of chunks) {
176
+ result.set(chunk, offset)
177
+ offset += chunk.length
178
+ }
179
+ return result
180
+ }
181
+
182
+ // ─── provider ────────────────────────────────────────────────────────────────
183
+
184
+ export function createElevenLabsSpeechProvider(): SpeechProvider {
185
+ return {
186
+ async generate(options: SpeechProviderOptions): Promise<SpeechProviderResult> {
187
+ const apiKey = process.env['ELEVENLABS_API_KEY']
188
+ if (!apiKey) {
189
+ throw new Error('Missing ELEVENLABS_API_KEY. Run: egaki login --provider elevenlabs --key <key>')
190
+ }
191
+
192
+ const voiceId = options.voice || DEFAULT_VOICE_ID
193
+ const outputFormat = resolveOutputFormat(options.outputFormat)
194
+
195
+ const body: Record<string, unknown> = {
196
+ text: options.text,
197
+ model_id: options.modelId,
198
+ }
199
+
200
+ if (options.language) {
201
+ body.language_code = options.language
202
+ }
203
+
204
+ if (options.speed != null) {
205
+ body.voice_settings = { speed: options.speed }
206
+ }
207
+
208
+ const queryParams = new URLSearchParams({ output_format: outputFormat })
209
+ const url = `${ELEVENLABS_API_BASE}/v1/text-to-speech/${voiceId}/stream/with-timestamps?${queryParams}`
210
+
211
+ const response = await fetch(url, {
212
+ method: 'POST',
213
+ headers: {
214
+ 'Content-Type': 'application/json',
215
+ 'xi-api-key': apiKey,
216
+ },
217
+ body: JSON.stringify(body),
218
+ signal: options.abortSignal,
219
+ })
220
+
221
+ if (!response.ok) {
222
+ const errorText = await response.text().catch(() => '')
223
+ throw new Error(
224
+ `ElevenLabs TTS API error ${response.status}: ${errorText || response.statusText}`,
225
+ )
226
+ }
227
+
228
+ if (!response.body) {
229
+ throw new Error('ElevenLabs TTS stream response has no body')
230
+ }
231
+
232
+ const { audioChunks, alignment } = await parseElevenLabsStream(response.body)
233
+ const audio = concatUint8Arrays(audioChunks)
234
+ const timestamps = alignment ? groupCharactersToWords(alignment) : undefined
235
+
236
+ return {
237
+ audio,
238
+ mediaType: formatToMediaType(outputFormat),
239
+ timestamps,
240
+ }
241
+ },
242
+ }
243
+ }
@@ -0,0 +1,166 @@
1
+ // Tests for the programmatic generation API (generate.ts).
2
+ // Tests the error-as-value pattern and validation logic without
3
+ // making actual API calls (no credentials needed).
4
+ import { describe, it, expect } from 'vitest'
5
+ import {
6
+ generateImage,
7
+ generateVideo,
8
+ calculateCost,
9
+ buildImageProviderOptions,
10
+ buildVideoProviderOptions,
11
+ ValidationError,
12
+ } from './generate.js'
13
+
14
+ describe('generateImage', () => {
15
+ it('returns ValidationError for unknown model', async () => {
16
+ const result = await generateImage({
17
+ prompt: 'test',
18
+ model: 'nonexistent-model-xyz',
19
+ })
20
+ expect(result).toBeInstanceOf(ValidationError)
21
+ expect((result as Error).message).toMatch(/Unknown model/)
22
+ })
23
+ })
24
+
25
+ describe('generateVideo', () => {
26
+ it('returns Error for unknown model', async () => {
27
+ const result = await generateVideo({
28
+ prompt: 'test',
29
+ model: 'nonexistent-model-xyz',
30
+ })
31
+ expect(result).toBeInstanceOf(Error)
32
+ expect((result as Error).message).toMatch(/Unknown model/)
33
+ })
34
+
35
+ it('returns Error when using image model as video model', async () => {
36
+ const result = await generateVideo({
37
+ prompt: 'test',
38
+ model: 'imagen-4.0-generate-001',
39
+ })
40
+ expect(result).toBeInstanceOf(Error)
41
+ expect((result as Error).message).toMatch(/not a video model/)
42
+ })
43
+
44
+ it('returns Error when reference-to-video mode has no reference images', async () => {
45
+ const result = await generateVideo({
46
+ prompt: 'test',
47
+ model: 'veo-3.1-fast-generate-001',
48
+ mode: 'reference-to-video',
49
+ })
50
+ expect(result).toBeInstanceOf(ValidationError)
51
+ expect((result as Error).message).toMatch(/referenceImages is required/)
52
+ })
53
+
54
+ it('returns Error when edit-video mode has no videoUrl', async () => {
55
+ const result = await generateVideo({
56
+ prompt: 'test',
57
+ model: 'grok-imagine-video',
58
+ mode: 'edit-video',
59
+ inputImage: new Uint8Array([1, 2, 3]),
60
+ })
61
+ expect(result).toBeInstanceOf(ValidationError)
62
+ expect((result as Error).message).toMatch(/videoUrl is required/)
63
+ })
64
+ })
65
+
66
+ describe('calculateCost', () => {
67
+ it('calculates per-image cost', () => {
68
+ const cost = calculateCost(
69
+ { type: 'per-image', perImage: 0.04 },
70
+ {},
71
+ 3,
72
+ )
73
+ expect(cost).toBeCloseTo(0.12)
74
+ })
75
+
76
+ it('calculates per-token cost', () => {
77
+ const cost = calculateCost(
78
+ { type: 'per-token', inputPerM: 1.25, outputPerM: 5.0 },
79
+ { inputTokens: 1000, outputTokens: 500 },
80
+ )
81
+ // (1000 * 1.25 + 500 * 5.0) / 1_000_000 = 0.00375
82
+ expect(cost).toBeCloseTo(0.00375)
83
+ })
84
+
85
+ it('calculates per-video-second cost', () => {
86
+ const cost = calculateCost(
87
+ {
88
+ type: 'per-video-second',
89
+ defaultDurationSec: 5,
90
+ tiers: [{ costPerSecond: 0.05 }],
91
+ },
92
+ { durationSeconds: 10 },
93
+ )
94
+ expect(cost).toBeCloseTo(0.5)
95
+ })
96
+
97
+ it('uses default duration when none specified', () => {
98
+ const cost = calculateCost(
99
+ {
100
+ type: 'per-video-second',
101
+ defaultDurationSec: 5,
102
+ tiers: [{ costPerSecond: 0.05 }],
103
+ },
104
+ {},
105
+ )
106
+ expect(cost).toBeCloseTo(0.25)
107
+ })
108
+
109
+ it('returns null for unknown cost type', () => {
110
+ const cost = calculateCost({ type: 'unknown' }, {})
111
+ expect(cost).toBeNull()
112
+ })
113
+ })
114
+
115
+ describe('buildImageProviderOptions', () => {
116
+ it('builds google options with person generation', () => {
117
+ const opts = buildImageProviderOptions('google', {
118
+ allowPeople: true,
119
+ aspectRatio: '16:9',
120
+ })
121
+ expect(opts.google).toEqual({
122
+ personGeneration: 'allow_all',
123
+ aspectRatio: '16:9',
124
+ })
125
+ })
126
+
127
+ it('builds xai options with quality', () => {
128
+ const opts = buildImageProviderOptions('xai', {
129
+ allowPeople: false,
130
+ quality: 'high',
131
+ resolution: '4k',
132
+ })
133
+ expect(opts.xai).toEqual({
134
+ quality: 'high',
135
+ resolution: '4k',
136
+ })
137
+ })
138
+
139
+ it('returns empty for unknown provider', () => {
140
+ const opts = buildImageProviderOptions('unknown-provider', {
141
+ allowPeople: false,
142
+ })
143
+ expect(opts).toEqual({})
144
+ })
145
+ })
146
+
147
+ describe('buildVideoProviderOptions', () => {
148
+ it('builds xai edit-video options', () => {
149
+ const opts = buildVideoProviderOptions('xai', {
150
+ mode: 'edit-video',
151
+ videoUrl: 'https://example.com/video.mp4',
152
+ model: 'grok-imagine-video',
153
+ })
154
+ expect(opts?.xai).toEqual({
155
+ mode: 'edit-video',
156
+ videoUrl: 'https://example.com/video.mp4',
157
+ })
158
+ })
159
+
160
+ it('returns undefined for provider with no options', () => {
161
+ const opts = buildVideoProviderOptions('google', {
162
+ model: 'veo-3.1-fast-generate-001',
163
+ })
164
+ expect(opts).toBeUndefined()
165
+ })
166
+ })