akari-video 0.1.82 → 0.1.83

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/narration-command.mjs +179 -84
  3. package/src/tts-engines.mjs +181 -0
  4. package/src/voice-command.mjs +130 -20
  5. package/vendor/.akari-capability-sources.json +1 -0
  6. package/vendor/docs/contract-2026-09-02-item-caption-anchor-v0.md +8 -4
  7. package/vendor/docs/contract-2026-09-13-generation-v0.md +5 -2
  8. package/vendor/docs/contract-2026-09-24-caption-runs-v0.md +40 -0
  9. package/vendor/docs/contract-2026-09-24-style-v0.md +23 -3
  10. package/vendor/packages/akari-launcher/package.json +1 -1
  11. package/vendor/packages/creator-root/src/index.mjs +18 -0
  12. package/vendor/packages/creator-root/test/direct-tts-providers.test.mjs +19 -0
  13. package/vendor/packages/edit-lint/src/edit-lint.mjs +69 -1
  14. package/vendor/packages/edit-lint/src/engine-capabilities.json +1 -1
  15. package/vendor/packages/edit-store/lib/caption-display.d.ts +4 -0
  16. package/vendor/packages/edit-store/lib/caption-display.js +50 -2
  17. package/vendor/packages/edit-store/lib/caption-runs.d.ts +64 -0
  18. package/vendor/packages/edit-store/lib/caption-runs.js +320 -0
  19. package/vendor/packages/edit-store/lib/caption-store.d.ts +42 -1
  20. package/vendor/packages/edit-store/lib/caption-store.js +93 -4
  21. package/vendor/packages/edit-store/lib/caption-window.d.ts +4 -0
  22. package/vendor/packages/edit-store/lib/caption-window.js +6 -0
  23. package/vendor/packages/edit-store/lib/caption-words-rederive.d.ts +3 -0
  24. package/vendor/packages/edit-store/lib/caption-words-rederive.js +20 -1
  25. package/vendor/packages/edit-store/lib/edit-v2.d.ts +17 -9
  26. package/vendor/packages/edit-store/lib/edit-v2.js +38 -2
  27. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +1 -1
  28. package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +1 -0
  29. package/vendor/packages/edit-store/lib/index.d.ts +1 -0
  30. package/vendor/packages/edit-store/lib/index.js +1 -0
  31. package/vendor/packages/edit-store/lib/item-anchor.d.ts +4 -3
  32. package/vendor/packages/edit-store/lib/item-anchor.js +27 -5
  33. package/vendor/packages/edit-store/lib/tree-ops.js +2 -0
  34. package/vendor/packages/edit-store/lib/webview-kernel.js +63 -7
  35. package/vendor/packages/schemas/captions.schema.json +29 -1
  36. package/vendor/packages/schemas/edit.schema.json +12 -0
  37. package/vendor/packages/schemas/engine-capabilities.json +1 -1
  38. package/vendor/packages/schemas/test/engine-capabilities.test.mjs +8 -0
  39. package/vendor/skills/generate-narration/SKILL.md +3 -2
  40. package/vendor/skills/generate-narration/engines.md +44 -8
  41. package/vendor/skills/manage-connections/SKILL.md +7 -0
  42. package/vendor/skills/manage-connections/bin/doctor.mjs +4 -0
  43. package/vendor/skills/manage-connections/test/direct-tts-doctor.test.mjs +31 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.82",
3
+ "version": "0.1.83",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,11 +3,13 @@
3
3
 
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
+ import crypto from "node:crypto";
6
7
  import path from "node:path";
7
8
  import { spawn, spawnSync } from "node:child_process";
8
9
  import { fileURLToPath, pathToFileURL } from "node:url";
9
10
  import { resolveLauncherAssets } from "./repo-assets.mjs";
10
- import { listProfiles, readFalKey, resolveVoiceProfile } from "./voice-command.mjs";
11
+ import { listProfiles, readFalKey, readProviderKey, resolveVoiceProfile } from "./voice-command.mjs";
12
+ import { FAL_TTS_ENGINES, DIRECT_TTS_ENGINES, falTtsEngine, ttsEngine, estimateTtsCost, referenceDataUri } from "./tts-engines.mjs";
11
13
 
12
14
  const VOICEVOX_BASE_URL = "http://127.0.0.1:50021";
13
15
  const VOICEVOX_RUN_ENV = "VOICEVOX_RUN";
@@ -38,22 +40,12 @@ function irodoriTimeout(env = process.env) {
38
40
  if (!Number.isSafeInteger(value) || value <= 0) throw new PublicError("AKARI_IRODORI_TIMEOUT_MS は正の整数にしてください", 2);
39
41
  return value;
40
42
  }
41
- const GEMINI_VOICES = Object.entries({
42
- Leda: "Youthful", Achernar: "Soft", Achird: "Friendly", Algenib: "Gravelly",
43
- Algieba: "Smooth", Alnilam: "Firm", Aoede: "Breezy", Autonoe: "Bright",
44
- Callirrhoe: "Easy-going", Charon: "Informative", Despina: "Smooth",
45
- Enceladus: "Breathy", Erinome: "Clear", Fenrir: "Excitable", Gacrux: "Mature",
46
- Iapetus: "Clear", Kore: "Firm", Laomedeia: "Upbeat", Orus: "Firm",
47
- Pulcherrima: "Forward", Puck: "Upbeat", Rasalgethi: "Informative",
48
- Sadachbia: "Lively", Sadaltager: "Knowledgeable", Schedar: "Even",
49
- Sulafat: "Warm", Umbriel: "Easy-going", Vindemiatrix: "Gentle",
50
- Zephyr: "Bright", Zubenelgenubi: "Casual",
51
- }).map(([id, description]) => ({ id, label: `${id}(${description})`, ...(id === "Leda" ? { default: true } : {}) }));
43
+ const GEMINI_VOICES = falTtsEngine('gemini-tts').voices;
52
44
 
