Skip to content

Commit 81163c4

Browse files
ENH: Windowsでエンジンの多重起動を可能にする (#1514)
Co-authored-by: Hiroshiba <[email protected]> Co-authored-by: Hiroshiba Kazuyuki <[email protected]>
1 parent 1db9ede commit 81163c4

File tree

8 files changed

+86
-68
lines changed

8 files changed

+86
-68
lines changed

poetry.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ uvicorn = "^0.32.1"
5252
soundfile = "^0.12.1"
5353
pyyaml = "^6.0.1"
5454
pyworld = "^0.3.0"
55-
pyopenjtalk = { git = "https://github.com/VOICEVOX/pyopenjtalk", rev = "b35fc89fe42948a28e33aed886ea145a51113f88" }
55+
pyopenjtalk = { git = "https://github.com/VOICEVOX/pyopenjtalk", rev = "0fcb731c94555e8d160d18e7f1a4d005b2e8e852" }
5656
semver = "^3.0.0"
5757
platformdirs = "^4.2.0"
5858
soxr = "^0.5.0"

requirements-build.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "3.12"
2020
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "3.12"
2121
pyinstaller-hooks-contrib==2024.10 ; python_version >= "3.11" and python_version < "3.12"
2222
pyinstaller==5.13.2 ; python_version >= "3.11" and python_version < "3.12"
23-
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@b35fc89fe42948a28e33aed886ea145a51113f88 ; python_version >= "3.11" and python_version < "3.12"
23+
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@0fcb731c94555e8d160d18e7f1a4d005b2e8e852 ; python_version >= "3.11" and python_version < "3.12"
2424
python-multipart==0.0.17 ; python_version >= "3.11" and python_version < "3.12"
2525
pywin32-ctypes==0.2.3 ; python_version >= "3.11" and python_version < "3.12" and sys_platform == "win32"
2626
pyworld==0.3.4 ; python_version >= "3.11" and python_version < "3.12"

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "3.12"
7474
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "3.12"
7575
pyflakes==3.2.0 ; python_version >= "3.11" and python_version < "3.12"
7676
pygments==2.18.0 ; python_version >= "3.11" and python_version < "3.12"
77-
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@b35fc89fe42948a28e33aed886ea145a51113f88 ; python_version >= "3.11" and python_version < "3.12"
77+
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@0fcb731c94555e8d160d18e7f1a4d005b2e8e852 ; python_version >= "3.11" and python_version < "3.12"
7878
pyproject-hooks==1.2.0 ; python_version >= "3.11" and python_version < "3.12"
7979
pysen==0.11.0 ; python_version >= "3.11" and python_version < "3.12"
8080
pytest==8.3.3 ; python_version >= "3.11" and python_version < "3.12"

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ platformdirs==4.3.6 ; python_version >= "3.11" and python_version < "3.12"
1414
pycparser==2.22 ; python_version >= "3.11" and python_version < "3.12"
1515
pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "3.12"
1616
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "3.12"
17-
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@b35fc89fe42948a28e33aed886ea145a51113f88 ; python_version >= "3.11" and python_version < "3.12"
17+
pyopenjtalk @ git+https://github.com/VOICEVOX/pyopenjtalk@0fcb731c94555e8d160d18e7f1a4d005b2e8e852 ; python_version >= "3.11" and python_version < "3.12"
1818
python-multipart==0.0.17 ; python_version >= "3.11" and python_version < "3.12"
1919
pyworld==0.3.4 ; python_version >= "3.11" and python_version < "3.12"
2020
pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "3.12"

test/e2e/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ def app_params(tmp_path: Path) -> dict[str, Any]:
4444
user_dict = UserDictionary(
4545
default_dict_path=_copy_under_dir(DEFAULT_DICT_PATH, tmp_path),
4646
user_dict_path=_generate_user_dict(tmp_path),
47-
compiled_dict_path=tmp_path / "user.dic",
4847
)
4948

5049
engine_manifest = load_manifest(engine_manifest_path())

test/unit/user_dict/test_user_dict.py

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ def test_create_word() -> None:
9898
def test_apply_word_without_json(tmp_path: Path) -> None:
9999

100100
user_dict = UserDictionary(
101-
user_dict_path=tmp_path / "test_apply_word_without_json.json",
102-
compiled_dict_path=tmp_path / "test_apply_word_without_json.dic",
101+
user_dict_path=tmp_path / "test_apply_word_without_json.json"
103102
)
104103
user_dict.apply_word(
105104
WordProperty(surface="test", pronunciation="テスト", accent_type=1)
@@ -119,10 +118,7 @@ def test_apply_word_with_json(tmp_path: Path) -> None:
119118
user_dict_path.write_text(
120119
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
121120
)
122-
user_dict = UserDictionary(
123-
user_dict_path=user_dict_path,
124-
compiled_dict_path=tmp_path / "test_apply_word_with_json.dic",
125-
)
121+
user_dict = UserDictionary(user_dict_path=user_dict_path)
126122
user_dict.apply_word(
127123
WordProperty(surface="test2", pronunciation="テストツー", accent_type=3)
128124
)
@@ -141,10 +137,7 @@ def test_rewrite_word_invalid_id(tmp_path: Path) -> None:
141137
user_dict_path.write_text(
142138
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
143139
)
144-
user_dict = UserDictionary(
145-
user_dict_path=user_dict_path,
146-
compiled_dict_path=(tmp_path / "test_rewrite_word_invalid_id.dic"),
147-
)
140+
user_dict = UserDictionary(user_dict_path=user_dict_path)
148141
with pytest.raises(UserDictInputError):
149142
user_dict.rewrite_word(
150143
"c2be4dc5-d07d-4767-8be1-04a1bb3f05a9",
@@ -157,10 +150,7 @@ def test_rewrite_word_valid_id(tmp_path: Path) -> None:
157150
user_dict_path.write_text(
158151
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
159152
)
160-
user_dict = UserDictionary(
161-
user_dict_path=user_dict_path,
162-
compiled_dict_path=tmp_path / "test_rewrite_word_valid_id.dic",
163-
)
153+
user_dict = UserDictionary(user_dict_path=user_dict_path)
164154
user_dict.rewrite_word(
165155
"aab7dda2-0d97-43c8-8cb7-3f440dab9b4e",
166156
WordProperty(surface="test2", pronunciation="テストツー", accent_type=2),
@@ -178,10 +168,7 @@ def test_delete_word_invalid_id(tmp_path: Path) -> None:
178168
user_dict_path.write_text(
179169
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
180170
)
181-
user_dict = UserDictionary(
182-
user_dict_path=user_dict_path,
183-
compiled_dict_path=tmp_path / "test_delete_word_invalid_id.dic",
184-
)
171+
user_dict = UserDictionary(user_dict_path=user_dict_path)
185172
with pytest.raises(UserDictInputError):
186173
user_dict.delete_word(word_uuid="c2be4dc5-d07d-4767-8be1-04a1bb3f05a9")
187174

@@ -191,10 +178,7 @@ def test_delete_word_valid_id(tmp_path: Path) -> None:
191178
user_dict_path.write_text(
192179
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
193180
)
194-
user_dict = UserDictionary(
195-
user_dict_path=user_dict_path,
196-
compiled_dict_path=tmp_path / "test_delete_word_valid_id.dic",
197-
)
181+
user_dict = UserDictionary(user_dict_path=user_dict_path)
198182
user_dict.delete_word(word_uuid="aab7dda2-0d97-43c8-8cb7-3f440dab9b4e")
199183
assert len(user_dict.read_dict()) == 0
200184

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

219203
def test_import_dict(tmp_path: Path) -> None:
220204
user_dict_path = tmp_path / "test_import_dict.json"
221-
compiled_dict_path = tmp_path / "test_import_dict.dic"
222205
user_dict_path.write_text(
223206
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
224207
)
225-
user_dict = UserDictionary(
226-
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
227-
)
208+
user_dict = UserDictionary(user_dict_path=user_dict_path)
228209
user_dict.import_user_dict(
229210
{"b1affe2a-d5f0-4050-926c-f28e0c1d9a98": import_word}, override=False
230211
)
@@ -236,13 +217,10 @@ def test_import_dict(tmp_path: Path) -> None:
236217

237218
def test_import_dict_no_override(tmp_path: Path) -> None:
238219
user_dict_path = tmp_path / "test_import_dict_no_override.json"
239-
compiled_dict_path = tmp_path / "test_import_dict_no_override.dic"
240220
user_dict_path.write_text(
241221
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
242222
)
243-
user_dict = UserDictionary(
244-
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
245-
)
223+
user_dict = UserDictionary(user_dict_path=user_dict_path)
246224
user_dict.import_user_dict(
247225
{"aab7dda2-0d97-43c8-8cb7-3f440dab9b4e": import_word}, override=False
248226
)
@@ -253,13 +231,10 @@ def test_import_dict_no_override(tmp_path: Path) -> None:
253231

254232
def test_import_dict_override(tmp_path: Path) -> None:
255233
user_dict_path = tmp_path / "test_import_dict_override.json"
256-
compiled_dict_path = tmp_path / "test_import_dict_override.dic"
257234
user_dict_path.write_text(
258235
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
259236
)
260-
user_dict = UserDictionary(
261-
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
262-
)
237+
user_dict = UserDictionary(user_dict_path=user_dict_path)
263238
user_dict.import_user_dict(
264239
{"aab7dda2-0d97-43c8-8cb7-3f440dab9b4e": import_word}, override=True
265240
)
@@ -268,15 +243,12 @@ def test_import_dict_override(tmp_path: Path) -> None:
268243

269244
def test_import_invalid_word(tmp_path: Path) -> None:
270245
user_dict_path = tmp_path / "test_import_invalid_dict.json"
271-
compiled_dict_path = tmp_path / "test_import_invalid_dict.dic"
272246
invalid_accent_associative_rule_word = deepcopy(import_word)
273247
invalid_accent_associative_rule_word.accent_associative_rule = "invalid"
274248
user_dict_path.write_text(
275249
json.dumps(valid_dict_dict_json, ensure_ascii=False), encoding="utf-8"
276250
)
277-
user_dict = UserDictionary(
278-
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
279-
)
251+
user_dict = UserDictionary(user_dict_path=user_dict_path)
280252
with pytest.raises(AssertionError):
281253
user_dict.import_user_dict(
282254
{
@@ -299,10 +271,7 @@ def test_import_invalid_word(tmp_path: Path) -> None:
299271

300272
def test_update_dict(tmp_path: Path) -> None:
301273
user_dict_path = tmp_path / "test_update_dict.json"
302-
compiled_dict_path = tmp_path / "test_update_dict.dic"
303-
user_dict = UserDictionary(
304-
user_dict_path=user_dict_path, compiled_dict_path=compiled_dict_path
305-
)
274+
user_dict = UserDictionary(user_dict_path=user_dict_path)
306275
user_dict.update_dict()
307276
test_text = "テスト用の文字列"
308277
success_pronunciation = "デフォルトノジショデハゼッタイニセイセイサレナイヨミ"

voicevox_engine/user_dict/user_dict_manager.py

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ def func(*args: Any, **kw: Any) -> Any:
5050
# デフォルトのファイルパス
5151
DEFAULT_DICT_PATH: Final = resource_dir / "default.csv" # VOICEVOXデフォルト辞書
5252
_USER_DICT_PATH: Final = save_dir / "user_dict.json" # ユーザー辞書
53-
_COMPILED_DICT_PATH: Final = save_dir / "user.dic" # コンパイル済み辞書
5453

5554

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

6362

63+
def _delete_file_on_close(file_path: Path) -> None:
64+
"""
65+
ファイルのハンドルが全て閉じたときにファイルを削除する。OpenJTalk用のカスタム辞書用。
66+
67+
WindowsではCreateFileW関数で`FILE_FLAG_DELETE_ON_CLOSE`を付けてすぐに閉じることで、
68+
`FILE_SHARE_DELETE`を付けて開かれているファイルのハンドルが全て閉じた時に削除されるようにする。
69+
70+
Windows以外では即座にファイルを削除する。
71+
"""
72+
if sys.platform == "win32":
73+
import ctypes
74+
from ctypes.wintypes import DWORD, HANDLE, LPCWSTR
75+
76+
_CreateFileW = ctypes.windll.kernel32.CreateFileW
77+
_CreateFileW.argtypes = [
78+
LPCWSTR,
79+
DWORD,
80+
DWORD,
81+
ctypes.c_void_p,
82+
DWORD,
83+
DWORD,
84+
HANDLE,
85+
]
86+
_CreateFileW.restype = HANDLE
87+
_CloseHandle = ctypes.windll.kernel32.CloseHandle
88+
_CloseHandle.argtypes = [HANDLE]
89+
90+
_FILE_SHARE_DELETE = 0x00000004
91+
_FILE_SHARE_READ = 0x00000001
92+
_OPEN_EXISTING = 3
93+
_FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
94+
_INVALID_HANDLE_VALUE = HANDLE(-1).value
95+
96+
h_file = _CreateFileW(
97+
str(file_path),
98+
0,
99+
_FILE_SHARE_DELETE | _FILE_SHARE_READ,
100+
None,
101+
_OPEN_EXISTING,
102+
_FILE_FLAG_DELETE_ON_CLOSE,
103+
None,
104+
)
105+
if h_file == _INVALID_HANDLE_VALUE:
106+
raise RuntimeError(
107+
f"Failed to CreateFileW for {file_path}"
108+
) from ctypes.WinError()
109+
110+
result = _CloseHandle(h_file)
111+
if result == 0:
112+
raise RuntimeError(
113+
f"Failed to CloseHandle for {file_path}"
114+
) from ctypes.WinError()
115+
else:
116+
file_path.unlink()
117+
118+
64119
class UserDictionary:
65120
"""ユーザー辞書"""
66121

67122
def __init__(
68123
self,
69124
default_dict_path: Path = DEFAULT_DICT_PATH,
70125
user_dict_path: Path = _USER_DICT_PATH,
71-
compiled_dict_path: Path = _COMPILED_DICT_PATH,
72126
) -> None:
73127
"""
74128
Parameters
@@ -77,12 +131,9 @@ def __init__(
77131
デフォルト辞書ファイルのパス
78132
user_dict_path : Path
79133
ユーザー辞書ファイルのパス
80-
compiled_dict_path : Path
81-
コンパイル済み辞書ファイルのパス
82134
"""
83135
self._default_dict_path = default_dict_path
84136
self._user_dict_path = user_dict_path
85-
self._compiled_dict_path = compiled_dict_path
86137
self.update_dict()
87138

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

104155
random_string = uuid4()
105-
tmp_csv_path = compiled_dict_path.with_suffix(
106-
f".dict_csv-{random_string}.tmp"
156+
tmp_csv_path = user_dict_path.with_name(
157+
f"user.dict_csv-{random_string}.tmp"
107158
) # csv形式辞書データの一時保存ファイル
108-
tmp_compiled_path = compiled_dict_path.with_suffix(
109-
f".dict_compiled-{random_string}.tmp"
159+
tmp_compiled_path = user_dict_path.with_name(
160+
f"user.dict_compiled-{random_string}.tmp"
110161
) # コンパイル済み辞書データの一時保存ファイル
111162

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

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

166216
except Exception as e:
167217
print("Error: Failed to update dictionary.", file=sys.stderr)
@@ -172,7 +222,7 @@ def update_dict(self) -> None:
172222
if tmp_csv_path.exists():
173223
tmp_csv_path.unlink()
174224
if tmp_compiled_path.exists():
175-
tmp_compiled_path.unlink()
225+
_delete_file_on_close(tmp_compiled_path)
176226

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

0 commit comments

Comments
 (0)