Resolve a queued voice to its speaker by alias, not just exact name
Every item in Deal Hunter's voice_queue.json had failed with
error: "'primary_name'". _speaker_info matched only an exact primary_name,
so any other spelling returned {} and build_profile_from_speaker subscripts
that key directly - the job died before synthesising a single sample and the
reviewer saw only an error tag.
That is a live trap for Save & Re-voice specifically: renaming a speaker and
then re-voicing it leaves the queued name matching nothing.
- Case-insensitive, whitespace-tolerant match, then an alias pass.
- An unknown name now raises LookupError naming the speaker, instead of a
KeyError about a dictionary key three frames away.
Fixed in the queue layer rather than voice_profile_manager.py, which has 6
pre-existing failures in that area and should not be disturbed by this.
8 new tests. Suite: 698 passed, 8 pre-existing.
This commit is contained in:
80
tests/test_voice_queue_lookup.py
Normal file
80
tests/test_voice_queue_lookup.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Regression tests for voice-queue speaker lookup.
|
||||
|
||||
Every queued item in a real project's voice_queue.json had failed with
|
||||
``error: "'primary_name'"``. ``_speaker_info`` matched only an exact
|
||||
``primary_name``, so any other spelling returned ``{}``, and
|
||||
``build_profile_from_speaker`` subscripts that key directly - the job died
|
||||
before a single sample was synthesised and the reviewer just saw an error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from umbraaudio.voices.voice_queue import VoiceQueue
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def queue(tmp_path: Path) -> VoiceQueue:
|
||||
data_dir = tmp_path / ".umbra"
|
||||
data_dir.mkdir(parents=True)
|
||||
(data_dir / "speakers.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"speakers": [
|
||||
{"primary_name": "Dealer", "aliases": ["Trader Sela"]},
|
||||
{"primary_name": "The Sergeant", "aliases": []},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
return VoiceQueue(data_dir, config=None)
|
||||
|
||||
|
||||
def test_exact_primary_name_matches(queue: VoiceQueue) -> None:
|
||||
assert queue._speaker_info("Dealer")["primary_name"] == "Dealer"
|
||||
|
||||
|
||||
def test_case_insensitive_match(queue: VoiceQueue) -> None:
|
||||
assert queue._speaker_info("dealer")["primary_name"] == "Dealer"
|
||||
|
||||
|
||||
def test_surrounding_whitespace_is_ignored(queue: VoiceQueue) -> None:
|
||||
assert queue._speaker_info(" Dealer ")["primary_name"] == "Dealer"
|
||||
|
||||
|
||||
def test_alias_resolves_to_its_speaker(queue: VoiceQueue) -> None:
|
||||
"""A stale label or a name the reviewer used should still find the voice."""
|
||||
assert queue._speaker_info("Trader Sela")["primary_name"] == "Dealer"
|
||||
|
||||
|
||||
def test_unknown_speaker_returns_empty(queue: VoiceQueue) -> None:
|
||||
assert queue._speaker_info("Nobody At All") == {}
|
||||
|
||||
|
||||
def test_missing_speakers_file_does_not_raise(tmp_path: Path) -> None:
|
||||
data_dir = tmp_path / ".umbra"
|
||||
data_dir.mkdir(parents=True)
|
||||
q = VoiceQueue(data_dir, config=None)
|
||||
assert q._speaker_info("Anyone") == {}
|
||||
|
||||
|
||||
def test_corrupt_speakers_json_does_not_raise(tmp_path: Path) -> None:
|
||||
data_dir = tmp_path / ".umbra"
|
||||
data_dir.mkdir(parents=True)
|
||||
(data_dir / "speakers.json").write_text("{ not json")
|
||||
q = VoiceQueue(data_dir, config=None)
|
||||
assert q._speaker_info("Anyone") == {}
|
||||
|
||||
|
||||
def test_lookup_never_yields_a_dict_without_primary_name(queue: VoiceQueue) -> None:
|
||||
"""The invariant that was violated: whatever we return must be buildable.
|
||||
|
||||
``build_profile_from_speaker`` subscripts ``primary_name``, so returning a
|
||||
speaker-less dict turns into a KeyError inside the worker.
|
||||
"""
|
||||
for name in ("Dealer", "dealer", "Trader Sela", "The Sergeant"):
|
||||
info = queue._speaker_info(name)
|
||||
assert "primary_name" in info, f"{name!r} returned a dict with no primary_name"
|
||||
@@ -259,6 +259,12 @@ class VoiceQueue:
|
||||
try:
|
||||
with gm.serialize("voices", 1, f"voice:{item.speaker}"):
|
||||
speaker_info = self._speaker_info(item.speaker)
|
||||
if not speaker_info:
|
||||
# Name the real problem instead of letting the profile
|
||||
# builder fail later on a missing 'primary_name' key.
|
||||
raise LookupError(
|
||||
f"no speaker named {item.speaker!r} in this project"
|
||||
)
|
||||
profile = get_profile_for_speaker(
|
||||
item.speaker, speaker_info, self._data_dir, force_rebuild=True
|
||||
)
|
||||
@@ -294,7 +300,17 @@ class VoiceQueue:
|
||||
data = _json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
for speaker in data.get("speakers", []):
|
||||
if speaker.get("primary_name") == name:
|
||||
speakers = data.get("speakers", [])
|
||||
wanted = (name or "").strip().casefold()
|
||||
for speaker in speakers:
|
||||
if str(speaker.get("primary_name", "")).strip().casefold() == wanted:
|
||||
return speaker
|
||||
# An alias or a stale label is common: a speaker renamed after being
|
||||
# queued, or a tag the reviewer used. Matching only the exact
|
||||
# primary_name sent those jobs straight into a KeyError, which surfaced
|
||||
# in the UI as "error: 'primary_name'" and no regenerated voice at all.
|
||||
for speaker in speakers:
|
||||
for alias in speaker.get("aliases", []) or []:
|
||||
if str(alias).strip().casefold() == wanted:
|
||||
return speaker
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user