Skip to content

Commit

Permalink
ENH: Windowsでエンジンの多重起動を可能にする (#1514)
Browse files Browse the repository at this point in the history
Co-authored-by: Hiroshiba <[email protected]>
Co-authored-by: Hiroshiba Kazuyuki <[email protected]>
  • Loading branch information
3 people authored Jan 15, 2025
1 parent 1db9ede commit 81163c4
Show file tree
Hide file tree
Showing 8 changed files with 86 additions and 68 deletions.
10 changes: 5 additions & 5 deletions poetry.lock

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

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ uvicorn = "^0.32.1"
soundfile = "^0.12.1"
pyyaml = "^6.0.1"
pyworld = "^0.3.0"
pyopenjtalk = { git = "https://github.com/VOICEVOX/pyopenjtalk", rev = "b35fc89fe42948a28e33aed886ea145a51113f88" }
pyopenjtalk = { git = "https://github.com/VOICEVOX/pyopenjtalk", rev = "0fcb731c94555e8d160d18e7f1a4d005b2e8e852" }
semver = "^3.0.0"
platformdirs = "^4.2.0"
soxr = "^0.5.0"
Expand Down
2 changes: 1 addition & 1 deletion requirements-build.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "3.12"
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "3.12"
pyinstaller-hooks-contrib==2024.10 ; python_version >= "3.11" and python_version < "3.12"
pyinstaller==5.13.2 ; python_version >= "3.11" and python_version < "3.12"
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@b35fc89fe42948a28e33aed886ea145a51113f88 ; python_version >= "3.11" and python_version < "3.12"
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@0fcb731c94555e8d160d18e7f1a4d005b2e8e852 ; python_version >= "3.11" and python_version < "3.12"
python-multipart==0.0.17 ; python_version >= "3.11" and python_version < "3.12"
pywin32-ctypes==0.2.3 ; python_version >= "3.11" and python_version < "3.12" and sys_platform == "win32"
pyworld==0.3.4 ; python_version >= "3.11" and python_version < "3.12"
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "3.12"
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "3.12"
pyflakes==3.2.0 ; python_version >= "3.11" and python_version < "3.12"
pygments==2.18.0 ; python_version >= "3.11" and python_version < "3.12"
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@b35fc89fe42948a28e33aed886ea145a51113f88 ; python_version >= "3.11" and python_version < "3.12"
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@0fcb731c94555e8d160d18e7f1a4d005b2e8e852 ; python_version >= "3.11" and python_version < "3.12"
pyproject-hooks==1.2.0 ; python_version >= "3.11" and python_version < "3.12"
pysen==0.11.0 ; python_version >= "3.11" and python_version < "3.12"
pytest==8.3.3 ; python_version >= "3.11" and python_version < "3.12"
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ platformdirs==4.3.6 ; python_version >= "3.11" and python_version < "3.12"
pycparser==2.22 ; python_version >= "3.11" and python_version < "3.12"
pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "3.12"
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "3.12"
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@b35fc89fe42948a28e33aed886ea145a51113f88 ; python_version >= "3.11" and python_version < "3.12"
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@0fcb731c94555e8d160d18e7f1a4d005b2e8e852 ; python_version >= "3.11" and python_version < "3.12"
python-multipart==0.0.17 ; python_version >= "3.11" and python_version < "3.12"
pyworld==0.3.4 ; python_version >= "3.11" and python_version < "3.12"
pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "3.12"
Expand Down
1 change: 0 additions & 1 deletion test/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ def app_params(tmp_path: Path) -> dict[str, Any]:
user_dict = UserDictionary(
default_dict_path=_copy_under_dir(DEFAULT_DICT_PATH, tmp_path),
user_dict_path=_generate_user_dict(tmp_path),
compiled_dict_path=tmp_path / "user.dic",
)

engine_manifest = load_manifest(engine_manifest_path())
Expand Down
53 changes: 11 additions & 42 deletions test/unit/user_dict/test_user_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,7 @@ def test_create_word() -> None:
def test_apply_word_without_json(tmp_path: Path) -> None:

user_dict = UserDictionary(
user_dict_path=tmp_path / "test_apply_word_without_json.json",
compiled_dict_path=tmp_path / "test_apply_word_without_json.dic",
user_dict_path=tmp_path / "test_apply_word_without_json.json"
)
user_dict.apply_word(
WordProperty(surface="test", pronunciation="テスト", accent_type=1)
Expand All @@ -119,10 +118,7 @@ def test_apply_word_with_json(tmp_path: Path) -> None:
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path,
compiled_dict_path=tmp_path / "test_apply_word_with_json.dic",
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.apply_word(
WordProperty(surface="test2", pronunciation="テストツー", accent_type=3)
)
Expand All @@ -141,10 +137,7 @@ def test_rewrite_word_invalid_id(tmp_path: Path) -> None:
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path,
compiled_dict_path=(tmp_path / "test_rewrite_word_invalid_id.dic"),
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
with pytest.raises(UserDictInputError):
user_dict.rewrite_word(
"c2be4dc5-d07d-4767-8be1-04a1bb3f05a9",
Expand All @@ -157,10 +150,7 @@ def test_rewrite_word_valid_id(tmp_path: Path) -> None:
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path,
compiled_dict_path=tmp_path / "test_rewrite_word_valid_id.dic",
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.rewrite_word(
"aab7dda2-0d97-43c8-8cb7-3f440dab9b4e",
WordProperty(surface="test2", pronunciation="テストツー", accent_type=2),
Expand All @@ -178,10 +168,7 @@ def test_delete_word_invalid_id(tmp_path: Path) -> None:
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path,
compiled_dict_path=tmp_path / "test_delete_word_invalid_id.dic",
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
with pytest.raises(UserDictInputError):
user_dict.delete_word(word_uuid="c2be4dc5-d07d-4767-8be1-04a1bb3f05a9")

Expand All @@ -191,10 +178,7 @@ def test_delete_word_valid_id(tmp_path: Path) -> None:
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path,
compiled_dict_path=tmp_path / "test_delete_word_valid_id.dic",
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.delete_word(word_uuid="aab7dda2-0d97-43c8-8cb7-3f440dab9b4e")
assert len(user_dict.read_dict()) == 0

Expand All @@ -218,13 +202,10 @@ def test_priority() -> None:

def test_import_dict(tmp_path: Path) -> None:
user_dict_path = tmp_path / "test_import_dict.json"
compiled_dict_path = tmp_path / "test_import_dict.dic"
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.import_user_dict(
{"b1affe2a-d5f0-4050-926c-f28e0c1d9a98": import_word}, override=False
)
Expand All @@ -236,13 +217,10 @@ def test_import_dict(tmp_path: Path) -> None:

def test_import_dict_no_override(tmp_path: Path) -> None:
user_dict_path = tmp_path / "test_import_dict_no_override.json"
compiled_dict_path = tmp_path / "test_import_dict_no_override.dic"
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.import_user_dict(
{"aab7dda2-0d97-43c8-8cb7-3f440dab9b4e": import_word}, override=False
)
Expand All @@ -253,13 +231,10 @@ def test_import_dict_no_override(tmp_path: Path) -> None:

def test_import_dict_override(tmp_path: Path) -> None:
user_dict_path = tmp_path / "test_import_dict_override.json"
compiled_dict_path = tmp_path / "test_import_dict_override.dic"
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.import_user_dict(
{"aab7dda2-0d97-43c8-8cb7-3f440dab9b4e": import_word}, override=True
)
Expand All @@ -268,15 +243,12 @@ def test_import_dict_override(tmp_path: Path) -> None:

def test_import_invalid_word(tmp_path: Path) -> None:
user_dict_path = tmp_path / "test_import_invalid_dict.json"
compiled_dict_path = tmp_path / "test_import_invalid_dict.dic"
invalid_accent_associative_rule_word = deepcopy(import_word)
invalid_accent_associative_rule_word.accent_associative_rule = "invalid"
user_dict_path.write_text(
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
)
user_dict = UserDictionary(
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
with pytest.raises(AssertionError):
user_dict.import_user_dict(
{
Expand All @@ -299,10 +271,7 @@ def test_import_invalid_word(tmp_path: Path) -> None:

def test_update_dict(tmp_path: Path) -> None:
user_dict_path = tmp_path / "test_update_dict.json"
compiled_dict_path = tmp_path / "test_update_dict.dic"
user_dict = UserDictionary(
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
)
user_dict = UserDictionary(user_dict_path=user_dict_path)
user_dict.update_dict()
test_text = "テスト用の文字列"
success_pronunciation = "デフォルトノジショデハゼッタイニセイセイサレナイヨミ"
Expand Down
82 changes: 66 additions & 16 deletions voicevox_engine/user_dict/user_dict_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ def func(*args: Any, **kw: Any) -> Any:
# デフォルトのファイルパス
DEFAULT_DICT_PATH: Final = resource_dir / "default.csv" # VOICEVOXデフォルト辞書
_USER_DICT_PATH: Final = save_dir / "user_dict.json" # ユーザー辞書
_COMPILED_DICT_PATH: Final = save_dir / "user.dic" # コンパイル済み辞書


# 同時書き込みの制御
Expand All @@ -61,14 +60,69 @@ def func(*args: Any, **kw: Any) -> Any:
_save_format_dict_adapter = TypeAdapter(dict[str, SaveFormatUserDictWord])


def _delete_file_on_close(file_path: Path) -> None:
"""
ファイルのハンドルが全て閉じたときにファイルを削除する。OpenJTalk用のカスタム辞書用。
WindowsではCreateFileW関数で`FILE_FLAG_DELETE_ON_CLOSE`を付けてすぐに閉じることで、
`FILE_SHARE_DELETE`を付けて開かれているファイルのハンドルが全て閉じた時に削除されるようにする。
Windows以外では即座にファイルを削除する。
"""
if sys.platform == "win32":
import ctypes
from ctypes.wintypes import DWORD, HANDLE, LPCWSTR

_CreateFileW = ctypes.windll.kernel32.CreateFileW
_CreateFileW.argtypes = [
LPCWSTR,
DWORD,
DWORD,
ctypes.c_void_p,
DWORD,
DWORD,
HANDLE,
]
_CreateFileW.restype = HANDLE
_CloseHandle = ctypes.windll.kernel32.CloseHandle
_CloseHandle.argtypes = [HANDLE]

_FILE_SHARE_DELETE = 0x00000004
_FILE_SHARE_READ = 0x00000001
_OPEN_EXISTING = 3
_FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
_INVALID_HANDLE_VALUE = HANDLE(-1).value

h_file = _CreateFileW(
str(file_path),
0,
_FILE_SHARE_DELETE | _FILE_SHARE_READ,
None,
_OPEN_EXISTING,
_FILE_FLAG_DELETE_ON_CLOSE,
None,
)
if h_file == _INVALID_HANDLE_VALUE:
raise RuntimeError(
f"Failed to CreateFileW for {file_path}"
) from ctypes.WinError()

result = _CloseHandle(h_file)
if result == 0:
raise RuntimeError(
f"Failed to CloseHandle for {file_path}"
) from ctypes.WinError()
else:
file_path.unlink()


class UserDictionary:
"""ユーザー辞書"""

def __init__(
self,
default_dict_path: Path = DEFAULT_DICT_PATH,
user_dict_path: Path = _USER_DICT_PATH,
compiled_dict_path: Path = _COMPILED_DICT_PATH,
) -> None:
"""
Parameters
Expand All @@ -77,12 +131,9 @@ def __init__(
デフォルト辞書ファイルのパス
user_dict_path : Path
ユーザー辞書ファイルのパス
compiled_dict_path : Path
コンパイル済み辞書ファイルのパス
"""
self._default_dict_path = default_dict_path
self._user_dict_path = user_dict_path
self._compiled_dict_path = compiled_dict_path
self.update_dict()

@mutex_wrapper(mutex_user_dict)
Expand All @@ -99,14 +150,14 @@ def _write_to_json(self, user_dict: dict[str, UserDictWord]) -> None:
def update_dict(self) -> None:
"""辞書を更新する。"""
default_dict_path = self._default_dict_path
compiled_dict_path = self._compiled_dict_path
user_dict_path = self._user_dict_path

random_string = uuid4()
tmp_csv_path = compiled_dict_path.with_suffix(
f".dict_csv-{random_string}.tmp"
tmp_csv_path = user_dict_path.with_name(
f"user.dict_csv-{random_string}.tmp"
) # csv形式辞書データの一時保存ファイル
tmp_compiled_path = compiled_dict_path.with_suffix(
f".dict_compiled-{random_string}.tmp"
tmp_compiled_path = user_dict_path.with_name(
f"user.dict_compiled-{random_string}.tmp"
) # コンパイル済み辞書データの一時保存ファイル

try:
Expand Down Expand Up @@ -157,11 +208,10 @@ def update_dict(self) -> None:
if not tmp_compiled_path.is_file():
raise RuntimeError("辞書のコンパイル時にエラーが発生しました。")

# コンパイル済み辞書の置き換え・読み込み
pyopenjtalk.unset_user_dict()
tmp_compiled_path.replace(compiled_dict_path)
if compiled_dict_path.is_file():
pyopenjtalk.set_user_dict(str(compiled_dict_path.resolve(strict=True)))
# コンパイル済み辞書の読み込み
pyopenjtalk.set_user_dict(
str(tmp_compiled_path.resolve(strict=True))
) # NOTE: resolveによりコンパイル実行時でも相対パスを正しく認識できる

except Exception as e:
print("Error: Failed to update dictionary.", file=sys.stderr)
Expand All @@ -172,7 +222,7 @@ def update_dict(self) -> None:
if tmp_csv_path.exists():
tmp_csv_path.unlink()
if tmp_compiled_path.exists():
tmp_compiled_path.unlink()
_delete_file_on_close(tmp_compiled_path)

@mutex_wrapper(mutex_user_dict)
def read_dict(self) -> dict[str, UserDictWord]:
Expand Down

0 comments on commit 81163c4

Please sign in to comment.