's'
This commit is contained in:
@@ -2,10 +2,22 @@
|
|||||||
# 只写 stream key(推荐方式)
|
# 只写 stream key(推荐方式)
|
||||||
key = x04z-564w-aks7-embw-30y4
|
key = x04z-564w-aks7-embw-30y4
|
||||||
|
|
||||||
|
# ===== 优先:推流画质 =====
|
||||||
|
# 视频码率 Mbps(720p 推荐 4-4.5,YouTube 建议 3-8)
|
||||||
|
video_bitrate_mbps = 4.5
|
||||||
|
|
||||||
|
# 推流状态日志间隔(秒),每 N 秒打印帧率/码率/网络等
|
||||||
|
stats_interval_sec = 10
|
||||||
|
|
||||||
|
# ===== 其次:音频版权处理 =====
|
||||||
# 是否启用 Demucs 人声分离(防 YouTube Content ID 版权识别)
|
# 是否启用 Demucs 人声分离(防 YouTube Content ID 版权识别)
|
||||||
# 是 = 启用,只保留人声;否 = 使用原始音频
|
# 是 = 启用,只保留人声;否 = 使用原始音频
|
||||||
enable_demucs = 是
|
enable_demucs = 是
|
||||||
|
|
||||||
# Demucs 是否优先使用 NVIDIA GPU 加速
|
# Demucs 是否优先使用 NVIDIA GPU 加速
|
||||||
# 是 = 有显卡则用 GPU;否 = 强制 CPU;无显卡时自动回退到 CPU
|
# 是 = 有显卡则用 GPU;否 = 强制 CPU;无显卡时自动回退到 CPU
|
||||||
demucs_use_gpu = 是
|
demucs_use_gpu = 是
|
||||||
|
|
||||||
|
# 人声混合比例(0.5-1.0),改善人声自然度,减少尖锐感
|
||||||
|
# 0.85 = 85%人声+15%其他(推荐,更自然);1.0 = 纯人声(可能尖锐)
|
||||||
|
demucs_vocal_mix = 0.85
|
||||||
@@ -14,6 +14,12 @@ import queue
|
|||||||
import time
|
import time
|
||||||
import platform
|
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 和 PyTorch
|
||||||
DEMUCS_AVAILABLE = False
|
DEMUCS_AVAILABLE = False
|
||||||
DEMUCS_MODEL = None
|
DEMUCS_MODEL = None
|
||||||
@@ -69,8 +75,11 @@ DEMUCS_CHUNK_SEC = 2.0
|
|||||||
BYTES_PER_SAMPLE = 2
|
BYTES_PER_SAMPLE = 2
|
||||||
|
|
||||||
|
|
||||||
def _demucs_separate_vocals(model, audio_chunk, device):
|
def _demucs_separate_vocals(model, audio_chunk, device, vocal_mix_ratio=1.0):
|
||||||
"""对音频块进行人声分离,返回人声轨道"""
|
"""
|
||||||
|
对音频块进行人声分离,返回人声轨道。
|
||||||
|
vocal_mix_ratio: 1.0=纯人声;0.85=85%人声+15%other(更自然,减少尖锐感)
|
||||||
|
"""
|
||||||
import torch
|
import torch
|
||||||
# audio_chunk: numpy or tensor, shape (channels, samples)
|
# audio_chunk: numpy or tensor, shape (channels, samples)
|
||||||
if hasattr(audio_chunk, 'numpy'):
|
if hasattr(audio_chunk, 'numpy'):
|
||||||
@@ -85,11 +94,16 @@ def _demucs_separate_vocals(model, audio_chunk, device):
|
|||||||
out = apply_model(model, wav, device=device, progress=False, overlap=0.25)
|
out = apply_model(model, wav, device=device, progress=False, overlap=0.25)
|
||||||
# out: (batch, sources, channels, samples)
|
# out: (batch, sources, channels, samples)
|
||||||
# sources: [drums, bass, other, vocals]
|
# sources: [drums, bass, other, vocals]
|
||||||
vocals = out[0, 3] # 取 vocals
|
vocals = out[0, 3]
|
||||||
return vocals.cpu().numpy()
|
if vocal_mix_ratio >= 1.0:
|
||||||
|
return vocals.cpu().numpy()
|
||||||
|
# 混合 other 以保留更自然的人声,减少纯人声的尖锐感
|
||||||
|
other = out[0, 2]
|
||||||
|
mixed = vocals * vocal_mix_ratio + other * (1.0 - vocal_mix_ratio)
|
||||||
|
return mixed.cpu().numpy()
|
||||||
|
|
||||||
|
|
||||||
def _run_demucs_worker(audio_read_pipe, audio_write_socket, model, device, stop_event):
|
def _run_demucs_worker(audio_read_pipe, audio_write_socket, model, device, stop_event, vocal_mix_ratio=1.0):
|
||||||
"""Demucs 工作线程:从 FFmpeg 读音频,分离人声,写入 socket"""
|
"""Demucs 工作线程:从 FFmpeg 读音频,分离人声,写入 socket"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
chunk_samples = int(DEMUCS_SAMPLE_RATE * DEMUCS_CHUNK_SEC * DEMUCS_CHANNELS)
|
chunk_samples = int(DEMUCS_SAMPLE_RATE * DEMUCS_CHUNK_SEC * DEMUCS_CHANNELS)
|
||||||
@@ -117,7 +131,7 @@ def _run_demucs_worker(audio_read_pipe, audio_write_socket, model, device, stop_
|
|||||||
arr = np.frombuffer(chunk, dtype=np.int16)
|
arr = np.frombuffer(chunk, dtype=np.int16)
|
||||||
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
|
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
|
||||||
try:
|
try:
|
||||||
vocals = _demucs_separate_vocals(model, arr, device)
|
vocals = _demucs_separate_vocals(model, arr, device, vocal_mix_ratio)
|
||||||
# float -> s16le
|
# float -> s16le
|
||||||
out = (vocals * 32768).astype(np.int16).tobytes()
|
out = (vocals * 32768).astype(np.int16).tobytes()
|
||||||
try:
|
try:
|
||||||
@@ -136,7 +150,7 @@ def _run_demucs_worker(audio_read_pipe, audio_write_socket, model, device, stop_
|
|||||||
try:
|
try:
|
||||||
arr = np.frombuffer(buf, dtype=np.int16)
|
arr = np.frombuffer(buf, dtype=np.int16)
|
||||||
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
|
arr = arr.reshape(-1, DEMUCS_CHANNELS).T.astype(np.float32) / 32768.0
|
||||||
vocals = _demucs_separate_vocals(model, arr, device)
|
vocals = _demucs_separate_vocals(model, arr, device, vocal_mix_ratio)
|
||||||
out = (vocals * 32768).astype(np.int16).tobytes()
|
out = (vocals * 32768).astype(np.int16).tobytes()
|
||||||
audio_write_socket.sendall(out)
|
audio_write_socket.sendall(out)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -164,6 +178,8 @@ def run_demucs_pipeline(
|
|||||||
stream_ended_exception,
|
stream_ended_exception,
|
||||||
cleanup_proc_fn,
|
cleanup_proc_fn,
|
||||||
stderr_reader_fn,
|
stderr_reader_fn,
|
||||||
|
vocal_mix_ratio: float = 0.85,
|
||||||
|
stats_interval_sec: int = 10,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
运行 Demucs 管道:拉流 → 人声分离 → 推流
|
运行 Demucs 管道:拉流 → 人声分离 → 推流
|
||||||
@@ -178,7 +194,8 @@ def run_demucs_pipeline(
|
|||||||
|
|
||||||
device = next(model.parameters()).device
|
device = next(model.parameters()).device
|
||||||
device_str = "cuda" if "cuda" in str(device) else "cpu"
|
device_str = "cuda" if "cuda" in str(device) else "cpu"
|
||||||
print(f"[INFO] Demucs 人声分离已启用 ({device_str})")
|
mix_info = "纯人声" if vocal_mix_ratio >= 1.0 else f"{int(vocal_mix_ratio*100)}%人声+{int((1-vocal_mix_ratio)*100)}%其他"
|
||||||
|
print(f"[INFO] Demucs 人声分离已启用 ({device_str}),模式: {mix_info}")
|
||||||
|
|
||||||
# TCP 服务器用于音频(跨平台)
|
# TCP 服务器用于音频(跨平台)
|
||||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
@@ -246,7 +263,9 @@ def run_demucs_pipeline(
|
|||||||
"-c:v", nvenc_params.get("codec_v", "libx264"),
|
"-c:v", nvenc_params.get("codec_v", "libx264"),
|
||||||
"-preset", nvenc_params.get("preset_v", "fast"),
|
"-preset", nvenc_params.get("preset_v", "fast"),
|
||||||
"-profile:v", "high",
|
"-profile:v", "high",
|
||||||
"-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
|
"-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",
|
||||||
"-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
|
"-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
|
||||||
"-bf", "0", "-sc_threshold", "0",
|
"-bf", "0", "-sc_threshold", "0",
|
||||||
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
|
||||||
@@ -320,7 +339,7 @@ def run_demucs_pipeline(
|
|||||||
# 注意:proc_a 输出到 stdout,我们读。conn 是 FFmpeg 作为 client 连上来的,所以 FFmpeg 在读,我们写 conn。
|
# 注意:proc_a 输出到 stdout,我们读。conn 是 FFmpeg 作为 client 连上来的,所以 FFmpeg 在读,我们写 conn。
|
||||||
demucs_thread = threading.Thread(
|
demucs_thread = threading.Thread(
|
||||||
target=_run_demucs_worker,
|
target=_run_demucs_worker,
|
||||||
args=(proc_a.stdout, conn[0], model, device, stop_event),
|
args=(proc_a.stdout, conn[0], model, device, stop_event, vocal_mix_ratio),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
demucs_thread.start()
|
demucs_thread.start()
|
||||||
@@ -331,14 +350,23 @@ def run_demucs_pipeline(
|
|||||||
reader_t.daemon = True
|
reader_t.daemon = True
|
||||||
reader_t.start()
|
reader_t.start()
|
||||||
|
|
||||||
# 监控 proc_out 的 stderr(推流到 YouTube,错误必须可见)
|
# 监控 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():
|
def _proc_out_stderr_reader():
|
||||||
try:
|
try:
|
||||||
for line in iter(proc_out.stderr.readline, b""):
|
for line in iter(proc_out.stderr.readline, b""):
|
||||||
if line:
|
if line:
|
||||||
decoded = line.decode("utf-8", errors="replace").strip()
|
decoded = line.decode("utf-8", errors="replace").strip()
|
||||||
if decoded:
|
if decoded:
|
||||||
print(f"[YouTube推流] {decoded}")
|
if stream_stats and parse_and_maybe_print(decoded, stream_stats, "[YouTube推流]", verbose=False):
|
||||||
|
pass # 已由 parse 处理,进度行不重复打印
|
||||||
|
else:
|
||||||
|
print(f"[YouTube推流] {decoded}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -376,6 +404,8 @@ def run_demucs_pipeline(
|
|||||||
|
|
||||||
if line:
|
if line:
|
||||||
proc_v.last_out_ts = time.time()
|
proc_v.last_out_ts = time.time()
|
||||||
|
if stream_stats:
|
||||||
|
stream_stats.parse_lag_event(line) # 源端抖动统计
|
||||||
print(f"[FFmpeg] {line}")
|
print(f"[FFmpeg] {line}")
|
||||||
line_lower = line.lower()
|
line_lower = line.lower()
|
||||||
if any(kw in line_lower for kw in end_keywords):
|
if any(kw in line_lower for kw in end_keywords):
|
||||||
@@ -396,6 +426,8 @@ def run_demucs_pipeline(
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
finally:
|
finally:
|
||||||
stop_event.set()
|
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:
|
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}")
|
print(f"[INFO] YouTube 推流进程退出码: {proc_out.returncode}")
|
||||||
cleanup_proc_fn(proc_v)
|
cleanup_proc_fn(proc_v)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ except ImportError:
|
|||||||
_DEMUCS_MODULE_AVAILABLE = False
|
_DEMUCS_MODULE_AVAILABLE = False
|
||||||
_check_demucs_available = lambda: False
|
_check_demucs_available = lambda: False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from stream_stats import StreamStats, parse_and_maybe_print
|
||||||
|
_STREAM_STATS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_STREAM_STATS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
class StreamEnded(Exception):
|
class StreamEnded(Exception):
|
||||||
"""主播真实下播的专用异常"""
|
"""主播真实下播的专用异常"""
|
||||||
@@ -98,6 +104,9 @@ def start_douyin_youtube_ffplay(
|
|||||||
|
|
||||||
enable_demucs = False
|
enable_demucs = False
|
||||||
demucs_use_gpu = True
|
demucs_use_gpu = True
|
||||||
|
demucs_vocal_mix = 0.85 # 0.85=人声更自然,1.0=纯人声(可能尖锐)
|
||||||
|
video_bitrate_mbps = 4.5 # 优先画质,YouTube 720p 推荐 4-4.5
|
||||||
|
stats_interval_sec = 10 # 推流状态日志间隔
|
||||||
if os.path.exists(config_file):
|
if os.path.exists(config_file):
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
config.read(config_file, encoding="utf-8")
|
config.read(config_file, encoding="utf-8")
|
||||||
@@ -109,13 +118,34 @@ def start_douyin_youtube_ffplay(
|
|||||||
enable_demucs = val.lower() in ("1", "true", "yes", "是")
|
enable_demucs = val.lower() in ("1", "true", "yes", "是")
|
||||||
elif key_lower == "demucs_use_gpu":
|
elif key_lower == "demucs_use_gpu":
|
||||||
demucs_use_gpu = val.lower() not in ("0", "false", "no", "否")
|
demucs_use_gpu = val.lower() not in ("0", "false", "no", "否")
|
||||||
|
elif key_lower == "demucs_vocal_mix":
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
if 0.5 <= v <= 1.0:
|
||||||
|
demucs_vocal_mix = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif key_lower == "video_bitrate_mbps":
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
if 2.0 <= v <= 8.0:
|
||||||
|
video_bitrate_mbps = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif key_lower == "stats_interval_sec":
|
||||||
|
try:
|
||||||
|
v = int(val)
|
||||||
|
if 5 <= v <= 120:
|
||||||
|
stats_interval_sec = v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
elif val and key_lower in ("key", "stream_key", "streamkey"):
|
elif val and key_lower in ("key", "stream_key", "streamkey"):
|
||||||
youtube_rtmp = val if val.startswith("rtmp://") else rtmp_base + val
|
youtube_rtmp = val if val.startswith("rtmp://") else rtmp_base + val
|
||||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||||
# 兼容:若上面未匹配到 key,取第一个非配置项的值作为 stream key
|
# 兼容:若上面未匹配到 key,取第一个非配置项的值作为 stream key
|
||||||
if youtube_rtmp == rtmp_base + default_key:
|
if youtube_rtmp == rtmp_base + default_key:
|
||||||
for k, v in config.items("youtube"):
|
for k, v in config.items("youtube"):
|
||||||
if k.strip().lower() not in ("enable_demucs", "demucs_use_gpu") and v.strip():
|
if k.strip().lower() not in ("enable_demucs", "demucs_use_gpu", "demucs_vocal_mix", "video_bitrate_mbps", "stats_interval_sec") and v.strip():
|
||||||
v = v.strip()
|
v = v.strip()
|
||||||
youtube_rtmp = v if v.startswith("rtmp://") else rtmp_base + v
|
youtube_rtmp = v if v.startswith("rtmp://") else rtmp_base + v
|
||||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||||
@@ -157,6 +187,7 @@ def start_douyin_youtube_ffplay(
|
|||||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||||
|
|
||||||
# ====================== NVENC 检测 ======================
|
# ====================== NVENC 检测 ======================
|
||||||
|
# FFmpeg NVENC 需驱动 570+,否则会报 "Driver does not support the required nvenc API version"
|
||||||
codec_v = "libx264"
|
codec_v = "libx264"
|
||||||
preset_v = "veryfast"
|
preset_v = "veryfast"
|
||||||
tune_param = ["-tune", "zerolatency"]
|
tune_param = ["-tune", "zerolatency"]
|
||||||
@@ -166,18 +197,37 @@ def start_douyin_youtube_ffplay(
|
|||||||
if check.returncode == 0:
|
if check.returncode == 0:
|
||||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||||
if "h264_nvenc" in (enc.stdout or ""):
|
if "h264_nvenc" in (enc.stdout or ""):
|
||||||
codec_v = "h264_nvenc"
|
# 检查驱动版本,NVENC API 13.0 需 570+
|
||||||
preset_v = "p5"
|
driver_ok = False
|
||||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
try:
|
||||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
ver_out = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if ver_out.returncode == 0 and ver_out.stdout:
|
||||||
|
ver_str = ver_out.stdout.strip().split("\n")[0].strip()
|
||||||
|
major = int(ver_str.split(".")[0]) if ver_str else 0
|
||||||
|
driver_ok = major >= 570
|
||||||
|
if not driver_ok:
|
||||||
|
print(f"[INFO] NVIDIA 驱动 {ver_str} 低于 570,使用软件编码 (libx264)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if driver_ok:
|
||||||
|
codec_v = "h264_nvenc"
|
||||||
|
preset_v = "p5"
|
||||||
|
br_k = int(video_bitrate_mbps * 1000)
|
||||||
|
tune_param = ["-tune", "ll", "-rc", "vbr", "-b:v", f"{br_k}k", "-maxrate", f"{br_k+500}k", "-bufsize", f"{br_k*2}k"]
|
||||||
|
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||||
|
|
||||||
|
br_k = int(video_bitrate_mbps * 1000)
|
||||||
nvenc_params = {
|
nvenc_params = {
|
||||||
"codec_v": codec_v,
|
"codec_v": codec_v,
|
||||||
"preset_v": preset_v,
|
"preset_v": preset_v,
|
||||||
"tune_param": tune_param,
|
"tune_param": tune_param,
|
||||||
"demucs_use_gpu": demucs_use_gpu,
|
"demucs_use_gpu": demucs_use_gpu,
|
||||||
|
"video_bitrate_k": br_k,
|
||||||
}
|
}
|
||||||
use_demucs = enable_demucs and _DEMUCS_MODULE_AVAILABLE and _check_demucs_available()
|
use_demucs = enable_demucs and _DEMUCS_MODULE_AVAILABLE and _check_demucs_available()
|
||||||
|
|
||||||
@@ -202,9 +252,8 @@ def start_douyin_youtube_ffplay(
|
|||||||
"-preset", "fast",
|
"-preset", "fast",
|
||||||
# "-tune", "zerolatency",
|
# "-tune", "zerolatency",
|
||||||
"-profile:v", "high",
|
"-profile:v", "high",
|
||||||
# 720p
|
# 720p,画质优先,YouTube 推荐 3-8 Mbps
|
||||||
# "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate,保持真 CBR
|
"-b:v", f"{br_k}k", "-maxrate", f"{br_k+500}k", "-bufsize", f"{br_k*2}k",
|
||||||
"-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
|
|
||||||
"-vsync", "cfr",
|
"-vsync", "cfr",
|
||||||
"-g", "60", "-keyint_min", "60",
|
"-g", "60", "-keyint_min", "60",
|
||||||
"-r", "30",
|
"-r", "30",
|
||||||
@@ -482,6 +531,8 @@ def start_douyin_youtube_ffplay(
|
|||||||
stream_ended_exception=StreamEnded,
|
stream_ended_exception=StreamEnded,
|
||||||
cleanup_proc_fn=cleanup_proc,
|
cleanup_proc_fn=cleanup_proc,
|
||||||
stderr_reader_fn=_stderr_reader_thread,
|
stderr_reader_fn=_stderr_reader_thread,
|
||||||
|
vocal_mix_ratio=demucs_vocal_mix,
|
||||||
|
stats_interval_sec=stats_interval_sec,
|
||||||
)
|
)
|
||||||
except StreamEnded:
|
except StreamEnded:
|
||||||
raise
|
raise
|
||||||
@@ -506,6 +557,11 @@ def start_douyin_youtube_ffplay(
|
|||||||
reader_t.start()
|
reader_t.start()
|
||||||
|
|
||||||
start_time = last_frame_time = time.time()
|
start_time = last_frame_time = time.time()
|
||||||
|
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 输出帧率/码率/网络等")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -523,15 +579,22 @@ def start_douyin_youtube_ffplay(
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
line = line.strip()
|
line_str = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
|
||||||
if line.startswith("__ERR__READER__"):
|
if line_str.startswith("__ERR__READER__"):
|
||||||
print(f"[ERROR] stderr reader 异常: {line}")
|
print(f"[ERROR] stderr reader 异常: {line_str}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line:
|
if line_str:
|
||||||
proc.last_out_ts = time.time()
|
proc.last_out_ts = time.time()
|
||||||
print(f"[FFmpeg] {line}")
|
if stream_stats:
|
||||||
line_lower = line.lower()
|
stream_stats.parse_lag_event(line_str)
|
||||||
|
if stream_stats.parse_ffmpeg_progress(line_str):
|
||||||
|
pass # 进度行由周期摘要代替,不重复打印
|
||||||
|
else:
|
||||||
|
print(f"[FFmpeg] {line_str}")
|
||||||
|
else:
|
||||||
|
print(f"[FFmpeg] {line_str}")
|
||||||
|
line_lower = line_str.lower()
|
||||||
|
|
||||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||||
@@ -555,6 +618,9 @@ def start_douyin_youtube_ffplay(
|
|||||||
cleanup_proc(proc)
|
cleanup_proc(proc)
|
||||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||||
break
|
break
|
||||||
|
finally:
|
||||||
|
if stream_stats is not None:
|
||||||
|
stream_stats.stop()
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
174
stream_stats.py
Normal file
174
stream_stats.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# stream_stats.py
|
||||||
|
# 推流状态监控:解析 FFmpeg 输出,统计帧率、码率、网络等,便于调试
|
||||||
|
"""
|
||||||
|
推流状态监控模块
|
||||||
|
解析 FFmpeg stderr 输出,提取:帧率、码率、编码速度、源延迟事件
|
||||||
|
可选:Linux 网络上下行速度
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
|
class StreamStats:
|
||||||
|
"""推流状态统计,线程安全"""
|
||||||
|
|
||||||
|
def __init__(self, interval_sec=10):
|
||||||
|
self.interval_sec = interval_sec
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.frame = 0
|
||||||
|
self.fps = 0.0
|
||||||
|
self.bitrate_kbps = 0.0
|
||||||
|
self.speed = 0.0
|
||||||
|
self.encode_time = ""
|
||||||
|
self.size_kb = 0
|
||||||
|
self.quality_q = 0.0
|
||||||
|
self.lag_events = 0 # "Resumed reading" 次数,表示源端抖动
|
||||||
|
self.last_update_ts = 0.0
|
||||||
|
self.start_ts = time.time()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._printer_thread = None
|
||||||
|
# 网络统计 (Linux)
|
||||||
|
self._net_last = None
|
||||||
|
self._net_last_ts = 0
|
||||||
|
self.rx_mbps = 0.0
|
||||||
|
self.tx_mbps = 0.0
|
||||||
|
|
||||||
|
def parse_ffmpeg_progress(self, line: str) -> bool:
|
||||||
|
"""解析 FFmpeg 进度行,返回是否成功解析"""
|
||||||
|
# frame= 12345 fps= 30 q=28.0 size= 12345kB time=00:06:51.50 bitrate=2460.0kbits/s speed=1.00x
|
||||||
|
m = re.search(r'frame=\s*(\d+)', line, re.I)
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self.frame = int(m.group(1))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
m2 = re.search(r'fps=\s*([\d.]+)', line, re.I)
|
||||||
|
if m2:
|
||||||
|
self.fps = float(m2.group(1))
|
||||||
|
m3 = re.search(r'bitrate=\s*([\d.]+)\s*kbits/s', line, re.I)
|
||||||
|
if m3:
|
||||||
|
self.bitrate_kbps = float(m3.group(1))
|
||||||
|
m4 = re.search(r'speed=\s*([\d.]+)x', line, re.I)
|
||||||
|
if m4:
|
||||||
|
self.speed = float(m4.group(1))
|
||||||
|
m5 = re.search(r'time=([^\s]+)', line, re.I)
|
||||||
|
if m5:
|
||||||
|
self.encode_time = m5.group(1).strip()
|
||||||
|
m6 = re.search(r'size=\s*(\d+)\s*kB', line, re.I)
|
||||||
|
if m6:
|
||||||
|
self.size_kb = int(m6.group(1))
|
||||||
|
m7 = re.search(r'q=([\d.]+)', line, re.I)
|
||||||
|
if m7:
|
||||||
|
self.quality_q = float(m7.group(1))
|
||||||
|
self.last_update_ts = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def parse_lag_event(self, line: str) -> bool:
|
||||||
|
"""检测源端延迟事件 Resumed reading at pts"""
|
||||||
|
if "resumed reading at pts" in line.lower():
|
||||||
|
with self._lock:
|
||||||
|
self.lag_events += 1
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _read_net_bytes(self):
|
||||||
|
"""读取 /proc/net/dev 总字节数 (Linux)"""
|
||||||
|
try:
|
||||||
|
with open("/proc/net/dev", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
rx_total = tx_total = 0
|
||||||
|
for line in content.split("\n")[2:]: # 跳过表头
|
||||||
|
line = line.strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 10:
|
||||||
|
try:
|
||||||
|
rx_total += int(parts[1])
|
||||||
|
tx_total += int(parts[9])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return rx_total, tx_total
|
||||||
|
|
||||||
|
def update_network(self):
|
||||||
|
"""更新网络速度 (Linux)"""
|
||||||
|
if platform.system() != "Linux":
|
||||||
|
return
|
||||||
|
rx, tx = self._read_net_bytes()
|
||||||
|
if rx is None:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if self._net_last is not None:
|
||||||
|
dt = now - self._net_last_ts
|
||||||
|
if dt > 0.5: # 至少 0.5 秒间隔
|
||||||
|
drx = (rx - self._net_last[0]) / (1024 * 1024) / dt
|
||||||
|
dtx = (tx - self._net_last[1]) / (1024 * 1024) / dt
|
||||||
|
self.rx_mbps = round(drx, 2)
|
||||||
|
self.tx_mbps = round(dtx, 2)
|
||||||
|
self._net_last = (rx, tx)
|
||||||
|
self._net_last_ts = now
|
||||||
|
|
||||||
|
def format_summary(self) -> str:
|
||||||
|
"""生成状态摘要字符串"""
|
||||||
|
self.update_network()
|
||||||
|
with self._lock:
|
||||||
|
elapsed = time.time() - self.start_ts
|
||||||
|
parts = [
|
||||||
|
f"推流时长 {int(elapsed)}s",
|
||||||
|
f"帧率 {self.fps:.1f} fps",
|
||||||
|
f"已推帧 {self.frame}",
|
||||||
|
f"码率 {self.bitrate_kbps:.0f} kbps",
|
||||||
|
f"编码速度 {self.speed:.2f}x",
|
||||||
|
]
|
||||||
|
if self.lag_events > 0:
|
||||||
|
parts.append(f"源抖动 {self.lag_events} 次")
|
||||||
|
if platform.system() == "Linux":
|
||||||
|
parts.append(f"下行 {self.rx_mbps:.2f} MB/s 上行 {self.tx_mbps:.2f} MB/s")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
def start_periodic_printer(self):
|
||||||
|
"""启动周期性打印线程"""
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._stop.wait(self.interval_sec)
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
if self.last_update_ts > 0 and (time.time() - self.last_update_ts) < 60:
|
||||||
|
print(f"[推流状态] {self.format_summary()}")
|
||||||
|
|
||||||
|
self._printer_thread = threading.Thread(target=_run, daemon=True)
|
||||||
|
self._printer_thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止周期性打印"""
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_and_maybe_print(line: str, stats: StreamStats, prefix: str = "[FFmpeg]", verbose=True) -> bool:
|
||||||
|
"""
|
||||||
|
解析一行 FFmpeg 输出,更新 stats。
|
||||||
|
若为进度行则更新不打印;若为 lag 事件则更新并可选打印;其他行原样打印。
|
||||||
|
verbose: 是否打印非进度行
|
||||||
|
"""
|
||||||
|
line_stripped = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line_stripped:
|
||||||
|
return False
|
||||||
|
# 进度行:更新 stats,不重复打印(由周期摘要代替)
|
||||||
|
if stats.parse_ffmpeg_progress(line_stripped):
|
||||||
|
return True
|
||||||
|
# 源端延迟事件
|
||||||
|
if stats.parse_lag_event(line_stripped):
|
||||||
|
if verbose:
|
||||||
|
print(f"{prefix} {line_stripped}")
|
||||||
|
return True
|
||||||
|
if verbose:
|
||||||
|
print(f"{prefix} {line_stripped}")
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user