Spaces:
Running
Running
| import json | |
| import re | |
| import time | |
| from dataclasses import dataclass | |
| from typing import List, Union | |
| import requests | |
| from videotrans.configure.excepts import StopRetry, SpeechToTextError | |
| from videotrans.configure.config import tr, params, app_cfg, logger | |
| from videotrans.recognition._base import BaseRecogn | |
| from videotrans.task.taskcfg import SrtItem | |
| from videotrans.util import tools | |
| from videotrans.configure import contants | |
| """ | |
| 请求发送:以二进制形式发送键名为 audio 的wav格式音频数据,采样率为16k、通道为1 | |
| requests.post(api_url, files={"audio": open(audio_file, 'rb')},data={"language":2位语言代码}) | |
| 失败时返回 | |
| res={ | |
| "code":1, | |
| "msg":"错误原因" | |
| } | |
| 成功时返回 | |
| res={ | |
| "code":0, | |
| "data":srt格式字符串 | |
| } | |
| """ | |
| RETRY_NUMS = 2 | |
| RETRY_DELAY = 10 | |
| class APIRecogn(BaseRecogn): | |
| def __post_init__(self): | |
| super().__post_init__() | |
| api_url = params.get('recognapi_url', '').strip().rstrip('/').lower() | |
| if not api_url.startswith('http'): | |
| api_url = f'http://{api_url}' | |
| if params.get('recognapi_key'): | |
| if '?' in api_url: | |
| api_url += f'&sk={params.get("recognapi_key", "")}' | |
| else: | |
| api_url += f'?sk={params.get("recognapi_key", "")}' | |
| self.api_url = api_url | |
| def _exec(self) -> Union[List[SrtItem], None]: | |
| if self._exit(): return | |
| if re.search(r'api\.gladia\.io', self.api_url, re.I): | |
| return self._whisperzero() | |
| if 'vibevoice-asr' in params.get('recognapi_key', ''): | |
| return self._vibevoice_asr() | |
| with open(self.audio_file, 'rb') as f: | |
| chunk = f.read() | |
| files = {"audio": chunk} | |
| self.signal( | |
| text=tr("Recognition may take a while, please be patient")) | |
| res = requests.post(f"{self.api_url}", data={"language": self.detect_language}, files=files, timeout=1200) | |
| res.raise_for_status() | |
| content_type = res.headers.get('Content-Type','') | |
| if 'application/json' not in content_type: | |
| raise SpeechToTextError(res.text or res) | |
| res = res.json() | |
| if "code" not in res or res['code'] != 0: | |
| raise SpeechToTextError(f'{res["msg"]}') | |
| if "data" not in res or len(res['data']) < 1: | |
| testdata={ | |
| "code":0, | |
| "data":"SRT格式字符串" | |
| } | |
| testdata=json.dumps(testdata,ensure_ascii=False) | |
| raise SpeechToTextError(f'识别出错,应返回类似数据:\n{testdata}\n\n但实际返回: {res}') | |
| self.signal( | |
| text=tools.get_srt_from_list(res['data']), | |
| type='replace_subtitle' | |
| ) | |
| if isinstance(res['data'],list): | |
| data=[f'{i+1}\n{it["time"]}\n{it["text"]}' for i,it in enumerate(res['data'])] | |
| data="\n\n".join(data) | |
| else: | |
| data=res['data'] | |
| return tools.get_subtitle_from_srt(data, is_file=False) | |
| def _whisperzero(self)->Union[List[SrtItem], None]: | |
| api_key = params.get("recognapi_key") | |
| if not api_key: | |
| raise SpeechToTextError(tr("api key must be filled in")) | |
| # 上传 self.audio_file | |
| with open(self.audio_file, "rb") as f: | |
| audio_file = f.read() | |
| files = { | |
| "audio": (self.audio_file, audio_file, "audio/wav") # Content-Type 音频类型,有些API需要特别指定 | |
| } | |
| response = requests.post("https://api.gladia.io/v2/upload", files=files, headers={ | |
| "x-gladia-key": api_key | |
| }) | |
| response.raise_for_status() | |
| audio_url = response.json()['audio_url'] | |
| payload = { | |
| "detect_language": True if not self.detect_language or self.detect_language == 'auto' else False, | |
| "enable_code_switching": False, | |
| "language": "" if not self.detect_language or self.detect_language == 'auto' else self.detect_language[:2], | |
| "subtitles": True, | |
| "subtitles_config": { | |
| "formats": ["srt"], | |
| "minimum_duration": 1, | |
| "maximum_duration": 15.5, | |
| "maximum_characters_per_row": 80, | |
| "maximum_rows_per_caption": 2, | |
| "style": "default" | |
| }, | |
| "sentences": True, | |
| "punctuation_enhanced": True, | |
| "audio_url": audio_url | |
| } | |
| response = requests.request("POST", 'https://api.gladia.io/v2/pre-recorded', json=payload, headers={ | |
| "x-gladia-key": api_key, | |
| "Content-Type": "application/json" | |
| }) | |
| response.raise_for_status() | |
| id = response.json()['id'] | |
| # 获取结果 | |
| while 1: | |
| if app_cfg.exit_soft: return | |
| time.sleep(1) | |
| response = requests.get(f"https://api.gladia.io/v2/pre-recorded/{id}", headers={"x-gladia-key": api_key}) | |
| response.raise_for_status() | |
| d = response.json() | |
| if d['status'] == 'error': | |
| logger.warning(d) | |
| raise StopRetry(f"Error:{d['error_code']}") | |
| if d['status'] == 'done': | |
| sens = d['result']['transcription']['subtitles'][0]['subtitles'] | |
| raws = tools.get_subtitle_from_srt(sens, is_file=False) | |
| if self.detect_language and self.detect_language[:2] in contants.CJK_LANG: | |
| for i, it in enumerate(raws): | |
| text = re.sub(r'\s+', '', it['text'], flags=re.I | re.S) | |
| raws[i]['text'] = text | |
| return raws | |
| def _vibevoice_asr(self)->Union[List[SrtItem], None]: | |
| from gradio_client import Client, handle_file | |
| from pydub import AudioSegment | |
| import re | |
| import ast | |
| import os | |
| import json | |
| from pathlib import Path | |
| # 定义切片时长 (60分钟 = 60 * 60 * 1000 毫秒) | |
| CHUNK_DURATION_MS = 60 * 60 * 1000 | |
| # 初始化客户端 | |
| client = Client(self.api_url, httpx_kwargs={"timeout": 7200}) | |
| # 内部函数:处理单个片段的返回结果 | |
| def _process_chunk_result(raw_text, time_offset_ms, start_line_index): | |
| # 1. 使用正则表达式找到列表部分 | |
| match = re.search(r'(\[{.*?}])', raw_text, re.DOTALL) | |
| chunk_raws = [] | |
| chunk_speaker_raw_list = [] # 仅收集当前片段的原始说话人标记 | |
| if not match: | |
| # 如果某个片段没识别出内容(可能是静音),返回空而不是报错 | |
| logger.warning(f"No subtitles found in chunk starting at {time_offset_ms}ms") | |
| return [], [] | |
| list_str = match.group(1) | |
| list_str = re.sub(r'^.*?\[{', '[{', list_str, flags=re.S) | |
| list_str = re.sub(r'}].*$', '}]', list_str, flags=re.S) | |
| list_str = re.sub(r"\n?\n", '', list_str) | |
| segments = None | |
| try: | |
| segments = json.loads(list_str) | |
| except json.JSONDecodeError: | |
| try: | |
| segments = ast.literal_eval(list_str) | |
| except (ValueError, SyntaxError): | |
| context = { | |
| "null": None, | |
| "true": True, | |
| "false": False, | |
| "__builtins__": None | |
| } | |
| segments = eval(list_str, context) | |
| except Exception as e: | |
| logger.error(f"AST eval failed: {e}") | |
| if not segments: | |
| return [], [] | |
| # 2. 遍历结果并加上时间偏移 | |
| for i, seg in enumerate(segments): | |
| # 计算加上偏移量后的毫秒数 | |
| seg_start_ms = int(float(seg['Start']) * 1000) + time_offset_ms | |
| seg_end_ms = int(float(seg['End']) * 1000) + time_offset_ms | |
| tmp = { | |
| "line": start_line_index + i + 1, # 累加行号 | |
| "text": seg['Content'], | |
| "start_time": seg_start_ms, | |
| "end_time": seg_end_ms, | |
| } | |
| # [Noise]之类无有效信息 | |
| if re.match(r'^\[[a-zA-Z0-9\s]+]$', seg['Content'].strip()): | |
| continue | |
| # 假设 tools 是你类外部或全局可访问的工具 | |
| tmp['startraw'] = tools.ms_to_time_string(ms=tmp['start_time']) | |
| tmp['endraw'] = tools.ms_to_time_string(ms=tmp['end_time']) | |
| tmp['time'] = f"{tmp['startraw']} --> {tmp['endraw']}" | |
| chunk_raws.append(tmp) | |
| # 收集原始说话人信息 (例如 "Speaker 1") | |
| sp = seg.get("Speaker", '-') | |
| chunk_speaker_raw_list.append(sp) | |
| return chunk_raws, chunk_speaker_raw_list | |
| # self.audio_file 是 wav 路径 | |
| audio = AudioSegment.from_wav(self.audio_file) | |
| total_duration = len(audio) | |
| final_raws = [] | |
| all_speaker_raw_list = [] # 存储所有片段原本的说话人标记 | |
| current_line = 0 | |
| for i, start_ms in enumerate(range(0, total_duration, CHUNK_DURATION_MS)): | |
| end_ms = min(start_ms + CHUNK_DURATION_MS, total_duration) | |
| # 切割音频 | |
| chunk_audio = audio[start_ms:end_ms] | |
| # 保存临时文件 | |
| temp_chunk_path = os.path.join(self.cache_folder, f"temp_chunk_{i}.wav") | |
| chunk_audio.export(temp_chunk_path, format="wav") | |
| try: | |
| result = client.predict( | |
| audio_input=handle_file(temp_chunk_path), | |
| audio_path_input=None, | |
| start_time_input=None, | |
| end_time_input=None, | |
| max_new_tokens=65536, | |
| temperature=0, | |
| top_p=1, | |
| do_sample=False, | |
| repetition_penalty=1, | |
| context_info="", | |
| api_name="/transcribe_audio" | |
| ) | |
| # 处理返回结果,传入当前的 start_ms 作为时间偏移量 | |
| chunk_data, chunk_spk = _process_chunk_result( | |
| result[0], | |
| time_offset_ms=start_ms, | |
| start_line_index=current_line | |
| ) | |
| final_raws.extend(chunk_data) | |
| all_speaker_raw_list.extend(chunk_spk) | |
| current_line += len(chunk_data) | |
| except Exception as e: | |
| logger.exception(f"Error processing chunk {i}: {e}") | |
| finally: | |
| # 清理临时文件 | |
| if os.path.exists(temp_chunk_path): | |
| os.remove(temp_chunk_path) | |
| if not final_raws: | |
| raise SpeechToTextError(f'VibeVoice:{self.api_url} not return data') | |
| # 统一处理说话人逻辑 (合并后的重排序) | |
| # 这里是将所有片段的说话人混在一起处理。 | |
| # 警告:VibeVoice 是分段处理的,Chunk1 的 spk0 和 Chunk2 的 spk0 可能不是同一个人。 | |
| final_speaker_list = [] | |
| unique_speakers = [] | |
| # 提取不重复的说话人列表保持顺序 | |
| for sp in all_speaker_raw_list: | |
| if sp not in unique_speakers: | |
| unique_speakers.append(sp) | |
| if unique_speakers: | |
| try: | |
| # 生成最终的 spk0, spk1... 映射 | |
| for sp in all_speaker_raw_list: | |
| if sp == '-': | |
| # 如果没有识别出,暂定为最后一个新编号 | |
| final_speaker_list.append(f'spk{len(unique_speakers)}') | |
| else: | |
| final_speaker_list.append(f'spk{unique_speakers.index(sp)}') | |
| # 写入最终的 speaker.json | |
| if final_speaker_list: | |
| Path(f'{self.cache_folder}/speaker.json').write_text(json.dumps(final_speaker_list), | |
| encoding='utf-8') | |
| except Exception as e: | |
| logger.exception(f'说话人重排序出错,忽略{e}', exc_info=True) | |
| return final_raws | |