feat(api-space): expose Whisper transformers decode confidence metadata.
Browse filesReturn avg_logprob and zlib token compression_ratio from generate with
output_scores; aggregate per chunk in _transcribe_chunked.
Apply confidence_meta rejection for all forced Italian decodes when
signals exist; add ASR_AVG_LOGPROB_REJECT_IT and gate Italian restore
with ASR_IT_RESTORE_ON_FILTER (default off) so filters are not undone.
Co-authored-by: Cursor <cursoragent@cursor.com>
main.py
CHANGED
|
@@ -14,12 +14,14 @@ import math
|
|
| 14 |
import os
|
| 15 |
import re
|
| 16 |
import struct
|
|
|
|
| 17 |
import threading
|
| 18 |
import time
|
| 19 |
from typing import Optional
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
import torch
|
|
|
|
| 23 |
import torchaudio
|
| 24 |
from fastapi import FastAPI, Request, Response
|
| 25 |
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -66,6 +68,8 @@ _NO_SPEECH_THRESHOLD_IT = float(os.environ.get("ASR_NO_SPEECH_THRESHOLD_IT", "0.
|
|
| 66 |
# Lower threshold = reject repetitive / gibberish text sooner (helps tail hallucinations).
|
| 67 |
_COMPRESSION_RATIO_THRESHOLD = float(os.environ.get("ASR_COMPRESSION_RATIO_THRESHOLD", "1.6"))
|
| 68 |
_LOGPROB_THRESHOLD = float(os.environ.get("ASR_LOGPROB_THRESHOLD", "-1.0"))
|
|
|
|
|
|
|
| 69 |
_CPU_THREADS = max(1, int(os.environ.get("ASR_CPU_THREADS", str(os.cpu_count() or 4))))
|
| 70 |
_CPU_INTEROP_THREADS = max(1, int(os.environ.get("ASR_CPU_INTEROP_THREADS", "1")))
|
| 71 |
_ASR_BACKEND = (os.environ.get("ASR_BACKEND", "faster-whisper" if os.environ.get("ASR_BACKEND") is None else "").strip().lower() or "faster-whisper")
|
|
@@ -118,6 +122,10 @@ _ASR_DROP_MICRO_FRAGMENTS = (
|
|
| 118 |
_ASR_BLANK_NEAR_DUP_FORCED = (
|
| 119 |
os.environ.get("ASR_BLANK_NEAR_DUP_FORCED", "0").strip().lower() in ("1", "true", "yes")
|
| 120 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
# Match longer repeated tails across consecutive POST /audio responses (chunk boundary stitching).
|
| 122 |
_ASR_CROSS_CHUNK_OVERLAP_WORDS = max(3, int(os.environ.get("ASR_CROSS_CHUNK_OVERLAP_WORDS", "12")))
|
| 123 |
# One-switch preset for HoloLens-style HTTPS chunking + forced Italian (see ASR/HOLOLENS_TUNING.md).
|
|
@@ -776,6 +784,107 @@ if DEVICE.type == "cpu":
|
|
| 776 |
pass
|
| 777 |
|
| 778 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
class WhisperASRSingle:
|
| 780 |
def __init__(self, primary_model_name: str, fallback_model_name: str):
|
| 781 |
self.primary_model_name = primary_model_name
|
|
@@ -855,14 +964,15 @@ class WhisperASRSingle:
|
|
| 855 |
no_speech_threshold: float | None = None,
|
| 856 |
compression_ratio_threshold: float | None = None,
|
| 857 |
logprob_threshold: float | None = None,
|
| 858 |
-
) -> str:
|
|
|
|
| 859 |
if max_new_tokens is None:
|
| 860 |
max_new_tokens = _WHISPER_MAX_NEW
|
| 861 |
if num_beams is None:
|
| 862 |
num_beams = _WHISPER_BEAMS
|
| 863 |
w = np.asarray(waveform, dtype=np.float32).reshape(-1)
|
| 864 |
if w.size == 0 or not math.isfinite(float(np.max(np.abs(w)))):
|
| 865 |
-
return ""
|
| 866 |
sr = int(sample_rate)
|
| 867 |
t = torch.from_numpy(w).unsqueeze(0)
|
| 868 |
if sr != SAMPLE_RATE:
|
|
@@ -881,7 +991,7 @@ class WhisperASRSingle:
|
|
| 881 |
)
|
| 882 |
except TypeError:
|
| 883 |
inputs = self._primary_processor(audio_16k, sampling_rate=SAMPLE_RATE, return_tensors="pt")
|
| 884 |
-
|
| 885 |
input_features = inputs["input_features"].to(DEVICE)
|
| 886 |
attention_mask = inputs.get("attention_mask")
|
| 887 |
if attention_mask is not None:
|
|
@@ -893,7 +1003,8 @@ class WhisperASRSingle:
|
|
| 893 |
requested = (forced_language or "").strip().lower()
|
| 894 |
if requested not in {"english", "italian"}:
|
| 895 |
requested = "auto"
|
| 896 |
-
|
|
|
|
| 897 |
gen_common: dict = {
|
| 898 |
"max_new_tokens": cap,
|
| 899 |
"num_beams": num_beams,
|
|
@@ -911,37 +1022,58 @@ class WhisperASRSingle:
|
|
| 911 |
gen_common["compression_ratio_threshold"] = compression_ratio_threshold
|
| 912 |
if logprob_threshold is not None and no_speech_threshold is not None:
|
| 913 |
gen_common["logprob_threshold"] = logprob_threshold
|
|
|
|
|
|
|
| 914 |
try:
|
| 915 |
-
|
| 916 |
except TypeError:
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
|
| 925 |
# Explicit single-language decode for speed.
|
| 926 |
if requested == "italian":
|
| 927 |
-
return
|
| 928 |
|
| 929 |
if requested == "english":
|
| 930 |
if self._ensure_fallback_loaded() and self._fallback_model is not None and self._fallback_processor is not None:
|
| 931 |
-
return
|
| 932 |
-
|
| 933 |
-
return _decode(self._primary_model, self._primary_processor, "english")
|
| 934 |
|
| 935 |
# Auto mode: Italian first, English only when needed.
|
| 936 |
-
text_it =
|
| 937 |
should_fallback = _DUAL_DECODE or _should_try_english_fallback(text_it) or (not text_it and _FALLBACK_ON_EMPTY)
|
| 938 |
if not should_fallback:
|
| 939 |
-
return text_it
|
| 940 |
|
| 941 |
if not self._ensure_fallback_loaded() or self._fallback_model is None or self._fallback_processor is None:
|
| 942 |
-
return text_it
|
| 943 |
-
text_en =
|
| 944 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
|
| 946 |
|
| 947 |
class FasterWhisperASR:
|
|
@@ -1279,6 +1411,12 @@ def _forced_italian_speech_rms(audio_16k: np.ndarray) -> tuple[float, float]:
|
|
| 1279 |
return speech_rms, p90
|
| 1280 |
|
| 1281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
def _transcribe_chunked(
|
| 1283 |
arr: np.ndarray,
|
| 1284 |
forced_language: str,
|
|
@@ -1316,31 +1454,44 @@ def _transcribe_chunked(
|
|
| 1316 |
prev = ""
|
| 1317 |
conf_no_speech: list[float] = []
|
| 1318 |
conf_logprob: list[float] = []
|
|
|
|
| 1319 |
for seg in segments:
|
| 1320 |
if forced_language == "italian" and _ASR_ITALIAN_BACKEND == "transformers":
|
| 1321 |
-
text =
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
|
|
|
|
|
|
| 1330 |
)
|
| 1331 |
else:
|
| 1332 |
pipe = get_pipe()
|
| 1333 |
-
text =
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
|
|
|
|
|
|
| 1342 |
)
|
| 1343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1344 |
t = (text or "").strip()
|
| 1345 |
if not t:
|
| 1346 |
continue
|
|
@@ -1355,10 +1506,11 @@ def _transcribe_chunked(
|
|
| 1355 |
prev = t
|
| 1356 |
|
| 1357 |
text_out = " ".join(out_parts).strip()
|
|
|
|
| 1358 |
conf = {
|
| 1359 |
"no_speech_prob": (sum(conf_no_speech) / len(conf_no_speech)) if conf_no_speech else None,
|
| 1360 |
"avg_logprob": (sum(conf_logprob) / len(conf_logprob)) if conf_logprob else None,
|
| 1361 |
-
"compression_ratio": _text_compression_ratio(text_out),
|
| 1362 |
}
|
| 1363 |
return text_out, conf
|
| 1364 |
|
|
@@ -1571,6 +1723,8 @@ def startup() -> None:
|
|
| 1571 |
f"ASR_MAX_CHUNK_SECONDS_FORCED={_ASR_MAX_CHUNK_S_FORCED} ASR_MAX_CHUNK_SECONDS_AUTO={_ASR_MAX_CHUNK_S_AUTO} "
|
| 1572 |
f"ASR_CHUNK_OVERLAP_SECONDS={_ASR_CHUNK_OVERLAP_S} "
|
| 1573 |
f"ASR_NO_SPEECH_THRESHOLD_IT={_NO_SPEECH_THRESHOLD_IT} "
|
|
|
|
|
|
|
| 1574 |
f"ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_ACCEPT={_ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_ACCEPT} "
|
| 1575 |
f"ASR_MIN_SPEECH_RMS_FORCED_ITALIAN={_ASR_MIN_SPEECH_RMS_FORCED_ITALIAN} "
|
| 1576 |
f"ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_SHORT={_ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_SHORT} "
|
|
@@ -1839,22 +1993,31 @@ async def audio(request: Request) -> Response:
|
|
| 1839 |
f"speech_rms={speech_rms:.5f} threshold={min_speech_thr:.5f} chunk_s={duration_s:.2f}"
|
| 1840 |
)
|
| 1841 |
raw_text = ""
|
| 1842 |
-
|
|
|
|
| 1843 |
no_speech_prob = decode_conf.get("no_speech_prob")
|
| 1844 |
avg_logprob = decode_conf.get("avg_logprob")
|
| 1845 |
comp = decode_conf.get("compression_ratio")
|
| 1846 |
-
|
| 1847 |
-
|
| 1848 |
-
or (avg_logprob is not None and float(avg_logprob) < -0.90)
|
| 1849 |
-
or (comp is not None and float(comp) > (_COMPRESSION_RATIO_THRESHOLD + 0.25))
|
| 1850 |
)
|
| 1851 |
-
if
|
| 1852 |
-
|
| 1853 |
-
|
| 1854 |
-
|
| 1855 |
-
|
|
|
|
|
|
|
|
|
|
| 1856 |
)
|
| 1857 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1858 |
# Weak-tail after strong previous speech: reject tiny tails irrespective of token content.
|
| 1859 |
if raw_text:
|
| 1860 |
now_ts = time.time()
|
|
@@ -1918,10 +2081,10 @@ async def audio(request: Request) -> Response:
|
|
| 1918 |
raw_text, rms, duration_s
|
| 1919 |
):
|
| 1920 |
raw_text = ""
|
| 1921 |
-
#
|
| 1922 |
-
# (Previously we always restored here and undid Italian hallucination drops.)
|
| 1923 |
if (
|
| 1924 |
-
|
|
|
|
| 1925 |
and not raw_text
|
| 1926 |
and raw_text_original
|
| 1927 |
and rms >= 0.009
|
|
|
|
| 14 |
import os
|
| 15 |
import re
|
| 16 |
import struct
|
| 17 |
+
import zlib
|
| 18 |
import threading
|
| 19 |
import time
|
| 20 |
from typing import Optional
|
| 21 |
|
| 22 |
import numpy as np
|
| 23 |
import torch
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
import torchaudio
|
| 26 |
from fastapi import FastAPI, Request, Response
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 68 |
# Lower threshold = reject repetitive / gibberish text sooner (helps tail hallucinations).
|
| 69 |
_COMPRESSION_RATIO_THRESHOLD = float(os.environ.get("ASR_COMPRESSION_RATIO_THRESHOLD", "1.6"))
|
| 70 |
_LOGPROB_THRESHOLD = float(os.environ.get("ASR_LOGPROB_THRESHOLD", "-1.0"))
|
| 71 |
+
# Transformers forced-Italian: reject when average token logprob is below this (more negative = stricter).
|
| 72 |
+
_ASR_AVG_LOGPROB_REJECT_IT = float(os.environ.get("ASR_AVG_LOGPROB_REJECT_IT", "-0.92"))
|
| 73 |
_CPU_THREADS = max(1, int(os.environ.get("ASR_CPU_THREADS", str(os.cpu_count() or 4))))
|
| 74 |
_CPU_INTEROP_THREADS = max(1, int(os.environ.get("ASR_CPU_INTEROP_THREADS", "1")))
|
| 75 |
_ASR_BACKEND = (os.environ.get("ASR_BACKEND", "faster-whisper" if os.environ.get("ASR_BACKEND") is None else "").strip().lower() or "faster-whisper")
|
|
|
|
| 122 |
_ASR_BLANK_NEAR_DUP_FORCED = (
|
| 123 |
os.environ.get("ASR_BLANK_NEAR_DUP_FORCED", "0").strip().lower() in ("1", "true", "yes")
|
| 124 |
)
|
| 125 |
+
# Off by default: restoring filtered Italian output undoes RMS/confidence rejects (e.g. weak tails).
|
| 126 |
+
_ASR_IT_RESTORE_ON_FILTER = (
|
| 127 |
+
os.environ.get("ASR_IT_RESTORE_ON_FILTER", "0").strip().lower() in ("1", "true", "yes")
|
| 128 |
+
)
|
| 129 |
# Match longer repeated tails across consecutive POST /audio responses (chunk boundary stitching).
|
| 130 |
_ASR_CROSS_CHUNK_OVERLAP_WORDS = max(3, int(os.environ.get("ASR_CROSS_CHUNK_OVERLAP_WORDS", "12")))
|
| 131 |
# One-switch preset for HoloLens-style HTTPS chunking + forced Italian (see ASR/HOLOLENS_TUNING.md).
|
|
|
|
| 784 |
pass
|
| 785 |
|
| 786 |
|
| 787 |
+
def _zlib_compression_ratio_from_token_ids(token_ids: list[int], vocab_size: int) -> float:
|
| 788 |
+
"""Whisper-style zlib compression ratio over raw token bytes (HF generation_whisper)."""
|
| 789 |
+
if not token_ids:
|
| 790 |
+
return 1.0
|
| 791 |
+
vs = max(2, int(vocab_size))
|
| 792 |
+
length = int(math.log2(vs) / 8) + 1
|
| 793 |
+
token_bytes = b"".join([int(t).to_bytes(length, byteorder="little", signed=False) for t in token_ids])
|
| 794 |
+
compressed = zlib.compress(token_bytes)
|
| 795 |
+
if not compressed:
|
| 796 |
+
return 1.0
|
| 797 |
+
return float(len(token_bytes) / len(compressed))
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def _avg_logprob_from_generate_scores(
|
| 801 |
+
scores: tuple[torch.Tensor, ...] | None,
|
| 802 |
+
seq_tokens: torch.Tensor,
|
| 803 |
+
*,
|
| 804 |
+
temperature: float = 1.0,
|
| 805 |
+
) -> float | None:
|
| 806 |
+
"""Mirror HF Whisper `_retrieve_avg_logprobs` for greedy/beam batch 0."""
|
| 807 |
+
if not scores or seq_tokens.numel() == 0:
|
| 808 |
+
return None
|
| 809 |
+
stacked = torch.stack([s[0] for s in scores]).float()
|
| 810 |
+
tokens = seq_tokens.long()
|
| 811 |
+
if stacked.shape[0] > tokens.shape[0]:
|
| 812 |
+
stacked = stacked[: tokens.shape[0]]
|
| 813 |
+
else:
|
| 814 |
+
tokens = tokens[-stacked.shape[0] :]
|
| 815 |
+
if stacked.shape[0] == 0:
|
| 816 |
+
return None
|
| 817 |
+
rescale = temperature if temperature > 0 else 1.0
|
| 818 |
+
logprobs = F.log_softmax(stacked * rescale, dim=-1).to(stacked.dtype)
|
| 819 |
+
sum_lp = sum(logprobs[i][tokens[i]] for i in range(logprobs.shape[0]))
|
| 820 |
+
return float(sum_lp / logprobs.shape[0])
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
def _whisper_transformers_segment_meta(
|
| 824 |
+
model: WhisperForConditionalGeneration, gen_out: object
|
| 825 |
+
) -> dict[str, float | None]:
|
| 826 |
+
"""Decode-side confidence from Transformers Whisper `generate` when `return_dict_in_generate=True`."""
|
| 827 |
+
meta: dict[str, float | None] = {"no_speech_prob": None, "avg_logprob": None, "compression_ratio": None}
|
| 828 |
+
if gen_out is None or isinstance(gen_out, torch.Tensor):
|
| 829 |
+
return meta
|
| 830 |
+
sequences = getattr(gen_out, "sequences", None)
|
| 831 |
+
if sequences is None and isinstance(gen_out, dict):
|
| 832 |
+
sequences = gen_out.get("sequences")
|
| 833 |
+
if sequences is None:
|
| 834 |
+
return meta
|
| 835 |
+
try:
|
| 836 |
+
if sequences.numel() == 0:
|
| 837 |
+
return meta
|
| 838 |
+
except AttributeError:
|
| 839 |
+
return meta
|
| 840 |
+
seq_row = sequences[0]
|
| 841 |
+
vocab_size = int(getattr(model.config, "vocab_size", 51866))
|
| 842 |
+
ids_list = [int(x) for x in seq_row.tolist()]
|
| 843 |
+
pad_id = getattr(model.generation_config, "pad_token_id", None)
|
| 844 |
+
eos_id = getattr(model.generation_config, "eos_token_id", None)
|
| 845 |
+
if pad_id is not None:
|
| 846 |
+
while ids_list and ids_list[-1] == pad_id:
|
| 847 |
+
ids_list.pop()
|
| 848 |
+
try:
|
| 849 |
+
meta["compression_ratio"] = float(_zlib_compression_ratio_from_token_ids(ids_list, vocab_size))
|
| 850 |
+
except Exception:
|
| 851 |
+
meta["compression_ratio"] = None
|
| 852 |
+
|
| 853 |
+
scores = getattr(gen_out, "scores", None)
|
| 854 |
+
if scores is None and isinstance(gen_out, dict):
|
| 855 |
+
scores = gen_out.get("scores")
|
| 856 |
+
avg_lp: float | None = None
|
| 857 |
+
if scores:
|
| 858 |
+
try:
|
| 859 |
+
n_gen = len(scores)
|
| 860 |
+
tail = seq_row[-n_gen:] if seq_row.shape[-1] >= n_gen else seq_row
|
| 861 |
+
avg_lp = _avg_logprob_from_generate_scores(scores, tail, temperature=1.0)
|
| 862 |
+
except Exception:
|
| 863 |
+
avg_lp = None
|
| 864 |
+
if avg_lp is None:
|
| 865 |
+
seq_scores = getattr(gen_out, "sequences_scores", None)
|
| 866 |
+
if seq_scores is None and isinstance(gen_out, dict):
|
| 867 |
+
seq_scores = gen_out.get("sequences_scores")
|
| 868 |
+
if seq_scores is not None:
|
| 869 |
+
try:
|
| 870 |
+
total_lp = float(seq_scores[0].item())
|
| 871 |
+
denom = len(scores) if scores else max(1, len(ids_list))
|
| 872 |
+
avg_lp = total_lp / float(denom)
|
| 873 |
+
except Exception:
|
| 874 |
+
avg_lp = None
|
| 875 |
+
meta["avg_logprob"] = avg_lp
|
| 876 |
+
|
| 877 |
+
nsp = getattr(gen_out, "no_speech_prob", None)
|
| 878 |
+
if nsp is None and isinstance(gen_out, dict):
|
| 879 |
+
nsp = gen_out.get("no_speech_prob")
|
| 880 |
+
if nsp is not None:
|
| 881 |
+
try:
|
| 882 |
+
meta["no_speech_prob"] = float(nsp[0]) if hasattr(nsp, "__getitem__") else float(nsp)
|
| 883 |
+
except Exception:
|
| 884 |
+
meta["no_speech_prob"] = None
|
| 885 |
+
return meta
|
| 886 |
+
|
| 887 |
+
|
| 888 |
class WhisperASRSingle:
|
| 889 |
def __init__(self, primary_model_name: str, fallback_model_name: str):
|
| 890 |
self.primary_model_name = primary_model_name
|
|
|
|
| 964 |
no_speech_threshold: float | None = None,
|
| 965 |
compression_ratio_threshold: float | None = None,
|
| 966 |
logprob_threshold: float | None = None,
|
| 967 |
+
) -> tuple[str, dict[str, float | None]]:
|
| 968 |
+
meta_empty: dict[str, float | None] = {"no_speech_prob": None, "avg_logprob": None, "compression_ratio": None}
|
| 969 |
if max_new_tokens is None:
|
| 970 |
max_new_tokens = _WHISPER_MAX_NEW
|
| 971 |
if num_beams is None:
|
| 972 |
num_beams = _WHISPER_BEAMS
|
| 973 |
w = np.asarray(waveform, dtype=np.float32).reshape(-1)
|
| 974 |
if w.size == 0 or not math.isfinite(float(np.max(np.abs(w)))):
|
| 975 |
+
return "", meta_empty
|
| 976 |
sr = int(sample_rate)
|
| 977 |
t = torch.from_numpy(w).unsqueeze(0)
|
| 978 |
if sr != SAMPLE_RATE:
|
|
|
|
| 991 |
)
|
| 992 |
except TypeError:
|
| 993 |
inputs = self._primary_processor(audio_16k, sampling_rate=SAMPLE_RATE, return_tensors="pt")
|
| 994 |
+
|
| 995 |
input_features = inputs["input_features"].to(DEVICE)
|
| 996 |
attention_mask = inputs.get("attention_mask")
|
| 997 |
if attention_mask is not None:
|
|
|
|
| 1003 |
requested = (forced_language or "").strip().lower()
|
| 1004 |
if requested not in {"english", "italian"}:
|
| 1005 |
requested = "auto"
|
| 1006 |
+
|
| 1007 |
+
def _decode_with_meta(model: WhisperForConditionalGeneration, processor: WhisperProcessor, language: str):
|
| 1008 |
gen_common: dict = {
|
| 1009 |
"max_new_tokens": cap,
|
| 1010 |
"num_beams": num_beams,
|
|
|
|
| 1022 |
gen_common["compression_ratio_threshold"] = compression_ratio_threshold
|
| 1023 |
if logprob_threshold is not None and no_speech_threshold is not None:
|
| 1024 |
gen_common["logprob_threshold"] = logprob_threshold
|
| 1025 |
+
|
| 1026 |
+
meta_kw = {"return_dict_in_generate": True, "output_scores": True}
|
| 1027 |
try:
|
| 1028 |
+
out = self._generate_ids(model, input_features, {**gen_common, **meta_kw})
|
| 1029 |
except TypeError:
|
| 1030 |
+
try:
|
| 1031 |
+
out = self._generate_ids(model, input_features, dict(gen_common))
|
| 1032 |
+
except TypeError:
|
| 1033 |
+
gen_common.pop("condition_on_prev_tokens", None)
|
| 1034 |
+
gen_common.pop("no_speech_threshold", None)
|
| 1035 |
+
gen_common.pop("compression_ratio_threshold", None)
|
| 1036 |
+
gen_common.pop("logprob_threshold", None)
|
| 1037 |
+
gen_common.pop("forced_decoder_ids", None)
|
| 1038 |
+
out = self._generate_ids(model, input_features, dict(gen_common))
|
| 1039 |
+
|
| 1040 |
+
if isinstance(out, torch.Tensor):
|
| 1041 |
+
ids_local = out
|
| 1042 |
+
meta = dict(meta_empty)
|
| 1043 |
+
else:
|
| 1044 |
+
ids_local = getattr(out, "sequences", None)
|
| 1045 |
+
if ids_local is None and isinstance(out, dict):
|
| 1046 |
+
ids_local = out.get("sequences")
|
| 1047 |
+
if ids_local is None:
|
| 1048 |
+
ids_local = out
|
| 1049 |
+
meta = _whisper_transformers_segment_meta(model, out)
|
| 1050 |
+
|
| 1051 |
+
text = _clean_text_artifacts(processor.batch_decode(ids_local, skip_special_tokens=True)[0] or "")
|
| 1052 |
+
return text, meta
|
| 1053 |
|
| 1054 |
# Explicit single-language decode for speed.
|
| 1055 |
if requested == "italian":
|
| 1056 |
+
return _decode_with_meta(self._primary_model, self._primary_processor, "italian")
|
| 1057 |
|
| 1058 |
if requested == "english":
|
| 1059 |
if self._ensure_fallback_loaded() and self._fallback_model is not None and self._fallback_processor is not None:
|
| 1060 |
+
return _decode_with_meta(self._fallback_model, self._fallback_processor, "english")
|
| 1061 |
+
return _decode_with_meta(self._primary_model, self._primary_processor, "english")
|
|
|
|
| 1062 |
|
| 1063 |
# Auto mode: Italian first, English only when needed.
|
| 1064 |
+
text_it, meta_it = _decode_with_meta(self._primary_model, self._primary_processor, "italian")
|
| 1065 |
should_fallback = _DUAL_DECODE or _should_try_english_fallback(text_it) or (not text_it and _FALLBACK_ON_EMPTY)
|
| 1066 |
if not should_fallback:
|
| 1067 |
+
return text_it, meta_it
|
| 1068 |
|
| 1069 |
if not self._ensure_fallback_loaded() or self._fallback_model is None or self._fallback_processor is None:
|
| 1070 |
+
return text_it, meta_it
|
| 1071 |
+
text_en, meta_en = _decode_with_meta(self._fallback_model, self._fallback_processor, "english")
|
| 1072 |
+
chosen = _pick_best_text(text_it, text_en)
|
| 1073 |
+
en_st = (text_en or "").strip()
|
| 1074 |
+
if en_st and chosen == en_st:
|
| 1075 |
+
return chosen, meta_en
|
| 1076 |
+
return chosen, meta_it
|
| 1077 |
|
| 1078 |
|
| 1079 |
class FasterWhisperASR:
|
|
|
|
| 1411 |
return speech_rms, p90
|
| 1412 |
|
| 1413 |
|
| 1414 |
+
def _coerce_transcribe_result(out: tuple[str, dict] | str) -> tuple[str, dict[str, float | None]]:
|
| 1415 |
+
if isinstance(out, tuple) and len(out) >= 2 and isinstance(out[1], dict):
|
| 1416 |
+
return (out[0] or ""), out[1]
|
| 1417 |
+
return (out if isinstance(out, str) else str(out or "")), {}
|
| 1418 |
+
|
| 1419 |
+
|
| 1420 |
def _transcribe_chunked(
|
| 1421 |
arr: np.ndarray,
|
| 1422 |
forced_language: str,
|
|
|
|
| 1454 |
prev = ""
|
| 1455 |
conf_no_speech: list[float] = []
|
| 1456 |
conf_logprob: list[float] = []
|
| 1457 |
+
conf_compression: list[float] = []
|
| 1458 |
for seg in segments:
|
| 1459 |
if forced_language == "italian" and _ASR_ITALIAN_BACKEND == "transformers":
|
| 1460 |
+
text, seg_meta = _coerce_transcribe_result(
|
| 1461 |
+
get_italian_pipe_transformers().transcribe(
|
| 1462 |
+
seg,
|
| 1463 |
+
SAMPLE_RATE,
|
| 1464 |
+
forced_language="italian",
|
| 1465 |
+
max_new_tokens=max_new_tokens,
|
| 1466 |
+
num_beams=num_beams,
|
| 1467 |
+
no_speech_threshold=ns_thr,
|
| 1468 |
+
compression_ratio_threshold=_COMPRESSION_RATIO_THRESHOLD,
|
| 1469 |
+
logprob_threshold=_LOGPROB_THRESHOLD,
|
| 1470 |
+
)
|
| 1471 |
)
|
| 1472 |
else:
|
| 1473 |
pipe = get_pipe()
|
| 1474 |
+
text, seg_meta = _coerce_transcribe_result(
|
| 1475 |
+
pipe.transcribe(
|
| 1476 |
+
seg,
|
| 1477 |
+
SAMPLE_RATE,
|
| 1478 |
+
forced_language=forced_language,
|
| 1479 |
+
max_new_tokens=max_new_tokens,
|
| 1480 |
+
num_beams=num_beams,
|
| 1481 |
+
no_speech_threshold=ns_thr,
|
| 1482 |
+
compression_ratio_threshold=_COMPRESSION_RATIO_THRESHOLD,
|
| 1483 |
+
logprob_threshold=_LOGPROB_THRESHOLD,
|
| 1484 |
+
)
|
| 1485 |
)
|
| 1486 |
+
nsp = seg_meta.get("no_speech_prob")
|
| 1487 |
+
if nsp is not None:
|
| 1488 |
+
conf_no_speech.append(float(nsp))
|
| 1489 |
+
alp = seg_meta.get("avg_logprob")
|
| 1490 |
+
if alp is not None:
|
| 1491 |
+
conf_logprob.append(float(alp))
|
| 1492 |
+
cr = seg_meta.get("compression_ratio")
|
| 1493 |
+
if cr is not None:
|
| 1494 |
+
conf_compression.append(float(cr))
|
| 1495 |
t = (text or "").strip()
|
| 1496 |
if not t:
|
| 1497 |
continue
|
|
|
|
| 1506 |
prev = t
|
| 1507 |
|
| 1508 |
text_out = " ".join(out_parts).strip()
|
| 1509 |
+
zlib_cr = max(conf_compression) if conf_compression else None
|
| 1510 |
conf = {
|
| 1511 |
"no_speech_prob": (sum(conf_no_speech) / len(conf_no_speech)) if conf_no_speech else None,
|
| 1512 |
"avg_logprob": (sum(conf_logprob) / len(conf_logprob)) if conf_logprob else None,
|
| 1513 |
+
"compression_ratio": float(zlib_cr) if zlib_cr is not None else _text_compression_ratio(text_out),
|
| 1514 |
}
|
| 1515 |
return text_out, conf
|
| 1516 |
|
|
|
|
| 1723 |
f"ASR_MAX_CHUNK_SECONDS_FORCED={_ASR_MAX_CHUNK_S_FORCED} ASR_MAX_CHUNK_SECONDS_AUTO={_ASR_MAX_CHUNK_S_AUTO} "
|
| 1724 |
f"ASR_CHUNK_OVERLAP_SECONDS={_ASR_CHUNK_OVERLAP_S} "
|
| 1725 |
f"ASR_NO_SPEECH_THRESHOLD_IT={_NO_SPEECH_THRESHOLD_IT} "
|
| 1726 |
+
f"ASR_AVG_LOGPROB_REJECT_IT={_ASR_AVG_LOGPROB_REJECT_IT} "
|
| 1727 |
+
f"ASR_IT_RESTORE_ON_FILTER={_ASR_IT_RESTORE_ON_FILTER} "
|
| 1728 |
f"ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_ACCEPT={_ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_ACCEPT} "
|
| 1729 |
f"ASR_MIN_SPEECH_RMS_FORCED_ITALIAN={_ASR_MIN_SPEECH_RMS_FORCED_ITALIAN} "
|
| 1730 |
f"ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_SHORT={_ASR_MIN_SPEECH_RMS_FORCED_ITALIAN_SHORT} "
|
|
|
|
| 1993 |
f"speech_rms={speech_rms:.5f} threshold={min_speech_thr:.5f} chunk_s={duration_s:.2f}"
|
| 1994 |
)
|
| 1995 |
raw_text = ""
|
| 1996 |
+
# Decode-confidence gates (Transformers Whisper now supplies avg_logprob / zlib token compression).
|
| 1997 |
+
if raw_text:
|
| 1998 |
no_speech_prob = decode_conf.get("no_speech_prob")
|
| 1999 |
avg_logprob = decode_conf.get("avg_logprob")
|
| 2000 |
comp = decode_conf.get("compression_ratio")
|
| 2001 |
+
has_signal = no_speech_prob is not None or avg_logprob is not None or (
|
| 2002 |
+
comp is not None and float(comp) > float(_COMPRESSION_RATIO_THRESHOLD)
|
|
|
|
|
|
|
| 2003 |
)
|
| 2004 |
+
if has_signal:
|
| 2005 |
+
weak_decode = (
|
| 2006 |
+
(no_speech_prob is not None and float(no_speech_prob) > 0.55)
|
| 2007 |
+
or (
|
| 2008 |
+
avg_logprob is not None
|
| 2009 |
+
and float(avg_logprob) < float(_ASR_AVG_LOGPROB_REJECT_IT)
|
| 2010 |
+
)
|
| 2011 |
+
or (comp is not None and float(comp) > (float(_COMPRESSION_RATIO_THRESHOLD) + 0.2))
|
| 2012 |
)
|
| 2013 |
+
if weak_decode:
|
| 2014 |
+
print(
|
| 2015 |
+
f"[reject] lang=italian reason=confidence_meta "
|
| 2016 |
+
f"chunk_s={duration_s:.2f} speech_rms={speech_rms:.5f} "
|
| 2017 |
+
f"no_speech_prob={no_speech_prob} avg_logprob={avg_logprob} "
|
| 2018 |
+
f"compression_ratio={comp}"
|
| 2019 |
+
)
|
| 2020 |
+
raw_text = ""
|
| 2021 |
# Weak-tail after strong previous speech: reject tiny tails irrespective of token content.
|
| 2022 |
if raw_text:
|
| 2023 |
now_ts = time.time()
|
|
|
|
| 2081 |
raw_text, rms, duration_s
|
| 2082 |
):
|
| 2083 |
raw_text = ""
|
| 2084 |
+
# Optional restore: defaults off — otherwise RMS/confidence rejects get undone (e.g. weak tails).
|
|
|
|
| 2085 |
if (
|
| 2086 |
+
_ASR_IT_RESTORE_ON_FILTER
|
| 2087 |
+
and forced_language == "italian"
|
| 2088 |
and not raw_text
|
| 2089 |
and raw_text_original
|
| 2090 |
and rms >= 0.009
|