soulsync/core/matching/audio_verification.py
dev cf0a17c14a feat(verification): shared normalize() core for import + scan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:27:28 +02:00

59 lines
2.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Shared audio-verification decision core (pure; no file/DB I/O).
Single source of truth for normalization + the PASS/SKIP/FAIL decision used by
BOTH import-time verification (``core/acoustid_verification.py``) and the library
scan (``core/repair_jobs/acoustid_scanner.py``). Historically each path had its
own ``_normalize`` and decision branches that drifted apart and produced
inconsistent results (a correct cross-script anime-OST track passed at import but
was false-flagged by the scan). Centralising the decision here means the
thresholds, normalization, alias-aware comparison, cross-script handling, version
gate and duration guard are defined exactly once.
"""
import re
from difflib import SequenceMatcher
# Thresholds — the single definition both paths share.
MIN_ACOUSTID_SCORE = 0.80 # Minimum fingerprint score to trust a match.
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity to consider a match.
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity to consider a match.
def normalize(text: str) -> str:
"""Normalize a title/artist for comparison.
lowercase; strip ``()`` / ``[]`` / ``<>`` annotations (version tags,
performer credits like ``<Vocal: MIKA KOBAYASHI>``); strip trailing
version / featuring tags; KEEP CJK characters (``\\w`` is unicode-aware) so
Japanese/Chinese/Korean titles produce a comparable form instead of an empty
string; collapse whitespace.
"""
if not text:
return ""
s = text.lower().strip()
# Annotations that are metadata, not core identity.
s = re.sub(r'\s*\([^)]*\)', '', s)
s = re.sub(r'\s*\[[^\]]*\]', '', s)
s = re.sub(r'\s*<[^>]*>', '', s)
# Trailing featuring / version tags.
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
s = re.sub(
r'\s*-\s*(?:vocal|instrumental|acoustic|live|remix|cover|clean|explicit|'
r'radio\s*edit|original\s*mix|extended\s*mix|club\s*mix)\s*$',
'', s, flags=re.IGNORECASE,
)
s = re.sub(r'\s*-\s*from\s+.+$', '', s, flags=re.IGNORECASE)
# Drop remaining punctuation but keep word chars (incl. CJK) + spaces.
s = re.sub(r'[^\w\s]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def similarity(a: str, b: str) -> float:
"""Similarity (0.01.0) between two strings after normalization."""
na, nb = normalize(a), normalize(b)
if not na or not nb:
return 0.0
if na == nb:
return 1.0
return SequenceMatcher(None, na, nb).ratio()