Remove the dead narrator profiles; add --force for decisions a scan cannot make
The operator confirmed Narratorold and OrigNarrator are dead profiles. --force exists for exactly this: a decision the scan is not entitled to make. It prints the overridden conflict before acting, and never deletes a reference file. Applied to Last Dwarf: profiles 93 -> 91 approved voices 13 -> 13 (unchanged) voice ref files 72 -> 72 (unchanged, both WAVs left on disk) dialogue rows labelled 'narrator' 3 -> 0 (now the approved Narrator) narration rows labelled 'narrator' 5978 -> 5978 (untouched) The dead names survive as aliases of Narrator so no stray utterance can resurrect a profile, and the 3 re-pointed rows are a genuine attribution fix: ch5 utt26 and ch27 utt125 are narration mis-segmented as dialogue; ch42 utt290 is a first-person line that may be intended inner thought. Rescan: 10 auto, 2 review, 3 blocked, unresolved spoken names 3 -> 2. Full suite 575 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
This commit is contained in:
@@ -82,24 +82,62 @@ def _print_report(facts, groups, unresolved) -> None: # noqa: ANN001
|
||||
"-> exists only as a differently-cased profile")
|
||||
|
||||
|
||||
def _apply(data_dir: Path, spec: str) -> int:
|
||||
"""``"Survivor <- A, B"``"""
|
||||
def _apply(data_dir: Path, spec: str, force: bool) -> int:
|
||||
"""``"Survivor <- A, B"``. ``force`` overrides the conflicting-voice refusal."""
|
||||
if "<-" not in spec:
|
||||
print("--apply needs: 'Survivor <- A, B'", file=sys.stderr)
|
||||
return 1
|
||||
survivor, rest = spec.split("<-", 1)
|
||||
survivors = survivor.strip()
|
||||
survivor = survivor.strip()
|
||||
duplicates = [d.strip() for d in rest.split(",") if d.strip()]
|
||||
|
||||
if force:
|
||||
# A human has decided these are dead profiles or the same person, so the
|
||||
# conflicting-voice guard is deliberately bypassed. Print exactly what is being
|
||||
# overridden so the decision is on the record.
|
||||
from umbraaudio.analysis.orphan_resolution import merge_speakers
|
||||
|
||||
facts = load_speaker_facts(data_dir)
|
||||
clashes = [
|
||||
f"{name}={f.ref_sha256}"
|
||||
for name in duplicates
|
||||
if (f := facts.get(name)) and f.has_ref and f.ref_sha256
|
||||
]
|
||||
if clashes:
|
||||
print(f"FORCED: overriding conflicting voice references: {', '.join(clashes)}")
|
||||
print(" those reference FILES are left untouched on disk.")
|
||||
db = sqlite3.connect(str(data_dir / "project.db"))
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
for name in duplicates:
|
||||
if name in profile_names(data_dir):
|
||||
r = merge_speakers(db, data_dir, name, survivor)
|
||||
print(f" merged profile {name!r} into {survivor!r} "
|
||||
f"({r.get('utterances_changed', 0)} utt re-pointed)")
|
||||
else:
|
||||
cur = db.execute(
|
||||
"UPDATE utterances SET speaker_name=? "
|
||||
"WHERE speaker_name=? AND lower(type)='dialogue'",
|
||||
(survivor, name),
|
||||
)
|
||||
print(f" re-pointed spoken-only {name!r} -> {survivor!r} "
|
||||
f"({cur.rowcount or 0} dialogue utt)")
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
db = sqlite3.connect(str(data_dir / "project.db"))
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
result = merge_group(db, data_dir, survivors, duplicates)
|
||||
result = merge_group(db, data_dir, survivor, duplicates)
|
||||
finally:
|
||||
db.close()
|
||||
if not result.get("ok"):
|
||||
print(f"REFUSED: {result.get('reason')}", file=sys.stderr)
|
||||
for detail in result.get("detail", []) or []:
|
||||
print(f" {detail}", file=sys.stderr)
|
||||
print(" re-run with --force if you have decided this by hand", file=sys.stderr)
|
||||
return 2
|
||||
print(f"merged into {result['survivor']}: "
|
||||
f"{', '.join(result['merged'] + result['repointed_only'])}")
|
||||
@@ -118,6 +156,9 @@ def main() -> int:
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--verdict", choices=("auto", "review", "blocked"), default=None)
|
||||
parser.add_argument("--apply", default=None, metavar="'Survivor <- A, B'")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="override the conflicting-voice refusal; use when a human "
|
||||
"has already decided (e.g. dead profiles)")
|
||||
parser.add_argument("--apply-unresolved", action="store_true",
|
||||
help="re-point case-variant spoken names at their profile")
|
||||
args = parser.parse_args()
|
||||
@@ -126,7 +167,7 @@ def main() -> int:
|
||||
data_dir = cfg.data_dir
|
||||
|
||||
if args.apply:
|
||||
return _apply(data_dir, args.apply)
|
||||
return _apply(data_dir, args.apply, args.force)
|
||||
|
||||
if args.apply_unresolved:
|
||||
facts = load_speaker_facts(data_dir)
|
||||
|
||||
@@ -145,12 +145,51 @@ python scripts/scan_speaker_duplicates.py --project "Last Dwarf" --apply-unresol
|
||||
- A merged speaker's `chapters_appeared` widens, and the loser's name becomes an alias, so
|
||||
the merge is reversible by editing one JSON file.
|
||||
|
||||
## 9. Open, for the operator
|
||||
## 9. Applied 2026-10-03
|
||||
|
||||
The operator confirmed `Narratorold` and `OrigNarrator` are dead profiles. Applied with the
|
||||
new `--force` flag, which exists precisely for a decision a scan cannot make — it prints the
|
||||
overridden conflict before acting and never deletes a reference file:
|
||||
|
||||
```
|
||||
FORCED: overriding conflicting voice references: Narratorold=7e8a91ac2ff2936e,
|
||||
OrigNarrator=27572e3f16e1cfc7
|
||||
those reference FILES are left untouched on disk.
|
||||
merged profile 'Narratorold' into 'Narrator' (0 utt re-pointed)
|
||||
merged profile 'OrigNarrator' into 'Narrator' (0 utt re-pointed)
|
||||
re-pointed spoken-only 'narrator' -> 'Narrator' (3 dialogue utt)
|
||||
```
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| profiles | 93 | **91** |
|
||||
| approved voices | 13 | **13** |
|
||||
| voice ref files | 72 | **72** |
|
||||
| dialogue rows labelled lowercase `narrator` | 3 | **0** |
|
||||
| narration rows labelled lowercase `narrator` | 5978 | 5978 (untouched) |
|
||||
|
||||
The two dead names survive as aliases of `Narrator` (`narrator`, `the narrator`,
|
||||
`Narratorold`, `OrigNarrator`), so no stray utterance can resurrect a profile. Their two
|
||||
voice reference WAVs are **left on disk** — deleting a generated binary was not asked for.
|
||||
|
||||
The 3 re-pointed rows are a real attribution fix, not cosmetic: ch5 utt26, ch27 utt125 and
|
||||
ch42 utt290 were dialogue attributed to `narrator`. ch5 utt26 and ch27 utt125 read as
|
||||
narration mis-segmented as dialogue; ch42 utt290 is a first-person line that may be intended
|
||||
inner thought. All three now resolve to the approved `Narrator`.
|
||||
|
||||
Rescan after the change: **10 auto, 2 review, 3 blocked**, 2 unresolved names (down from 3).
|
||||
|
||||
## 10. Open, for the operator
|
||||
|
||||
1. **The Girl.** `The Girl` and `The Girl helping` are the same person with two generated
|
||||
voices. Which ref survives — or regenerate one voice for both?
|
||||
2. **Officer.** Same question, 24/4 against 5/2.
|
||||
3. **Narrator.** `Narrator` (approved) versus `Narratorold` and `OrigNarrator` — three refs.
|
||||
Are the latter two dead profiles from an earlier book state?
|
||||
4. **Guard 1.** Merge `The first guard` in, or are they two different guards?
|
||||
5. **The Big One / The Biggest One.** Merge cosmetically, or leave both?
|
||||
3. **Guard 1.** Merge `The first guard` in, or are they two different guards?
|
||||
4. **The Big One / The Biggest One.** Merge cosmetically, or leave both?
|
||||
|
||||
### Not duplicates, but worth knowing
|
||||
|
||||
Two voice reference files have no matching profile: `narrator2_ref.wav` and
|
||||
`rieland_ref.wav`. `Rieland` looks like a misspelling of the profile `Rielan` — a voice
|
||||
generated under a typo'd name and never wired to a speaker. Not a merge case; a prune-or-adopt
|
||||
decision. `manifest.md` already records orphan ref files as a known condition.
|
||||
|
||||
Reference in New Issue
Block a user