diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9da639..bbd3237 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.20.44] — 2026-08-19
+
+### Fixed
+- **Voice metadata is now written atomically.** Interrupted saves cannot leave truncated `.meta.json` files that make ratings or voice settings disappear.
+
## [1.20.43] — 2026-08-19
### Added
diff --git a/VERSION b/VERSION
index ce07dea..6a36890 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.20.43
+1.20.44
diff --git a/core/voice.py b/core/voice.py
index 17463cf..2f85be5 100644
--- a/core/voice.py
+++ b/core/voice.py
@@ -2,8 +2,10 @@
from __future__ import annotations
import json
+import os
import re
import shutil
+import tempfile
import time
import uuid
from datetime import datetime, timezone
@@ -119,7 +121,24 @@ def _load_meta(wav: Path) -> dict:
def _save_meta(wav: Path, meta: dict) -> None:
- _meta_path(wav).write_text(json.dumps(meta, indent=2))
+ # Write beside the final file and replace it atomically. A process exit or
+ # host/container interruption during write must never leave a truncated
+ # meta.json that makes ratings/settings appear to have vanished.
+ target = _meta_path(wav)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ json.dump(meta, handle, indent=2, ensure_ascii=False)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(tmp_name, target)
+ finally:
+ try:
+ os.unlink(tmp_name)
+ except FileNotFoundError:
+ pass
def _picture_path(wav: Path) -> Path | None:
diff --git a/static/index.html b/static/index.html
index cf584f1..d68fdac 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
-
+
@@ -27,7 +27,7 @@
-
+
@@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
-
+