# -*- coding: utf-8 -*- """ 音频分割模块 基于 VAD 的智能音频分割,支持长音频分段识别 """ import logging import numpy as np import librosa import soundfile as sf import tempfile import os import time from typing import List, Tuple, Optional from dataclasses import dataclass from ..core.config import settings from ..core.exceptions import DefaultServerErrorException logger = logging.getLogger(__name__) def _log_audio_split_timing(stage: str, duration_ms: float, **extra) -> None: payload = { "event": "audio_split_timing", "stage": stage, "duration_ms": round(duration_ms, 2), } payload.update(extra) logger.info("音频分割阶段耗时", extra=payload) @dataclass class AudioSegment: """音频片段信息""" start_ms: int # 开始时间(毫秒) end_ms: int # 结束时间(毫秒) audio_data: Optional[np.ndarray] = None # 音频数据 temp_file: Optional[str] = None # 临时文件路径 speaker_id: Optional[str] = None # 说话人ID(多说话人模式) @property def start_sec(self) -> float: """开始时间(秒)""" return self.start_ms / 1000.0 @property def end_sec(self) -> float: """结束时间(秒)""" return self.end_ms / 1000.0 @property def duration_ms(self) -> int: """时长(毫秒)""" return self.end_ms - self.start_ms @property def duration_sec(self) -> float: """时长(秒)""" return self.duration_ms / 1000.0 class AudioSplitter: """音频分割器 使用 VAD 模型检测语音边界,智能分割长音频 """ # 默认配置 DEFAULT_MIN_SEGMENT_SEC = 1.0 # 每段最小时长(秒) DEFAULT_SAMPLE_RATE = 16000 # 默认采样率 def __init__( self, min_segment_sec: float = DEFAULT_MIN_SEGMENT_SEC, device: str = "auto", ): """初始化音频分割器 Args: min_segment_sec: 每段最小时长(秒) device: 计算设备("cuda", "cpu", "auto") """ split_trigger_sec = settings.MAX_SEGMENT_SEC self.split_trigger_sec = split_trigger_sec self.min_segment_sec = min_segment_sec self.split_trigger_ms = int(split_trigger_sec * 1000) self.min_segment_ms = int(min_segment_sec * 1000) self.device = device def get_vad_segments( self, audio_path: str ) -> List[Tuple[int, int]]: """使用 VAD 模型获取语音段 Args: audio_path: 音频文件路径 Returns: 语音段列表,每个元素为 (start_ms, end_ms) """ try: from ..services.asr.engines import get_global_vad_model logger.info("开始 VAD 语音段检测...") vad_model = get_global_vad_model(self.device) if vad_model is None: raise DefaultServerErrorException("VAD 模型未加载") # 调用 VAD 模型 vad_started = time.perf_counter() result = vad_model.generate(input=audio_path, cache={}) vad_duration_ms = (time.perf_counter() - vad_started) * 1000 if not result or len(result) == 0: _log_audio_split_timing( "vad_generate", vad_duration_ms, audio_path=audio_path, vad_segment_count=0, ) logger.warning("VAD 未检测到语音段") return [] # 解析 VAD 结果 # FunASR VAD 返回格式: [[start_ms, end_ms], [start_ms, end_ms], ...] vad_segments = result[0].get("value", []) if not vad_segments: _log_audio_split_timing( "vad_generate", vad_duration_ms, audio_path=audio_path, vad_segment_count=0, ) logger.warning("VAD 结果为空") return [] _log_audio_split_timing( "vad_generate", vad_duration_ms, audio_path=audio_path, vad_segment_count=len(vad_segments), ) logger.info(f"VAD 检测到 {len(vad_segments)} 个语音段") logger.info( "开始按 VAD 边界重分段 " f"(split_trigger={self.split_trigger_sec}s, min_segment={self.min_segment_sec}s)..." ) return [(int(seg[0]), int(seg[1])) for seg in vad_segments] except Exception as e: logger.error(f"VAD 检测失败: {e}") raise DefaultServerErrorException(f"VAD 检测失败: {str(e)}") def merge_segments_greedy( self, vad_segments: List[Tuple[int, int]], total_duration_ms: int ) -> List[Tuple[int, int]]: """按 VAD 结果重分段 策略: 1. 默认保留 VAD 原始边界,避免将整段连续语音合并成超长片段 2. 仅对短片段(< min_segment_ms)做邻段合并 3. 对重叠片段进行边界修正,避免重复音频 Args: vad_segments: VAD 检测到的语音段列表 [(start_ms, end_ms), ...] total_duration_ms: 音频总时长(毫秒) Returns: 合并后的段列表 [(start_ms, end_ms), ...] """ if not vad_segments: # 没有 VAD 段,返回整个音频(按最大时长切分) return self._split_by_fixed_duration(total_duration_ms) # 按时间排序并修正边界(防止越界、重叠) sorted_vad = sorted(vad_segments, key=lambda x: x[0]) normalized: List[Tuple[int, int]] = [] for raw_start, raw_end in sorted_vad: start_ms = max(0, int(raw_start)) end_ms = min(total_duration_ms, int(raw_end)) if end_ms <= start_ms: continue if not normalized: normalized.append((start_ms, end_ms)) continue last_end = normalized[-1][1] # 有重叠时,优先保持边界,避免与上一段重复采样 if start_ms < last_end: start_ms = last_end if end_ms > start_ms: normalized.append((start_ms, end_ms)) if not normalized: return self._split_by_fixed_duration(total_duration_ms) merged = list(normalized) # 只处理短片段:与相邻片段合并(不基于静音间隙) idx = 0 while idx < len(merged): start_ms, end_ms = merged[idx] duration = end_ms - start_ms if duration >= self.min_segment_ms or len(merged) == 1: idx += 1 continue if idx == 0: # 首段过短:并入后段 next_end = merged[idx + 1][1] merged[idx + 1] = (start_ms, next_end) del merged[idx] continue if idx == len(merged) - 1: # 尾段过短:并入前段 prev_start, _ = merged[idx - 1] merged[idx - 1] = (prev_start, end_ms) del merged[idx] idx = max(0, idx - 1) continue # 中间短段:优先并入时长更短的一侧,避免单段过长 prev_start = merged[idx - 1][0] next_end = merged[idx + 1][1] merged_with_prev_duration = end_ms - prev_start merged_with_next_duration = next_end - start_ms if merged_with_prev_duration <= merged_with_next_duration: merged[idx - 1] = (prev_start, end_ms) del merged[idx] idx = max(0, idx - 1) else: merged[idx + 1] = (start_ms, next_end) del merged[idx] return merged def _split_by_fixed_duration(self, total_duration_ms: int) -> List[Tuple[int, int]]: """按固定时长切分(无 VAD 时的 fallback) Args: total_duration_ms: 音频总时长(毫秒) Returns: 切分后的段列表 """ segments = [] current = 0 while current < total_duration_ms: end = min(current + self.split_trigger_ms, total_duration_ms) if end - current >= self.min_segment_ms: segments.append((current, end)) current = end return segments def split_audio_file( self, audio_path: str, output_dir: Optional[str] = None, ) -> List[AudioSegment]: """分割音频文件 Args: audio_path: 音频文件路径 output_dir: 输出目录(可选,默认使用临时目录) Returns: 音频片段列表 """ try: total_started = time.perf_counter() # 加载音频 load_started = time.perf_counter() audio_data, sr = librosa.load(audio_path, sr=self.DEFAULT_SAMPLE_RATE) load_audio_ms = (time.perf_counter() - load_started) * 1000 total_duration_ms = int(len(audio_data) / sr * 1000) audio_duration_sec = total_duration_ms / 1000 logger.info(f"音频总时长: {audio_duration_sec:.2f}秒") _log_audio_split_timing( "load_audio", load_audio_ms, audio_path=audio_path, audio_duration_sec=round(audio_duration_sec, 2), sample_rate=sr, ) # 检查是否需要分割 if total_duration_ms <= self.split_trigger_ms: logger.info("音频时长在限制内,无需分割") total_duration_ms_for_log = (time.perf_counter() - total_started) * 1000 _log_audio_split_timing( "split_total", total_duration_ms_for_log, audio_path=audio_path, audio_duration_sec=round(audio_duration_sec, 2), output_segment_count=1, need_split=False, load_audio_ms=round(load_audio_ms, 2), vad_ms=0, merge_ms=0, write_segments_ms=0, ) return [ AudioSegment( start_ms=0, end_ms=total_duration_ms, audio_data=audio_data, temp_file=audio_path, ) ] # 获取 VAD 段 vad_started = time.perf_counter() vad_segments = self.get_vad_segments(audio_path) vad_ms = (time.perf_counter() - vad_started) * 1000 # 贪婪合并 merge_started = time.perf_counter() merged_segments = self.merge_segments_greedy(vad_segments, total_duration_ms) merge_ms = (time.perf_counter() - merge_started) * 1000 logger.info(f"重分段完成: 原始VAD={len(vad_segments)}, 输出={len(merged_segments)}") _log_audio_split_timing( "merge_segments", merge_ms, audio_path=audio_path, vad_segment_count=len(vad_segments), output_segment_count=len(merged_segments), audio_duration_sec=round(audio_duration_sec, 2), ) # 切分音频并保存到临时文件 logger.info("开始切分音频并保存临时文件...") output_dir = output_dir or settings.TEMP_DIR os.makedirs(output_dir, exist_ok=True) audio_segments = [] write_started = time.perf_counter() for idx, (start_ms, end_ms) in enumerate(merged_segments): # 计算采样点范围 start_sample = int(start_ms / 1000 * sr) end_sample = int(end_ms / 1000 * sr) # 提取音频片段 segment_data = audio_data[start_sample:end_sample] # 保存到临时文件 temp_file = tempfile.NamedTemporaryFile( delete=False, suffix=".wav", dir=output_dir, prefix=f"segment_{idx:03d}_", ) temp_path = temp_file.name temp_file.close() sf.write(temp_path, segment_data, sr) segment = AudioSegment( start_ms=start_ms, end_ms=end_ms, audio_data=segment_data, temp_file=temp_path, ) audio_segments.append(segment) logger.debug( f"分段 {idx + 1}/{len(merged_segments)}: " f"{start_ms / 1000:.2f}s - {end_ms / 1000:.2f}s " f"(时长: {segment.duration_sec:.2f}s)" ) write_segments_ms = (time.perf_counter() - write_started) * 1000 logger.info(f"音频切分完成,共 {len(audio_segments)} 个分段") _log_audio_split_timing( "write_segments", write_segments_ms, audio_path=audio_path, output_dir=output_dir, output_segment_count=len(audio_segments), audio_duration_sec=round(audio_duration_sec, 2), ) total_ms = (time.perf_counter() - total_started) * 1000 _log_audio_split_timing( "split_total", total_ms, audio_path=audio_path, audio_duration_sec=round(audio_duration_sec, 2), output_segment_count=len(audio_segments), need_split=True, load_audio_ms=round(load_audio_ms, 2), vad_ms=round(vad_ms, 2), merge_ms=round(merge_ms, 2), write_segments_ms=round(write_segments_ms, 2), ) return audio_segments except Exception as e: logger.error(f"音频分割失败: {e}") raise DefaultServerErrorException(f"音频分割失败: {str(e)}") @staticmethod def cleanup_segments(segments: List[AudioSegment]) -> None: """清理临时文件 Args: segments: 音频片段列表 """ for segment in segments: if segment.temp_file and os.path.exists(segment.temp_file): try: os.remove(segment.temp_file) except Exception as e: logger.warning(f"清理临时文件失败: {segment.temp_file}, {e}") def split_long_audio( audio_path: str, device: str = "auto", ) -> List[AudioSegment]: """分割长音频的便捷函数 Args: audio_path: 音频文件路径 device: 计算设备 Returns: 音频片段列表 """ splitter = AudioSplitter(device=device) return splitter.split_audio_file(audio_path)