Spaces:
Running
Running
import os | |
import json | |
import subprocess | |
import edge_tts | |
from edge_tts import submaker | |
from pydub import AudioSegment | |
from typing import List, Dict | |
from loguru import logger | |
from app.utils import utils | |
def check_ffmpeg(): | |
"""检查FFmpeg是否已安装""" | |
try: | |
subprocess.run(['ffmpeg', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
return True | |
except FileNotFoundError: | |
return False | |
def merge_audio_files(task_id: str, total_duration: float, list_script: list): | |
""" | |
合并音频文件 | |
Args: | |
task_id: 任务ID | |
total_duration: 总时长 | |
list_script: 完整脚本信息,包含duration时长和audio路径 | |
Returns: | |
str: 合并后的音频文件路径 | |
""" | |
# 检查FFmpeg是否安装 | |
if not check_ffmpeg(): | |
logger.error("FFmpeg未安装,无法合并音频文件") | |
return None | |
# 创建一个空的音频片段 | |
final_audio = AudioSegment.silent(duration=total_duration * 1000) # 总时长以毫秒为单位 | |
# 计算每个片段的开始位置(基于duration字段) | |
current_position = 0 # 初始位置(秒) | |
# 遍历脚本中的每个片段 | |
for segment in list_script: | |
try: | |
# 获取片段时长(秒) | |
duration = segment['duration'] | |
# 检查audio字段是否为空 | |
if segment['audio'] and os.path.exists(segment['audio']): | |
# 加载TTS音频文件 | |
tts_audio = AudioSegment.from_file(segment['audio']) | |
# 将TTS音频添加到最终音频 | |
final_audio = final_audio.overlay(tts_audio, position=current_position * 1000) | |
else: | |
# audio为空,不添加音频,仅保留间隔 | |
logger.info(f"片段 {segment.get('timestamp', '')} 没有音频文件,保留 {duration} 秒的间隔") | |
# 更新下一个片段的开始位置 | |
current_position += duration | |
except Exception as e: | |
logger.error(f"处理音频片段时出错: {str(e)}") | |
# 即使处理失败,也要更新位置,确保后续片段位置正确 | |
if 'duration' in segment: | |
current_position += segment['duration'] | |
continue | |
# 保存合并后的音频文件 | |
output_audio_path = os.path.join(utils.task_dir(task_id), "merger_audio.mp3") | |
final_audio.export(output_audio_path, format="mp3") | |
logger.info(f"合并后的音频文件已保存: {output_audio_path}") | |
return output_audio_path | |
def time_to_seconds(time_str): | |
""" | |
将时间字符串转换为秒数,支持多种格式: | |
1. 'HH:MM:SS,mmm' (时:分:秒,毫秒) | |
2. 'MM:SS,mmm' (分:秒,毫秒) | |
3. 'SS,mmm' (秒,毫秒) | |
""" | |
try: | |
# 处理毫秒部分 | |
if ',' in time_str: | |
time_part, ms_part = time_str.split(',') | |
ms = float(ms_part) / 1000 | |
else: | |
time_part = time_str | |
ms = 0 | |
# 分割时间部分 | |
parts = time_part.split(':') | |
if len(parts) == 3: # HH:MM:SS | |
h, m, s = map(int, parts) | |
seconds = h * 3600 + m * 60 + s | |
elif len(parts) == 2: # MM:SS | |
m, s = map(int, parts) | |
seconds = m * 60 + s | |
else: # SS | |
seconds = int(parts[0]) | |
return seconds + ms | |
except (ValueError, IndexError) as e: | |
logger.error(f"Error parsing time {time_str}: {str(e)}") | |
return 0.0 | |
def extract_timestamp(filename): | |
""" | |
从文件名中提取开始和结束时间戳 | |
例如: "audio_00_06,500-00_24,800.mp3" -> (6.5, 24.8) | |
""" | |
try: | |
# 从文件名中提取时间部分 | |
time_part = filename.split('_', 1)[1].split('.')[0] # 获取 "00_06,500-00_24,800" 部分 | |
start_time, end_time = time_part.split('-') # 分割成开始和结束时间 | |
# 将下划线格式转换回冒号格式 | |
start_time = start_time.replace('_', ':') | |
end_time = end_time.replace('_', ':') | |
# 将时间戳转换为秒 | |
start_seconds = time_to_seconds(start_time) | |
end_seconds = time_to_seconds(end_time) | |
return start_seconds, end_seconds | |
except Exception as e: | |
logger.error(f"Error extracting timestamp from {filename}: {str(e)}") | |
return 0.0, 0.0 | |
if __name__ == "__main__": | |
# 示例用法 | |
total_duration = 90 | |
video_script = [ | |
{'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!', | |
'timestamp': '00:00:00-00:00:26', | |
'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸!范闲在北齐"死"了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!', | |
'OST': 0, 'duration': 26, | |
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3'}, | |
{'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!', 'timestamp': '00:01:15-00:01:29', | |
'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…', | |
'OST': 0, 'duration': 14, | |
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3'}, | |
{'picture': '画面切到王启年小心翼翼地向范闲汇报。', 'timestamp': '00:04:41-00:04:58', | |
'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪', | |
'OST': 1, 'duration': 17, | |
'audio': ''}, | |
{'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。', | |
'timestamp': '00:04:58-00:05:20', | |
'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!', | |
'OST': 0, 'duration': 22, | |
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_04_58-00_05_45.mp3'}, | |
{'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!', | |
'timestamp': '00:05:45-00:05:53', | |
'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!', | |
'OST': 0, 'duration': 8, | |
'audio': '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_05_45-00_06_00.mp3'}, | |
{'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。', 'timestamp': '00:06:00-00:06:03', | |
'narration': '抓刺客', | |
'OST': 1, 'duration': 3, | |
'audio': ''}] | |
output_file = merge_audio_files("test456", total_duration, video_script) | |
print(output_file) | |