diff --git a/app/audio_cleaning.py b/app/audio_cleaning.py index 8cec167..29953bf 100644 --- a/app/audio_cleaning.py +++ b/app/audio_cleaning.py @@ -315,8 +315,8 @@ def clean_wav_files( denoise_applied = snr_db < 20.0 if denoise_applied: samples = _spectral_denoise(samples, voice_start, voice_end) - # <8 dB il rumore è troppo alto - riprova - warnings.append("low_snr_retry_recommended") + if snr_db < 8.0: + warnings.append("low_snr_retry_recommended") # 7. rimozione pezzi senza voce, lasciando 120 ms di padding per testa e coda padding = round(PADDING_MS * TARGET_SAMPLE_RATE / 1000) diff --git a/app/redis_bus.py b/app/redis_bus.py index 6d48478..d6418a7 100644 --- a/app/redis_bus.py +++ b/app/redis_bus.py @@ -64,33 +64,6 @@ def probe_capabilities( ) -def get_json(key: str, *, redis_client: Redis | None = None) -> Any | None: - """Read a disposable JSON cache value.""" - cache = redis_client or client - cached = cache.get(key) - if cached is None: - return None - if isinstance(cached, bytes): - cached = cached.decode("utf-8") - return json.loads(cached) - - -def set_json( - key: str, - value: Any, - *, - ttl_seconds: int, - redis_client: Redis | None = None, -) -> None: - """Write a JSON cache value with a mandatory finite lifetime.""" - if ttl_seconds <= 0: - raise ValueError("ttl_seconds must be positive") - cache = redis_client or client - cache.setex( - key, - ttl_seconds, - json.dumps(value, ensure_ascii=False), - ) def publish_audio_frame( diff --git a/app/sentence_repository.py b/app/sentence_repository.py deleted file mode 100644 index b149d04..0000000 --- a/app/sentence_repository.py +++ /dev/null @@ -1,137 +0,0 @@ -"""SQLite persistence for annotated known-sentence benchmarks.""" - -from __future__ import annotations - -import io -import json -from typing import Any - -import numpy as np - -from app.database import db_connection -from app.sentence_streaming import SentenceBenchmark, SentenceUnit -from app.temporal_features import FEATURE_DIMENSION - - -def _encode(values: np.ndarray) -> bytes: - buffer = io.BytesIO() - np.save(buffer, np.asarray(values, dtype=np.float32), allow_pickle=False) - return buffer.getvalue() - - -def _decode(blob: bytes) -> np.ndarray: - values = np.load(io.BytesIO(blob), allow_pickle=False) - if values.ndim != 2 or values.shape[1] != FEATURE_DIMENSION: - raise ValueError("invalid stored sentence features") - return values.astype(np.float32, copy=False) - - -def store_sentence_benchmark( - sentence_id: str, - text: str, - language: str, - locale: str, - reference: np.ndarray, - units: tuple[SentenceUnit, ...], - *, - error_threshold: float, - final_threshold: float, - version: str = "1", -) -> int: - payload = [ - {"text": unit.text, "start_ms": unit.start_ms, "end_ms": unit.end_ms} - for unit in units - ] - with db_connection() as database: - database.execute("BEGIN IMMEDIATE") - database.execute( - "UPDATE sentence_benchmarks SET active = 0 WHERE sentence_id = ?", - (sentence_id,), - ) - database.execute( - """ - INSERT INTO sentence_benchmarks ( - sentence_id, text, language, locale, units_json, - reference_blob, feature_dimension, error_threshold, - final_threshold, version - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(sentence_id, version) DO UPDATE SET - text = excluded.text, - language = excluded.language, - locale = excluded.locale, - units_json = excluded.units_json, - reference_blob = excluded.reference_blob, - feature_dimension = excluded.feature_dimension, - error_threshold = excluded.error_threshold, - final_threshold = excluded.final_threshold, - active = 1 - """, - ( - sentence_id, text, language, locale, - json.dumps(payload, ensure_ascii=False), _encode(reference), - FEATURE_DIMENSION, error_threshold, final_threshold, version, - ), - ) - row = database.execute( - """ - SELECT id FROM sentence_benchmarks - WHERE sentence_id = ? AND version = ? - """, - (sentence_id, version), - ).fetchone() - database.execute("COMMIT") - assert row is not None - return int(row["id"]) - - -def find_sentence_benchmark(sentence_id: str) -> SentenceBenchmark | None: - with db_connection() as database: - row = database.execute( - """ - SELECT * FROM sentence_benchmarks - WHERE sentence_id = ? AND active = 1 - ORDER BY id DESC LIMIT 1 - """, - (sentence_id,), - ).fetchone() - if row is None: - return None - units = tuple(SentenceUnit(**item) for item in json.loads(row["units_json"])) - return SentenceBenchmark( - id=int(row["id"]), - sentence_id=str(row["sentence_id"]), - text=str(row["text"]), - language=str(row["language"]), - locale=str(row["locale"]), - reference=_decode(row["reference_blob"]), - units=units, - error_threshold=float(row["error_threshold"]), - final_threshold=float(row["final_threshold"]), - version=str(row["version"]), - ) - - -def save_sentence_attempt( - session_id: str, - benchmark_id: int, - result: dict[str, Any], - errors: list[dict[str, Any]], -) -> int: - with db_connection() as database: - cursor = database.execute( - """ - INSERT INTO sentence_attempts ( - session_id, benchmark_id, status, distance, - errors_json, model_version - ) VALUES (?, ?, ?, ?, ?, ?) - """, - ( - session_id, - benchmark_id, - result["status"], - result["distance"], - json.dumps(errors, ensure_ascii=False), - result["model_version"], - ), - ) - return int(cursor.lastrowid) diff --git a/app/streaming_inference.py b/app/streaming_inference.py index fcb96b5..ff19995 100644 --- a/app/streaming_inference.py +++ b/app/streaming_inference.py @@ -170,6 +170,14 @@ def __init__( else None ) self._emitted_sentence_words: set[int] = set() + def _shutdown_executors(self) -> None: + if self._executor is not None: + self._executor.shutdown(wait=False, cancel_futures=True) + if self._sentence_executor is not None: + self._sentence_executor.shutdown(wait=False, cancel_futures=True) + + def __del__(self) -> None: + self._shutdown_executors() @property def pcm(self) -> bytes: @@ -324,6 +332,11 @@ def push_frame(self, frame: bytes) -> dict: } for index, token in enumerate(tokens) ] + + if self._emitted_sentence_words: + current_word_index = max(self._emitted_sentence_words) + 1 + if current_word_index >= len(tokens): + current_word_index = len(tokens) - 1 return { "type": "stream.inference.partial", "sequence": len(self._frames), diff --git a/app/vector_index.py b/app/vector_index.py deleted file mode 100644 index e412c0a..0000000 --- a/app/vector_index.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Optional Redis 8 Search index for 26-dimensional acoustic embeddings.""" - -from __future__ import annotations - -import numpy as np - - -INDEX_NAME = "idx:acoustic_embeddings" -KEY_PREFIX = "acoustic:embedding:" -DIMENSION = 26 - - -def _tag_value(value: str) -> str: - """Escape a value embedded in a RediSearch TAG query.""" - if not value: - raise ValueError("TAG filter value cannot be empty") - special = set(r""",.<>{}[]"'`:;!@#$%^&*()-+=~|\/? """) - return "".join(f"\\{character}" if character in special else character - for character in value) - - -def ensure_index(client) -> None: - try: - client.execute_command("FT.INFO", INDEX_NAME) - return - except Exception as error: - if "unknown command" in str(error).casefold(): - raise RuntimeError( - "Redis Search is unavailable; Redis Open Source 8 is required" - ) from error - try: - client.execute_command( - "FT.CREATE", INDEX_NAME, "ON", "HASH", "PREFIX", 1, KEY_PREFIX, - "SCHEMA", "word", "TAG", "locale", "TAG", "split", "TAG", - "vector", "VECTOR", "HNSW", 6, "TYPE", "FLOAT32", - "DIM", DIMENSION, "DISTANCE_METRIC", "COSINE", - ) - except Exception as error: - if "index already exists" not in str(error).casefold(): - raise - - -def upsert_embedding( - client, recording_id: int, embedding: list[float] | np.ndarray, *, - word: str, locale: str, split: str, -) -> str: - values = np.asarray(embedding, dtype=" list[dict]: - values = np.asarray(embedding, dtype=" 1_000: - raise ValueError("k must be between 1 and 1000") - response = client.execute_command( - "FT.SEARCH", INDEX_NAME, - ( - f"(@locale:{{{_tag_value(locale)}}})" - f"=>[KNN {int(k)} @vector $query AS distance]" - ), - "PARAMS", 2, "query", values.tobytes(), - "SORTBY", "distance", "RETURN", 3, "word", "locale", "distance", - "DIALECT", 2, - ) - results: list[dict] = [] - for index in range(1, len(response), 2): - fields = response[index + 1] - decoded = { - (key.decode() if isinstance(key, bytes) else key): - (value.decode() if isinstance(value, bytes) else value) - for key, value in zip(fields[::2], fields[1::2], strict=True) - } - decoded["key"] = ( - response[index].decode() - if isinstance(response[index], bytes) else response[index] - ) - # FLOAT32 cosine kernels may return a tiny negative value for an - # identical vector. Keep the public distance in its mathematical range. - decoded["distance"] = min(2.0, max(0.0, float(decoded["distance"]))) - results.append(decoded) - return results diff --git a/app/vector_sync.py b/app/vector_sync.py deleted file mode 100644 index b9c0fff..0000000 --- a/app/vector_sync.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Synchronize authoritative SQLite acoustic summaries into Redis Search.""" - -from __future__ import annotations - -from dataclasses import asdict, dataclass -import json - -import numpy as np - -from app.acoustic_repository import feature_matrix, list_recordings -from app.vector_index import ensure_index, nearest, upsert_embedding - - -NORMALIZATION_KEY = "acoustic:embedding:normalization:v1" -SYNC_VERSION = "sqlite-mfcc-summary-hnsw-v1" - - -def normalization_key(locale: str) -> str: - normalized = locale.strip().casefold() - if not normalized: - raise ValueError("locale cannot be empty") - return f"{NORMALIZATION_KEY}:{normalized}" - - -@dataclass(frozen=True) -class VectorSyncReport: - indexed: int - locale: str - dimension: int - words: tuple[str, ...] - version: str = SYNC_VERSION - - def as_dict(self) -> dict: - payload = asdict(self) - payload["words"] = list(self.words) - return payload - - -def sync_recording_embeddings(redis_client, *, locale: str = "en-US") -> VectorSyncReport: - recordings = list_recordings(locale=locale, accepted_only=True) - if not recordings: - raise ValueError(f"No accepted acoustic recordings for {locale}") - matrix = feature_matrix(recordings).astype(np.float32) - center = np.mean(matrix, axis=0) - scale = np.std(matrix, axis=0) - scale = np.where(scale < 1e-6, 1.0, scale).astype(np.float32) - normalized = ((matrix - center) / scale).astype(np.float32) - - ensure_index(redis_client) - pipeline = redis_client.pipeline(transaction=False) - for recording, embedding in zip(recordings, normalized, strict=True): - upsert_embedding( - pipeline, - recording.id, - embedding, - word=recording.word, - locale=locale, - split=recording.dataset_split, - ) - pipeline.set( - normalization_key(locale), - json.dumps( - { - "center": center.tolist(), - "scale": scale.tolist(), - "dimension": int(matrix.shape[1]), - "locale": locale, - "version": SYNC_VERSION, - }, - sort_keys=True, - ), - ) - pipeline.execute() - return VectorSyncReport( - indexed=len(recordings), - locale=locale, - dimension=int(matrix.shape[1]), - words=tuple(sorted({recording.word for recording in recordings})), - ) - - -def query_recording_neighbors( - redis_client, recording_id: int, *, locale: str = "en-US", k: int = 5, -) -> list[dict]: - metadata = redis_client.get(normalization_key(locale)) - if metadata is None: - raise LookupError("Vector normalization metadata is missing; run sync first") - if isinstance(metadata, bytes): - metadata = metadata.decode() - normalization = json.loads(metadata) - if normalization.get("locale") != locale: - raise ValueError("Stored vector normalization uses a different locale") - - recordings = list_recordings(locale=locale, accepted_only=True) - recording = next((item for item in recordings if item.id == recording_id), None) - if recording is None or recording.feature is None: - raise LookupError(f"Recording {recording_id} is unavailable") - center = np.asarray(normalization["center"], dtype=np.float32) - scale = np.asarray(normalization["scale"], dtype=np.float32) - embedding = ( - np.asarray(recording.feature, dtype=np.float32) - center - ) / scale - return nearest(redis_client, embedding, locale=locale, k=k) diff --git a/app/word_identity_calibration.py b/app/word_identity_calibration.py deleted file mode 100644 index d4962ef..0000000 --- a/app/word_identity_calibration.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Calibration for the closed-vocabulary wrong-word margin.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Iterable - -import numpy as np - - -@dataclass(frozen=True) -class LabeledWordDistances: - target_word: str - spoken_word: str - distances: dict[str, float] - speaker_id: str - - -@dataclass(frozen=True) -class WordGateCalibration: - mismatch_margin_threshold: float - wrong_word_false_acceptance_rate: float - correct_word_false_rejection_rate: float - sample_count: int - - -def calibrate_mismatch_margin( - samples: Iterable[LabeledWordDistances], - *, - maximum_wrong_word_false_acceptance_rate: float = 0.02, -) -> WordGateCalibration: - """Choose the smallest winner margin that meets the wrong-word FAR budget.""" - observations: list[tuple[bool, bool, float]] = [] - for sample in samples: - if not sample.speaker_id or sample.target_word not in sample.distances: - raise ValueError("Every sample needs speaker and target distance") - if len(sample.distances) < 2: - raise ValueError("Closed-vocabulary calibration needs competitors") - ordered = sorted(sample.distances.items(), key=lambda item: item[1]) - if not all(np.isfinite(distance) for _, distance in ordered): - raise ValueError("Distances must be finite") - winner = ordered[0][0] - margin = ordered[1][1] - ordered[0][1] - observations.append( - ( - sample.spoken_word != sample.target_word, - winner == sample.target_word, - float(margin), - ) - ) - if not observations or not any(item[0] for item in observations): - raise ValueError("Calibration requires wrong-word examples") - - margins = sorted({margin for _, _, margin in observations}) - candidates = [0.0, *margins, float(np.nextafter(margins[-1], np.inf))] - evaluated: list[tuple[float, float, float]] = [] - for threshold in candidates: - wrong = [item for item in observations if item[0]] - correct = [item for item in observations if not item[0]] - false_accepts = sum( - target_won and margin >= threshold - for _, target_won, margin in wrong - ) - false_rejects = sum( - not target_won or margin < threshold - for _, target_won, margin in correct - ) - far = false_accepts / len(wrong) - frr = false_rejects / len(correct) if correct else 0.0 - evaluated.append((far, frr, float(threshold))) - feasible = [ - item - for item in evaluated - if item[0] <= maximum_wrong_word_false_acceptance_rate - ] - if not feasible: - raise ValueError("No margin meets the wrong-word false acceptance budget") - far, frr, threshold = min(feasible, key=lambda item: (item[1], item[2])) - return WordGateCalibration(threshold, far, frr, len(observations)) diff --git a/scripts/build_sentence_benchmark.py b/scripts/build_sentence_benchmark.py deleted file mode 100644 index d5c03f6..0000000 --- a/scripts/build_sentence_benchmark.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Create one annotated sentence benchmark from a reference WAV.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import soundfile as sf - -from app.sentence_repository import store_sentence_benchmark -from app.sentence_streaming import SentenceUnit, extract_streaming_features - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("sentence_id") - parser.add_argument("text") - parser.add_argument("wav", type=Path) - parser.add_argument( - "units_json", - type=Path, - help='JSON array: [{"text":"hello","start_ms":0,"end_ms":400}]', - ) - parser.add_argument("--language", default="en") - parser.add_argument("--locale", default="en-US") - parser.add_argument("--error-threshold", type=float, default=0.55) - parser.add_argument("--final-threshold", type=float, default=0.40) - parser.add_argument("--version", default="1") - args = parser.parse_args() - - samples, sample_rate = sf.read(args.wav, dtype="int16") - if sample_rate != 16_000 or samples.ndim != 1: - parser.error("reference WAV must be mono 16 kHz") - pcm = samples.astype(" None: - import sounddevice as sd - - parser = argparse.ArgumentParser() - parser.add_argument("sentence_id") - parser.add_argument("--duration", type=float, default=8.0) - parser.add_argument("--session-id") - parser.add_argument("--voice-threshold", type=float, default=0.012) - args = parser.parse_args() - benchmark = find_sentence_benchmark(args.sentence_id) - if benchmark is None: - parser.error(f"unknown sentence benchmark: {args.sentence_id}") - - settings = get_settings() - session_id = args.session_id or str(uuid.uuid4()) - inference = SentenceStreamingSession( - benchmark, voice_threshold=args.voice_threshold - ) - errors: list[dict] = [] - print(f"Sessione: {session_id}") - print(f"Pronuncia: {benchmark.text}") - input("Premi Invio per iniziare...") - - last_id: bytes | str = "0-0" - frame_count = round(args.duration * 1_000 / settings.frame_ms) - with sd.RawInputStream( - samplerate=settings.sample_rate, - blocksize=settings.samples_per_frame, - channels=1, - dtype="int16", - ) as microphone: - for sequence in range(frame_count): - frame, overflow = microphone.read(settings.samples_per_frame) - if overflow: - print("ERRORE AUDIO", flush=True) - publish_audio_frame( - session_id, sequence, time.monotonic_ns(), bytes(frame) - ) - batches = client.xread( - {f"audio:{session_id}": last_id}, count=1, block=1_000 - ) - if not batches: - continue - _, messages = batches[0] - message_id, fields = messages[0] - last_id = message_id - _, transported_pcm = parse_audio_frame(fields) - for event in inference.push_frame(transported_pcm): - publish_ui_event(session_id, event) - if event["type"] == "pronunciation.error": - errors.append(event) - print( - f"{event['detected_at_ms']:05d} ms ERRORE " - f"su «{event['unit']}» " - f"(iniziato a {event['start_ms']} ms)", - flush=True, - ) - elif event["type"] == "sentence.alignment": - print( - f"{event['elapsed_ms']:05d} ms " - f"{event['unit']} ASCOLTO", - flush=True, - ) - - result = inference.finish() - result["attempt_id"] = save_sentence_attempt( - session_id, benchmark.id, result, errors - ) - publish_ui_event(session_id, result) - print(json.dumps(result, indent=2, ensure_ascii=False)) - - -if __name__ == "__main__": - main() diff --git a/scripts/sync_vector_index.py b/scripts/sync_vector_index.py deleted file mode 100644 index 9523050..0000000 --- a/scripts/sync_vector_index.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Populate and inspect the Redis 8 acoustic HNSW index.""" - -from __future__ import annotations - -import argparse -import json - -from app.redis_bus import client, probe_capabilities -from app.vector_sync import query_recording_neighbors, sync_recording_embeddings - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--locale", default="en-US") - parser.add_argument("--query-recording-id", type=int) - parser.add_argument("-k", type=int, default=5) - arguments = parser.parse_args() - - capabilities = probe_capabilities(redis_client=client) - if not capabilities.reachable or not capabilities.search: - raise SystemExit("Redis 8 with Search is required; run scripts.check_redis") - - report = sync_recording_embeddings(client, locale=arguments.locale) - payload: dict = {"sync": report.as_dict()} - if arguments.query_recording_id is not None: - payload["neighbors"] = query_recording_neighbors( - client, - arguments.query_recording_id, - locale=arguments.locale, - k=arguments.k, - ) - print(json.dumps(payload, indent=2, sort_keys=True)) - - -if __name__ == "__main__": - main() diff --git a/tests/test_vector_index.py b/tests/test_vector_index.py deleted file mode 100644 index 42d746f..0000000 --- a/tests/test_vector_index.py +++ /dev/null @@ -1,77 +0,0 @@ -import numpy as np -import pytest - -from app.vector_index import ensure_index, nearest, upsert_embedding - - -class FakeRedis8: - def __init__(self): - self.commands = [] - self.hashes = {} - - def execute_command(self, *command): - self.commands.append(command) - if command[0] == "FT.INFO": - raise RuntimeError("Unknown index name") - if command[0] == "FT.SEARCH": - return [ - 1, b"acoustic:embedding:7", - [b"word", b"bird", b"locale", b"en-US", - b"distance", b"0.12"], - ] - return b"OK" - - def hset(self, key, mapping): - self.hashes[key] = mapping - - -def test_vector_index_create_upsert_and_query(): - client = FakeRedis8() - ensure_index(client) - key = upsert_embedding( - client, 7, np.zeros(26), word="Bird", locale="en-US", split="train" - ) - results = nearest(client, np.ones(26), locale="en-US", k=1) - - assert key == "acoustic:embedding:7" - assert len(client.hashes[key]["vector"]) == 26 * 4 - assert client.hashes[key]["word"] == "bird" - assert results == [{ - "word": "bird", "locale": "en-US", "distance": 0.12, - "key": "acoustic:embedding:7", - }] - search = next(command for command in client.commands - if command[0] == "FT.SEARCH") - assert "@locale:{en\\-US}" in search[2] - - -def test_vector_shape_is_checked(): - with pytest.raises(ValueError): - upsert_embedding( - FakeRedis8(), 1, np.zeros(25), - word="bird", locale="en-US", split="train", - ) - - -def test_nearest_rejects_unbounded_k(): - with pytest.raises(ValueError, match="between 1 and 1000"): - nearest(FakeRedis8(), np.zeros(26), locale="en-US", k=0) - - -def test_nearest_clamps_float32_cosine_roundoff(): - client = FakeRedis8() - original = client.execute_command - - def response(*command): - if command[0] == "FT.SEARCH": - return [ - 1, b"acoustic:embedding:1", - [b"word", b"bird", b"locale", b"en-US", - b"distance", b"-0.000000119"], - ] - return original(*command) - - client.execute_command = response - assert nearest( - client, np.zeros(26), locale="en-US", k=1 - )[0]["distance"] == 0.0 diff --git a/tests/test_vector_sync.py b/tests/test_vector_sync.py deleted file mode 100644 index af0f765..0000000 --- a/tests/test_vector_sync.py +++ /dev/null @@ -1,112 +0,0 @@ -import json - -from app.acoustic_repository import StoredRecording -from app.vector_sync import ( - normalization_key, - query_recording_neighbors, - sync_recording_embeddings, -) - - -def _recording(recording_id, word, values): - return StoredRecording( - id=recording_id, - word=word, - speaker_hash=f"speaker-{recording_id}", - dataset_split="train", - source_hash=f"hash-{recording_id}", - duration_ms=500, - feature=values, - quality_status="ACCEPT", - cleaning_version="clean-v1", - extractor_version="mfcc-summary-v1", - ) - - -class RedisSearchDouble: - def __init__(self): - self.values = {} - self.hashes = {} - self.search_commands = [] - - def execute_command(self, *command): - if command[0] == "FT.INFO": - return [b"index_name", b"idx:acoustic_embeddings"] - if command[0] == "FT.SEARCH": - self.search_commands.append(command) - return [ - 1, - b"acoustic:embedding:1", - [b"word", b"bird", b"locale", b"en-US", - b"distance", b"0"], - ] - raise AssertionError(command) - - def pipeline(self, transaction=False): - assert transaction is False - return self - - def hset(self, key, mapping): - self.hashes[key] = mapping - - def set(self, key, value): - self.values[key] = value - - def get(self, key): - return self.values.get(key) - - def execute(self): - return [] - - -def test_sync_is_normalized_and_queryable(monkeypatch): - recordings = [ - _recording(1, "bird", [1.0] * 26), - _recording(2, "happy", [3.0] * 26), - ] - monkeypatch.setattr( - "app.vector_sync.list_recordings", lambda **_: recordings - ) - redis = RedisSearchDouble() - - report = sync_recording_embeddings(redis) - neighbors = query_recording_neighbors(redis, 1, k=1) - - assert report.indexed == 2 - assert report.words == ("bird", "happy") - assert len(redis.hashes) == 2 - metadata = json.loads(redis.values[normalization_key("en-US")]) - assert metadata["center"] == [2.0] * 26 - assert metadata["scale"] == [1.0] * 26 - assert neighbors[0]["word"] == "bird" - assert neighbors[0]["distance"] == 0.0 - assert "@locale:{en\\-US}" in redis.search_commands[-1][2] - - -def test_normalizations_and_queries_are_isolated_by_locale(monkeypatch): - redis = RedisSearchDouble() - current_locale = "en-US" - - def recordings_for_locale(*, locale, **_): - nonlocal current_locale - current_locale = locale - offset = 1.0 if locale == "en-US" else 10.0 - return [ - _recording(1, "bird", [offset] * 26), - _recording(2, "happy", [offset + 2.0] * 26), - ] - - monkeypatch.setattr( - "app.vector_sync.list_recordings", recordings_for_locale - ) - sync_recording_embeddings(redis, locale="en-US") - sync_recording_embeddings(redis, locale="en-GB") - - assert normalization_key("en-US") in redis.values - assert normalization_key("en-GB") in redis.values - assert redis.values[normalization_key("en-US")] != ( - redis.values[normalization_key("en-GB")] - ) - - query_recording_neighbors(redis, 1, locale="en-US", k=1) - assert "@locale:{en\\-US}" in redis.search_commands[-1][2] diff --git a/tests/test_word_identity.py b/tests/test_word_identity.py deleted file mode 100644 index afa98ef..0000000 --- a/tests/test_word_identity.py +++ /dev/null @@ -1,35 +0,0 @@ -from itertools import permutations - -from app.lexicon import EN_US_LEXICON -from app.phonetic_pipeline import DtwWordIdentityGate -from app.word_identity_calibration import ( - LabeledWordDistances, - calibrate_mismatch_margin, -) - - -def test_all_twenty_ordered_word_mismatches_are_rejected() -> None: - words = tuple(EN_US_LEXICON) - assert len(words) == 5 - pairs = tuple(permutations(words, 2)) - assert len(pairs) == 20 - - for target, spoken in pairs: - distances = {word: 1.0 for word in words} - distances[spoken] = 0.1 - distances[target] = 0.8 - result = DtwWordIdentityGate(lambda _: distances).evaluate(b"pcm", target) - assert result.status == "MISMATCH", (target, spoken) - assert result.recognized_word == spoken - - -def test_mismatch_margin_is_calibrated_to_far_budget() -> None: - samples = [ - LabeledWordDistances("bird", "happy", {"bird": 0.2, "happy": 0.3}, "s1"), - LabeledWordDistances("bird", "happy", {"bird": 0.8, "happy": 0.1}, "s2"), - LabeledWordDistances("bird", "bird", {"bird": 0.1, "happy": 0.5}, "s3"), - ] - calibration = calibrate_mismatch_margin(samples) - - assert calibration.mismatch_margin_threshold > 0.1 - assert calibration.wrong_word_false_acceptance_rate == 0