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:
B.D. Murphy
2026-10-11 21:00:43 -07:00
parent 4ac0388d7b
commit 44d77cdc9b
2 changed files with 98 additions and 2 deletions

View 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"

View File

@@ -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 {}