diff --git "a/docs/\343\202\250\343\203\263\343\202\270\343\203\263\345\221\250\343\202\212\343\201\253\343\201\244\343\201\204\343\201\246.md" "b/docs/\343\202\250\343\203\263\343\202\270\343\203\263\345\221\250\343\202\212\343\201\253\343\201\244\343\201\204\343\201\246.md" new file mode 100644 index 0000000000..9b8cf8d649 --- /dev/null +++ "b/docs/\343\202\250\343\203\263\343\202\270\343\203\263\345\221\250\343\202\212\343\201\253\343\201\244\343\201\204\343\201\246.md" @@ -0,0 +1,20 @@ +# エンジン周りについて + +VOICEVOXはマルチエンジン機能を介して複数のVOICEVOX API準拠エンジンを扱うことができます。 +それ以外にも多様な概念が存在するため、ここでまとめてドキュメント化しています。 + +## 用語説明 + +| 用語 | 説明 | +| ------------------------- | -------------------------------------------------------------------------------------------- | +| VOICEVOX API 準拠エンジン | VOICEVOX API に準拠したエンジンのこと。 | +| マルチエンジン | エディタ内で複数のエンジンを扱う機能のこと。 | +| デフォルトエンジン | エディタに付属しているエンジンのこと。
デフォルトエンジンがないとエディタが起動しない。 | +| VVPP | VOICEVOXにインストールできるエンジンのパッケージファイル。
エンジンやエンジン情報を含む。 | + + diff --git a/src/backend/electron/engineAndVvppController.ts b/src/backend/electron/engineAndVvppController.ts index e11e280e8d..433984a8b4 100644 --- a/src/backend/electron/engineAndVvppController.ts +++ b/src/backend/electron/engineAndVvppController.ts @@ -1,3 +1,5 @@ +import path from "path"; +import fs from "fs"; import log from "electron-log/main"; import { BrowserWindow, dialog } from "electron"; @@ -12,6 +14,15 @@ import { engineSettingSchema, EngineSettingType, } from "@/type/preload"; +import { + EnginePackage, + fetchLatestDefaultEngineInfo, + getSuitablePackage, +} from "@/domain/defaultEngine/latetDefaultEngine"; +import { + EnvEngineInfoType, + loadEnvEngineInfos, +} from "@/domain/defaultEngine/envEngineInfo"; /** * エンジンとVVPP周りの処理の流れを制御するクラス。 @@ -123,6 +134,98 @@ export class EngineAndVvppController { } } + /** + * インストールが必要なデフォルトエンジンの情報とパッケージの情報を取得する。 + */ + async fetchEngineAndPackageInfosToInstall(): Promise< + { + envEngineInfo: EnvEngineInfoType; + packageInfo: EnginePackage; + }[] + > { + // .envのデフォルトエンジン情報のうち、downloadVvppなものを集める + const targetInfos = []; + for (const envEngineInfo of loadEnvEngineInfos()) { + if (envEngineInfo.type != "downloadVvpp") { + continue; + } + + // 更新情報を取得 + const latestUrl = envEngineInfo.latestUrl; + if (latestUrl == undefined) throw new Error("latestUrl is undefined"); + + const latestInfo = await fetchLatestDefaultEngineInfo(latestUrl); + if (latestInfo.formatVersion != 1) { + log.error(`Unsupported format version: ${latestInfo.formatVersion}`); + continue; + } + + const packageInfo = getSuitablePackage(latestInfo); + log.info(`Latest default engine version: ${packageInfo.version}`); + + // インストール済みだった場合はスキップ + // FIXME: より新しいバージョンであれば更新する + if (this.engineInfoManager.hasEngineInfo(envEngineInfo.uuid)) { + log.info(`Default engine ${envEngineInfo.uuid} is already installed.`); + + // vvppとしてインストールされていない場合は警告を出す + const engineInfo = this.engineInfoManager.fetchEngineInfo( + envEngineInfo.uuid, + ); + if (engineInfo.type != "vvpp") { + log.warn( + `Default engine ${envEngineInfo.uuid} is already installed as "${engineInfo.type}", not "vvpp"`, + ); + } + continue; + } + + targetInfos.push({ + envEngineInfo, + packageInfo, + }); + } + + return targetInfos; + } + + /** VVPPパッケージをダウンロードし、インストールする */ + async downloadAndInstallVvppEngine( + downloadDir: string, + packageInfo: EnginePackage, + ) { + // ダウンロード + const downloadedPaths = new Array(packageInfo.packages.length); + await Promise.all( + packageInfo.packages.map(async (p, index) => { + const { url, name, size } = p; + + log.info(`Download ${name} from ${url}, size: ${size}`); + const res = await fetch(url); + const buffer = await res.arrayBuffer(); + + const downloadPath = path.join(downloadDir, name); + await fs.promises.writeFile(downloadPath, Buffer.from(buffer)); + log.info(`Downloaded ${name} to ${downloadPath}`); + + downloadedPaths[index] = downloadPath; + + // TODO: ハッシュチェック + }), + ); + + // インストール + await this.installVvppEngine(downloadedPaths[0]); + + // ダウンロードしたファイルを削除 + await Promise.all( + downloadedPaths.map(async (path) => { + log.info(`Delete downloaded file: ${path}`); + await fs.promises.unlink(path); + }), + ); + } + /** エンジンの設定を更新し、保存する */ updateEngineSetting(engineId: EngineId, engineSetting: EngineSettingType) { const engineSettings = this.configManager.get("engineSettings"); diff --git a/src/backend/electron/main.ts b/src/backend/electron/main.ts index 6b636c0883..e808403ea1 100644 --- a/src/backend/electron/main.ts +++ b/src/backend/electron/main.ts @@ -939,6 +939,31 @@ app.on("ready", async () => { } } + // VVPPがデフォルトエンジンに指定されていたらインストールする + // NOTE: 工事中。参照: https://github.com/VOICEVOX/voicevox/issues/1194 + const engineAndPackageInfosToInstall = + await engineAndVvppController.fetchEngineAndPackageInfosToInstall(); + for (const { envEngineInfo, packageInfo } of engineAndPackageInfosToInstall) { + // インストールするか確認 + // FIXME: 案内文をよりユーザーフレンドリーにする + const result = dialog.showMessageBoxSync(win, { + type: "info", + title: "デフォルトエンジンのインストール", + message: `デフォルトエンジン ${envEngineInfo.name} をインストールしますか?`, + buttons: ["インストール", "キャンセル"], + cancelId: 1, + }); + if (result == 1) { + continue; + } + + // ダウンロードしてインストールする + await engineAndVvppController.downloadAndInstallVvppEngine( + app.getPath("downloads"), + packageInfo, + ); + } + // runEngineAllの前にVVPPを読み込む let filePath: string | undefined; if (process.platform === "darwin") { diff --git a/src/backend/electron/manager/engineInfoManager.ts b/src/backend/electron/manager/engineInfoManager.ts index 1bc76259e0..bd4328274b 100644 --- a/src/backend/electron/manager/engineInfoManager.ts +++ b/src/backend/electron/manager/engineInfoManager.ts @@ -15,32 +15,82 @@ import { } from "@/type/preload"; import { AltPortInfos } from "@/store/type"; import { BaseConfigManager } from "@/backend/common/ConfigManager"; -import { loadEnvEngineInfos } from "@/domain/defaultEngine/envEngineInfo"; +import { UnreachableError } from "@/type/utility"; +import { + EnvEngineInfoType, + loadEnvEngineInfos, +} from "@/domain/defaultEngine/envEngineInfo"; +import { failure, Result, success } from "@/type/result"; /** * デフォルトエンジンの情報を取得する */ -function fetchDefaultEngineInfos(defaultEngineDir: string): EngineInfo[] { +function fetchDefaultEngineInfos( + envEngineInfos: EnvEngineInfoType[], + defaultEngineDir: string, +): EngineInfo[] { // TODO: envから直接ではなく、envに書いたengine_manifest.jsonから情報を得るようにする - const engines = loadEnvEngineInfos(); - - return engines.map((engineInfo) => { - const { protocol, hostname, port, pathname } = new URL(engineInfo.host); - return { - ...engineInfo, - protocol, - hostname, - defaultPort: port, - pathname: pathname === "/" ? "" : pathname, - isDefault: true, - type: "path", - executionFilePath: path.resolve(engineInfo.executionFilePath), - path: - engineInfo.path == undefined - ? undefined - : path.resolve(defaultEngineDir, engineInfo.path), - } satisfies EngineInfo; - }); + return envEngineInfos + .filter((engineInfo) => engineInfo.type != "downloadVvpp") + .map((engineInfo) => { + if (engineInfo.type == "downloadVvpp") throw new UnreachableError(); + const { protocol, hostname, port, pathname } = new URL(engineInfo.host); + return { + ...engineInfo, + protocol, + hostname, + defaultPort: port, + pathname: pathname === "/" ? "" : pathname, + isDefault: true, + type: engineInfo.type, + executionFilePath: path.resolve(engineInfo.executionFilePath), + path: + engineInfo.path == undefined + ? undefined + : path.resolve(defaultEngineDir, engineInfo.path), + } satisfies EngineInfo; + }); +} + +/** エンジンディレクトリからエンジン情報を読み込む */ +function loadEngineInfo( + engineDir: string, + type: "vvpp" | "path", +): Result { + const manifestPath = path.join(engineDir, "engine_manifest.json"); + if (!fs.existsSync(manifestPath)) { + return failure("manifestNotFound", new Error("manifest not found")); + } + let manifest: MinimumEngineManifestType; + try { + manifest = minimumEngineManifestSchema.parse( + JSON.parse(fs.readFileSync(manifestPath, { encoding: "utf8" })), + ); + } catch (e) { + if (e instanceof Error) { + return failure("manifestParseError", e); + } else { + throw new UnreachableError(); + } + } + + const [command, ...args] = shlex.split(manifest.command); + + const engineInfo = { + uuid: manifest.uuid, + protocol: "http:", + hostname: "127.0.0.1", + defaultPort: manifest.port.toString(), + pathname: "", + name: manifest.name, + path: engineDir, + executionEnabled: true, + executionFilePath: path.join(engineDir, command), + executionArgs: args, + type, + isDefault: false, + } satisfies EngineInfo; + return success(engineInfo); } /** エンジンの情報を管理するクラス */ @@ -52,6 +102,8 @@ export class EngineInfoManager { /** 代替ポート情報 */ public altPortInfos: AltPortInfos = {}; + private envEngineInfos = loadEnvEngineInfos(); + constructor(payload: { configManager: BaseConfigManager; defaultEngineDir: string; @@ -63,43 +115,10 @@ export class EngineInfoManager { } /** - * 追加エンジンの一覧を取得する。 - * FIXME: store.get("registeredEngineDirs")への副作用をEngineManager外に移動する + * VVPPエンジン情報の一覧を取得する。 */ - private fetchAdditionalEngineInfos(): EngineInfo[] { - const engines: EngineInfo[] = []; - const addEngine = (engineDir: string, type: "vvpp" | "path") => { - const manifestPath = path.join(engineDir, "engine_manifest.json"); - if (!fs.existsSync(manifestPath)) { - return "manifestNotFound"; - } - let manifest: MinimumEngineManifestType; - try { - manifest = minimumEngineManifestSchema.parse( - JSON.parse(fs.readFileSync(manifestPath, { encoding: "utf8" })), - ); - } catch (e) { - return "manifestParseError"; - } - - const [command, ...args] = shlex.split(manifest.command); - - engines.push({ - uuid: manifest.uuid, - protocol: "http:", - hostname: "127.0.0.1", - defaultPort: manifest.port.toString(), - pathname: "", - name: manifest.name, - path: engineDir, - executionEnabled: true, - executionFilePath: path.join(engineDir, command), - executionArgs: args, - type, - isDefault: false, - } satisfies EngineInfo); - return "ok"; - }; + private fetchVvppEngineInfos(): EngineInfo[] { + const engineInfos: EngineInfo[] = []; for (const dirName of fs.readdirSync(this.vvppEngineDir)) { const engineDir = path.join(this.vvppEngineDir, dirName); if (!fs.statSync(engineDir).isDirectory()) { @@ -109,16 +128,26 @@ export class EngineInfoManager { if (dirName === ".tmp") { continue; } - const result = addEngine(engineDir, "vvpp"); - if (result !== "ok") { - log.log(`Failed to load engine: ${result}, ${engineDir}`); + const result = loadEngineInfo(engineDir, "vvpp"); + if (!result.ok) { + log.log(`Failed to load engine: ${result.code}, ${engineDir}`); + continue; } + engineInfos.push(result.value); } - // FIXME: この関数の引数でregisteredEngineDirsを受け取り、動かないエンジンをreturnして、EngineManager外でconfig.setする + return engineInfos; + } + + /** + * 設定で登録したエンジン情報を取得する。 + * FIXME: store.get("registeredEngineDirs")への副作用をEngineManager外に移動する + */ + private fetchRegisteredEngineInfos(): EngineInfo[] { + const engineInfos: EngineInfo[] = []; for (const engineDir of this.configManager.get("registeredEngineDirs")) { - const result = addEngine(engineDir, "path"); - if (result !== "ok") { - log.log(`Failed to load engine: ${result}, ${engineDir}`); + const result = loadEngineInfo(engineDir, "path"); + if (!result.ok) { + log.log(`Failed to load engine: ${result.code}, ${engineDir}`); // 動かないエンジンは追加できないので削除 // FIXME: エンジン管理UIで削除可能にする dialog.showErrorBox( @@ -131,19 +160,49 @@ export class EngineInfoManager { .get("registeredEngineDirs") .filter((p) => p !== engineDir), ); + continue; } + engineInfos.push(result.value); } - return engines; + return engineInfos; + } + + /** + * 追加エンジンの一覧を取得する。 + */ + private fetchAdditionalEngineInfos(): EngineInfo[] { + return [ + ...this.fetchVvppEngineInfos(), + ...this.fetchRegisteredEngineInfos(), + ]; } /** * 全てのエンジンの一覧を取得する。デフォルトエンジン+追加エンジン。 */ fetchEngineInfos(): EngineInfo[] { + // TOOD: vvpp内にあるもの含むデフォルトエンジン一覧と、デフォルトエンジン以外の追加エンジン一覧を取得する関数に分ける + // ↑のためには、VVPP情報一覧関数と、追加ディレクトリエンジン一覧にわけ、デフォルトエンジン一覧側はVVPP情報一覧を使うようにし、更に別で追加エンジン一覧関数を作る const engineInfos = [ - ...fetchDefaultEngineInfos(this.defaultEngineDir), + ...fetchDefaultEngineInfos(this.envEngineInfos, this.defaultEngineDir), ...this.fetchAdditionalEngineInfos(), ]; + + // 追加エンジンがダウンロードしたデフォルトエンジンと同じなら、デフォルトエンジンとして扱う + const targetEngineUuids = this.envEngineInfos + .filter((e) => e.type == "downloadVvpp") + .map((e) => e.uuid); + for (const engineInfo of engineInfos) { + if (targetEngineUuids.includes(engineInfo.uuid)) { + if (engineInfo.type != "vvpp") { + log.warn( + `Engine ${engineInfo.uuid} is same as default engine, but type is "${engineInfo.type}"`, + ); + } + engineInfo.isDefault = true; + } + } + return engineInfos; } @@ -161,6 +220,14 @@ export class EngineInfoManager { return engineInfo; } + /** + * 指定したエンジンの情報が存在するかどうかを判定する。 + */ + hasEngineInfo(engineId: EngineId): boolean { + const engineInfos = this.fetchEngineInfos(); + return engineInfos.some((engineInfo) => engineInfo.uuid === engineId); + } + /** * エンジンのディレクトリを取得する。存在しない場合はエラーを返す。 */ diff --git a/src/domain/defaultEngine/envEngineInfo.ts b/src/domain/defaultEngine/envEngineInfo.ts index 3003f70c62..5eb102d4e8 100644 --- a/src/domain/defaultEngine/envEngineInfo.ts +++ b/src/domain/defaultEngine/envEngineInfo.ts @@ -11,10 +11,12 @@ export const envEngineInfoSchema = z.object({ uuid: engineIdSchema, host: z.string(), name: z.string(), - executionEnabled: z.boolean(), - executionFilePath: z.string(), + executionEnabled: z.boolean(), // FIXME: typeがurlのときのみ必要 + executionFilePath: z.string(), // FIXME: typeがpathのときは必須 executionArgs: z.array(z.string()), - path: z.string().optional(), + path: z.string().optional(), // FIXME: typeがpathで、アンインストール可能なときは必須 + type: z.union([z.literal("path"), z.literal("downloadVvpp")]).default("path"), + latestUrl: z.string().optional(), // FIXME: typeがdownloadVvppのときは必須 }); export type EnvEngineInfoType = z.infer; diff --git a/src/domain/defaultEngine/latetDefaultEngine.ts b/src/domain/defaultEngine/latetDefaultEngine.ts index 6c6637e959..a87c04dee3 100644 --- a/src/domain/defaultEngine/latetDefaultEngine.ts +++ b/src/domain/defaultEngine/latetDefaultEngine.ts @@ -4,47 +4,76 @@ import { z } from "zod"; -/** パッケージ(vvppやvvppp1ファイル)ごとのスキーマ */ -const defaultEnginePackageSchema = z.object({ - url: z.string(), - name: z.string(), - size: z.number(), - hash: z.string().optional(), -}); - -/** デバイスごとのスキーマ */ -const defaultEngineDeviceSchema = z.object({ +/** パッケージ情報のスキーマ */ +const enginePackageSchema = z.object({ version: z.string(), - packages: z.array(defaultEnginePackageSchema), + packages: z + .object({ + url: z.string(), + name: z.string(), + size: z.number(), + hash: z.string().optional(), + }) + .array(), }); +export type EnginePackage = z.infer; /** デフォルトエンジンの更新情報のスキーマ */ -const defaultEngineInfosSchema = z.object({ +const latestDefaultEngineInfoSchema = z.object({ formatVersion: z.number(), windows: z.object({ x64: z.object({ - CPU: defaultEngineDeviceSchema, - "GPU/CPU": defaultEngineDeviceSchema, + CPU: enginePackageSchema, + "GPU/CPU": enginePackageSchema, }), }), macos: z.object({ x64: z.object({ - CPU: defaultEngineDeviceSchema, + CPU: enginePackageSchema, }), arm64: z.object({ - CPU: defaultEngineDeviceSchema, + CPU: enginePackageSchema, }), }), linux: z.object({ x64: z.object({ - CPU: defaultEngineDeviceSchema, - "GPU/CPU": defaultEngineDeviceSchema, + CPU: enginePackageSchema, + "GPU/CPU": enginePackageSchema, }), }), }); /** デフォルトエンジンの更新情報を取得する */ -export const fetchDefaultEngineInfos = async (url: string) => { +export const fetchLatestDefaultEngineInfo = async (url: string) => { const response = await fetch(url); - return defaultEngineInfosSchema.parse(await response.json()); + return latestDefaultEngineInfoSchema.parse(await response.json()); +}; + +/** + * 実行環境に合うパッケージを取得する。GPU版があればGPU版を返す。 + * FIXME: どのデバイス版にするかはユーザーが選べるようにするべき。 + */ +export const getSuitablePackage = ( + updateInfo: z.infer, +): EnginePackage => { + const platform = process.platform; + const arch = process.arch; + + if (platform === "win32") { + if (arch === "x64") { + return updateInfo.windows.x64["GPU/CPU"]; + } + } else if (platform === "darwin") { + if (arch === "x64") { + return updateInfo.macos.x64.CPU; + } else if (arch === "arm64") { + return updateInfo.macos.arm64.CPU; + } + } else if (platform === "linux") { + if (arch === "x64") { + return updateInfo.linux.x64["GPU/CPU"]; + } + } + + throw new Error(`Unsupported platform: ${platform} ${arch}`); }; diff --git a/tests/unit/domain/defaultEngine/defaultEngine.node.spec.ts b/tests/unit/domain/defaultEngine/defaultEngine.node.spec.ts index 0e49596d76..a6b76c168d 100644 --- a/tests/unit/domain/defaultEngine/defaultEngine.node.spec.ts +++ b/tests/unit/domain/defaultEngine/defaultEngine.node.spec.ts @@ -2,11 +2,11 @@ import path from "path"; import fs from "fs"; -import { fetchDefaultEngineInfos } from "@/domain/defaultEngine/latetDefaultEngine"; +import { fetchLatestDefaultEngineInfo } from "@/domain/defaultEngine/latetDefaultEngine"; const currentDir = "tests/unit/domain/defaultEngine"; -test("fetchDefaultEngineInfos", async () => { +test("fetchLatestDefaultEngineInfo", async () => { // テスト用のjsonファイルでfetchをモックする // 元ファイルは https://raw.githubusercontent.com/VOICEVOX/voicevox_blog/master/src/generateLatestDefaultEngineInfos.ts const p = path.resolve(currentDir, "latestDefaultEngineInfos.json"); @@ -14,7 +14,7 @@ test("fetchDefaultEngineInfos", async () => { const spy = vi.spyOn(global, "fetch").mockResolvedValue(new Response(json)); // 読み込めることを確認 - const infos = await fetchDefaultEngineInfos("https://example.com/"); + const infos = await fetchLatestDefaultEngineInfo("https://example.com/"); expect(infos.formatVersion).toBe(1); spy.mockRestore();