Download streampa/frontend.py from faeea/StreamPA: direct link, hf CLI and curl.
- Browser
- Download file 65.6 kB
-
https://huggingface.co/faeea/StreamPA/resolve/main/streampa/frontend.py
- Command line
-
hf download hf://faeea/StreamPA/streampa/frontend.py
-
curl -L -o frontend.py https://huggingface.co/faeea/StreamPA/resolve/main/streampa/frontend.py
65.6 kB
| # Frozen inference function extraction. See source provenance and licenses. | |
| import gc | |
| import math | |
| import re | |
| from collections import Counter | |
| from itertools import groupby | |
| import numpy as np | |
| import librosa | |
| import torch | |
| from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq | |
| EPS = 1e-8 | |
| EPS_TOKEN = '<eps>' | |
| def normalize_phone(phone): | |
| phone = phone.upper().strip() | |
| phone = phone.replace('[', '').replace(']', '') | |
| phone = re.sub(r'[_\d].*', '', phone) | |
| return phone | |
| def normalize_word(word): | |
| word = word.upper().strip() | |
| word = word.replace('[', '').replace(']', '') | |
| word = re.sub(r"[^A-Z']", '', word) | |
| return word | |
| def monotonic_align(neg_log_probs, phone_ids): | |
| frame_num = neg_log_probs.shape[0] | |
| token_num = len(phone_ids) | |
| if frame_num < token_num: | |
| raise ValueError(f'Not enough frames ({frame_num}) to align {token_num} phones.') | |
| dp = np.full((frame_num, token_num), np.inf, dtype=np.float32) | |
| back = np.zeros((frame_num, token_num), dtype=np.int8) | |
| dp[0, 0] = neg_log_probs[0, phone_ids[0]] | |
| for t in range(1, frame_num): | |
| max_token = min(t, token_num - 1) | |
| for tok in range(max_token + 1): | |
| stay_cost = dp[t - 1, tok] | |
| move_cost = dp[t - 1, tok - 1] if tok > 0 else np.inf | |
| if move_cost < stay_cost: | |
| dp[t, tok] = move_cost + neg_log_probs[t, phone_ids[tok]] | |
| back[t, tok] = 1 | |
| else: | |
| dp[t, tok] = stay_cost + neg_log_probs[t, phone_ids[tok]] | |
| path = np.zeros(frame_num, dtype=np.int32) | |
| tok = token_num - 1 | |
| for t in range(frame_num - 1, -1, -1): | |
| path[t] = tok | |
| if t > 0 and back[t, tok] == 1: | |
| tok -= 1 | |
| return path | |
| def select_visible_frames(probs, keep_mask, audio_end, frame_step): | |
| frame_limit = max(int(math.ceil(audio_end / max(frame_step, EPS))), 1) | |
| frame_mask = np.zeros(len(probs), dtype=bool) | |
| frame_mask[: min(frame_limit, len(probs))] = True | |
| visible_mask = keep_mask & frame_mask | |
| return np.flatnonzero(visible_mask), probs[visible_mask] | |
| def softmax_1d(values): | |
| arr = np.asarray(values, dtype=np.float64) | |
| if arr.size == 0: | |
| return arr.astype(np.float32) | |
| pos_inf = np.isposinf(arr) | |
| if np.any(pos_inf): | |
| out = np.zeros_like(arr, dtype=np.float64) | |
| out[pos_inf] = 1.0 / float(np.sum(pos_inf)) | |
| return out.astype(np.float32) | |
| finite = np.isfinite(arr) | |
| if not np.any(finite): | |
| return np.full(arr.shape, 1.0 / float(arr.size), dtype=np.float32) | |
| min_finite = np.min(arr[finite]) | |
| arr = np.where(finite, arr, min_finite - 50.0) | |
| arr = arr - np.max(arr) | |
| exp_arr = np.exp(arr) | |
| return (exp_arr / np.clip(exp_arr.sum(), EPS, None)).astype(np.float32) | |
| def entropy_np(probs): | |
| probs = np.asarray(probs, dtype=np.float64) | |
| probs = probs[probs > 0] | |
| if probs.size == 0: | |
| return 0.0 | |
| return float(-np.sum(probs * np.log(np.clip(probs, EPS, None)))) | |
| def top_margin(probs): | |
| probs = np.asarray(probs, dtype=np.float32) | |
| if probs.size == 0: | |
| return 0.0, 0.0 | |
| top = np.sort(probs)[::-1] | |
| top1 = float(top[0]) | |
| top2 = float(top[1]) if top.size > 1 else 0.0 | |
| return top1, float(top1 - top2) | |
| def js_divergence(p, q): | |
| p = np.asarray(p, dtype=np.float64) | |
| q = np.asarray(q, dtype=np.float64) | |
| p = p / np.clip(p.sum(), EPS, None) | |
| q = q / np.clip(q.sum(), EPS, None) | |
| m = 0.5 * (p + q) | |
| kl_pm = np.sum(p * (np.log(np.clip(p, EPS, None)) - np.log(np.clip(m, EPS, None)))) | |
| kl_qm = np.sum(q * (np.log(np.clip(q, EPS, None)) - np.log(np.clip(m, EPS, None)))) | |
| return float(0.5 * (kl_pm + kl_qm)) | |
| def safe_json(value): | |
| if isinstance(value, dict): | |
| return {str(key): safe_json(val) for key, val in value.items()} | |
| if isinstance(value, (list, tuple)): | |
| return [safe_json(item) for item in value] | |
| if isinstance(value, np.ndarray): | |
| return safe_json(value.tolist()) | |
| if isinstance(value, (np.floating, np.integer)): | |
| return value.item() | |
| return value | |
| def softmax_np_matrix(values): | |
| arr = np.asarray(values, dtype=np.float64) | |
| if arr.size == 0: | |
| return arr.astype(np.float32) | |
| arr = arr - np.max(arr, axis=-1, keepdims=True) | |
| exp_arr = np.exp(arr) | |
| return (exp_arr / np.clip(np.sum(exp_arr, axis=-1, keepdims=True), EPS, None)).astype(np.float32) | |
| def audio_array_logits(audio, processor, model, sample_rate, device): | |
| audio = np.asarray(audio, dtype=np.float32) | |
| if hasattr(processor, 'audio_preprocess'): | |
| inputs = processor.audio_preprocess(audio, sr=sample_rate) | |
| inputs = torch.tensor(inputs, dtype=torch.float32).unsqueeze(0).to(device) | |
| model_inputs = {'input_values': inputs} | |
| else: | |
| model_inputs = processor(audio, sampling_rate=sample_rate, return_tensors='pt') | |
| model_inputs = {key: value.to(device) for key, value in model_inputs.items()} | |
| with torch.no_grad(): | |
| logits = model(**model_inputs).logits[0].detach().cpu().numpy() | |
| return softmax_np_matrix(logits), float(len(audio) / sample_rate) | |
| def non_overlapping_occurrences(tokens, pattern): | |
| positions = [] | |
| pattern_size = len(pattern) | |
| cursor = 0 | |
| while cursor <= len(tokens) - pattern_size: | |
| if tokens[cursor : cursor + pattern_size] == pattern: | |
| positions.append(cursor) | |
| cursor += pattern_size | |
| else: | |
| cursor += 1 | |
| return positions | |
| def find_repeated_ngram( | |
| words, | |
| min_repeats, | |
| max_ngram_size=12, | |
| strict=False, | |
| min_coverage=0.6, | |
| dominant_token_ratio=0.5, | |
| ): | |
| if min_repeats <= 0: | |
| return None | |
| tokens = [str(word['text']) for word in words] | |
| if not tokens: | |
| return None | |
| for ngram_size in range(1, min(max_ngram_size, len(tokens)) + 1): | |
| span = ngram_size * min_repeats | |
| for start in range(0, len(tokens) - span + 1): | |
| pattern = tokens[start : start + ngram_size] | |
| repeats = 1 | |
| cursor = start + ngram_size | |
| while tokens[cursor : cursor + ngram_size] == pattern: | |
| repeats += 1 | |
| cursor += ngram_size | |
| if repeats >= min_repeats: | |
| return { | |
| 'start': int(start), | |
| 'ngram_size': int(ngram_size), | |
| 'repeats': int(repeats), | |
| 'pattern': pattern, | |
| 'detection': 'contiguous_cycle', | |
| } | |
| if not strict: | |
| return None | |
| token_counts = Counter(tokens) | |
| dominant_token, dominant_count = token_counts.most_common(1)[0] | |
| token_ratio = float(dominant_count) / float(len(tokens)) | |
| if dominant_count >= max(min_repeats * 2, 8) and token_ratio >= dominant_token_ratio: | |
| return { | |
| 'start': None, | |
| 'ngram_size': 1, | |
| 'repeats': int(dominant_count), | |
| 'pattern': [dominant_token], | |
| 'coverage': token_ratio, | |
| 'detection': 'dominant_token', | |
| } | |
| max_size = min(max_ngram_size, len(tokens) // max(min_repeats, 1)) | |
| for ngram_size in range(max_size, 1, -1): | |
| candidates = Counter( | |
| tuple(tokens[start : start + ngram_size]) | |
| for start in range(0, len(tokens) - ngram_size + 1) | |
| ) | |
| for pattern, raw_count in candidates.most_common(): | |
| if raw_count < min_repeats: | |
| break | |
| positions = non_overlapping_occurrences(tokens, list(pattern)) | |
| if len(positions) < min_repeats: | |
| continue | |
| coverage = float(len(positions) * ngram_size) / float(len(tokens)) | |
| if coverage >= min_coverage: | |
| return { | |
| 'start': int(positions[0]), | |
| 'ngram_size': int(ngram_size), | |
| 'repeats': int(len(positions)), | |
| 'pattern': list(pattern), | |
| 'coverage': coverage, | |
| 'detection': 'high_coverage_periodicity', | |
| } | |
| return None | |
| class PhoneMapper: | |
| def __init__(self, lexicon, phn_dict): | |
| self.lexicon = lexicon | |
| self.phn_dict = phn_dict | |
| self.cache = {} | |
| self.fallback_phone = 'AH' if 'AH' in phn_dict else (next(iter(phn_dict)) if phn_dict else None) | |
| self.g2p = None | |
| try: | |
| from g2p_en import G2p | |
| self.g2p = G2p() | |
| except Exception: | |
| pass | |
| def word_to_phones(self, word): | |
| word = normalize_word(word) | |
| if word in self.lexicon: | |
| return list(self.lexicon[word]), 'lexicon' | |
| if word in self.cache: | |
| return self.cache[word] | |
| if self.g2p is not None: | |
| try: | |
| phones = [] | |
| for raw_phone in self.g2p(word.lower()): | |
| phone = normalize_phone(raw_phone) | |
| if phone and phone in self.phn_dict: | |
| phones.append(phone) | |
| if phones: | |
| result = (phones, 'g2p_en') | |
| self.cache[word] = result | |
| return result | |
| except Exception: | |
| self.g2p = None | |
| if self.fallback_phone is None: | |
| result = ([], 'unmapped') | |
| else: | |
| approx_len = max(1, int(math.ceil(len(re.sub(r'[^A-Z]', '', word)) / 3.0))) | |
| result = ([self.fallback_phone] * approx_len, 'fallback_phone') | |
| self.cache[word] = result | |
| return result | |
| def words_to_phone_sequence(self, words): | |
| phones = [] | |
| phone_to_word = [] | |
| source_counts = Counter() | |
| for word_idx, word in enumerate(words): | |
| cur_phones, source = self.word_to_phones(word) | |
| source_counts[source] += 1 | |
| for phone in cur_phones: | |
| phones.append(phone) | |
| phone_to_word.append(word_idx) | |
| return phones, phone_to_word, dict(source_counts) | |
| class WhisperNBestGenerator: | |
| def __init__(self, model_name, language, device, nbest, beam_size, max_new_tokens, no_repeat_ngram_size): | |
| self.processor = AutoProcessor.from_pretrained(model_name) | |
| torch_dtype = torch.float16 if str(device).startswith('cuda') else torch.float32 | |
| self.model = AutoModelForSpeechSeq2Seq.from_pretrained(model_name, torch_dtype=torch_dtype) | |
| self.model.to(device) | |
| self.model.eval() | |
| self.model_dtype = next(self.model.parameters()).dtype | |
| self.language = language | |
| self.device = device | |
| self.nbest = int(nbest) | |
| self.beam_size = max(int(beam_size), int(nbest)) | |
| self.max_new_tokens = int(max_new_tokens) | |
| self.no_repeat_ngram_size = int(no_repeat_ngram_size) | |
| self.forced_decoder_ids = None | |
| if hasattr(self.processor, 'get_decoder_prompt_ids'): | |
| try: | |
| self.forced_decoder_ids = self.processor.get_decoder_prompt_ids( | |
| language=language, | |
| task='transcribe', | |
| ) | |
| except Exception: | |
| self.forced_decoder_ids = None | |
| def generate(self, audio, sample_rate, audio_end): | |
| inputs = self.processor( | |
| np.asarray(audio, dtype=np.float32), | |
| sampling_rate=sample_rate, | |
| return_tensors='pt', | |
| return_attention_mask=True, | |
| ) | |
| inputs = {key: value.to(self.device) for key, value in inputs.items()} | |
| for key in ('input_features', 'input_values'): | |
| if key in inputs and torch.is_floating_point(inputs[key]): | |
| inputs[key] = inputs[key].to(dtype=self.model_dtype) | |
| generate_kwargs = { | |
| 'num_beams': self.beam_size, | |
| 'num_return_sequences': self.nbest, | |
| 'max_new_tokens': self.max_new_tokens, | |
| 'return_dict_in_generate': True, | |
| 'output_scores': True, | |
| 'do_sample': False, | |
| } | |
| if self.no_repeat_ngram_size > 0: | |
| generate_kwargs['no_repeat_ngram_size'] = self.no_repeat_ngram_size | |
| if self.forced_decoder_ids is not None: | |
| generate_kwargs['forced_decoder_ids'] = self.forced_decoder_ids | |
| output = self.model.generate(**inputs, **generate_kwargs) | |
| if torch.is_tensor(output): | |
| sequences = output | |
| sequence_scores = [0.0 for _ in range(int(sequences.shape[0]))] | |
| transition_scores = [] | |
| else: | |
| sequences = output.sequences | |
| if getattr(output, 'sequences_scores', None) is not None: | |
| sequence_scores = output.sequences_scores.detach().float().cpu().numpy().tolist() | |
| else: | |
| sequence_scores = [0.0 for _ in range(int(sequences.shape[0]))] | |
| transition_scores = self._transition_scores(output) | |
| texts = self.processor.batch_decode(sequences, skip_special_tokens=True) | |
| rows = [] | |
| for rank, (text, sequence_score) in enumerate(zip(texts, sequence_scores)): | |
| token_ids = sequences[rank].detach().cpu().tolist() | |
| token_logprobs = ( | |
| transition_scores[rank] | |
| if rank < len(transition_scores) | |
| else [float(sequence_score) for _ in token_ids] | |
| ) | |
| token_rows, word_rows = self._token_word_scores(token_ids, token_logprobs) | |
| words = [row['word'] for row in word_rows] | |
| length_norm = ( | |
| float(np.mean([row['logprob'] for row in token_rows])) | |
| if token_rows | |
| else float(sequence_score) | |
| ) | |
| rows.append({ | |
| 'rank': int(rank), | |
| 'text': ' '.join(words).lower(), | |
| 'words': words, | |
| 'logprob': float(length_norm), | |
| 'sequence_score': float(sequence_score), | |
| 'length_normalized_sequence_score': float(length_norm), | |
| 'token_ids': [int(row['token_id']) for row in token_rows], | |
| 'token_logprobs': [float(row['logprob']) for row in token_rows], | |
| 'token_confidences': [float(row['confidence']) for row in token_rows], | |
| 'word_token_ranges': [[int(row['token_start']), int(row['token_end'])] for row in word_rows], | |
| 'word_logprobs': [float(row['logprob']) for row in word_rows], | |
| 'word_confidences': [float(row['confidence']) for row in word_rows], | |
| 'word_timestamps': estimate_word_timestamps(words, audio_end), | |
| }) | |
| if str(self.device).startswith('cuda'): | |
| torch.cuda.empty_cache() | |
| gc.collect() | |
| return rows | |
| def _transition_scores(self, output): | |
| if getattr(output, 'scores', None) is None: | |
| return [] | |
| try: | |
| sequences = output.sequences.detach().cpu() | |
| step_scores = [score.detach().float().cpu() for score in output.scores] | |
| if not step_scores: | |
| return [] | |
| beam_indices = getattr(output, 'beam_indices', None) | |
| if beam_indices is not None: | |
| beam_indices = beam_indices.detach().cpu() | |
| generated_steps = len(step_scores) | |
| start = max(0, int(sequences.shape[1]) - generated_steps) | |
| rows = [] | |
| for seq_idx in range(int(sequences.shape[0])): | |
| cur = [] | |
| for step, logits in enumerate(step_scores): | |
| token_pos = start + step | |
| if token_pos >= int(sequences.shape[1]): | |
| break | |
| token_id = int(sequences[seq_idx, token_pos]) | |
| if token_id < 0 or token_id >= int(logits.shape[-1]): | |
| cur.append(0.0) | |
| continue | |
| score_row = min(seq_idx, int(logits.shape[0]) - 1) | |
| if beam_indices is not None and seq_idx < int(beam_indices.shape[0]) and token_pos < int(beam_indices.shape[1]): | |
| candidate = int(beam_indices[seq_idx, token_pos]) | |
| if 0 <= candidate < int(logits.shape[0]): | |
| score_row = candidate | |
| log_probs = torch.log_softmax(logits[score_row], dim=-1) | |
| cur.append(float(log_probs[token_id].item())) | |
| rows.append(cur) | |
| return rows | |
| except Exception: | |
| return [] | |
| def _token_word_scores(self, token_ids, token_logprobs): | |
| tokenizer = self.processor.tokenizer | |
| special_ids = set(getattr(tokenizer, 'all_special_ids', []) or []) | |
| if len(token_logprobs) != len(token_ids): | |
| aligned = [0.0 for _ in token_ids] | |
| offset = max(0, len(token_ids) - len(token_logprobs)) | |
| for idx, value in enumerate(token_logprobs[-len(token_ids) :]): | |
| pos = offset + idx | |
| if pos < len(aligned): | |
| aligned[pos] = float(value) | |
| token_logprobs = aligned | |
| token_rows = [] | |
| words = [] | |
| current = [] | |
| current_token_ids = [] | |
| current_logprobs = [] | |
| current_start = None | |
| def flush(): | |
| nonlocal current, current_token_ids, current_logprobs, current_start | |
| word = normalize_word(''.join(current)) | |
| if word: | |
| avg_logprob = float(np.mean(current_logprobs)) if current_logprobs else 0.0 | |
| words.append({ | |
| 'word': word, | |
| 'token_start': int(current_start if current_start is not None else 0), | |
| 'token_end': int((current_token_ids[-1] + 1) if current_token_ids else 0), | |
| 'logprob': avg_logprob, | |
| 'confidence': float(np.clip(np.exp(avg_logprob), 0.0, 1.0)), | |
| }) | |
| current = [] | |
| current_token_ids = [] | |
| current_logprobs = [] | |
| current_start = None | |
| for pos, token_id in enumerate(token_ids): | |
| if int(token_id) in special_ids: | |
| continue | |
| logprob = float(token_logprobs[pos]) if pos < len(token_logprobs) else 0.0 | |
| try: | |
| text = tokenizer.decode([int(token_id)], skip_special_tokens=True, clean_up_tokenization_spaces=False) | |
| except TypeError: | |
| text = tokenizer.decode([int(token_id)], skip_special_tokens=True) | |
| if not text: | |
| continue | |
| token_rows.append({ | |
| 'token_id': int(token_id), | |
| 'logprob': logprob, | |
| 'confidence': float(np.clip(np.exp(logprob), 0.0, 1.0)), | |
| }) | |
| for char in text: | |
| if char.isalpha() or char == "'": | |
| if current_start is None: | |
| current_start = pos | |
| current.append(char) | |
| current_token_ids.append(pos) | |
| current_logprobs.append(logprob) | |
| else: | |
| flush() | |
| flush() | |
| return token_rows, words | |
| def normalize_text_to_words(text): | |
| tokens = [] | |
| for token in re.findall(r"[A-Za-z']+", text): | |
| norm = normalize_word(token) | |
| if norm: | |
| tokens.append(norm) | |
| return tokens | |
| def estimate_word_timestamps(words, audio_end): | |
| if not words: | |
| return [] | |
| step = float(audio_end) / max(len(words), 1) | |
| rows = [] | |
| for idx, word in enumerate(words): | |
| rows.append({ | |
| 'word': word.lower(), | |
| 'start': float(idx * step), | |
| 'end': float(min(audio_end, (idx + 1) * step)), | |
| 'source': 'duration_proportional_estimate', | |
| }) | |
| return rows | |
| def hypothesis_phone_rows(row, phone_mapper, phn_dict): | |
| phones = [] | |
| phone_ids = [] | |
| phone_to_word = [] | |
| phone_confidences = [] | |
| source_counts = Counter() | |
| word_confidences = row.get('word_confidences') or [1.0 for _ in row.get('words', [])] | |
| for word_idx, word in enumerate(row.get('words', [])): | |
| cur_phones, source = phone_mapper.word_to_phones(word) | |
| source_counts[source] += 1 | |
| word_conf = float(word_confidences[word_idx]) if word_idx < len(word_confidences) else 1.0 | |
| word_conf = float(np.clip(word_conf, 0.0, 1.0)) | |
| for phone in cur_phones: | |
| if phone not in phn_dict: | |
| continue | |
| phones.append(phone) | |
| phone_ids.append(int(phn_dict[phone])) | |
| phone_to_word.append(int(word_idx)) | |
| phone_confidences.append(word_conf) | |
| return { | |
| 'phones': phones, | |
| 'phone_ids': phone_ids, | |
| 'phone_to_word': phone_to_word, | |
| 'phone_confidences': phone_confidences, | |
| 'source_counts': dict(source_counts), | |
| } | |
| def align_hypothesis_with_charsiu(item, row, phone_mapper, phn_dict, phone_to_frame_id, audio_end): | |
| hyp = hypothesis_phone_rows(row, phone_mapper, phn_dict) | |
| words = row.get('words', []) | |
| if not words or not hyp['phone_ids']: | |
| row['word_timestamps'] = [] | |
| row['timestamp_source'] = 'empty_hypothesis' | |
| row['phone_times'] = [] | |
| row['phone_acoustic_supports'] = [] | |
| return row | |
| kept_indices, kept_probs = select_visible_frames(item['probs'], item['keep_mask'], audio_end, item['frame_step']) | |
| frame_phone_ids = [] | |
| id_to_phone = {idx: phone for phone, idx in phn_dict.items()} | |
| try: | |
| for phone_idx in hyp['phone_ids']: | |
| phone = id_to_phone[int(phone_idx)] | |
| frame_phone_ids.append(phone_to_frame_id[phone]) | |
| if kept_probs.shape[0] < len(frame_phone_ids): | |
| raise ValueError('not_enough_visible_frames_for_hypothesis') | |
| path = monotonic_align(-np.log(np.clip(kept_probs, EPS, None)), frame_phone_ids) | |
| phone_times = [] | |
| phone_acoustic_supports = [] | |
| for phone_pos, frame_phone_id in enumerate(frame_phone_ids): | |
| local_frames = kept_indices[path == phone_pos] | |
| if local_frames.size == 0: | |
| raise ValueError('empty_aligned_phone_span') | |
| start_time = float(local_frames.min() * item['frame_step']) | |
| end_time = float((local_frames.max() + 1) * item['frame_step']) | |
| support = float(np.mean(item['probs'][local_frames, frame_phone_id])) | |
| phone_times.append((start_time, min(float(audio_end), end_time))) | |
| phone_acoustic_supports.append(float(np.clip(support, 0.0, 1.0))) | |
| word_rows = [] | |
| for word_idx, word in enumerate(words): | |
| positions = [idx for idx, cur_word in enumerate(hyp['phone_to_word']) if cur_word == word_idx] | |
| if not positions: | |
| continue | |
| start = min(phone_times[idx][0] for idx in positions) | |
| end = max(phone_times[idx][1] for idx in positions) | |
| word_rows.append({ | |
| 'word': word, | |
| 'start': float(start), | |
| 'end': float(end), | |
| 'source': 'charsiu_hypothesis_alignment', | |
| 'word_logprob': float(row.get('word_logprobs', [0.0] * len(words))[word_idx]) | |
| if word_idx < len(row.get('word_logprobs', [])) | |
| else 0.0, | |
| 'word_confidence': float(row.get('word_confidences', [1.0] * len(words))[word_idx]) | |
| if word_idx < len(row.get('word_confidences', [])) | |
| else 1.0, | |
| }) | |
| row['word_timestamps'] = word_rows | |
| row['timestamp_source'] = 'charsiu_hypothesis_alignment' | |
| row['phone_times'] = phone_times | |
| row['phone_acoustic_supports'] = phone_acoustic_supports | |
| except Exception as exc: | |
| row['word_timestamps'] = estimate_word_timestamps(words, audio_end) | |
| row['timestamp_source'] = 'duration_proportional_fallback' | |
| row['timestamp_fallback_reason'] = repr(exc) | |
| phone_times = [] | |
| phone_acoustic_supports = [] | |
| for phone_idx, word_idx in enumerate(hyp['phone_to_word']): | |
| if word_idx < len(row['word_timestamps']): | |
| word_time = row['word_timestamps'][word_idx] | |
| phone_times.append((float(word_time['start']), float(word_time['end']))) | |
| else: | |
| phone_times.append((0.0, float(audio_end))) | |
| phone_acoustic_supports.append(0.0) | |
| row['phone_times'] = phone_times | |
| row['phone_acoustic_supports'] = phone_acoustic_supports | |
| row.update(hyp) | |
| return row | |
| def interval_overlap_ratio(a, b): | |
| if a is None or b is None: | |
| return 0.0 | |
| a0, a1 = a | |
| b0, b1 = b | |
| inter = max(0.0, min(float(a1), float(b1)) - max(float(a0), float(b0))) | |
| union = max(float(a1), float(b1)) - min(float(a0), float(b0)) | |
| if union <= 0: | |
| return 0.0 | |
| return inter / union | |
| def align_token_sequences( | |
| ref_tokens, | |
| hyp_tokens, | |
| mismatch_cost=1.0, | |
| ref_times=None, | |
| hyp_times=None, | |
| hyp_confidences=None, | |
| time_overlap_reward=0.0, | |
| ): | |
| n = len(ref_tokens) | |
| m = len(hyp_tokens) | |
| dp = np.zeros((n + 1, m + 1), dtype=np.float32) | |
| back = {} | |
| for i in range(1, n + 1): | |
| dp[i, 0] = dp[i - 1, 0] + 1.0 | |
| back[(i, 0)] = (i - 1, 0, 'delete') | |
| for j in range(1, m + 1): | |
| dp[0, j] = dp[0, j - 1] + 1.0 | |
| back[(0, j)] = (0, j - 1, 'insert') | |
| for i in range(1, n + 1): | |
| for j in range(1, m + 1): | |
| sub_cost = 0.0 if ref_tokens[i - 1] == hyp_tokens[j - 1] else mismatch_cost | |
| if ref_times is not None and hyp_times is not None: | |
| sub_cost -= float(time_overlap_reward) * interval_overlap_ratio(ref_times[i - 1], hyp_times[j - 1]) | |
| if hyp_confidences is not None: | |
| sub_cost -= 0.1 * float(np.clip(hyp_confidences[j - 1], 0.0, 1.0)) | |
| sub_cost = max(0.0, sub_cost) | |
| candidates = [ | |
| (dp[i - 1, j - 1] + sub_cost, i - 1, j - 1, 'match'), | |
| (dp[i - 1, j] + 1.0, i - 1, j, 'delete'), | |
| (dp[i, j - 1] + 1.0, i, j - 1, 'insert'), | |
| ] | |
| cost, prev_i, prev_j, op = min(candidates, key=lambda item: item[0]) | |
| dp[i, j] = cost | |
| back[(i, j)] = (prev_i, prev_j, op) | |
| pairs = [] | |
| i, j = n, m | |
| while i > 0 or j > 0: | |
| prev_i, prev_j, op = back[(i, j)] | |
| if op == 'match': | |
| pairs.append((i - 1, j - 1)) | |
| elif op == 'delete': | |
| pairs.append((i - 1, None)) | |
| else: | |
| pairs.append((None, j - 1)) | |
| i, j = prev_i, prev_j | |
| pairs.reverse() | |
| return pairs | |
| def slot_top_phone(slot_counts, eps_index): | |
| best_idx = None | |
| best_mass = -1.0 | |
| for phone_idx, mass in slot_counts.items(): | |
| if phone_idx == eps_index: | |
| continue | |
| if mass > best_mass: | |
| best_idx = phone_idx | |
| best_mass = mass | |
| return best_idx | |
| def slot_time(slot): | |
| mass = max(float(slot.get('time_mass', 0.0)), EPS) | |
| if mass <= EPS: | |
| return None | |
| return float(slot.get('start_sum', 0.0) / mass), float(slot.get('end_sum', 0.0) / mass) | |
| def add_slot_phone_mass(slot, phone_idx, phone_mass, eps_index, eps_mass=0.0, phone_time=None, confidence=1.0): | |
| if phone_mass > 0: | |
| slot['counts'][int(phone_idx)] += float(phone_mass) | |
| if eps_mass > 0: | |
| slot['counts'][eps_index] += float(eps_mass) | |
| if phone_time is not None and phone_mass > 0: | |
| slot['start_sum'] = slot.get('start_sum', 0.0) + float(phone_time[0]) * float(phone_mass) | |
| slot['end_sum'] = slot.get('end_sum', 0.0) + float(phone_time[1]) * float(phone_mass) | |
| slot['time_mass'] = slot.get('time_mass', 0.0) + float(phone_mass) | |
| slot['confidence_sum'] = slot.get('confidence_sum', 0.0) + float(confidence) * max(float(phone_mass), 0.0) | |
| slot['confidence_mass'] = slot.get('confidence_mass', 0.0) + max(float(phone_mass), 0.0) | |
| def insert_slot(slots, insert_at, eps_index, prior_mass): | |
| slot = {'counts': Counter(), 'start_sum': 0.0, 'end_sum': 0.0, 'time_mass': 0.0} | |
| if prior_mass > 0: | |
| slot['counts'][eps_index] += float(prior_mass) | |
| slots.insert(insert_at, slot) | |
| return slot | |
| def build_pcn_from_hypotheses(hypotheses, phone_mapper, phn_dict): | |
| eps_index = len(phn_dict) | |
| phone_dim = len(phn_dict) + 1 | |
| hyp_logprobs = [float(row.get('length_normalized_sequence_score', row.get('logprob', 0.0))) for row in hypotheses] | |
| hyp_weights = softmax_1d(hyp_logprobs) | |
| id_to_phone = {idx: phone for phone, idx in phn_dict.items()} | |
| hyp_phone_rows = [] | |
| for row, weight in zip(hypotheses, hyp_weights): | |
| if 'phone_ids' not in row: | |
| row = align_hypothesis_with_charsiu( | |
| item={'probs': np.zeros((0, phone_dim), dtype=np.float32), 'keep_mask': np.zeros((0,), dtype=np.int32), 'frame_step': 0.02}, | |
| row=row, | |
| phone_mapper=phone_mapper, | |
| phn_dict=phn_dict, | |
| phone_to_frame_id={}, | |
| audio_end=0.0, | |
| ) | |
| phone_ids = [int(phone_idx) for phone_idx in row.get('phone_ids', [])] | |
| phone_confidences = [float(x) for x in row.get('phone_confidences', [1.0] * len(phone_ids))] | |
| phone_acoustic = [float(x) for x in row.get('phone_acoustic_supports', [0.0] * len(phone_ids))] | |
| phone_times = row.get('phone_times', [None] * len(phone_ids)) | |
| hyp_phone_rows.append({ | |
| 'phones': row.get('phones', []), | |
| 'phone_ids': phone_ids, | |
| 'phone_to_word': list(row.get('phone_to_word', [])), | |
| 'phone_confidences': phone_confidences, | |
| 'phone_acoustic_supports': phone_acoustic, | |
| 'phone_times': phone_times, | |
| 'weight': float(weight), | |
| 'source_counts': row.get('source_counts', {}), | |
| }) | |
| slots = [] | |
| total_mass = 0.0 | |
| for hyp_idx, hyp in enumerate(hyp_phone_rows): | |
| phone_ids = hyp['phone_ids'] | |
| weight = float(hyp['weight']) | |
| if hyp_idx == 0: | |
| for local_idx, phone_idx in enumerate(phone_ids): | |
| slot = {'counts': Counter(), 'start_sum': 0.0, 'end_sum': 0.0, 'time_mass': 0.0} | |
| quality = float(np.clip( | |
| 0.5 * hyp['phone_confidences'][local_idx] + 0.5 * hyp['phone_acoustic_supports'][local_idx], | |
| 0.0, | |
| 1.0, | |
| )) | |
| add_slot_phone_mass( | |
| slot, | |
| phone_idx, | |
| weight * quality, | |
| eps_index, | |
| eps_mass=weight * (1.0 - quality), | |
| phone_time=hyp['phone_times'][local_idx] if local_idx < len(hyp['phone_times']) else None, | |
| confidence=hyp['phone_confidences'][local_idx], | |
| ) | |
| slots.append(slot) | |
| total_mass += weight | |
| continue | |
| consensus_slot_ids = [] | |
| consensus_phone_ids = [] | |
| for slot_idx, slot in enumerate(slots): | |
| top_idx = slot_top_phone(slot['counts'], eps_index) | |
| if top_idx is not None: | |
| consensus_slot_ids.append(slot_idx) | |
| consensus_phone_ids.append(top_idx) | |
| consensus_times = [slot_time(slots[slot_idx]) for slot_idx in consensus_slot_ids] | |
| pairs = align_token_sequences( | |
| consensus_phone_ids, | |
| phone_ids, | |
| ref_times=consensus_times, | |
| hyp_times=hyp.get('phone_times'), | |
| hyp_confidences=hyp.get('phone_confidences'), | |
| time_overlap_reward=0.3, | |
| ) | |
| cursor = 0 | |
| matched_slots = set() | |
| for ref_pos, hyp_pos in pairs: | |
| if ref_pos is None: | |
| insert_at = consensus_slot_ids[cursor] if cursor < len(consensus_slot_ids) else len(slots) | |
| slot = insert_slot(slots, insert_at, eps_index, total_mass) | |
| quality = float(np.clip( | |
| 0.5 * hyp['phone_confidences'][hyp_pos] + 0.5 * hyp['phone_acoustic_supports'][hyp_pos], | |
| 0.0, | |
| 1.0, | |
| )) | |
| add_slot_phone_mass( | |
| slot, | |
| phone_ids[hyp_pos], | |
| weight * quality, | |
| eps_index, | |
| eps_mass=weight * (1.0 - quality), | |
| phone_time=hyp['phone_times'][hyp_pos] if hyp_pos < len(hyp['phone_times']) else None, | |
| confidence=hyp['phone_confidences'][hyp_pos], | |
| ) | |
| cursor += 1 | |
| else: | |
| slot_idx = consensus_slot_ids[ref_pos] | |
| matched_slots.add(slot_idx) | |
| cursor = ref_pos + 1 | |
| if hyp_pos is None: | |
| slots[slot_idx]['counts'][eps_index] += weight | |
| else: | |
| quality = float(np.clip( | |
| 0.5 * hyp['phone_confidences'][hyp_pos] + 0.5 * hyp['phone_acoustic_supports'][hyp_pos], | |
| 0.0, | |
| 1.0, | |
| )) | |
| add_slot_phone_mass( | |
| slots[slot_idx], | |
| phone_ids[hyp_pos], | |
| weight * quality, | |
| eps_index, | |
| eps_mass=weight * (1.0 - quality), | |
| phone_time=hyp['phone_times'][hyp_pos] if hyp_pos < len(hyp['phone_times']) else None, | |
| confidence=hyp['phone_confidences'][hyp_pos], | |
| ) | |
| for slot_idx, slot in enumerate(slots): | |
| if slot_idx not in matched_slots and sum(slot['counts'].values()) < total_mass + weight - EPS: | |
| if slot_top_phone(slot['counts'], eps_index) is not None: | |
| slot['counts'][eps_index] += weight | |
| total_mass += weight | |
| if not slots: | |
| slot = {'counts': Counter()} | |
| slot['counts'][eps_index] = 1.0 | |
| slots = [slot] | |
| total_mass = 1.0 | |
| cn_post = np.zeros((len(slots), phone_dim), dtype=np.float32) | |
| top_phone_ids = [] | |
| slot_times = [] | |
| slot_confidences = [] | |
| for slot_idx, slot in enumerate(slots): | |
| slot_mass = sum(slot['counts'].values()) | |
| if slot_mass < total_mass: | |
| slot['counts'][eps_index] += total_mass - slot_mass | |
| for phone_idx, mass in slot['counts'].items(): | |
| cn_post[slot_idx, int(phone_idx)] = float(mass) / max(float(total_mass), EPS) | |
| cn_post[slot_idx] /= np.clip(cn_post[slot_idx].sum(), EPS, None) | |
| top_phone_ids.append(slot_top_phone(slot['counts'], eps_index)) | |
| slot_times.append(slot_time(slot)) | |
| conf_mass = max(float(slot.get('confidence_mass', 0.0)), EPS) | |
| slot_confidences.append(float(np.clip(slot.get('confidence_sum', 0.0) / conf_mass, 0.0, 1.0))) | |
| return { | |
| 'cn_post': cn_post, | |
| 'top_phone_ids': top_phone_ids, | |
| 'slot_times': slot_times, | |
| 'slot_confidences': slot_confidences, | |
| 'hyp_phone_rows': hyp_phone_rows, | |
| 'hyp_weights': hyp_weights.tolist(), | |
| 'id_to_phone': id_to_phone, | |
| 'eps_index': eps_index, | |
| } | |
| def top_hyp_word_ids_for_slots(pcn): | |
| slot_word_ids = np.zeros((len(pcn['top_phone_ids']),), dtype=np.int32) - 1 | |
| if not pcn['hyp_phone_rows']: | |
| return slot_word_ids | |
| top_hyp = pcn['hyp_phone_rows'][0] | |
| hyp_phone_ids = list(top_hyp['phone_ids']) | |
| hyp_phone_to_word = list(top_hyp['phone_to_word']) | |
| consensus_slot_ids = [] | |
| consensus_phone_ids = [] | |
| for slot_idx, phone_idx in enumerate(pcn['top_phone_ids']): | |
| if phone_idx is not None: | |
| consensus_slot_ids.append(slot_idx) | |
| consensus_phone_ids.append(phone_idx) | |
| pairs = align_token_sequences(consensus_phone_ids, hyp_phone_ids) | |
| for ref_pos, hyp_pos in pairs: | |
| if ref_pos is None or hyp_pos is None: | |
| continue | |
| slot_idx = consensus_slot_ids[ref_pos] | |
| if hyp_pos < len(hyp_phone_to_word): | |
| slot_word_ids[slot_idx] = int(hyp_phone_to_word[hyp_pos]) | |
| return slot_word_ids | |
| def pcn_stats(cn_post, top_phone_ids, prev_top_phone_ids, eps_index): | |
| stats = np.zeros((cn_post.shape[0], 5), dtype=np.float32) | |
| stable_prefix = 0 | |
| for idx, phone_idx in enumerate(top_phone_ids): | |
| if idx < len(prev_top_phone_ids) and phone_idx == prev_top_phone_ids[idx]: | |
| stable_prefix += 1 | |
| else: | |
| break | |
| prefix_stability = float(stable_prefix) / float(max(len(top_phone_ids), 1)) | |
| for idx, probs in enumerate(cn_post): | |
| non_eps = np.delete(probs, eps_index) | |
| top1, margin = top_margin(non_eps) | |
| stats[idx] = np.array( | |
| [ | |
| float(probs[eps_index]), | |
| entropy_np(probs), | |
| top1, | |
| margin, | |
| prefix_stability, | |
| ], | |
| dtype=np.float32, | |
| ) | |
| return stats, prefix_stability | |
| def validate_pcn(pcn): | |
| cn_post = np.asarray(pcn['cn_post'], dtype=np.float32) | |
| if not np.isfinite(cn_post).all(): | |
| raise ValueError('pcn_contains_nan_or_inf') | |
| sums = cn_post.sum(axis=-1) | |
| if not np.allclose(sums, 1.0, atol=1e-3): | |
| raise ValueError(f'pcn_posterior_not_normalized:max_abs_err={float(np.max(np.abs(sums - 1.0)))}') | |
| eps_index = int(pcn['eps_index']) | |
| if eps_index < 0 or eps_index >= cn_post.shape[-1]: | |
| raise ValueError('invalid_epsilon_index') | |
| eps_probs = cn_post[:, eps_index] | |
| if np.any(eps_probs < -1e-6) or np.any(eps_probs > 1.0 + 1e-6): | |
| raise ValueError('epsilon_probability_out_of_range') | |
| for row in pcn.get('hyp_phone_rows', []): | |
| for value in row.get('phone_confidences', []): | |
| if not np.isfinite(value) or value < -1e-6 or value > 1.0 + 1e-6: | |
| raise ValueError('phone_confidence_out_of_range') | |
| for value in pcn.get('slot_confidences', []): | |
| if not np.isfinite(value) or value < -1e-6 or value > 1.0 + 1e-6: | |
| raise ValueError('slot_confidence_out_of_range') | |
| def cumulative_candidate_mask_from_asr(pcn_word_id, word_timestamps, commit_time, audio_end, is_final): | |
| mask = np.zeros((pcn_word_id.shape[0],), dtype=np.float32) | |
| committed_words = set() | |
| for word_idx, word_time in enumerate(word_timestamps or []): | |
| end_time = float(word_time.get('end', 0.0)) | |
| if end_time <= commit_time + 1e-6 or (is_final and end_time <= audio_end + 1e-6): | |
| committed_words.add(int(word_idx)) | |
| for slot_idx, word_idx in enumerate(pcn_word_id.tolist()): | |
| if int(word_idx) in committed_words: | |
| mask[slot_idx] = 1.0 | |
| return mask | |
| def align_previous_commits(prev_state, current_top_phone_ids): | |
| if prev_state is None: | |
| return {}, [], [] | |
| prev_top_phone_ids = prev_state.get('top_phone_ids', []) | |
| prev_committed = set(int(x) for x in prev_state.get('committed_slots', [])) | |
| pairs = align_token_sequences(prev_top_phone_ids, current_top_phone_ids, mismatch_cost=1.0) | |
| prev_to_current = {} | |
| for prev_idx, cur_idx in pairs: | |
| if prev_idx is None or cur_idx is None: | |
| continue | |
| if prev_idx < len(prev_top_phone_ids) and cur_idx < len(current_top_phone_ids): | |
| if prev_top_phone_ids[prev_idx] == current_top_phone_ids[cur_idx]: | |
| prev_to_current[int(prev_idx)] = int(cur_idx) | |
| mapped_old = sorted(prev_to_current[idx] for idx in prev_committed if idx in prev_to_current) | |
| dropped = sorted(idx for idx in prev_committed if idx not in prev_to_current) | |
| return prev_to_current, mapped_old, dropped | |
| def _top_word_timestamps(word_timestamps): | |
| rows = word_timestamps or [] | |
| if rows and isinstance(rows[0], list): | |
| rows = rows[0] | |
| return rows if isinstance(rows, list) else [] | |
| def _commit_word_metadata(word_timestamps, word_idx): | |
| rows = _top_word_timestamps(word_timestamps) | |
| row = rows[word_idx] if 0 <= int(word_idx) < len(rows) and isinstance(rows[word_idx], dict) else {} | |
| text = normalize_word(str(row.get('word', ''))).casefold() | |
| try: | |
| start = float(row.get('start')) | |
| end = float(row.get('end')) | |
| except (TypeError, ValueError): | |
| start = None | |
| end = None | |
| return {'text': text, 'start': start, 'end': end} | |
| def _commit_time_iou(left, right): | |
| if any(item.get(key) is None for item in (left, right) for key in ('start', 'end')): | |
| return 0.0 | |
| overlap = max(0.0, min(left['end'], right['end']) - max(left['start'], right['start'])) | |
| union = max(left['end'], right['end']) - min(left['start'], right['start']) | |
| return overlap / union if union > 0 else 0.0 | |
| def _word_level_lineage(prev_state, pcn_word_id, current_to_prev, word_timestamps): | |
| """Build deterministic one-to-one predecessor decisions without using supervision.""" | |
| current_positions = { | |
| word_idx: np.flatnonzero(pcn_word_id == word_idx) | |
| for word_idx in sorted(set(int(value) for value in pcn_word_id.tolist() if int(value) >= 0)) | |
| } | |
| if prev_state is None: | |
| return current_positions, { | |
| word_idx: { | |
| 'candidate_prev_word_id': None, | |
| 'selected_prev_word_id': None, | |
| 'predecessor_word_ids': [], | |
| 'vote_counts': {}, | |
| 'mapping_status': 'first_seen', | |
| 'split_competitor_count': 0, | |
| 'reliable': True, | |
| 'text_equal': False, | |
| 'time_iou': 0.0, | |
| } | |
| for word_idx in current_positions | |
| } | |
| prev_word_ids = np.asarray(prev_state.get('pcn_word_id', []), dtype=np.int32) | |
| prev_word_states = prev_state.get('word_states', {}) | |
| decisions = {} | |
| competitors_by_prev = {} | |
| for word_idx, positions in current_positions.items(): | |
| votes = Counter() | |
| for position in positions.tolist(): | |
| old_slot = int(current_to_prev[position]) if position < len(current_to_prev) else -1 | |
| if 0 <= old_slot < len(prev_word_ids): | |
| prev_word_idx = int(prev_word_ids[old_slot]) | |
| if prev_word_idx >= 0: | |
| votes[prev_word_idx] += 1 | |
| strict = [key for key, count in votes.items() if count > len(positions) / 2.0] | |
| decision = { | |
| 'candidate_prev_word_id': None, | |
| 'selected_prev_word_id': None, | |
| 'predecessor_word_ids': sorted(int(key) for key in votes), | |
| 'vote_counts': {str(key): int(value) for key, value in sorted(votes.items())}, | |
| 'mapping_status': 'unmatched_new' if not votes else 'ambiguous_no_majority', | |
| 'split_competitor_count': 0, | |
| 'reliable': not votes, | |
| 'text_equal': False, | |
| 'time_iou': 0.0, | |
| } | |
| if len(strict) == 1: | |
| prev_word_idx = int(strict[0]) | |
| prev_meta = prev_word_states.get(prev_word_idx, {}).get('metadata', {}) | |
| cur_meta = _commit_word_metadata(word_timestamps, word_idx) | |
| decision.update({ | |
| 'candidate_prev_word_id': prev_word_idx, | |
| 'mapping_status': 'candidate', | |
| 'text_equal': bool(prev_meta.get('text') and cur_meta['text'] and prev_meta['text'] == cur_meta['text']), | |
| 'time_iou': float(_commit_time_iou(prev_meta, cur_meta)), | |
| }) | |
| competitors_by_prev.setdefault(prev_word_idx, []).append((word_idx, decision)) | |
| decisions[word_idx] = decision | |
| # A strict-majority edge is unique for each current word. Selecting the | |
| # highest-weight edge for each predecessor is therefore the global maximum | |
| # weight one-to-one matching for this candidate graph. | |
| for prev_word_idx, competitors in sorted(competitors_by_prev.items()): | |
| for _, decision in competitors: | |
| decision['split_competitor_count'] = len(competitors) | |
| ranked = sorted( | |
| competitors, | |
| key=lambda item: ( | |
| -int(item[1]['vote_counts'][str(prev_word_idx)]), | |
| -round(float(item[1]['time_iou']), 12), | |
| -int(item[1]['text_equal']), | |
| int(item[0]), | |
| ), | |
| ) | |
| winner_key = ( | |
| int(ranked[0][1]['vote_counts'][str(prev_word_idx)]), | |
| round(float(ranked[0][1]['time_iou']), 12), | |
| int(ranked[0][1]['text_equal']), | |
| ) | |
| tied = [ | |
| item for item in ranked | |
| if ( | |
| int(item[1]['vote_counts'][str(prev_word_idx)]), | |
| round(float(item[1]['time_iou']), 12), | |
| int(item[1]['text_equal']), | |
| ) == winner_key | |
| ] | |
| if len(tied) == 1: | |
| _, winner = ranked[0] | |
| winner['selected_prev_word_id'] = int(prev_word_idx) | |
| winner['mapping_status'] = 'accepted' | |
| winner['reliable'] = True | |
| else: | |
| for _, decision in tied: | |
| decision['mapping_status'] = 'ambiguous_competition_tie' | |
| decision['reliable'] = False | |
| for _, loser in ranked[1:]: | |
| if loser['mapping_status'] == 'candidate': | |
| loser['mapping_status'] = 'split_new_successor' | |
| loser['reliable'] = True | |
| return current_positions, decisions | |
| def build_stateful_commit_masks( | |
| prev_state, | |
| pcn, | |
| pcn_word_id, | |
| word_timestamps, | |
| commit_time, | |
| audio_end, | |
| is_final, | |
| current_to_prev_override=None, | |
| commit_rule='legacy', | |
| ): | |
| if commit_rule not in {'legacy', 'merge_aware'}: | |
| raise ValueError(f"commit_rule must be 'legacy' or 'merge_aware', got {commit_rule!r}") | |
| word_timestamps = _top_word_timestamps(word_timestamps) | |
| candidate = cumulative_candidate_mask_from_asr(pcn_word_id, word_timestamps, commit_time, audio_end, is_final) | |
| if current_to_prev_override is None: | |
| prev_to_current, mapped_old_slots, dropped_or_revised = align_previous_commits(prev_state, pcn['top_phone_ids']) | |
| current_to_prev = np.zeros((len(pcn['top_phone_ids']),), dtype=np.int32) - 1 | |
| for prev_idx, cur_idx in prev_to_current.items(): | |
| if 0 <= cur_idx < current_to_prev.shape[0]: | |
| current_to_prev[cur_idx] = int(prev_idx) | |
| else: | |
| current_to_prev = np.asarray(current_to_prev_override, dtype=np.int32).copy() | |
| prev_to_current = { | |
| int(prev_idx): int(cur_idx) | |
| for cur_idx, prev_idx in enumerate(current_to_prev.tolist()) | |
| if int(prev_idx) >= 0 | |
| } | |
| prev_committed = set(int(value) for value in (prev_state or {}).get('committed_slots', [])) | |
| mapped_old_slots = sorted(cur for prev, cur in prev_to_current.items() if prev in prev_committed) | |
| dropped_or_revised = sorted(prev for prev in prev_committed if prev not in prev_to_current) | |
| mapped_old_set = set(mapped_old_slots) | |
| cumulative = candidate.copy() | |
| for slot_idx in mapped_old_set: | |
| if 0 <= slot_idx < cumulative.shape[0]: | |
| cumulative[slot_idx] = 1.0 | |
| if commit_rule == 'legacy': | |
| new_mask = np.zeros_like(cumulative) | |
| for word_idx in sorted(set(int(value) for value in pcn_word_id.tolist() if int(value) >= 0)): | |
| positions = np.flatnonzero(pcn_word_id == word_idx) | |
| if positions.size == 0 or not bool(np.all(candidate[positions] > 0)): | |
| continue | |
| if any(int(position) in mapped_old_set for position in positions.tolist()): | |
| continue | |
| new_mask[positions] = 1.0 | |
| committed_slots = sorted(np.flatnonzero(cumulative > 0).astype(int).tolist()) | |
| new_slots = sorted(np.flatnonzero(new_mask > 0).astype(int).tolist()) | |
| diagnostics = { | |
| 'mapped_old_slots': mapped_old_slots, | |
| 'new_slots': new_slots, | |
| 'dropped_or_revised_slots': dropped_or_revised, | |
| 'state_event_semantics': 'legacy_slot_any_v1', | |
| } | |
| next_state = { | |
| 'top_phone_ids': list(pcn['top_phone_ids']), | |
| 'committed_slots': committed_slots, | |
| } | |
| return cumulative.astype(np.float32), new_mask.astype(np.float32), current_to_prev, diagnostics, next_state | |
| current_positions, lineage = _word_level_lineage( | |
| prev_state, | |
| np.asarray(pcn_word_id, dtype=np.int32), | |
| current_to_prev, | |
| word_timestamps, | |
| ) | |
| prev_word_states = (prev_state or {}).get('word_states', {}) | |
| next_logical_id = int((prev_state or {}).get('next_logical_word_id', 0)) | |
| next_event_id = int((prev_state or {}).get('next_state_event_id', 0)) | |
| next_word_states = {} | |
| word_event_rows = [] | |
| new_mask = np.zeros_like(cumulative) | |
| for word_idx, positions in sorted(current_positions.items()): | |
| decision = lineage[word_idx] | |
| selected_prev = decision['selected_prev_word_id'] | |
| predecessor_ids = decision['predecessor_word_ids'] | |
| predecessor_states = [prev_word_states.get(value, {}) for value in predecessor_ids] | |
| predecessor_committed = [bool(value.get('committed', False)) for value in predecessor_states] | |
| completed = bool(np.all(candidate[positions] > 0)) | |
| split = int(decision['split_competitor_count']) > 1 | |
| merge = len(predecessor_ids) > 1 | |
| inherited_state = prev_word_states.get(selected_prev, {}) if selected_prev is not None else {} | |
| has_old_committed_slot = any(int(position) in mapped_old_set for position in positions.tolist()) | |
| prev_word_ids_array = np.asarray((prev_state or {}).get('pcn_word_id', []), dtype=np.int32) | |
| prev_committed_slots = set(int(value) for value in (prev_state or {}).get('committed_slots', [])) | |
| unassigned_committed_predecessors = 0 | |
| for position in positions.tolist(): | |
| old_slot = int(current_to_prev[position]) if position < len(current_to_prev) else -1 | |
| if old_slot not in prev_committed_slots: | |
| continue | |
| if old_slot >= len(prev_word_ids_array) or int(prev_word_ids_array[old_slot]) < 0: | |
| unassigned_committed_predecessors += 1 | |
| if unassigned_committed_predecessors: | |
| decision['mapping_reliable'] = False | |
| decision['reliable'] = False | |
| decision['mapping_status'] = 'lineage_broken_unassigned_committed_slot' | |
| legacy_emit = completed and not has_old_committed_slot | |
| mixed_merge = bool( | |
| merge | |
| and predecessor_committed | |
| and any(predecessor_committed) | |
| and not all(predecessor_committed) | |
| ) | |
| partial_word_completed = bool( | |
| completed | |
| and selected_prev is not None | |
| and not inherited_state.get('committed', False) | |
| and not merge | |
| and bool(decision['reliable']) | |
| ) | |
| # A3-Lite is intentionally minimal: reproduce the legacy mask exactly, | |
| # then add one event for a completed mixed committed/partial merge or | |
| # for a reliably inherited partial word that has now become complete. | |
| emit_event = bool(legacy_emit or (completed and mixed_merge) or partial_word_completed) | |
| committed = bool(completed or inherited_state.get('committed', False)) | |
| if selected_prev is not None and not (completed and mixed_merge): | |
| logical_id = int(inherited_state['logical_word_id']) | |
| else: | |
| logical_id = next_logical_id | |
| next_logical_id += 1 | |
| if completed and mixed_merge: | |
| event_reason = 'mixed_committed_partial_merge_completed' | |
| elif mixed_merge: | |
| event_reason = 'mixed_committed_partial_merge_partial' | |
| elif split: | |
| event_reason = 'split_completed' if completed else 'split_partial' | |
| elif merge: | |
| if completed: | |
| event_reason = 'merge_completed' if emit_event else 'merge_completed_legacy_suppressed' | |
| else: | |
| event_reason = 'merge_partial' | |
| elif unassigned_committed_predecessors and completed and not emit_event: | |
| event_reason = 'lineage_broken_completed_legacy_suppressed' | |
| elif selected_prev is not None and inherited_state.get('committed', False): | |
| event_reason = 'persistent_committed' | |
| elif selected_prev is not None: | |
| event_reason = 'partial_became_completed' if completed else 'partial_persistent' | |
| elif predecessor_ids: | |
| event_reason = 'ambiguous_completed' if completed else 'ambiguous_partial' | |
| else: | |
| event_reason = 'newly_completed' if completed else 'new_partial' | |
| state_event_id = -1 | |
| if emit_event: | |
| new_mask[positions] = 1.0 | |
| state_event_id = next_event_id | |
| next_event_id += 1 | |
| metadata = _commit_word_metadata(word_timestamps, word_idx) | |
| next_word_states[int(word_idx)] = { | |
| 'logical_word_id': int(logical_id), | |
| 'committed': bool(committed), | |
| 'state_event_id': int(state_event_id if state_event_id >= 0 else inherited_state.get('state_event_id', -1)), | |
| 'metadata': metadata, | |
| } | |
| word_event_rows.append({ | |
| 'current_word_id': int(word_idx), | |
| 'logical_word_id': int(logical_id), | |
| 'state_event_id': int(state_event_id), | |
| 'completed': bool(completed), | |
| 'emit_state_event': bool(emit_event), | |
| 'event_reason': event_reason, | |
| 'mapping_status': decision['mapping_status'], | |
| 'mapping_reliable': bool(decision['reliable']), | |
| 'selected_prev_word_id': selected_prev, | |
| 'predecessor_word_ids': predecessor_ids, | |
| 'predecessor_committed': predecessor_committed, | |
| 'vote_counts': decision['vote_counts'], | |
| 'split_competitor_count': int(decision['split_competitor_count']), | |
| 'text': metadata['text'], | |
| 'text_equal': bool(decision['text_equal']), | |
| 'time_iou': float(decision['time_iou']), | |
| 'unassigned_committed_predecessor_slots': int(unassigned_committed_predecessors), | |
| }) | |
| committed_slots = sorted(np.flatnonzero(cumulative > 0).astype(int).tolist()) | |
| new_slots = sorted(np.flatnonzero(new_mask > 0).astype(int).tolist()) | |
| diagnostics = { | |
| 'mapped_old_slots': mapped_old_slots, | |
| 'new_slots': new_slots, | |
| 'dropped_or_revised_slots': dropped_or_revised, | |
| 'word_level_state_events': word_event_rows, | |
| 'state_event_semantics': 'word_predecessor_one_to_one_v1', | |
| } | |
| next_state = { | |
| 'top_phone_ids': list(pcn['top_phone_ids']), | |
| 'pcn_word_id': np.asarray(pcn_word_id, dtype=np.int32).tolist(), | |
| 'committed_slots': committed_slots, | |
| 'word_states': next_word_states, | |
| 'next_logical_word_id': int(next_logical_id), | |
| 'next_state_event_id': int(next_event_id), | |
| } | |
| return cumulative.astype(np.float32), new_mask.astype(np.float32), current_to_prev, diagnostics, next_state | |
| def acoustic_distribution_for_frames(frame_probs, phone_to_frame_id, phn_dict, phone_dim): | |
| out = np.zeros((phone_dim,), dtype=np.float32) | |
| if frame_probs.size == 0: | |
| return out | |
| mean_probs = frame_probs.mean(axis=0) | |
| for phone, phone_idx in phn_dict.items(): | |
| frame_idx = phone_to_frame_id.get(phone) | |
| if frame_idx is not None and frame_idx < mean_probs.shape[0]: | |
| out[int(phone_idx)] = float(mean_probs[frame_idx]) | |
| if out.sum() > 0: | |
| out /= out.sum() | |
| return out | |
| def build_acoustic_evidence(item, cn_post, top_phone_ids, phone_to_frame_id, phn_dict, audio_end): | |
| phone_dim = cn_post.shape[-1] | |
| acoustic_post = np.zeros_like(cn_post, dtype=np.float32) | |
| acoustic_stats = np.zeros((cn_post.shape[0], 4), dtype=np.float32) | |
| kept_indices, kept_probs = select_visible_frames(item['probs'], item['keep_mask'], audio_end, item['frame_step']) | |
| alignable = [ | |
| (slot_idx, phone_idx) | |
| for slot_idx, phone_idx in enumerate(top_phone_ids) | |
| if phone_idx is not None | |
| ] | |
| if kept_probs.shape[0] < len(alignable) or not alignable: | |
| for slot_idx in range(cn_post.shape[0]): | |
| acoustic_stats[slot_idx, 3] = js_divergence(cn_post[slot_idx], acoustic_post[slot_idx]) | |
| return acoustic_post, acoustic_stats, 0 | |
| id_to_phone = {idx: phone for phone, idx in phn_dict.items()} | |
| frame_phone_ids = [phone_to_frame_id[id_to_phone[phone_idx]] for _, phone_idx in alignable] | |
| try: | |
| path = monotonic_align(-np.log(np.clip(kept_probs, EPS, None)), frame_phone_ids) | |
| except Exception: | |
| for slot_idx in range(cn_post.shape[0]): | |
| acoustic_stats[slot_idx, 3] = js_divergence(cn_post[slot_idx], acoustic_post[slot_idx]) | |
| return acoustic_post, acoustic_stats, 0 | |
| for local_idx, (slot_idx, _) in enumerate(alignable): | |
| tok_frames = kept_indices[path == local_idx] | |
| if tok_frames.size == 0: | |
| acoustic_stats[slot_idx, 3] = js_divergence(cn_post[slot_idx], acoustic_post[slot_idx]) | |
| continue | |
| dist = acoustic_distribution_for_frames( | |
| item['probs'][tok_frames], | |
| phone_to_frame_id, | |
| phn_dict, | |
| phone_dim, | |
| ) | |
| acoustic_post[slot_idx] = dist | |
| non_eps = dist[:-1] | |
| _, margin = top_margin(non_eps) | |
| duration = float(tok_frames.size * item['frame_step']) | |
| acoustic_stats[slot_idx] = np.array( | |
| [ | |
| entropy_np(dist), | |
| margin, | |
| duration, | |
| js_divergence(cn_post[slot_idx], dist), | |
| ], | |
| dtype=np.float32, | |
| ) | |
| return acoustic_post, acoustic_stats, int(kept_probs.shape[0]) | |
| def compute_prosody(audio, sample_rate, audio_end, word_count, phone_count): | |
| audio = np.asarray(audio, dtype=np.float32) | |
| duration = float(max(audio_end, EPS)) | |
| if audio.size == 0: | |
| return np.zeros((14,), dtype=np.float32) | |
| hop_length = max(int(0.01 * sample_rate), 1) | |
| frame_length = max(int(0.025 * sample_rate), hop_length) | |
| rms = librosa.feature.rms(y=audio, frame_length=frame_length, hop_length=hop_length)[0] | |
| log_energy = np.log(np.clip(rms, EPS, None)) | |
| energy_threshold = np.percentile(rms, 20) if rms.size else 0.0 | |
| silence = rms <= max(energy_threshold, EPS) | |
| silence_ratio = float(np.mean(silence)) if silence.size else 0.0 | |
| pause_count, longest_pause = count_pauses(silence, hop_length / sample_rate) | |
| try: | |
| f0, voiced_flag, voiced_prob = librosa.pyin( | |
| audio, | |
| fmin=50, | |
| fmax=500, | |
| sr=sample_rate, | |
| frame_length=frame_length, | |
| hop_length=hop_length, | |
| ) | |
| voiced = np.isfinite(f0) | |
| f0_values = f0[voiced] | |
| voiced_prob_mean = float(np.nanmean(voiced_prob)) if voiced_prob is not None else float(np.mean(voiced)) | |
| except Exception: | |
| f0_values = np.array([], dtype=np.float32) | |
| voiced = np.zeros_like(rms, dtype=bool) | |
| voiced_prob_mean = 0.0 | |
| f0_mean = float(np.mean(f0_values)) if f0_values.size else 0.0 | |
| f0_std = float(np.std(f0_values)) if f0_values.size else 0.0 | |
| f0_slope = slope(f0_values) | |
| energy_mean = float(np.mean(log_energy)) if log_energy.size else 0.0 | |
| energy_std = float(np.std(log_energy)) if log_energy.size else 0.0 | |
| energy_slope = slope(log_energy) | |
| voiced_duration = max(float(np.mean(voiced)) * duration, EPS) if voiced.size else EPS | |
| return np.array( | |
| [ | |
| duration, | |
| f0_mean, | |
| f0_std, | |
| f0_slope, | |
| voiced_prob_mean, | |
| energy_mean, | |
| energy_std, | |
| energy_slope, | |
| silence_ratio, | |
| float(pause_count), | |
| float(longest_pause), | |
| float(word_count) / duration, | |
| float(phone_count) / duration, | |
| float(phone_count) / voiced_duration, | |
| ], | |
| dtype=np.float32, | |
| ) | |
| def slot_prosody_feature_names(): | |
| return [ | |
| 'slot_duration', | |
| 'slot_log_energy_mean', | |
| 'slot_log_energy_std', | |
| 'slot_log_energy_max', | |
| 'slot_f0_mean', | |
| 'slot_f0_std', | |
| 'slot_f0_max', | |
| 'slot_voiced_ratio', | |
| 'slot_position_in_word', | |
| 'word_phone_count', | |
| 'energy_relative_to_word_mean', | |
| 'duration_relative_to_word_mean', | |
| 'f0_relative_to_word_mean', | |
| 'is_vowel', | |
| 'lexical_stress_0', | |
| 'lexical_stress_1', | |
| 'lexical_stress_2', | |
| ] | |
| def compute_slot_prosody(audio, sample_rate, slot_times, pcn_word_id, top_phone_ids, phn_dict): | |
| feature_names = slot_prosody_feature_names() | |
| slot_count = len(top_phone_ids) | |
| out = np.zeros((slot_count, len(feature_names)), dtype=np.float32) | |
| if slot_count <= 0: | |
| return out | |
| audio = np.asarray(audio, dtype=np.float32) | |
| id_to_phone = {int(idx): phone for phone, idx in phn_dict.items()} | |
| hop_length = max(int(0.01 * sample_rate), 1) | |
| frame_length = max(int(0.025 * sample_rate), hop_length) | |
| if audio.size: | |
| rms = librosa.feature.rms(y=audio, frame_length=frame_length, hop_length=hop_length)[0] | |
| log_energy = np.log(np.clip(rms, EPS, None)) | |
| frame_times = librosa.frames_to_time(np.arange(rms.shape[0]), sr=sample_rate, hop_length=hop_length) | |
| else: | |
| log_energy = np.zeros((0,), dtype=np.float32) | |
| frame_times = np.zeros((0,), dtype=np.float32) | |
| try: | |
| f0, _, _ = librosa.pyin( | |
| audio, | |
| fmin=50, | |
| fmax=500, | |
| sr=sample_rate, | |
| frame_length=frame_length, | |
| hop_length=hop_length, | |
| ) | |
| except Exception: | |
| f0 = np.zeros_like(log_energy, dtype=np.float32) * np.nan | |
| if f0 is None: | |
| f0 = np.zeros_like(log_energy, dtype=np.float32) * np.nan | |
| f0 = np.asarray(f0, dtype=np.float32) | |
| common_frames = min(frame_times.shape[0], log_energy.shape[0], f0.shape[0]) | |
| frame_times = frame_times[:common_frames] | |
| log_energy = log_energy[:common_frames] | |
| f0 = f0[:common_frames] | |
| durations = np.zeros((slot_count,), dtype=np.float32) | |
| energy_means = np.zeros((slot_count,), dtype=np.float32) | |
| f0_means = np.zeros((slot_count,), dtype=np.float32) | |
| pcn_word_id = np.asarray(pcn_word_id, dtype=np.int32) | |
| for slot_idx in range(slot_count): | |
| if slot_idx < len(slot_times) and slot_times[slot_idx] is not None: | |
| start, end = slot_times[slot_idx] | |
| start = float(start) | |
| end = float(max(end, start)) | |
| else: | |
| start = 0.0 | |
| end = 0.0 | |
| durations[slot_idx] = max(end - start, 0.0) | |
| frame_mask = (frame_times >= start) & (frame_times <= end) if frame_times.size else np.zeros((0,), dtype=bool) | |
| energy_values = log_energy[frame_mask] | |
| f0_values = f0[frame_mask] | |
| f0_values = f0_values[np.isfinite(f0_values)] | |
| if energy_values.size: | |
| out[slot_idx, 1] = float(np.mean(energy_values)) | |
| out[slot_idx, 2] = float(np.std(energy_values)) | |
| out[slot_idx, 3] = float(np.max(energy_values)) | |
| energy_means[slot_idx] = out[slot_idx, 1] | |
| if f0_values.size: | |
| out[slot_idx, 4] = float(np.mean(f0_values)) | |
| out[slot_idx, 5] = float(np.std(f0_values)) | |
| out[slot_idx, 6] = float(np.max(f0_values)) | |
| out[slot_idx, 7] = float(f0_values.size / max(frame_mask.sum(), 1)) | |
| f0_means[slot_idx] = out[slot_idx, 4] | |
| out[slot_idx, 0] = durations[slot_idx] | |
| word_idx = int(pcn_word_id[slot_idx]) if slot_idx < pcn_word_id.shape[0] else -1 | |
| same_word = np.flatnonzero(pcn_word_id == word_idx) if word_idx >= 0 else np.asarray([slot_idx], dtype=np.int64) | |
| if same_word.size: | |
| position = np.flatnonzero(same_word == slot_idx) | |
| out[slot_idx, 8] = float(position[0] / max(same_word.size - 1, 1)) if position.size else 0.0 | |
| out[slot_idx, 9] = float(same_word.size) | |
| phone_idx = top_phone_ids[slot_idx] if slot_idx < len(top_phone_ids) else None | |
| phone = id_to_phone.get(int(phone_idx), '') if phone_idx is not None else '' | |
| out[slot_idx, 13] = 1.0 if phone[:1].upper() in {'A', 'E', 'I', 'O', 'U'} else 0.0 | |
| for slot_idx in range(slot_count): | |
| word_idx = int(pcn_word_id[slot_idx]) if slot_idx < pcn_word_id.shape[0] else -1 | |
| same_word = np.flatnonzero(pcn_word_id == word_idx) if word_idx >= 0 else np.asarray([slot_idx], dtype=np.int64) | |
| if same_word.size: | |
| word_energy = float(np.mean(energy_means[same_word])) | |
| word_duration = float(np.mean(durations[same_word])) | |
| word_f0 = float(np.mean(f0_means[same_word])) | |
| out[slot_idx, 10] = out[slot_idx, 1] - word_energy | |
| out[slot_idx, 11] = durations[slot_idx] / max(word_duration, EPS) | |
| out[slot_idx, 12] = out[slot_idx, 4] - word_f0 | |
| return out.astype(np.float32) | |
| def slope(values): | |
| values = np.asarray(values, dtype=np.float32) | |
| if values.size < 2: | |
| return 0.0 | |
| x = np.arange(values.size, dtype=np.float32) | |
| x = x - x.mean() | |
| y = values - values.mean() | |
| denom = float(np.sum(x * x)) | |
| if denom <= EPS: | |
| return 0.0 | |
| return float(np.sum(x * y) / denom) | |
| def count_pauses(silence_mask, frame_step): | |
| pause_count = 0 | |
| longest = 0.0 | |
| cur = 0 | |
| for value in silence_mask.tolist(): | |
| if value: | |
| cur += 1 | |
| elif cur > 0: | |
| pause_count += 1 | |
| longest = max(longest, cur * frame_step) | |
| cur = 0 | |
| if cur > 0: | |
| pause_count += 1 | |
| longest = max(longest, cur * frame_step) | |
| return pause_count, longest | |