"""语音识别服务:faster-whisper,输出带词级时间戳的 segments。 CPU dev: tiny.en + int8;GPU prod: large-v3-turbo + float16。同一份代码。 """ from __future__ import annotations import logging import math from pathlib import Path from typing import Callable from ..config import get_settings from .model_manager import get_model_manager from .types import Segment, Word logger = logging.getLogger("audio2text.asr") # Whisper 默认 chunk_length(秒):BatchedInferencePipeline 按 30s 窗口切音频 _CHUNK_SECONDS = 30.0 def transcribe( wav_path: Path, on_progress: Callable[[int, int], None] | None = None, ) -> list[Segment]: """转写 wav,返回 segments(含词级时间戳)。 Args: wav_path: 16kHz mono PCM wav on_progress: 可选进度回调 (current_chunk, total_chunks)。 每 transcribe 完一个 30s chunk 调一次,用于细分进度条。 Returns: list[Segment],每个 Segment 带词级 words(若 word_timestamps 启用)。 """ s = get_settings().asr if not wav_path.is_file(): raise FileNotFoundError(f"音频不存在:{wav_path}") model = get_model_manager().get_asr() logger.debug("开始转写 %s(model=%s language=%s batch_size=%d beam_size=%d)", wav_path.name, s.model, s.language, s.batch_size, s.beam_size) segments_gen, info = model.transcribe( str(wav_path), language=s.language, word_timestamps=s.word_timestamps, vad_filter=s.vad_filter, beam_size=s.beam_size, # beam search 宽度(config 可调);GPU turbo 建议降到 2 加速 batch_size=s.batch_size, # 批量解码:多音频块一次性送 GPU without_timestamps=False, # BatchedInferencePipeline 默认 True,需显式关闭以生成段级时间戳 ) # VAD 过滤后的实际语音时长 → 算总 chunk 数(进度颗粒度细分用) duration = info.duration_after_vad or info.duration total_chunks = max(1, math.ceil(duration / _CHUNK_SECONDS)) logger.debug( "音频时长 %.1fs(VAD 后 %.1fs),检测语言=%s(置信度 %.2f),约 %d 个 chunk", info.duration, duration, info.language, info.language_probability, total_chunks, ) if on_progress is not None: on_progress(0, total_chunks) segments: list[Segment] = [] last_chunk = 0 # 已报进度的 chunk 序号(避免同 chunk 内多个 segment 重复回调) for seg in segments_gen: words: list[Word] = [] if s.word_timestamps and getattr(seg, "words", None): for w in seg.words: words.append(Word( text=w.word.strip(), start=float(w.start), end=float(w.end), probability=float(getattr(w, "probability", 1.0)), )) segments.append(Segment( text=seg.text.strip(), start=float(seg.start), end=float(seg.end), words=words, )) # 按 30s chunk 边界报进度:seg.end 跨过 chunk 边界时回调 if on_progress is not None: cur_chunk = min(total_chunks, int(seg.end / _CHUNK_SECONDS) + 1) if cur_chunk > last_chunk: last_chunk = cur_chunk on_progress(cur_chunk, total_chunks) logger.debug("转写完成:%d 段,%d 词。", len(segments), sum(len(seg.words) for seg in segments)) return segments