Skip to content

Commit

Permalink
Fix: マルチエンジン有効時も AivisSpeech Engine 向けに拡張された品詞情報がユーザー辞書に保持されるよう修正
Browse files Browse the repository at this point in the history
今までは登録されている全てのエンジンの内容をごちゃ混ぜにマージしたものを全エンジンに適用する形で実装されていたようだが、よく考えたらユーザー辞書の最終更新日時といったフィールドがない以上値が競合した際にどちらの値を優先すべきか判断できず厳密な双方向同期は実装できない(=今の実装だとどのみち VOICEVOX 側で編集した場合に AivisSpeech 側の編集内容がロールバックされる可能性がある)ことに気づいた…
原則 VVPP で導入する前提の元、追加エンジン側の同じ辞書エントリへの変更は無視する(新しい辞書エントリのみ追加エンジン側から受け入れる)部分的な片方向増分同期としたことで、少なくとも AivisSpeech ユーザーの意図に反する挙動は回避できるようになっているはず(結構面倒で時間を使ってしまった…)
  • Loading branch information
tsukumijima committed Jan 6, 2025
1 parent f068694 commit 93e56bc
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 56 deletions.
2 changes: 1 addition & 1 deletion openapi.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/openapi/apis/DefaultApi.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

166 changes: 113 additions & 53 deletions src/store/dictionary.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createPartialStore } from "./vuex";
import { UserDictWord, UserDictWordToJSON } from "@/openapi";
import { DictionaryStoreState, DictionaryStoreTypes } from "@/store/type";
import { EngineId } from "@/type/preload";

export const dictionaryStoreState: DictionaryStoreState = {};

