Thedeezat Cursor commited on
Commit
0df3dfc
·
1 Parent(s): d031ccb

feat(api-space): expose Whisper transformers decode confidence metadata.

Browse files

Return 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>

Files changed (1) hide show
  1. main.py +218 -55
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
- def _decode(model, processor, language: str) -> str:
 
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
- ids_local = self._generate_ids(model, input_features, gen_common)
916
  except TypeError:
917
- gen_common.pop("condition_on_prev_tokens", None)
918
- gen_common.pop("no_speech_threshold", None)
919
- gen_common.pop("compression_ratio_threshold", None)
920
- gen_common.pop("logprob_threshold", None)
921
- gen_common.pop("forced_decoder_ids", None)
922
- ids_local = self._generate_ids(model, input_features, gen_common)
923
- return _clean_text_artifacts(processor.batch_decode(ids_local, skip_special_tokens=True)[0] or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
924
 
925
  # Explicit single-language decode for speed.
926
  if requested == "italian":
927
- return _decode(self._primary_model, self._primary_processor, "italian")
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 _decode(self._fallback_model, self._fallback_processor, "english")
932
- # Last resort if fallback model unavailable.
933
- return _decode(self._primary_model, self._primary_processor, "english")
934
 
935
  # Auto mode: Italian first, English only when needed.
936
- text_it = _decode(self._primary_model, self._primary_processor, "italian")
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 = _decode(self._fallback_model, self._fallback_processor, "english")
944
- return _pick_best_text(text_it, text_en)
 
 
 
 
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 = get_italian_pipe_transformers().transcribe(
1322
- seg,
1323
- SAMPLE_RATE,
1324
- forced_language="italian",
1325
- max_new_tokens=max_new_tokens,
1326
- num_beams=num_beams,
1327
- no_speech_threshold=ns_thr,
1328
- compression_ratio_threshold=_COMPRESSION_RATIO_THRESHOLD,
1329
- logprob_threshold=_LOGPROB_THRESHOLD,
 
 
1330
  )
1331
  else:
1332
  pipe = get_pipe()
1333
- text = pipe.transcribe(
1334
- seg,
1335
- SAMPLE_RATE,
1336
- forced_language=forced_language,
1337
- max_new_tokens=max_new_tokens,
1338
- num_beams=num_beams,
1339
- no_speech_threshold=ns_thr,
1340
- compression_ratio_threshold=_COMPRESSION_RATIO_THRESHOLD,
1341
- logprob_threshold=_LOGPROB_THRESHOLD,
 
 
1342
  )
1343
- # Confidence metadata is populated when backend exposes it without extra decode.
 
 
 
 
 
 
 
 
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
- if raw_text and short_mode_forced_it:
 
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
- weak_short = (
1847
- (no_speech_prob is not None and float(no_speech_prob) > 0.55)
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 weak_short:
1852
- print(
1853
- f"[reject] lang=italian reason=short_confidence_meta "
1854
- f"chunk_s={duration_s:.2f} speech_rms={speech_rms:.5f} "
1855
- f"no_speech_prob={no_speech_prob} avg_logprob={avg_logprob} compression_ratio={comp}"
 
 
 
1856
  )
1857
- raw_text = ""
 
 
 
 
 
 
 
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
- # Italian fallback only when filters blanked output but ASR text is not flagged as hallucination.
1922
- # (Previously we always restored here and undid Italian hallucination drops.)
1923
  if (
1924
- forced_language == "italian"
 
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