Files
gitlab-instance-0a899031_do…/demucs_pipeline.py
2026-03-19 03:24:58 -05:00

457 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# demucs_pipeline.py
# Demucs 人声分离管道FFmpeg 拉流 → Demucs 分离人声 → FFmpeg 推流
# 兼容 Windows / Ubuntu支持 NVIDIA GPU 加速与 CPU 回退
"""
Demucs 音频管道模块
用于将混音分离为人声,推送到 YouTube 时避免 Content ID 识别背景音乐。
"""
import os
import sys
import socket
import threading
import subprocess
import queue
import time
import platform
try:
from stream_stats import StreamStats, parse_and_maybe_print
_STREAM_STATS_AVAILABLE = True
except ImportError:
_STREAM_STATS_AVAILABLE = False
# 可选依赖Demucs 和 PyTorch
DEMUCS_AVAILABLE = False
DEMUCS_MODEL = None
TORCH_AVAILABLE = False
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
pass
try:
from demucs.pretrained import get_model
from demucs.apply import apply_model
DEMUCS_AVAILABLE = True
except ImportError:
pass
def _check_demucs_available():
"""检查 Demucs 是否可用"""
return DEMUCS_AVAILABLE and TORCH_AVAILABLE
def _load_demucs_model(use_gpu: bool):
"""加载 Demucs 模型,支持 GPU/CPU 回退"""
global DEMUCS_MODEL
if not _check_demucs_available():
return None
try:
# htdemucs 较轻量适合实时htdemucs_ft 质量更好但更慢
model_name = "htdemucs"
device = "cpu"
if use_gpu and TORCH_AVAILABLE and torch.cuda.is_available():
try:
device = "cuda"
except Exception:
device = "cpu"
model = get_model(model_name)
model.eval()
return model.to(device)
except Exception as e:
print(f"[WARN] Demucs 模型加载失败,回退原始音频: {e}")
return None
# Demucs 模型采样率
DEMUCS_SAMPLE_RATE = 44100
DEMUCS_CHANNELS = 2
# 每次处理的音频长度1.0 更易实时2.0 质量更好但 P40 可能跟不上
DEMUCS_CHUNK_SEC = 1.0
# 字节 per sample (s16le: 2 bytes)
BYTES_PER_SAMPLE = 2
def _demucs_separate_vocals(model, audio_chunk, device, vocal_mix_ratio=1.0, mix_source="bass"):
"""
对音频块进行人声分离,返回人声轨道。
vocal_mix_ratio: 1.0=纯人声0.75=75%人声+25%混合(更自然)
mix_source: "bass"=与低音混合(少混响无重复感)"other"=与其他混合(可能带混响)
"""
import torch
# audio_chunk: numpy or tensor, shape (channels, samples)
if hasattr(audio_chunk, 'numpy'):
wav = audio_chunk
else:
import numpy as np
wav = torch.from_numpy(audio_chunk).float()
# Demucs 期望 (batch, channels, samples)
if wav.dim() == 2:
wav = wav.unsqueeze(0)
with torch.no_grad():
out = apply_model(model, wav, device=device, progress=False, overlap=0.1)
# out: (batch, sources, channels, samples)
# sources: [drums=0, bass=1, other=2, vocals=3]
vocals = out[0, 3]
if vocal_mix_ratio >= 1.0:
return vocals.cpu().numpy()
# 混合 bass 或 otherbass 无混响、无重复感other 可能带混响导致"重复一遍"
blend = out[0, 1] if mix_source == "bass" else out[0, 2]
mixed = vocals * vocal_mix_ratio + blend * (1.0 - vocal_mix_ratio)
return mixed.cpu().numpy()
def _run_demucs_worker(audio_read_pipe, audio_write_socket, model, device, stop_event, vocal_mix_ratio=1.0, mix_source="bass"):
"""Demucs 工作线程:从 FFmpeg 读音频,分离人声,写入 socket"""
import numpy as np
chunk_samples = int(DEMUCS_SAMPLE_RATE * DEMUCS_CHUNK_SEC * DEMUCS_CHANNELS)
chunk_bytes = chunk_samples * BYTES_PER_SAMPLE
buf = b""
def read_audio(size):
if hasattr(audio_read_pipe, 'recv'):
return audio_read_pipe.recv(size)
return audio_read_pipe.read(size)
try:
while not stop_event.is_set():
try:
data = read_audio(65536)
if not data:
break
buf += data
except (socket.error, OSError, ValueError):
break
while len(buf) >= chunk_bytes:
chunk = buf[:chunk_bytes]
buf = buf[chunk_bytes:]
# s16le -> float
arr = np.frombuffer(chunk, dtype=np.int16)
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
try:
vocals = _demucs_separate_vocals(model, arr, device, vocal_mix_ratio, mix_source)
# float -> s16le
out = (vocals * 32768).astype(np.int16).tobytes()
try:
audio_write_socket.sendall(out)
except (socket.error, OSError):
return
except Exception as e:
print(f"[WARN] Demucs 处理异常: {e}")
# 回退:直接透传原始音频
try:
audio_write_socket.sendall(chunk)
except (socket.error, OSError):
return
# 剩余 buffer
if buf and audio_write_socket:
try:
arr = np.frombuffer(buf, dtype=np.int16)
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
vocals = _demucs_separate_vocals(model, arr, device, vocal_mix_ratio, mix_source)
out = (vocals * 32768).astype(np.int16).tobytes()
audio_write_socket.sendall(out)
except Exception:
pass
except Exception as e:
print(f"[WARN] Demucs worker 异常: {e}")
finally:
try:
audio_write_socket.shutdown(socket.SHUT_WR)
audio_write_socket.close()
except Exception:
pass
def run_demucs_pipeline(
real_url: str,
youtube_rtmp: str,
douyin_headers: str,
nvenc_params: dict,
end_keywords: list,
stderr_queue_timeout: float,
no_frame_timeout: int,
start_check_after: int,
stale_output_seconds: int,
stream_ended_exception,
cleanup_proc_fn,
stderr_reader_fn,
vocal_mix_ratio: float = 0.75,
mix_source: str = "bass",
stats_interval_sec: int = 10,
):
"""
运行 Demucs 管道:拉流 → 人声分离 → 推流
若主播下播应停止则 raise stream_ended_exception
"""
# 检查 Demucs
use_gpu = nvenc_params.get("demucs_use_gpu", True)
model = _load_demucs_model(use_gpu)
if model is None:
raise RuntimeError("Demucs 模型不可用,将回退原始推流")
device = next(model.parameters()).device
device_str = "cuda" if "cuda" in str(device) else "cpu"
src_name = "低音" if mix_source == "bass" else "其他"
mix_info = "纯人声" if vocal_mix_ratio >= 1.0 else f"{int(vocal_mix_ratio*100)}%人声+{int((1-vocal_mix_ratio)*100)}%{src_name}"
print(f"[INFO] Demucs 人声分离已启用 ({device_str}),模式: {mix_info},低通 9kHz 降尖锐")
# TCP 服务器用于音频(跨平台)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
port = server.getsockname()[1]
server.listen(1)
tcp_audio_url = f"tcp://127.0.0.1:{port}"
stop_event = threading.Event()
conn = [None] # 用 list 以便在闭包中修改
def accept_conn():
server.settimeout(30)
try:
c, _ = server.accept()
conn[0] = c
except Exception:
pass
finally:
try:
server.close()
except Exception:
pass
accept_thread = threading.Thread(target=accept_conn, daemon=True)
accept_thread.start()
time.sleep(0.2) # 确保 server 已 listen
# FFmpeg 拉流(视频)
ffmpeg_v_cmd = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-err_detect", "ignore_err",
"-headers", douyin_headers,
"-i", real_url,
"-map", "0:v", "-c:v", "copy", "-f", "nut", "pipe:1",
]
# FFmpeg 拉流(音频)
ffmpeg_a_cmd = [
"ffmpeg", "-y", "-loglevel", "error", "-nostdin", "-re",
"-rw_timeout", "30000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-headers", douyin_headers,
"-i", real_url,
"-map", "0:a", "-f", "s16le", "-ac", str(DEMUCS_CHANNELS),
"-ar", str(DEMUCS_SAMPLE_RATE), "pipe:1",
]
# FFmpeg 推流:音频 tcp 放第一FFmpeg 按顺序打开输入pipe:0 会阻塞直到有数据,
# 若 pipe 在前则永远连不上 tcp导致 accept 超时)
vf = "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black"
ffmpeg_out_cmd = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
"-f", "s16le", "-ac", str(DEMUCS_CHANNELS), "-ar", str(DEMUCS_SAMPLE_RATE),
"-i", tcp_audio_url,
"-f", "nut", "-i", "pipe:0",
"-map", "1:v", "-map", "0:a",
"-vf", vf,
"-c:v", nvenc_params.get("codec_v", "libx264"),
"-preset", nvenc_params.get("preset_v", "fast"),
"-profile:v", "high",
"-b:v", f"{nvenc_params.get('video_bitrate_k', 4500)}k",
"-maxrate", f"{nvenc_params.get('video_bitrate_k', 4500)+500}k",
"-bufsize", f"{nvenc_params.get('video_bitrate_k', 4500)*2}k",
"-fps_mode", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
"-bf", "0",
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
"-af", "lowpass=f=9000,aresample=async=1:first_pts=0",
"-flags", "+global_header", "-flvflags", "no_duration_filesize",
"-f", "flv", youtube_rtmp,
]
# NVENC 用 tune_paramlibx264 用 sc_threshold + x264-params
codec = nvenc_params.get("codec_v", "libx264")
if nvenc_params.get("tune_param") and codec == "h264_nvenc":
idx = ffmpeg_out_cmd.index("-profile:v")
for p in reversed(nvenc_params["tune_param"]):
ffmpeg_out_cmd.insert(idx, p)
elif codec == "libx264":
idx = ffmpeg_out_cmd.index("-bf")
ffmpeg_out_cmd.insert(idx + 2, "-sc_threshold")
ffmpeg_out_cmd.insert(idx + 3, "0")
idx = ffmpeg_out_cmd.index("-pix_fmt")
ffmpeg_out_cmd.insert(idx, "-x264-params")
ffmpeg_out_cmd.insert(idx + 1, "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0")
proc_v = None
proc_a = None
proc_out = None
demucs_thread = None
try:
proc_v = subprocess.Popen(
ffmpeg_v_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1024 * 1024,
)
proc_a = subprocess.Popen(
ffmpeg_a_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=1024 * 1024,
)
proc_out = subprocess.Popen(
ffmpeg_out_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
bufsize=1024 * 1024,
)
# 等待 TCP 连接FFmpeg_out 会连接)
accept_thread.join(timeout=15)
if conn[0] is None:
raise RuntimeError("Demucs 管道: 音频 TCP 连接超时")
# 视频转发线程
def copy_video():
try:
while proc_v.poll() is None and proc_out.poll() is None:
chunk = proc_v.stdout.read(1024 * 256)
if not chunk:
break
proc_out.stdin.write(chunk)
except Exception:
pass
finally:
try:
proc_out.stdin.close()
except Exception:
pass
copy_t = threading.Thread(target=copy_video, daemon=True)
copy_t.start()
# Demucs 工作线程(从 proc_a 读,处理后写 conn
# proc_a 输出是 stdoutconn 是 socket。需要把 proc_a.stdout 的数据通过 socket 发到 FFmpeg。
# FFmpeg_out 的第二个输入是 tcp已经连接了 conn。所以我们从 proc_a 读Demucs 处理,写 conn。
# 但 proc_a.stdout 是 pipeconn 是 socket。我们需要一个线程读 proc_a 并写 conn。
# 注意proc_a 输出到 stdout我们读。conn 是 FFmpeg 作为 client 连上来的,所以 FFmpeg 在读,我们写 conn。
demucs_thread = threading.Thread(
target=_run_demucs_worker,
args=(proc_a.stdout, conn[0], model, device, stop_event, vocal_mix_ratio, mix_source),
daemon=True,
)
demucs_thread.start()
# 监控 proc_v 的 stderr与源连接用于检测下播
stderr_q = queue.Queue()
reader_t = threading.Thread(target=stderr_reader_fn, args=(proc_v, stderr_q))
reader_t.daemon = True
reader_t.start()
# 监控 proc_out 的 stderr推流到 YouTube解析状态用于详细日志
stream_stats = None
if _STREAM_STATS_AVAILABLE:
stream_stats = StreamStats(interval_sec=stats_interval_sec)
stream_stats.start_periodic_printer()
print(f"[INFO] 推流状态监控已启用,每 {stats_interval_sec}s 输出帧率/码率/网络等")
def _proc_out_stderr_reader():
try:
for line in iter(proc_out.stderr.readline, b""):
if line:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
if stream_stats and parse_and_maybe_print(decoded, stream_stats, "[YouTube推流]", verbose=False):
pass # 已由 parse 处理,进度行不重复打印
else:
print(f"[YouTube推流] {decoded}")
except Exception:
pass
proc_out_reader_t = threading.Thread(target=_proc_out_stderr_reader, daemon=True)
proc_out_reader_t.start()
start_time = last_frame_time = time.time()
proc_v.last_out_ts = time.time()
while True:
try:
line = stderr_q.get(timeout=stderr_queue_timeout)
except queue.Empty:
# 若 proc_out推流进程已异常退出立即报告
if proc_out.poll() is not None and proc_out.returncode != 0:
print(f"[ERROR] YouTube 推流进程已退出,返回码: {proc_out.returncode},可能是 Demucs 处理跟不上或网络问题")
print(f"[TIP] 若频繁断流,可尝试 config/youtube.ini 中设置 enable_demucs = 否 使用原始音频")
break
if time.time() - getattr(proc_v, "last_out_ts", time.time()) > stale_output_seconds:
print("[WARN] 长时间无任何日志,强制重启进程")
break
continue
if line is None:
if proc_v.poll() is not None:
break
continue
# proc_v 的 stderr 为 bytes需解码为 str
if isinstance(line, bytes):
line = line.decode("utf-8", errors="replace")
line = line.strip()
if line.startswith("__ERR__READER__"):
print(f"[ERROR] stderr reader 异常: {line}")
continue
if line:
proc_v.last_out_ts = time.time()
if stream_stats:
stream_stats.parse_lag_event(line) # 源端抖动统计
print(f"[FFmpeg] {line}")
line_lower = line.lower()
if any(kw in line_lower for kw in end_keywords):
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
raise stream_ended_exception()
if "frame=" in line_lower or "fps=" in line_lower:
last_frame_time = time.time()
if time.time() - start_time > start_check_after:
if time.time() - last_frame_time > no_frame_timeout:
print(f"[WARN] 连续 {no_frame_timeout}s 无新帧,判定主播已下播")
raise stream_ended_exception()
except stream_ended_exception:
raise
except Exception as e:
print(f"[ERROR] Demucs 管道异常: {e}")
import traceback
traceback.print_exc()
finally:
stop_event.set()
if stream_stats is not None:
stream_stats.stop()
if proc_out is not None and proc_out.poll() is not None and proc_out.returncode != 0:
print(f"[INFO] YouTube 推流进程退出码: {proc_out.returncode}")
cleanup_proc_fn(proc_v)
cleanup_proc_fn(proc_a)
cleanup_proc_fn(proc_out)
try:
if conn[0]:
conn[0].close()
except Exception:
pass
try:
server.close()
except Exception:
pass