Expand Down Expand Up @@ -34,21 +33,50 @@ export const dictionaryStore = createPartialStore<DictionaryStoreTypes>({
},

LOAD_ALL_USER_DICT: {
async action({ actions, state }) {
async action({ actions, getters }) {
// 事前にソート済みのエンジンの ID リストを取得する (AivisSpeech Engine が常に先頭に来るようにする)
const engineIds = getters.GET_SORTED_ENGINE_INFOS.map((engine) => engine.uuid);
const defaultEngineId = getters.DEFAULT_ENGINE_ID;
const allDict = await Promise.all(
state.engineIds.map((engineId) => {
engineIds.map((engineId) => {
return actions.LOAD_USER_DICT({ engineId });
}),
);

// デフォルトエンジンの辞書をベースにする
const defaultEngineIndex = engineIds.indexOf(defaultEngineId);
if (defaultEngineIndex === -1) {
throw new Error("Default engine not found");
}
const defaultDict = allDict[defaultEngineIndex];
const mergedDictMap = new Map<string, [string, UserDictWord]>();
for (const dict of allDict) {
const usedPairs = new Set<string>(); // 使用済みの読み-表層形ペアを記録

// まずデフォルトエンジンの辞書をすべて登録
for (const [id, dictItem] of Object.entries(defaultDict)) {
const pairKey = `${dictItem.yomi}-${dictItem.surface}`;
mergedDictMap.set(id, [id, dictItem]);
usedPairs.add(pairKey);
}

// 追加エンジンからは新規項目のみを追加
// 厳密な双方向同期は最終更新タイムスタンプなどのフィールドがない以上実装不可能ため、
// 同じ項目があって値だけ追加エンジン側で異なる場合は、常にデフォルトエンジンの値を優先する仕様とした
for (let i = 0; i < allDict.length; i++) {
if (i === defaultEngineIndex) continue; // デフォルトエンジンはスキップ
const dict = allDict[i];
for (const [id, dictItem] of Object.entries(dict)) {
mergedDictMap.set(`${dictItem.yomi}-${dictItem.surface}`, [
id,
dictItem,
]);
const pairKey = `${dictItem.yomi}-${dictItem.surface}`;
// 既存のエントリがない場合のみ追加(新規項目)
// ただし、その読み-表層形ペアが既に使用されている場合は追加しない
if (!mergedDictMap.has(id) && !usedPairs.has(pairKey)) {
mergedDictMap.set(id, [id, dictItem]);
usedPairs.add(pairKey);
}
}
}

// 50音順でソート
const mergedDict = [...mergedDictMap.values()];
mergedDict.sort((a, b) => {
if (a[1].yomi > b[1].yomi) {
Expand All @@ -63,17 +91,14 @@ export const dictionaryStore = createPartialStore<DictionaryStoreTypes>({

ADD_WORD: {
async action(
{ state, actions },
{ actions, getters },
{ surface, pronunciation, accentType, wordType, priority },
) {
// 同じ単語IDで登録するために、1つのエンジンで登録したあと全エンジンに同期する。
const engineId: EngineId | undefined = state.engineIds[0];

if (engineId == undefined)
throw new Error("No such engine registered: index == 0");
// 同じ単語 ID で登録するために、まずデフォルトエンジン (AivisSpeech Engine) に追加したあと、他の全エンジンに同期する
const defaultEngineId = getters.DEFAULT_ENGINE_ID;
await actions
.INSTANTIATE_ENGINE_CONNECTOR({
engineId,
engineId: defaultEngineId,
})
.then((instance) =>
instance.invoke("addUserDictWordUserDictWordPost")({
Expand All @@ -85,33 +110,38 @@ export const dictionaryStore = createPartialStore<DictionaryStoreTypes>({
}),
);

// 変更を他の全エンジンに同期する
await actions.SYNC_ALL_USER_DICT();
},
},

REWRITE_WORD: {
async action(
{ state, actions },
{ state, actions, getters },
{ wordUuid, surface, pronunciation, accentType, wordType, priority },
) {
if (state.engineIds.length === 0)
throw new Error("At least one engine must be registered");
for (const engineId of state.engineIds) {
await actions
.INSTANTIATE_ENGINE_CONNECTOR({
engineId,
})
.then((instance) =>
instance.invoke("rewriteUserDictWordUserDictWordWordUuidPut")({
wordUuid,
surface,
pronunciation,
accentType,
wordType,
priority,
}),
);
}

// 実装のシンプル化のため、まずデフォルトエンジン (AivisSpeech Engine) で更新したあと、他の全エンジンに同期する
const defaultEngineId = getters.DEFAULT_ENGINE_ID;
await actions
.INSTANTIATE_ENGINE_CONNECTOR({
engineId: defaultEngineId,
})
.then((instance) =>
instance.invoke("rewriteUserDictWordUserDictWordWordUuidPut")({
wordUuid,
surface,
pronunciation,
accentType,
wordType,
priority,
}),
);

// 変更を他の全エンジンに同期する
await actions.SYNC_ALL_USER_DICT();
},
},

Expand All @@ -134,10 +164,19 @@ export const dictionaryStore = createPartialStore<DictionaryStoreTypes>({
},

SYNC_ALL_USER_DICT: {
async action({ actions, state }) {
async action({ actions, getters }) {
const defaultEngineId = getters.DEFAULT_ENGINE_ID;
// 事前にソート済みのエンジンの ID リストを取得する (AivisSpeech Engine が常に先頭に来るようにする)
const engineIds = getters.GET_SORTED_ENGINE_INFOS.map((engine) => engine.uuid);
// エンジン ID が1個のみ (= AivisSpeech Engine のみの場合) は何もしない
if (engineIds.length === 1) return;

// 全エンジンの現在のユーザー辞書をマージする
const mergedDict = await actions.LOAD_ALL_USER_DICT();
for (const engineId of state.engineIds) {
// エンジンの辞書のIDリストを取得する。

// マージした辞書を各エンジンにインポートする
for (const engineId of engineIds) {
// エンジンの辞書の ID リストを取得する
const dictIdSet = await actions
.INSTANTIATE_ENGINE_CONNECTOR({
engineId,
Expand All @@ -150,24 +189,45 @@ export const dictionaryStore = createPartialStore<DictionaryStoreTypes>({
),
),
);
if (Object.keys(mergedDict).some((id) => !dictIdSet.has(id))) {
await actions
.INSTANTIATE_ENGINE_CONNECTOR({
engineId,
})
.then((instance) =>
// マージした辞書をエンジンにインポートする。
instance.invoke("importUserDictWordsImportUserDictPost")({
override: true,
requestBody: Object.fromEntries(
Object.entries(mergedDict).map(([k, v]) => [
k,
UserDictWordToJSON(v),
]),
),
}),
);
}
// if (Object.keys(mergedDict).some((id) => !dictIdSet.has(id))) {
await actions
.INSTANTIATE_ENGINE_CONNECTOR({
engineId,
})
.then((instance) =>
// マージした辞書をエンジンにインポートする。
instance.invoke("importUserDictWordsImportUserDictPost")({
override: true,
requestBody: Object.fromEntries(
Object.entries(mergedDict).map(([k, v]) => [
k,
(() => {
// AivisSpeech Engine の場合はそのままインポートする
if (engineId === defaultEngineId) {
return UserDictWordToJSON(v) as { [key: string]: string };
}
// (API 互換性対策) AivisSpeech Engine 以外のエンジンへのインポートで、かつ固有名詞の場合は、
// context_id: 1348 、partOfSpeechDetail1: "固有名詞" に、partOfSpeechDetail2: "一般" 、partOfSpeechDetail3: "*" に固定する
if (v.partOfSpeech !== "名詞" || v.partOfSpeechDetail1 !== "固有名詞") {
return UserDictWordToJSON(v) as { [key: string]: string }; // 固有名詞でない場合はそのままインポートする
}
return UserDictWordToJSON({
...v,
contextId: 1348, // 名詞,固有名詞,一般,* の OpenJTalk 上での文脈 ID
partOfSpeechDetail1: "固有名詞",
partOfSpeechDetail2: "一般",
partOfSpeechDetail3: "*",
}) as { [key: string]: string };
// 通常は OpenAPI クライアント側で camelCase から snake_case に変換されるはずだが、
// この API の requestBody ではなぜかその変換が行われないため、手動で snake_case に変換している
// これにより型が合わなくなるため、やむを得ず any を付与している
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})() as any,
]),
),
}),
);
// }
const removedDictIdSet = new Set(dictIdSet);
// マージされた辞書にあるIDを削除する。
// これにより、マージ処理で削除された項目のIDが残る。
Expand Down

0 comments on commit 93e56bc

Please sign in to comment.