53
45
  const usage = [
54
46
  "使い方:",
55
47
  " akari narration generate \\",
56
- " --project <projectDir> --engine <voicevox|gemini-tts|irodori|fal-qwen3> \\",
48
+ " --project <projectDir> --engine <voicevox|gemini-tts|irodori|fal-qwen3|gemini-3.1-flash-tts|elevenlabs-v3|minimax-2.6-hd|chatterbox|index-tts-2|fish-s2.1-pro|gemini-3.8-flash-tts> \\",
57
49
  " (--reading-file <読み原稿.txt> | --text <原稿>) [--script-file <表示原稿.txt>] \\",
58
50
  " [--t <タイムライン秒>] [--gain-db 0] [--id n-0001] \\",
59
51
  " [--speaker 3] [--voice Leda] [--style <text>] [--speed 1] \\",
@@ -305,8 +297,8 @@ function parseArguments(argv) {
305
297
  }
306
298
 
307
299
  if (!options.project) throw new PublicError("--project が必要です");
308
- if (!["voicevox", "gemini-tts", "irodori", "fal-qwen3"].includes(options.engine)) {
309
- throw new PublicError("--engine には voicevox、gemini-tts、irodori または fal-qwen3 を指定してください", 2);
300
+ if (!["voicevox", "irodori"].includes(options.engine) && !ttsEngine(options.engine)) {
301
+ throw new PublicError(`--engine には voicevox、irodori、${[...FAL_TTS_ENGINES, ...DIRECT_TTS_ENGINES].map(engine => engine.id).join('、')} を指定してください`, 2);
310
302
  }
311
303
  if (options.engine === "irodori") {
312
304
  options.voice ??= "narrator-male";
@@ -314,10 +306,11 @@ function parseArguments(argv) {
314
306
  if (options.voice === "custom" && !options.style?.trim()) throw new PublicError("自分で書く声には --style が必要です", 2);
315
307
  options.irodori = irodoriEndpoint(options.irodoriUrl);
316
308
  irodoriTimeout();
317
- } else options.voice ??= "Leda";
309
+ } else options.voice ??= ttsEngine(options.engine)?.default_voice ?? (ttsEngine(options.engine) ? null : 'Leda');
318
310
  if (!options.readingFile && !options.text) throw new PublicError("--reading-file または --text が必要です");
319
- if (options.engine === "gemini-tts" && !GEMINI_VOICES.some(({ id }) => id === options.voice)) {
320
- throw new PublicError(`--voice に使える声: ${GEMINI_VOICES.map(({ id }) => id).join(", ")}`, 2);
311
+ const catalogVoices = ttsEngine(options.engine)?.voices;
312
+ if (catalogVoices && !options.profile && !catalogVoices.some(({ id }) => id === options.voice)) {
313
+ throw new PublicError(`--voice に使える声: ${catalogVoices.map(({ id }) => id).join(", ")}`, 2);
321
314
  }
322
315
  if (options.speed !== null && (!isFiniteNumber(options.speed) || options.speed < (options.engine === "irodori" ? 0.25 : 0.5) || options.speed > (options.engine === "irodori" ? 4 : 2))) {
323
316
  throw new PublicError(options.engine === "irodori" ? "--speed は 0.25 から 4.0 の範囲で指定してください" : "--speed は 0.5 から 2.0 の範囲で指定してください", 2);
@@ -335,8 +328,11 @@ function parseArguments(argv) {
335
328
  if (options.id !== null && !/^n-\d{4}$/.test(options.id)) {
336
329
  throw new PublicError("--id は n- に続く 4 桁の数字である必要があります(例: n-0001)");
337
330
  }
338
- if (options.engine === "fal-qwen3" && !options.profile) {
339
- throw new PublicError("--engine fal-qwen3 には --profile が必要です");
331
+ if ((options.engine === "fal-qwen3" || options.engine === "index-tts-2") && !options.profile) {
332
+ throw new PublicError(`--engine ${options.engine} には --profile が必要です`);
333
+ }
334
+ if (options.engine === 'elevenlabs-v3' && (options.text?.length ?? 0) > 5000) {
335
+ throw new PublicError('ElevenLabs v3 は 1 回 5000 字までです。原稿を短くしてください', 2);
340
336
  }
341
337
 
342
338
  options.project = path.resolve(options.project);
@@ -378,7 +374,7 @@ function computeNextId(projectDir) {
378
374
  }
379
375
 
380
376
  function extensionFor(engine) {
381
- return ["voicevox", "irodori"].includes(engine) ? "wav" : "mp3";
377
+ return ["voicevox", "irodori", 'chatterbox', 'gemini-3.8-flash-tts'].includes(engine) ? "wav" : "mp3";
382
378
  }
383
379
 
384
380
  function relativeOutputPath(id, engine) {
@@ -406,6 +402,38 @@ function readProfileMeta(profileName) {
406
402
  return { ...meta, embedding_source_url };
407
403
  }
408
404
 
405
+ function guardedProfile(profileName, engine) {
406
+ let record;
407
+ try { record = resolveVoiceProfile(profileName); }
408
+ catch { throw new PublicError(`声プロファイルが見つかりません: ${profileName}`, 2); }
409
+ const { meta } = record;
410
+ if (meta.consent?.self_voice !== true || meta.consent?.cloud_upload !== true ||
411
+ !(meta.reference?.verification?.score >= 0.7)) {
412
+ throw new PublicError('クラウド送信には本人同意・cloud_upload 同意・原稿照合 0.7 以上が必要です', 2);
413
+ }
414
+ if (engine.supports.clone === 'registered' &&
415
+ (engine.id === 'gemini-3.8-flash-tts' ?
416
+ (!/^voice_[A-Za-z0-9_-]+$/.test(meta.engines?.[engine.id]?.voice_id ?? '') || meta.engines[engine.id].stale) :
417
+ (!meta.engines?.[engine.id]?.custom_voice_id || meta.engines[engine.id].stale))) {
418
+ throw new PublicError('この声には指定した作り手の写しがありません。akari voice copy で作ってください', 2);
419
+ }
420
+ const name = meta.reference?.file;
421
+ if (engine.supports.clone === 'per-request' &&
422
+ (!name || path.basename(name) !== name || !fs.existsSync(path.join(record.dir, name)))) {
423
+ throw new PublicError('正本の録音が見つかりません', 2);
424
+ }
425
+ if (engine.supports.clone === 'per-request' && meta.reference.sha256 &&
426
+ crypto.createHash('sha256').update(fs.readFileSync(path.join(record.dir, name))).digest('hex') !== meta.reference.sha256) {
427
+ throw new PublicError('正本の録音が保存時から変わっています', 2);
428
+ }
429
+ return record;
430
+ }
431
+
432
+ function profileReferenceDataUri(record) {
433
+ try { return referenceDataUri(fs.readFileSync(path.join(record.dir, record.meta.reference.file))); }
434
+ catch (error) { throw new PublicError(error.message, 2); }
435
+ }
436
+
409
437
  function irodoriProfileVoice(profileName) {
410
438
  let meta;
411
439
  try { meta = resolveVoiceProfile(profileName).meta; }
@@ -416,13 +444,7 @@ function irodoriProfileVoice(profileName) {
416
444
  }
417
445
 
418
446
  function buildFalPayload(readingText, meta) {
419
- return {
420
- text: readingText,
421
- language: "Japanese",
422
- speaker_voice_embedding_file_url: meta.embedding_source_url,
423
- reference_text: meta.reference_text,
424
- max_new_tokens: 2048,
425
- };
447
+ return falTtsEngine('fal-qwen3').buildPayload({ text: readingText, profileMeta: meta });
426
448
  }
427
449
 
428
450
  function estimateFalCostUsd(readingText) {
@@ -432,53 +454,28 @@ function estimateGeminiTtsCostUsd(chars) {
432
454
  return Number(((chars / 1000) * GEMINI_USD_PER_1000_CHARS).toFixed(6));
433
455
  }
434
456
 
435
- async function synthesizeGeminiTts(readingText, { voice, model, style, falKey }) {
436
- const payload = {
437
- prompt: readingText, voice, model,
438
- output_format: "mp3", language_code: "Japanese (Japan)",
439
- ...(style ? { style_instructions: style } : {}),
440
- };
457
+ async function synthesizeCatalog(engine, payload, falKey) {
441
458
  let response;
442
459
  try {
443
- response = await fetch(GEMINI_TTS_URL, {
444
- method: "POST",
445
- headers: { Authorization: `Key ${falKey}`, "Content-Type": "application/json" },
446
- body: JSON.stringify(payload),
447
- });
448
- } catch {
449
- throw new PublicError("fal API へのネットワーク接続に失敗しました");
450
- }
460
+ const request = engine.request(payload, falKey);
461
+ response = await fetch(request.url, request.options);
462
+ } catch { throw new PublicError('fal API へのネットワーク接続に失敗しました'); }
451
463
  if (!response.ok) throw new PublicError(`fal API が HTTP ${response.status} を返しました`);
452
- const audioUrl = (await response.json())?.audio?.url;
453
- if (typeof audioUrl !== "string" || !audioUrl) throw new PublicError("fal API の応答に audio.url がありません");
464
+ const audioUrl = engine.parseAudio(await response.json());
465
+ if (!audioUrl) throw new PublicError('fal API の応答に audio.url がありません');
454
466
  const audioResponse = await fetch(audioUrl);
455
467
  if (!audioResponse.ok) throw new PublicError(`生成音声の取得に失敗しました(HTTP ${audioResponse.status})`);
456
468
  return Buffer.from(await audioResponse.arrayBuffer());
457
469
  }
458
470
 
459
- async function synthesizeFal(readingText, meta, falKey) {
460
- const payload = buildFalPayload(readingText, meta);
471
+ async function synthesizeDirect(engine, payload, key) {
461
472
  let response;
462
- try {
463
- response = await fetch(FAL_TTS_URL, {
464
- method: "POST",
465
- headers: { Authorization: `Key ${falKey}`, "Content-Type": "application/json" },
466
- body: JSON.stringify(payload),
467
- });
468
- } catch {
469
- throw new PublicError("fal API へのネットワーク接続に失敗しました");
470
- }
471
- if (!response.ok) throw new PublicError(`fal API が HTTP ${response.status} を返しました`);
472
- const result = await response.json();
473
- const audioUrl = result?.audio?.url;
474
- if (typeof audioUrl !== "string" || !audioUrl) {
475
- throw new PublicError("fal API の応答に audio.url がありません");
476
- }
477
- const audioResponse = await fetch(audioUrl);
478
- if (!audioResponse.ok) {
479
- throw new PublicError(`生成音声の取得に失敗しました(HTTP ${audioResponse.status})`);
480
- }
481
- return Buffer.from(await audioResponse.arrayBuffer());
473
+ try { const request = engine.request(payload, key); response = await fetch(request.url, request.options); }
474
+ catch { throw new PublicError(`${engine.label} API へのネットワーク接続に失敗しました`); }
475
+ if (!response.ok) throw new PublicError(`${engine.label} API が HTTP ${response.status} を返しました`);
476
+ if (engine.provider === 'fish-audio') return Buffer.from(await response.arrayBuffer());
477
+ try { return engine.parseAudio(await response.json()); }
478
+ catch (error) { throw new PublicError(error.message); }
482
479
  }
483
480
 
484
481
  // --- VOICEVOX ローカルエンジン ---
@@ -804,22 +801,41 @@ async function listEngines(runtime = {}) {
804
801
  const falAvailability = configured
805
802
  ? { state: "available", label: "fal を使用できます" }
806
803
  : { state: "unconfigured", label: "fal の鍵を登録" };
804
+ const directAvailability = Object.fromEntries([['fish-audio', 'FISH_AUDIO_API_KEY', 'Fish Audio'],
805
+ ['google-ai', 'GEMINI_API_KEY', 'Google AI']].map(([provider, name, label]) => {
806
+ let hasKey = false;
807
+ try { hasKey = Boolean(readProviderKey(name, runtime.env || process.env)); } catch { /* 一覧は維持する */ }
808
+ return [provider, hasKey ? { state: 'available', label: `${label} を使用できます` }
809
+ : { state: 'unconfigured', label: `${label} の鍵を登録` }];
810
+ }));
807
811
  const profilesWithFal = listFalProfiles(runtime).length;
808
812
  let endpoint;
809
813
  try { endpoint = irodoriEndpoint(runtime.irodoriUrl, runtime.env || process.env); }
810
814
  catch (error) { if (!(error instanceof PublicError)) throw error; }
811
815
  const irodoriAvailable = endpoint ? await probeIrodori(endpoint, runtime.fetchImpl || fetch) : false;
812
816
  return { version: 1, engines: [
813
- { id: "voicevox", label: "VOICEVOX", place: "local", price: { usd_per_1000_chars: 0, verified: true }, availability: voicevox, credit_required: true, supports: { speed: true, style: false } },
814
- { id: "gemini-tts", label: "Gemini 2.5 Flash TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: GEMINI_USD_PER_1000_CHARS, verified: false, as_of: "2026-09-22" }, availability: falAvailability, default_voice: "Leda", credit_required: false, supports: { speed: false, style: true } },
817
+ { id: "voicevox", label: "VOICEVOX", place: "local", price: { usd_per_1000_chars: 0, verified: true }, availability: voicevox, credit_required: true, supports: { speed: true, style: false, clone: 'none' }, group: 'local' },
818
+ { id: "gemini-tts", label: "Gemini 2.5 Flash TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: GEMINI_USD_PER_1000_CHARS, verified: false, as_of: "2026-09-22" }, availability: falAvailability, default_voice: "Leda", credit_required: false, supports: { speed: false, style: true, clone: 'none' }, group: 'cloud' },
815
819
  { id: "irodori", label: "彩(Irodori-TTS)", place: endpoint?.network ? "network" : "local", experimental: true,
816
820
  price: { usd_per_1000_chars: 0, verified: true }, credit_required: false, default_voice: "narrator-male",
817
- supports: { speed: true, style: true }, availability: !endpoint
821
+ supports: { speed: true, style: true, clone: 'registered' }, group: 'local', availability: !endpoint
818
822
  ? { state: "unconfigured", label: "接続先 URL が正しくありません(お試し)", detail: { setup_url: IRODORI_SETUP_URL } }
819
823
  : irodoriAvailable
820
824
  ? { state: "available", label: "お試し · 接続済み", detail: { url: endpoint.server } }
821
825
  : { state: "unconfigured", label: "Irodori サーバーにつながりません(お試し)", detail: { setup_url: IRODORI_SETUP_URL } } },
822
- { id: "fal-qwen3", label: "fal Qwen3-TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: FAL_USD_PER_1000_CHARS, verified: false }, availability: !configured ? { ...falAvailability, detail: { profiles_with_fal: profilesWithFal } } : profilesWithFal ? { state: "available", label: "声プロファイルを使用できます", detail: { profiles_with_fal: profilesWithFal } } : { state: "needs", label: "fal の写しがある声がありません(自分の声をつくる)", detail: { profiles_with_fal: 0 } }, credit_required: false, supports: { speed: false, style: false } },
826
+ { id: "fal-qwen3", label: "fal Qwen3-TTS", place: "cloud", provider: "fal", price: { usd_per_1000_chars: FAL_USD_PER_1000_CHARS, verified: false }, availability: !configured ? { ...falAvailability, detail: { profiles_with_fal: profilesWithFal } } : profilesWithFal ? { state: "available", label: "声プロファイルを使用できます", detail: { profiles_with_fal: profilesWithFal } } : { state: "needs", label: "fal の写しがある声がありません(自分の声をつくる)", detail: { profiles_with_fal: 0 } }, credit_required: false, supports: { speed: false, style: false, clone: 'registered' }, group: 'cloud' },
827
+ ...FAL_TTS_ENGINES.filter(engine => !['gemini-tts', 'fal-qwen3'].includes(engine.id)).map(engine => ({
828
+ id: engine.id, label: engine.label, place: engine.place, provider: engine.provider,
829
+ group: 'cloud', endpoint: engine.endpoint, price: engine.price, ...(engine.caution ? { caution: engine.caution } : {}),
830
+ availability: falAvailability, default_voice: engine.default_voice, credit_required: false,
831
+ supports: engine.supports,
832
+ })),
833
+ ...DIRECT_TTS_ENGINES.map(engine => ({
834
+ id: engine.id, label: engine.label, place: engine.place, provider: engine.provider,
835
+ group: 'cloud', endpoint: engine.endpoint, price: engine.price,
836
+ availability: directAvailability[engine.provider], default_voice: engine.default_voice,
837
+ credit_required: false, supports: engine.supports,
838
+ })),
823
839
  ] };
824
840
  }
825
841
 
@@ -827,6 +843,13 @@ async function listVoices(engine, runtime = {}) {
827
843
  if (engine === "irodori") return [...IRODORI_RECIPES.map(({ id, label, default: isDefault }) => ({ id, label, ...(isDefault ? { default: true } : {}) })),
828
844
  { id: "custom", label: "自分で書く(声の指示)" }];
829
845
  if (engine === "gemini-tts") return GEMINI_VOICES;
846
+ const catalog = ttsEngine(engine);
847
+ if (catalog && !['fal-qwen3'].includes(engine)) {
848
+ if (catalog.voices) return catalog.voices;
849
+ return catalog.supports.clone === 'per-request' ? listProfiles(resolveProfileEnv(runtime))
850
+ .filter(profile => profile.consent.self_voice && profile.consent.cloud_upload && profile.verification?.score >= 0.7)
851
+ .map(profile => ({ id: profile.id, label: profile.label })) : [];
852
+ }
830
853
  if (engine === "voicevox") {
831
854
  const handle = await ensureVoicevoxEngine();
832
855
  try {
@@ -880,6 +903,25 @@ async function runDryRun(options, readingText, io) {
880
903
  }, io.log);
881
904
  return;
882
905
  }
906
+ if (DIRECT_TTS_ENGINES.some(engine => engine.id === options.engine)) {
907
+ const engine = ttsEngine(options.engine);
908
+ const record = options.profile ? guardedProfile(options.profile, engine) : null;
909
+ if (record && engine.supports.clone === 'per-request') {
910
+ profileReferenceDataUri(record); // 送信前と同じ容量ガード。録音は表示しない。
911
+ if (!record.meta.reference_text) throw new PublicError('声プロファイルの reference_text がありません', 2);
912
+ }
913
+ const body = engine.buildPayload({ text: readingText,
914
+ voice: engine.id === 'gemini-3.8-flash-tts' && record ? record.meta.engines[engine.id].voice_id : options.voice, style: options.style,
915
+ ...(record && engine.supports.clone === 'per-request' ? { referenceAudio: Buffer.from('<audio bytes>'), referenceText: record.meta.reference_text } : {}) });
916
+ if (record && engine.supports.clone === 'per-request') body.references[0].audio = '<audio bytes>';
917
+ emit({ dry_run: true, engine: engine.id, output_path: outputPath,
918
+ estimated_cost_usd: estimateTtsCost(engine, readingText),
919
+ request: { endpoint: engine.endpoint, headers: engine.provider === 'fish-audio'
920
+ ? { Authorization: `Bearer ${maskKey(readProviderKey('FISH_AUDIO_API_KEY'))}`, model: 's2.1-pro',
921
+ 'Content-Type': record ? 'application/msgpack' : 'application/json' }
922
+ : { 'x-goog-api-key': maskKey(readProviderKey('GEMINI_API_KEY')), 'Content-Type': 'application/json' }, body } }, io.log);
923
+ return;
924
+ }
883
925
 
884
926
  if (options.engine === "gemini-tts") {
885
927
  emit({
@@ -898,6 +940,21 @@ async function runDryRun(options, readingText, io) {
898
940
  ...(profileVoice ? {} : { irodori: { caption: options.style?.trim() || IRODORI_RECIPES.find(recipe => recipe.id === options.voice)?.caption } }) } } }, io.log);
899
941
  return;
900
942
  }
943
+ if (!['fal-qwen3'].includes(options.engine)) {
944
+ const engine = ttsEngine(options.engine);
945
+ const record = options.profile && engine.supports.clone !== 'none' ? guardedProfile(options.profile, engine) : null;
946
+ let audioUrl;
947
+ if (record && engine.supports.clone === 'per-request') {
948
+ profileReferenceDataUri(record); // 容量を確認し、録音そのものは dry-run に表示しない。
949
+ audioUrl = '<data:audio/wav;base64,...>';
950
+ }
951
+ const payload = engine.buildPayload({ text: readingText, voice: options.voice, style: options.style,
952
+ speed: options.speed, profileMeta: record?.meta, audioUrl });
953
+ emit({ dry_run: true, engine: engine.id, output_path: outputPath,
954
+ estimated_cost_usd: estimateTtsCost(engine, readingText),
955
+ request: { endpoint: `https://fal.run/${engine.endpoint}`, body: payload } }, io.log);
956
+ return;
957
+ }
901
958
  const falKey = resolveFalKey();
902
959
  const meta = readProfileMeta(options.profile);
903
960
  const payload = buildFalPayload(readingText, meta);
@@ -916,7 +973,7 @@ async function runDryRun(options, readingText, io) {
916
973
  }
917
974
 
918
975
  function durationForAudio(buffer, outputPath, engine, warnings) {
919
- if (["voicevox", "irodori"].includes(engine)) {
976
+ if (["voicevox", "irodori", 'chatterbox', 'gemini-3.8-flash-tts'].includes(engine)) {
920
977
  if (buffer.length >= 44 && buffer.toString("ascii", 0, 4) === "RIFF") {
921
978
  const bytesPerSecond = buffer.readUInt32LE(28);
922
979
  const dataOffset = buffer.indexOf("data", 36, "ascii");
@@ -939,6 +996,12 @@ async function runGenerate(options, io) {
939
996
  const readingText = options.readingFile ? readTextFile(options.readingFile, "読み原稿") : options.text.trim();
940
997
  const scriptText = options.scriptFile ? readTextFile(options.scriptFile, "表示原稿") : options.text;
941
998
  if (!readingText) throw new PublicError("読み原稿が空です", 2);
999
+ if (options.engine === 'elevenlabs-v3' && readingText.length > 5000) {
1000
+ throw new PublicError('ElevenLabs v3 は 1 回 5000 字までです。原稿を短くしてください', 2);
1001
+ }
1002
+ if (options.engine === 'chatterbox' && readingText.length > 300) {
1003
+ throw new PublicError('Chatterbox 多言語版は 1 回 300 字までです。原稿を短くしてください', 2);
1004
+ }
942
1005
 
943
1006
  if (options.dryRun) {
944
1007
  await runDryRun(options, readingText, io);
@@ -953,7 +1016,7 @@ async function runGenerate(options, io) {
953
1016
  let provenance;
954
1017
  let costUsd = 0;
955
1018
  const warnings = [];
956
- if (options.speed !== null && !["voicevox", "irodori"].includes(options.engine)) {
1019
+ if (options.speed !== null && !["voicevox", "irodori", 'minimax-2.6-hd'].includes(options.engine)) {
957
1020
  const warning = `${options.engine} は --speed に対応していないため無視しました`;
958
1021
  warnings.push(warning);
959
1022
  io.logError(warning);
@@ -986,28 +1049,60 @@ async function runGenerate(options, io) {
986
1049
  provenance = { provider: "irodori", engine: "irodori-tts-v4-small", voice: options.profile ? `profile:${options.profile}` : options.style?.trim() ? "caption:custom" : `recipe:${options.voice}`,
987
1050
  generated_at: new Date().toISOString(), experimental: true, server: options.irodori.server };
988
1051
  } else {
989
- const estimatedCostUsd = options.engine === "gemini-tts"
990
- ? estimateGeminiTtsCostUsd(readingText.length) : estimateFalCostUsd(readingText);
1052
+ const engine = ttsEngine(options.engine);
1053
+ const estimatedCostUsd = estimateTtsCost(engine, readingText);
991
1054
  costUsd = estimatedCostUsd;
992
- io.logError(`${options.engine} 推定費用: 約 $${estimatedCostUsd}(${readingText.length} 文字)`);
1055
+ io.logError(estimatedCostUsd === null ? `${options.engine} 見積不可(${readingText.length} 文字)` :
1056
+ `${options.engine} 推定費用: 約 $${estimatedCostUsd}(${readingText.length} 文字)`);
993
1057
  if (!options.yes) {
994
1058
  if (options.json) printCompactJson({ version: 1, status: "needs_approval", estimate_usd: estimatedCostUsd, chars: readingText.length,
1059
+ ...(estimatedCostUsd === null ? { reason: '見積不可' } : {}),
995
1060
  ...(options.speed !== null ? { speed_applied: false, warnings } : {}) }, io.log);
996
1061
  else printJson({ sent: false, engine: options.engine, estimated_cost_usd: estimatedCostUsd,
997
1062
  reason: "費用承認(--yes)がありません。実リクエストは送信していません。" }, io.log);
998
1063
  return 2;
999
1064
  }
1000
- const falKey = resolveFalKey();
1001
- // --yes が明示された場合のみ、ここで初めて課金の発生する fal API を呼び出す。
1002
- if (options.engine === "gemini-tts") {
1003
- audioBuffer = await synthesizeGeminiTts(readingText, { voice: options.voice, model: "gemini-2.5-flash-tts", style: options.style, falKey });
1065
+ // --yes が明示された場合のみ、ここで初めて課金の発生する API を呼び出す。
1066
+ if (engine.provider !== 'fal') {
1067
+ const name = engine.provider === 'fish-audio' ? 'FISH_AUDIO_API_KEY' : 'GEMINI_API_KEY';
1068
+ const key = readProviderKey(name);
1069
+ if (!key) throw new PublicError(`${name} が未設定です。<AKARI_HOME>/credentials.env に登録してください`, 2);
1070
+ const record = options.profile ? guardedProfile(options.profile, engine) : null;
1071
+ let referenceAudio;
1072
+ if (record && engine.supports.clone === 'per-request') {
1073
+ profileReferenceDataUri(record); // 正本 wav と 20 MB 上限を確認する。
1074
+ if (!record.meta.reference_text) throw new PublicError('声プロファイルの reference_text がありません', 2);
1075
+ referenceAudio = fs.readFileSync(path.join(record.dir, record.meta.reference.file));
1076
+ }
1077
+ const payload = engine.buildPayload({ text: readingText,
1078
+ voice: engine.id === 'gemini-3.8-flash-tts' && record ? record.meta.engines[engine.id].voice_id : options.voice, style: options.style,
1079
+ referenceAudio, referenceText: record?.meta.reference_text });
1080
+ audioBuffer = await synthesizeDirect(engine, payload, key);
1081
+ provenance = { provider: engine.provider, engine: engine.id,
1082
+ model: engine.provider === 'fish-audio' ? 's2.1-pro' : 'gemini-3.8-flash-tts',
1083
+ voice: record ? `profile:${options.profile}` : options.voice,
1084
+ generated_at: new Date().toISOString(), price_verified: engine.price.verified };
1085
+ } else if (options.engine === "gemini-tts") {
1086
+ const falKey = resolveFalKey();
1087
+ audioBuffer = await synthesizeCatalog(engine, engine.buildPayload({ text: readingText, voice: options.voice, style: options.style }), falKey);
1004
1088
  provenance = { provider: "fal", engine: "gemini-2.5-flash-tts", voice: `gemini:${options.voice}`,
1005
- generated_at: new Date().toISOString(), price_verified: false };
1006
- } else {
1089
+ generated_at: new Date().toISOString(), price_verified: false, endpoint: engine.endpoint };
1090
+ } else if (options.engine === 'fal-qwen3') {
1091
+ const falKey = resolveFalKey();
1007
1092
  const meta = readProfileMeta(options.profile);
1008
- audioBuffer = await synthesizeFal(readingText, meta, falKey);
1093
+ audioBuffer = await synthesizeCatalog(engine, engine.buildPayload({ text: readingText, profileMeta: meta }), falKey);
1009
1094
  provenance = { provider: "fal", engine: "qwen-3-tts-1.7b", voice: `profile:${options.profile}`,
1010
- generated_at: new Date().toISOString() };
1095
+ generated_at: new Date().toISOString(), endpoint: engine.endpoint, price_verified: false };
1096
+ } else {
1097
+ const falKey = resolveFalKey();
1098
+ const record = options.profile && engine.supports.clone !== 'none' ? guardedProfile(options.profile, engine) : null;
1099
+ const audioUrl = record && engine.supports.clone === 'per-request' ? profileReferenceDataUri(record) : undefined;
1100
+ const payload = engine.buildPayload({ text: readingText, voice: options.voice, style: options.style,
1101
+ speed: options.speed, profileMeta: record?.meta, audioUrl });
1102
+ audioBuffer = await synthesizeCatalog(engine, payload, falKey);
1103
+ provenance = { provider: 'fal', engine: engine.id, endpoint: engine.endpoint,
1104
+ voice: record ? `profile:${options.profile}` : options.voice ?? 'default',
1105
+ generated_at: new Date().toISOString(), price_verified: engine.price.verified };
1011
1106
  }
1012
1107
  }
1013
1108
 
@@ -1031,7 +1126,7 @@ async function runGenerate(options, io) {
1031
1126
  if (options.json) printCompactJson({ version: 1, status: "ok", id, path: relativePath,
1032
1127
  duration_s: duration, engine: options.engine, voice: provenance.voice, cost_usd: costUsd,
1033
1128
  applied: options.apply, caption_ref: options.captionRef, provenance, warnings,
1034
- ...(options.speed !== null ? { speed_applied: ["voicevox", "irodori"].includes(options.engine) } : {}) }, io.log);
1129
+ ...(options.speed !== null ? { speed_applied: ["voicevox", "irodori", 'minimax-2.6-hd'].includes(options.engine) } : {}) }, io.log);
1035
1130
  else printJson(entry, io.log);
1036
1131
  return 0;
1037
1132
  }
@@ -0,0 +1,181 @@
1
+ // fal TTS の追加点。価格の verified は fal の課金単位を確認できた場合だけ true にする。
2
+ const geminiVoices = Object.entries({
3
+ Leda: 'Youthful', Achernar: 'Soft', Achird: 'Friendly', Algenib: 'Gravelly',
4
+ Algieba: 'Smooth', Alnilam: 'Firm', Aoede: 'Breezy', Autonoe: 'Bright',
5
+ Callirrhoe: 'Easy-going', Charon: 'Informative', Despina: 'Smooth',
6
+ Enceladus: 'Breathy', Erinome: 'Clear', Fenrir: 'Excitable', Gacrux: 'Mature',
7
+ Iapetus: 'Clear', Kore: 'Firm', Laomedeia: 'Upbeat', Orus: 'Firm',
8
+ Pulcherrima: 'Forward', Puck: 'Upbeat', Rasalgethi: 'Informative',
9
+ Sadachbia: 'Lively', Sadaltager: 'Knowledgeable', Schedar: 'Even',
10
+ Sulafat: 'Warm', Umbriel: 'Easy-going', Vindemiatrix: 'Gentle',
11
+ Zephyr: 'Bright', Zubenelgenubi: 'Casual',
12
+ }).map(([id, description]) => ({ id, label: `${id}(${description})`, ...(id === 'Leda' ? { default: true } : {}) }));
13
+
14
+ const price = (unit, value, verified = false, as_of = '2026-09-24') => ({ unit, value, verified, as_of });
15
+ const audio = result => result?.audio?.url;
16
+ const fixture = endpoint => `${endpoint.replaceAll('/', '_')}.json`;
17
+ const row = (id, label, endpoint, details) => ({
18
+ id, label, provider: 'fal', place: 'cloud', endpoint,
19
+ openapi_fixture: fixture(endpoint), ...details, parseAudio: audio,
20
+ request: (payload, key) => ({ url: `https://fal.run/${endpoint}`, options: {
21
+ method: 'POST', headers: { Authorization: `Key ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
22
+ } }),
23
+ });
24
+
25
+ export const FAL_TTS_ENGINES = Object.freeze([
26
+ row('gemini-tts', 'Gemini 2.5 Flash TTS', 'fal-ai/gemini-tts', {
27
+ openapi_fixture: 'fal_gemini-tts.json',
28
+ price: price('usd_per_1000_chars', 0.05, false, '2026-09-22'), voices: geminiVoices,
29
+ default_voice: 'Leda', supports: { speed: false, style: true, clone: 'none' },
30
+ buildPayload: ({ text, voice, style }) => ({ prompt: text, voice, model: 'gemini-2.5-flash-tts', output_format: 'mp3',
31
+ language_code: 'Japanese (Japan)', ...(style ? { style_instructions: style } : {}) }),
32
+ }),
33
+ row('fal-qwen3', 'fal Qwen3-TTS', 'fal-ai/qwen-3-tts/text-to-speech/1.7b', {
34
+ price: price('usd_per_1000_chars', 0.09), voices: null,
35
+ default_voice: null, supports: { speed: false, style: false, clone: 'registered' },
36
+ buildPayload: ({ text, profileMeta }) => ({ text, language: 'Japanese',
37
+ speaker_voice_embedding_file_url: profileMeta.embedding_source_url,
38
+ reference_text: profileMeta.reference_text, max_new_tokens: 2048 }),
39
+ }),
40
+ row('gemini-3.1-flash-tts', 'Gemini 3.1 Flash TTS(既製の声)', 'fal-ai/gemini-3.1-flash-tts', {
41
+ price: price(null, null), voices: geminiVoices, default_voice: 'Leda',
42
+ supports: { speed: false, style: true, clone: 'none' },
43
+ buildPayload: ({ text, voice, style }) => ({ prompt: text, voice, output_format: 'mp3',
44
+ language_code: 'Japanese (Japan)', ...(style ? { style_instructions: style } : {}) }),
45
+ }),
46
+ row('elevenlabs-v3', 'ElevenLabs v3(既製の声)', 'fal-ai/elevenlabs/tts/eleven-v3', {
47
+ price: price('usd_per_1000_chars', 0.10),
48
+ voices: ['Rachel', 'Aria', 'Sarah', 'Laura', 'Charlie', 'George', 'River', 'Liam', 'Charlotte', 'Alice']
49
+ .map(id => ({ id, label: id, ...(id === 'Rachel' ? { default: true } : {}) })),
50
+ default_voice: 'Rachel', supports: { speed: false, style: false, clone: 'none' },
51
+ buildPayload: ({ text, voice }) => ({ text, voice, language_code: 'ja' }),
52
+ }),
53
+ row('minimax-2.6-hd', 'MiniMax Speech 2.6 HD(自分の声対応)', 'fal-ai/minimax/speech-2.6-hd', {
54
+ price: price(null, null),
55
+ voices: ['Wise_Woman', 'Friendly_Person', 'Inspirational_girl', 'Deep_Voice_Man',
56
+ 'Calm_Woman', 'Casual_Guy', 'Lively_Girl', 'Patient_Man', 'Lovely_Girl', 'Elegant_Man']
57
+ .map(id => ({ id, label: id, ...(id === 'Wise_Woman' ? { default: true } : {}) })),
58
+ default_voice: 'Wise_Woman', supports: { speed: true, style: false, clone: 'registered' },
59
+ buildPayload: ({ text, voice, profileMeta, speed }) => ({ prompt: text, language_boost: 'Japanese',
60
+ output_format: 'url', voice_setting: { voice_id: profileMeta?.engines?.['minimax-2.6-hd']?.custom_voice_id ?? voice,
61
+ ...(speed != null ? { speed } : {}) } }),
62
+ }),
63
+ row('chatterbox', 'Chatterbox 多言語(参照音声対応)', 'fal-ai/chatterbox/text-to-speech/multilingual', {
64
+ caution: '日本語の読みが不安定です(聞き取りで確かめるのがおすすめ)',
65
+ price: price('usd_per_1000_chars', 0.025), voices: [{ id: 'japanese', label: '日本語', default: true }], default_voice: 'japanese',
66
+ supports: { speed: false, style: false, clone: 'per-request' },
67
+ buildPayload: ({ text, audioUrl }) => ({ text, voice: audioUrl ?? 'japanese',
68
+ ...(audioUrl ? { custom_audio_language: 'japanese' } : {}) }),
69
+ }),
70
+ row('index-tts-2', 'Index TTS 2(参照音声対応)', 'fal-ai/index-tts-2/text-to-speech', {
71
+ price: price('usd_per_second', 0.002), voices: null, default_voice: null,
72
+ supports: { speed: false, style: false, clone: 'per-request' },
73
+ buildPayload: ({ text, audioUrl }) => ({ prompt: text, audio_url: audioUrl }),
74
+ }),
75
+ ]);
76
+
77
+ export const falTtsEngine = id => FAL_TTS_ENGINES.find(engine => engine.id === id);
78
+
79
+ const fishVoices = [
80
+ ['5161d41404314212af1254556477c17d', '元気な女性'],
81
+ ['46745543e52548238593a3962be77e3a', 'ふうか'],
82
+ ['63bc41e652214372b15d9416a30a60b4', '元気な女性v2'],
83
+ ['0089dce5fefb4c6ba9b9f2f0debe1ddc', '落ち着いた女性'],
84
+ ['45c5d3723c9c42f598e4776dcfd5f02d', '落ち着いた男性'],
85
+ ].map(([id, label], index) => ({ id, label, ...(index === 0 ? { default: true } : {}) }));
86
+
87
+ const fishStyle = style => {
88
+ const value = style?.trim();
89
+ if (!value) return '';
90
+ return /^(?:\[[^\[\]]+\])+$/.test(value) ? value : `[${value}]`;
91
+ };
92
+
93
+ // Fish の TTSRequest.references はバイナリ音声を含み、公式 OpenAPI は MessagePack 必須とする。
94
+ // 保存済み model ID を作らず正本録音を毎回送るため、声の更新と撤回がローカルの正本に従う。
95
+ function msgpack(value) {
96
+ const parts = [];
97
+ const write = (head, data) => { parts.push(Buffer.from(head)); if (data) parts.push(data); };
98
+ const encode = item => {
99
+ if (Buffer.isBuffer(item)) { const head = Buffer.alloc(5); head[0] = 0xc6; head.writeUInt32BE(item.length, 1); write(head, item); }
100
+ else if (typeof item === 'string') { const data = Buffer.from(item); const head = Buffer.alloc(5); head[0] = 0xdb; head.writeUInt32BE(data.length, 1); write(head, data); }
101
+ else if (Array.isArray(item)) { const head = Buffer.alloc(5); head[0] = 0xdd; head.writeUInt32BE(item.length, 1); write(head); item.forEach(encode); }
102
+ else if (item && typeof item === 'object') { const entries = Object.entries(item); const head = Buffer.alloc(5); head[0] = 0xdf; head.writeUInt32BE(entries.length, 1); write(head); for (const [key, value] of entries) { encode(key); encode(value); } }
103
+ else throw new TypeError('MessagePack の値が不正です');
104
+ };
105
+ encode(value);
106
+ return Buffer.concat(parts);
107
+ }
108
+
109
+ export function pcmToWav(pcm) {
110
+ if (pcm.length % 2) throw new Error('PCM のバイト数が不正です');
111
+ const header = Buffer.alloc(44);
112
+ header.write('RIFF', 0); header.writeUInt32LE(pcm.length + 36, 4); header.write('WAVEfmt ', 8);
113
+ header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22);
114
+ header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28); header.writeUInt16LE(2, 32);
115
+ header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40);
116
+ return Buffer.concat([header, pcm]);
117
+ }
118
+
119
+ export function parseGeminiAudio(result) {
120
+ const content = result?.steps?.filter(step => step.type === 'model_output').flatMap(step => step.content ?? [])
121
+ .find(part => part.type === 'audio' && typeof part.data === 'string');
122
+ if (!content) throw new Error('Gemini API の応答に音声がありません');
123
+ const bytes = Buffer.from(content.data, 'base64');
124
+ if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WAVE') return bytes;
125
+ if (content.mime_type === 'audio/l16') return pcmToWav(bytes);
126
+ throw new Error(`Gemini API の音声形式に対応していません: ${content.mime_type ?? 'unknown'}`);
127
+ }
128
+
129
+ export const DIRECT_TTS_ENGINES = Object.freeze([
130
+ {
131
+ id: 'fish-s2.1-pro', label: 'Fish Audio S2.1-Pro', provider: 'fish-audio', place: 'cloud',
132
+ endpoint: 'https://api.fish.audio/v1/tts', openapi_fixture: 'direct/fish-audio.md',
133
+ price: price('usd_per_1000_chars', 0.045, true), voices: fishVoices,
134
+ default_voice: fishVoices[0].id, supports: { speed: false, style: true, clone: 'per-request' },
135
+ buildPayload: ({ text, voice, style, referenceAudio, referenceText }) => ({
136
+ text: style?.trim() ? `${fishStyle(style)} ${text}` : text,
137
+ ...(referenceAudio ? { references: [{ audio: referenceAudio, text: referenceText }] } : { reference_id: voice }),
138
+ format: 'mp3',
139
+ }),
140
+ request: (payload, key) => ({ url: 'https://api.fish.audio/v1/tts', options: {
141
+ method: 'POST', headers: { Authorization: `Bearer ${key}`, model: 's2.1-pro',
142
+ 'Content-Type': payload.references ? 'application/msgpack' : 'application/json' },
143
+ body: payload.references ? msgpack(payload) : JSON.stringify(payload),
144
+ } }),
145
+ },
146
+ {
147
+ id: 'gemini-3.8-flash-tts', label: 'Gemini 3.8 Flash TTS', provider: 'google-ai', place: 'cloud',
148
+ endpoint: 'https://generativelanguage.googleapis.com/v1beta/interactions', openapi_fixture: 'direct/gemini-tts.md',
149
+ price: price('usd_per_second', 9 * 25 / 1_000_000, true), voices: geminiVoices,
150
+ default_voice: 'Leda', supports: { speed: false, style: true, clone: 'registered' },
151
+ buildPayload: ({ text, voice, style }) => ({ model: 'gemini-3.8-flash-tts', input: [{ type: 'user_input',
152
+ content: [{ type: 'text', text, ...(style ? { annotations: [{ type: 'speech_metadata', style }] } : {}) }] }],
153
+ response_format: { type: 'audio', mime_type: 'audio/l16', sample_rate: 24000 },
154
+ generation_config: { speech_config: [{ voice }] },
155
+ }),
156
+ request: (payload, key) => ({ url: 'https://generativelanguage.googleapis.com/v1beta/interactions', options: {
157
+ method: 'POST', headers: { 'x-goog-api-key': key, 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
158
+ } }),
159
+ parseAudio: parseGeminiAudio,
160
+ },
161
+ ]);
162
+
163
+ export const ttsEngine = id => falTtsEngine(id) ?? DIRECT_TTS_ENGINES.find(engine => engine.id === id);
164
+
165
+ export const MAX_REFERENCE_BYTES = 20 * 1024 * 1024;
166
+
167
+ export function referenceDataUri(bytes) {
168
+ if (!Buffer.isBuffer(bytes)) throw new Error('参照音声のデータが不正です');
169
+ if (bytes.length > MAX_REFERENCE_BYTES) throw new Error('20 MB 超の参照音声は送信できません');
170
+ return `data:audio/wav;base64,${bytes.toString('base64')}`;
171
+ }
172
+
173
+ export function estimateTtsCost(engine, text) {
174
+ if (engine.id === 'fish-s2.1-pro') return Number((Buffer.byteLength(text, 'utf8') * 15 / 1_000_000).toFixed(6));
175
+ const { unit, value } = engine.price;
176
+ if (value == null) return null;
177
+ if (unit === 'usd_per_1000_chars') return Number((text.length * value / 1000).toFixed(6));
178
+ if (unit === 'usd_per_second') return Number((text.length / 5 * value).toFixed(6)); // 日本語約 5 字/秒の概算
179
+ if (unit === 'usd_per_request') return value;
180
+ return null;
181
+ }