akari-video 0.1.82 → 0.1.84
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.
- package/package.json +1 -1
- package/src/narration-command.mjs +179 -84
- package/src/tts-engines.mjs +181 -0
- package/src/voice-command.mjs +130 -20
- package/vendor/.akari-capability-sources.json +1 -0
- package/vendor/docs/contract-2026-09-02-item-caption-anchor-v0.md +8 -4
- package/vendor/docs/contract-2026-09-13-generation-v0.md +5 -2
- package/vendor/docs/contract-2026-09-24-caption-runs-v0.md +40 -0
- package/vendor/docs/contract-2026-09-24-style-v0.md +23 -3
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/creator-root/src/index.mjs +18 -0
- package/vendor/packages/creator-root/test/direct-tts-providers.test.mjs +19 -0
- package/vendor/packages/edit-lint/src/edit-lint.mjs +69 -1
- package/vendor/packages/edit-lint/src/engine-capabilities.json +1 -1
- package/vendor/packages/edit-store/lib/caption-display.d.ts +4 -0
- package/vendor/packages/edit-store/lib/caption-display.js +50 -2
- package/vendor/packages/edit-store/lib/caption-runs.d.ts +64 -0
- package/vendor/packages/edit-store/lib/caption-runs.js +320 -0
- package/vendor/packages/edit-store/lib/caption-store.d.ts +42 -1
- package/vendor/packages/edit-store/lib/caption-store.js +93 -4
- package/vendor/packages/edit-store/lib/caption-window.d.ts +4 -0
- package/vendor/packages/edit-store/lib/caption-window.js +6 -0
- package/vendor/packages/edit-store/lib/caption-words-rederive.d.ts +3 -0
- package/vendor/packages/edit-store/lib/caption-words-rederive.js +20 -1
- package/vendor/packages/edit-store/lib/edit-v2.d.ts +17 -9
- package/vendor/packages/edit-store/lib/edit-v2.js +38 -2
- package/vendor/packages/edit-store/lib/generated/edit-v2-keys.d.ts +1 -1
- package/vendor/packages/edit-store/lib/generated/edit-v2-keys.js +1 -0
- package/vendor/packages/edit-store/lib/index.d.ts +1 -0
- package/vendor/packages/edit-store/lib/index.js +1 -0
- package/vendor/packages/edit-store/lib/item-anchor.d.ts +4 -3
- package/vendor/packages/edit-store/lib/item-anchor.js +27 -5
- package/vendor/packages/edit-store/lib/tree-ops.js +2 -0
- package/vendor/packages/edit-store/lib/webview-kernel.js +63 -7
- package/vendor/packages/schemas/captions.schema.json +29 -1
- package/vendor/packages/schemas/edit.schema.json +12 -0
- package/vendor/packages/schemas/engine-capabilities.json +1 -1
- package/vendor/packages/schemas/test/engine-capabilities.test.mjs +8 -0
- package/vendor/skills/generate-narration/SKILL.md +3 -2
- package/vendor/skills/generate-narration/engines.md +44 -8
- package/vendor/skills/manage-connections/SKILL.md +7 -0
- package/vendor/skills/manage-connections/bin/doctor.mjs +4 -0
- 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.
|
|
3
|
+
"version": "0.1.84",
|
|
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 =
|
|
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", "
|
|
309
|
-
throw new PublicError(
|
|
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 ??=
|
|
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
|
-
|
|
320
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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())
|
|
453
|
-
if (
|
|
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
|
|
460
|
-
const payload = buildFalPayload(readingText, meta);
|
|
471
|
+
async function synthesizeDirect(engine, payload, key) {
|
|
461
472
|
let response;
|
|
462
|
-
try {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
990
|
-
|
|
1052
|
+
const engine = ttsEngine(options.engine);
|
|
1053
|
+
const estimatedCostUsd = estimateTtsCost(engine, readingText);
|
|
991
1054
|
costUsd = estimatedCostUsd;
|
|
992
|
-
io.logError(`${options.engine}
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
|
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
|
+
}
|