Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8705d4847 | ||
|
|
def681cbe3 | ||
|
|
7a03a670bc | ||
|
|
1a2b43fd11 | ||
|
|
136362a34a | ||
|
|
9ccafcc0d5 | ||
|
|
43a07f144f | ||
|
|
fccb46ae7b | ||
|
|
ea99d733dc | ||
|
|
8c14404831 |
834
README.md
834
README.md
@@ -1,679 +1,259 @@
|
||||

|
||||
# DouyinLiveRecorder 转播版
|
||||
|
||||
## 💡简介
|
||||
[](https://www.python.org/downloads/release/python-3116/)
|
||||
[](https://github.com/ihmily/DouyinLiveRecorder)
|
||||
[](https://hub.docker.com/r/ihmily/douyin-live-recorder/tags)
|
||||

|
||||
[](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
|
||||
[](https://github.com/ihmily/DouyinLiveRecorder/releases/latest)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://github.com/ihmily/DouyinLiveRecorder)
|
||||
[](LICENSE)
|
||||
|
||||
一款**简易**的可循环值守的直播录制工具,基于FFmpeg实现多平台直播源录制,支持自定义配置录制以及直播状态推送。
|
||||
基于 [DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder) 的**转播增强版**,将原本的录制下载功能扩展为**实时推流转播**,支持抖音/TikTok 等直播推送到 YouTube、HDMI 等目标。
|
||||
|
||||
</div>
|
||||
## 与原版的主要区别
|
||||
|
||||
## 😺已支持平台
|
||||
| 功能 | 原版 DouyinLiveRecorder | 本转播版 |
|
||||
|------|-------------------------|----------|
|
||||
| 核心用途 | 录制直播到本地文件 | 实时转播到 YouTube / HDMI |
|
||||
| 抖音 → YouTube | 不支持 | ✅ 支持,含 AI 人声分离防版权 |
|
||||
| 抖音 → HDMI/SRS | 不支持 | ✅ 支持(TikTok 模式) |
|
||||
| 录制到本地 | ✅ 支持 | ✅ 保留支持 |
|
||||
| 平台支持 | 40+ 平台 | 继承全部平台 |
|
||||
|
||||
- [x] 抖音
|
||||
- [x] TikTok
|
||||
- [x] 快手
|
||||
- [x] 虎牙
|
||||
- [x] 斗鱼
|
||||
- [x] YY
|
||||
- [x] B站
|
||||
- [x] 小红书
|
||||
- [x] bigo
|
||||
- [x] blued
|
||||
- [x] SOOP(原AfreecaTV)
|
||||
- [x] 网易cc
|
||||
- [x] 千度热播
|
||||
- [x] PandaTV
|
||||
- [x] 猫耳FM
|
||||
- [x] Look直播
|
||||
- [x] WinkTV
|
||||
- [x] TTingLive(原Flextv)
|
||||
- [x] PopkonTV
|
||||
- [x] TwitCasting
|
||||
- [x] 百度直播
|
||||
- [x] 微博直播
|
||||
- [x] 酷狗直播
|
||||
- [x] TwitchTV
|
||||
- [x] LiveMe
|
||||
- [x] 花椒直播
|
||||
- [x] 流星直播
|
||||
- [x] ShowRoom
|
||||
- [x] Acfun
|
||||
- [x] 映客直播
|
||||
- [x] 音播直播
|
||||
- [x] 知乎直播
|
||||
- [x] CHZZK
|
||||
- [x] 嗨秀直播
|
||||
- [x] vv星球直播
|
||||
- [x] 17Live
|
||||
- [x] 浪Live
|
||||
- [x] 畅聊直播
|
||||
- [x] 飘飘直播
|
||||
- [x] 六间房直播
|
||||
- [x] 乐嗨直播
|
||||
- [x] 花猫直播
|
||||
- [x] Shopee
|
||||
- [x] Youtube
|
||||
- [x] 淘宝
|
||||
- [x] 京东
|
||||
- [x] Faceit
|
||||
- [x] 咪咕
|
||||
- [x] 连接直播
|
||||
- [x] 来秀直播
|
||||
- [x] Picarto
|
||||
- [ ] 更多平台正在更新中
|
||||
---
|
||||
|
||||
</div>
|
||||
## 功能特性
|
||||
|
||||
## 🎈项目结构
|
||||
### 转播模式
|
||||
|
||||
- **YouTube 转播**:抖音直播 → YouTube Live,支持 AI 人声分离(Demucs)降低音乐版权识别
|
||||
- **TikTok/HDMI 转播**:抖音直播 → SRS 推流 → HDMI 采集卡或 OBS
|
||||
- **本地录制**:保留原版全部录制能力
|
||||
|
||||
### 防版权识别(YouTube 专用)
|
||||
|
||||
- **Demucs AI 人声分离**:只保留人声,去除背景音乐,从源头避免 Content ID 匹配
|
||||
- **NVIDIA GPU 加速**:自动检测 CUDA,优先使用 GPU 加速 Demucs
|
||||
- **无 GPU 回退**:无显卡时自动回退到 CPU,不报错
|
||||
- **可配置开关**:可在 `config/youtube.ini` 中关闭 Demucs,使用原始音频
|
||||
|
||||
### 循环值守与多直播间
|
||||
|
||||
- 自动轮询直播间状态
|
||||
- 主播下播后自动停止推流,并快速重检
|
||||
- 支持多直播间 URL 配置,单路推流时自动切换
|
||||
- 直播状态推送(钉钉、微信、Telegram 等)
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
└── DouyinLiveRecorder/
|
||||
├── /config -> (config record)
|
||||
├── /logs -> (save runing log file)
|
||||
├── /backup_config -> (backup file)
|
||||
├── /douyinliverecorder -> (package)
|
||||
├── initializer.py-> (check and install nodejs)
|
||||
├── spider.py-> (get live data)
|
||||
├── stream.py-> (get live stream address)
|
||||
├── utils.py -> (contains utility functions)
|
||||
├── logger.py -> (logger handdle)
|
||||
├── room.py -> (get room info)
|
||||
├── ab_sign.py-> (generate dy token)
|
||||
├── /javascript -> (some decrypt code)
|
||||
├── main.py -> (main file)
|
||||
├── ffmpeg_install.py -> (ffmpeg install script)
|
||||
├── demo.py -> (call package test demo)
|
||||
├── msg_push.py -> (send live status update message)
|
||||
├── ffmpeg.exe -> (record video)
|
||||
├── index.html -> (play m3u8 and flv video)
|
||||
├── requirements.txt -> (library dependencies)
|
||||
├── docker-compose.yaml -> (Container Orchestration File)
|
||||
├── Dockerfile -> (Application Build Recipe)
|
||||
├── StopRecording.vbs -> (stop recording script on Windows)
|
||||
...
|
||||
├── main.py # 主入口(本地录制 + SRS 转播)
|
||||
├── youtube.py # YouTube 转播入口
|
||||
├── tiktok.py # TikTok/HDMI 转播入口
|
||||
├── douyin_youtube_ffplay.py # 抖音 → YouTube 推流(含 Demucs 管道)
|
||||
├── douyin_srs_ffplay.py # 抖音 → SRS/HDMI 推流
|
||||
├── web.py # Web 配置界面
|
||||
├── config/
|
||||
│ ├── config.ini # 通用配置
|
||||
│ ├── youtube.ini # YouTube 推流配置(stream key、Demucs 开关)
|
||||
│ └── URL_config.ini # 直播间 URL 列表
|
||||
├── src/ # 爬虫、流解析等核心逻辑
|
||||
├── backup/ # 备份与辅助模块
|
||||
├── requirements.txt
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
</div>
|
||||
---
|
||||
|
||||
## 🌱使用说明
|
||||
## 快速开始
|
||||
|
||||
- 对于只想使用录制软件的小白用户,进入[Releases](https://github.com/ihmily/DouyinLiveRecorder/releases) 中下载最新发布的 zip压缩包即可,里面有打包好的录制软件。(有些电脑可能会报毒,直接忽略即可,如果下载时被浏览器屏蔽,请更换浏览器下载)
|
||||
### 1. 环境要求
|
||||
|
||||
- 压缩包解压后,在 `config` 文件夹内的 `URL_config.ini` 中添加录制直播间地址,一行一个直播间地址。如果要自定义配置录制,可以修改`config.ini` 文件,推荐将录制格式修改为`ts`。
|
||||
- 以上步骤都做好后,就可以运行`DouyinLiveRecorder.exe` 程序进行录制了。录制的视频文件保存在同目录下的 `downloads` 文件夹内。
|
||||
- **Python** >= 3.10
|
||||
- **FFmpeg**(需支持 libx264、h264_nvenc)
|
||||
- **可选**:NVIDIA GPU + CUDA(用于 Demucs 加速)
|
||||
|
||||
- 另外,如果需要录制TikTok、AfreecaTV等海外平台,请在配置文件中设置开启代理并添加proxy_addr链接 如:`127.0.0.1:7890` (这只是示例地址,具体根据实际填写)。
|
||||
### 2. 安装依赖
|
||||
|
||||
- 假如`URL_config.ini`文件中添加的直播间地址,有个别直播间暂时不想录制又不想移除链接,可以在对应直播间的链接开头加上`#`,那么将停止该直播间的监测以及录制。
|
||||
```bash
|
||||
# 克隆或下载项目
|
||||
cd douyinyoutube
|
||||
|
||||
- 软件默认录制清晰度为 `原画` ,如果要单独设置某个直播间的录制画质,可以在添加直播间地址时前面加上画质即可,如`超清,https://live.douyin.com/745964462470` 记得中间要有`,` 分隔。
|
||||
# 创建虚拟环境(推荐)
|
||||
python -m venv .venv
|
||||
# Windows:
|
||||
.venv\Scripts\activate
|
||||
# Ubuntu/Linux:
|
||||
source .venv/bin/activate
|
||||
|
||||
- 如果要长时间挂着软件循环监测直播,最好循环时间设置长一点(咱也不差没录制到的那几分钟),避免因请求频繁导致被官方封禁IP 。
|
||||
|
||||
- 要停止直播录制,Windows平台可执行StopRecording.vbs脚本文件,或者在录制界面使用 `Ctrl+C ` 组合键中断录制,若要停止其中某个直播间的录制,可在`URL_config.ini`文件中的地址前加#,会自动停止对应直播间的录制并正常保存已录制的视频。
|
||||
- 最后,欢迎右上角给本项目一个star,同时也非常乐意大家提交pr。
|
||||
|
||||
 
|
||||
|
||||
直播间链接示例:
|
||||
# 安装基础依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 若使用 YouTube 转播 + Demucs 防版权,需额外安装:
|
||||
pip install -r requirements-youtube.txt
|
||||
# GPU 版本(NVIDIA)需先安装 CUDA 版 PyTorch:
|
||||
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
```
|
||||
抖音:
|
||||
|
||||
### 3. 配置
|
||||
|
||||
#### YouTube 转播
|
||||
|
||||
1. 编辑 `config/youtube.ini`:
|
||||
|
||||
```ini
|
||||
[youtube]
|
||||
# YouTube 直播 stream key(从 YouTube 直播控制台获取)
|
||||
key = xxxx-xxxx-xxxx-xxxx
|
||||
|
||||
# 是否启用 Demucs 人声分离(防版权识别)
|
||||
# 是 = 启用,否 = 使用原始音频
|
||||
enable_demucs = 是
|
||||
|
||||
# Demucs 是否优先使用 GPU(有 NVIDIA 显卡时)
|
||||
# 是 = 优先 GPU,否 = 强制 CPU
|
||||
demucs_use_gpu = 是
|
||||
```
|
||||
|
||||
2. 编辑 `config/URL_config.ini`,添加抖音直播间地址:
|
||||
|
||||
```ini
|
||||
https://live.douyin.com/745964462470
|
||||
https://v.douyin.com/iQFeBnt/
|
||||
https://live.douyin.com/yall1102 (链接+抖音号)
|
||||
https://v.douyin.com/CeiU5cbX (主播主页地址)
|
||||
|
||||
TikTok:
|
||||
https://www.tiktok.com/@pearlgaga88/live
|
||||
|
||||
快手:
|
||||
https://live.kuaishou.com/u/yall1102
|
||||
|
||||
虎牙:
|
||||
https://www.huya.com/52333
|
||||
|
||||
斗鱼:
|
||||
https://www.douyu.com/3637778?dyshid=
|
||||
https://www.douyu.com/topic/wzDBLS6?rid=4921614&dyshid=
|
||||
|
||||
YY:
|
||||
https://www.yy.com/22490906/22490906
|
||||
|
||||
B站:
|
||||
https://live.bilibili.com/320
|
||||
|
||||
小红书(直播间分享地址):
|
||||
http://xhslink.com/xpJpfM
|
||||
|
||||
bigo直播:
|
||||
https://www.bigo.tv/cn/716418802
|
||||
|
||||
buled直播:
|
||||
https://app.blued.cn/live?id=Mp6G2R
|
||||
|
||||
SOOP:
|
||||
https://play.sooplive.co.kr/sw7love
|
||||
|
||||
网易cc:
|
||||
https://cc.163.com/583946984
|
||||
|
||||
千度热播:
|
||||
https://qiandurebo.com/web/video.php?roomnumber=33333
|
||||
|
||||
PandaTV:
|
||||
https://www.pandalive.co.kr/live/play/bara0109
|
||||
|
||||
猫耳FM:
|
||||
https://fm.missevan.com/live/868895007
|
||||
|
||||
Look直播:
|
||||
https://look.163.com/live?id=65108820&position=3
|
||||
|
||||
WinkTV:
|
||||
https://www.winktv.co.kr/live/play/anjer1004
|
||||
|
||||
FlexTV(TTinglive)::
|
||||
https://www.flextv.co.kr/channels/593127/live
|
||||
|
||||
PopkonTV:
|
||||
https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117
|
||||
https://www.popkontv.com/channel/notices?mcid=wjfal007&mcPartnerCode=P-00117
|
||||
|
||||
TwitCasting:
|
||||
https://twitcasting.tv/c:uonq
|
||||
|
||||
百度直播:
|
||||
https://live.baidu.com/m/media/pclive/pchome/live.html?room_id=9175031377&tab_category
|
||||
|
||||
微博直播:
|
||||
https://weibo.com/l/wblive/p/show/1022:2321325026370190442592
|
||||
|
||||
酷狗直播:
|
||||
https://fanxing2.kugou.com/50428671?refer=2177&sourceFrom=
|
||||
|
||||
TwitchTV:
|
||||
https://www.twitch.tv/gamerbee
|
||||
|
||||
LiveMe:
|
||||
https://www.liveme.com/zh/v/17141543493018047815/index.html
|
||||
|
||||
花椒直播:
|
||||
https://www.huajiao.com/l/345096174
|
||||
|
||||
流星直播:
|
||||
https://www.7u66.com/100960
|
||||
|
||||
ShowRoom:
|
||||
https://www.showroom-live.com/room/profile?room_id=480206 (主播主页地址)
|
||||
|
||||
Acfun:
|
||||
https://live.acfun.cn/live/179922
|
||||
|
||||
映客直播:
|
||||
https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904
|
||||
|
||||
音播直播:
|
||||
https://live.ybw1666.com/800002949
|
||||
|
||||
知乎直播:
|
||||
https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址)
|
||||
|
||||
CHZZK:
|
||||
https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2
|
||||
|
||||
嗨秀直播:
|
||||
https://www.haixiutv.com/6095106
|
||||
|
||||
VV星球直播:
|
||||
https://h5webcdn-pro.vvxqiu.com//activity/videoShare/videoShare.html?h5Server=https://h5p.vvxqiu.com&roomId=LP115924473&platformId=vvstar
|
||||
|
||||
17Live:
|
||||
https://17.live/en/live/6302408
|
||||
|
||||
浪Live:
|
||||
https://www.lang.live/en-US/room/3349463
|
||||
|
||||
畅聊直播:
|
||||
https://live.tlclw.com/106188
|
||||
|
||||
飘飘直播:
|
||||
https://m.pp.weimipopo.com/live/preview.html?uid=91648673&anchorUid=91625862&app=plpl
|
||||
|
||||
六间房直播:
|
||||
https://v.6.cn/634435
|
||||
|
||||
乐嗨直播:
|
||||
https://www.lehaitv.com/8059096
|
||||
|
||||
花猫直播:
|
||||
https://h.catshow168.com/live/preview.html?uid=19066357&anchorUid=18895331
|
||||
|
||||
Shopee:
|
||||
https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458
|
||||
|
||||
Youtube:
|
||||
https://www.youtube.com/watch?v=cS6zS5hi1w0
|
||||
|
||||
淘宝(需cookie):
|
||||
https://tbzb.taobao.com/live?liveId=532359023188
|
||||
https://m.tb.cn/h.TWp0HTd
|
||||
|
||||
京东:
|
||||
https://3.cn/28MLBy-E
|
||||
|
||||
Faceit:
|
||||
https://www.faceit.com/zh/players/Compl1/stream
|
||||
|
||||
连接直播:
|
||||
https://show.lailianjie.com/10000258
|
||||
|
||||
咪咕直播:
|
||||
https://www.miguvideo.com/p/live/120000541321
|
||||
|
||||
来秀直播:
|
||||
https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496
|
||||
|
||||
Picarto:
|
||||
https://www.picarto.tv/cuteavalanche
|
||||
https://live.douyin.com/xxx,主播名
|
||||
```
|
||||
|
||||
 
|
||||
3. 在 `config/config.ini` 中将 `视频保存格式` 设为 `ts`(触发 YouTube 推流逻辑)
|
||||
|
||||
## 🎃源码运行
|
||||
使用源码运行,可参考下面的步骤。
|
||||
#### TikTok/HDMI 转播
|
||||
|
||||
1.首先拉取或手动下载本仓库项目代码
|
||||
- 使用 `tiktok.py` 入口,配置 SRS 推流地址
|
||||
- 详见 `tiktok/` 目录说明
|
||||
|
||||
### 4. 运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ihmily/DouyinLiveRecorder.git
|
||||
# YouTube 转播
|
||||
python youtube.py
|
||||
|
||||
# 或使用 uv
|
||||
uv run youtube.py
|
||||
```
|
||||
|
||||
2.进入项目文件夹,安装依赖
|
||||
---
|
||||
|
||||
## 详细配置说明
|
||||
|
||||
### config/youtube.ini
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| key | YouTube 直播 stream key | 必填 |
|
||||
| enable_demucs | 是否启用 Demucs 人声分离 | 是 |
|
||||
| demucs_use_gpu | 是否优先使用 NVIDIA GPU | 是 |
|
||||
|
||||
### config/config.ini
|
||||
|
||||
- **视频保存格式**:设为 `ts` 时,youtube.py 会走 YouTube 推流逻辑,而非本地录制
|
||||
- **循环时间**:轮询间隔(秒)
|
||||
- **代理**:海外平台需配置代理
|
||||
|
||||
### URL_config.ini 格式
|
||||
|
||||
```
|
||||
# 单行一个直播间
|
||||
https://live.douyin.com/745964462470
|
||||
|
||||
# 可加画质和备注
|
||||
超清,https://live.douyin.com/xxx
|
||||
https://live.douyin.com/yyy,主播名
|
||||
|
||||
# 注释掉不监测
|
||||
# https://live.douyin.com/zzz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demucs 防版权说明
|
||||
|
||||
### 原理
|
||||
|
||||
- 使用 [Demucs](https://github.com/facebookresearch/demucs) 对混音进行人声分离
|
||||
- 只保留人声轨道推送到 YouTube,背景音乐被物理移除
|
||||
- Content ID 无法匹配已移除的音乐
|
||||
|
||||
### 延迟与性能
|
||||
|
||||
- **延迟**:约 4–8 秒(与 chunk 大小相关)
|
||||
- **GPU**:有 NVIDIA 显卡时自动使用 CUDA 加速
|
||||
- **CPU**:无 GPU 时自动回退,不影响运行
|
||||
|
||||
### 回退机制
|
||||
|
||||
1. **Demucs 未安装**:自动使用原始 FFmpeg 推流
|
||||
2. **GPU 不可用**:自动使用 CPU 运行 Demucs
|
||||
3. **enable_demucs = 否**:直接使用原始音频
|
||||
|
||||
---
|
||||
|
||||
## 平台兼容性
|
||||
|
||||
| 平台 | 录制 | YouTube 转播 | Demucs |
|
||||
|------|------|--------------|--------|
|
||||
| Windows | ✅ | ✅ | ✅(CPU/GPU) |
|
||||
| Ubuntu Server | ✅ | ✅ | ✅(优先) |
|
||||
| 其他 Linux | ✅ | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Web 配置界面
|
||||
|
||||
```bash
|
||||
cd DouyinLiveRecorder
|
||||
python web.py
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> - 不论你是否已安装 **Python>=3.10** 环境, 都推荐使用 [**uv**](https://github.com/astral-sh/uv) 运行, 因为它可以自动管理虚拟环境和方便地管理 **Python** 版本, **不过这完全是可选的**<br />
|
||||
> 使用以下命令安装
|
||||
> ```bash
|
||||
> # 在 macOS 和 Linux 上安装 uv
|
||||
> curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
> ```
|
||||
> ```powershell
|
||||
> # 在 Windows 上安装 uv
|
||||
> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
> ```
|
||||
> - 如果安装依赖速度太慢, 你可以考虑使用国内 pip 镜像源:<br />
|
||||
> 在 `pip` 命令使用 `-i` 参数指定, 如 `pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`<br />
|
||||
> 或者在 `uv` 命令 `--index` 选项指定, 如 `uv sync --index https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||
- 浏览器访问配置页面
|
||||
- 支持编辑 `youtube.ini`、`URL_config.ini`
|
||||
- 选择 youtube / tiktok / obs 等模式
|
||||
|
||||
<details>
|
||||
---
|
||||
|
||||
<summary>如果已安装 <b>Python>=3.10</b> 环境</summary>
|
||||
## 常见问题
|
||||
|
||||
- :white_check_mark: 在虚拟环境中安装 (推荐)
|
||||
|
||||
1. 创建虚拟环境
|
||||
|
||||
- 使用系统已安装的 Python, 不使用 uv
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
- 使用 uv, 默认使用系统 Python, 你可以添加 `--python` 选项指定 Python 版本而不使用系统 Python [uv官方文档](https://docs.astral.sh/uv/concepts/python-versions/)
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
```
|
||||
|
||||
2. 在终端激活虚拟环境 (在未安装 uv 或你想要手动激活虚拟环境时执行, 若已安装 uv, 可以跳过这一步, uv 会自动激活并使用虚拟环境)
|
||||
|
||||
**Bash** 中
|
||||
```bash
|
||||
source .venv/Scripts/activate
|
||||
```
|
||||
|
||||
**Powershell** 中
|
||||
```powershell
|
||||
.venv\Scripts\activate.ps1
|
||||
```
|
||||
|
||||
**Windows CMD** 中
|
||||
```bat
|
||||
.venv\Scripts\activate.bat
|
||||
```
|
||||
|
||||
3. 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 pip (若安装太慢或失败, 可使用 `-i` 指定镜像源)
|
||||
pip3 install -U pip && pip3 install -r requirements.txt
|
||||
# 或者使用 uv (可使用 `--index` 指定镜像源)
|
||||
uv sync
|
||||
# 或者
|
||||
uv pip sync requirements.txt
|
||||
```
|
||||
|
||||
- :x: 在系统 Python 环境中安装 (不推荐)
|
||||
|
||||
```bash
|
||||
pip3 install -U pip && pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>如果未安装 <b>Python>=3.10</b> 环境</summary>
|
||||
|
||||
你可以使用 [**uv**](https://github.com/astral-sh/uv) 安装依赖
|
||||
|
||||
```bash
|
||||
# uv 将使用 3.10 及以上的最新 python 发行版自动创建并使用虚拟环境, 可使用 --python 选项指定 python 版本, 参见 https://docs.astral.sh/uv/reference/cli/#uv-sync--python 和 https://docs.astral.sh/uv/reference/cli/#uv-pip-sync--python
|
||||
uv sync
|
||||
# 或
|
||||
uv pip sync requirements.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
3.安装[FFmpeg](https://ffmpeg.org/download.html#build-linux),如果是Windows系统,这一步可跳过。对于Linux系统,执行以下命令安装
|
||||
|
||||
CentOS执行
|
||||
### 1. Demucs 安装失败
|
||||
|
||||
```bash
|
||||
yum install epel-release
|
||||
yum install ffmpeg
|
||||
# 仅 CPU
|
||||
pip install demucs torch torchaudio
|
||||
|
||||
# 使用国内镜像
|
||||
pip install demucs torch torchaudio -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
```
|
||||
|
||||
Ubuntu则执行
|
||||
### 2. 无 NVIDIA 显卡
|
||||
|
||||
```bash
|
||||
apt update
|
||||
apt install ffmpeg
|
||||
```
|
||||
- 程序会自动使用 CPU 运行 Demucs
|
||||
- 可在 `youtube.ini` 中设置 `demucs_use_gpu = 否` 以跳过 GPU 检测
|
||||
|
||||
macOS 执行
|
||||
### 3. 推流中断
|
||||
|
||||
**如果已经安装 Homebrew 请跳过这一步**
|
||||
- 检查网络与代理
|
||||
- 检查 YouTube stream key 是否有效
|
||||
- 查看控制台日志定位错误
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
### 4. 抖音风控
|
||||
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
- 适当增大循环时间
|
||||
- 确保 Cookie 有效(录制抖音需配置)
|
||||
|
||||
4.运行程序
|
||||
---
|
||||
|
||||
```python
|
||||
python main.py
|
||||
## 致谢
|
||||
|
||||
```
|
||||
或
|
||||
- 原项目:[ihmily/DouyinLiveRecorder](https://github.com/ihmily/DouyinLiveRecorder)
|
||||
- 人声分离:[facebookresearch/demucs](https://github.com/facebookresearch/demucs)
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
---
|
||||
|
||||
其中Linux系统请使用`python3 main.py` 运行。
|
||||
## 许可证
|
||||
|
||||
 
|
||||
## 🐋容器运行
|
||||
|
||||
在运行命令之前,请确保您的机器上安装了 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
1.快速启动
|
||||
|
||||
最简单方法是运行项目中的 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,只需简单执行以下命令:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
可选 `-d` 在后台运行。
|
||||
|
||||
|
||||
|
||||
2.构建镜像(可选)
|
||||
|
||||
如果你只想简单的运行程序,则不需要做这一步。Docker镜像仓库中代码版本可能不是最新的,如果要运行本仓库主分支最新代码,可以本地自定义构建,通过修改 [docker-compose.yaml](https://github.com/ihmily/DouyinLiveRecorder/blob/main/docker-compose.yaml) 文件,如将镜像名修改为 `douyin-live-recorder:latest`,并取消 `# build: .` 注释,然后再执行
|
||||
|
||||
```bash
|
||||
docker build -t douyin-live-recorder:latest .
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
或者直接使用下面命令进行构建并启动
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.yaml up
|
||||
```
|
||||
|
||||
|
||||
|
||||
3.停止容器实例
|
||||
|
||||
```bash
|
||||
docker-compose stop
|
||||
```
|
||||
|
||||
|
||||
|
||||
4.注意事项
|
||||
|
||||
①在docker容器内运行本程序之前,请先在配置文件中添加要录制的直播间地址。
|
||||
|
||||
②在容器内时,如果手动中断容器运行停止录制,会导致正在录制的视频文件损坏!
|
||||
|
||||
**无论哪种运行方式,为避免手动中断或者异常中断导致录制的视频文件损坏的情况,推荐使用 `ts` 格式保存**。
|
||||
|
||||
 
|
||||
|
||||
## 🤖相关项目
|
||||
|
||||
- StreamCap: https://github.com/ihmily/StreamCap
|
||||
- streamget: https://github.com/ihmily/streamget
|
||||
|
||||
 
|
||||
|
||||
## ❤️贡献者
|
||||
|
||||
   [](https://github.com/ihmily)
|
||||
[](https://github.com/iridescentGray)
|
||||
[](https://github.com/annidy)
|
||||
[](https://github.com/wwkk2580)
|
||||
[](https://github.com/missuo)
|
||||
<a href="https://github.com/xueli12" target="_blank"><img src="https://github.com/xueli12.png?size=50" alt="xueli12" style="width:53px; height:51px;" /></a>
|
||||
<a href="https://github.com/kaine1973" target="_blank"><img src="https://github.com/kaine1973.png?size=50" alt="kaine1973" style="width:53px; height:51px;" /></a>
|
||||
<a href="https://github.com/yinruiqing" target="_blank"><img src="https://github.com/yinruiqing.png?size=50" alt="yinruiqing" style="width:53px; height:51px;" /></a>
|
||||
<a href="https://github.com/Max-Tortoise" target="_blank"><img src="https://github.com/Max-Tortoise.png?size=50" alt="Max-Tortoise" style="width:53px; height:51px;" /></a>
|
||||
[](https://github.com/justdoiting)
|
||||
[](https://github.com/dhbxs)
|
||||
[](https://github.com/wujiyu115)
|
||||
[](https://github.com/zhanghao333)
|
||||
<a href="https://github.com/gyc0123" target="_blank"><img src="https://github.com/gyc0123.png?size=50" alt="gyc0123" style="width:53px; height:51px;" /></a>
|
||||
|
||||
   [](https://github.com/HoratioShaw)
|
||||
[](https://github.com/nov30th)
|
||||
[](https://github.com/727155455)
|
||||
[](https://github.com/nixingshiguang)
|
||||
[](https://github.com/1411430556)
|
||||
[](https://github.com/Ovear)
|
||||
 
|
||||
|
||||
## ⏳提交日志
|
||||
|
||||
- 20251024
|
||||
- 修复抖音风控无法获取数据问题
|
||||
|
||||
- 新增soop.com录制支持
|
||||
|
||||
- 修复bigo录制
|
||||
|
||||
- 20250127
|
||||
- 新增淘宝、京东、faceit直播录制
|
||||
- 修复小红书直播流录制以及转码问题
|
||||
- 修复畅聊、VV星球、flexTV直播录制
|
||||
- 修复批量微信直播推送
|
||||
- 新增email发送ssl和port配置
|
||||
- 新增强制转h264配置
|
||||
- 更新ffmpeg版本
|
||||
- 重构包为异步函数!
|
||||
|
||||
- 20241130
|
||||
- 新增shopee、youtube直播录制
|
||||
- 新增支持自定义m3u8、flv地址录制
|
||||
- 新增自定义执行脚本,支持python、bat、bash等
|
||||
- 修复YY直播、花椒直播和小红书直播录制
|
||||
- 修复b站标题获取错误
|
||||
- 修复log日志错误
|
||||
- 20241030
|
||||
- 新增嗨秀直播、vv星球直播、17Live、浪Live、SOOP、畅聊直播(原时光直播)、飘飘直播、六间房直播、乐嗨直播、花猫直播等10个平台直播录制
|
||||
- 修复小红书直播录制,支持小红书作者主页地址录制直播
|
||||
- 新增支持ntfy消息推送,以及新增支持批量推送多个地址(逗号分隔多个推送地址)
|
||||
- 修复Liveme直播录制、twitch直播录制
|
||||
- 新增Windows平台一键停止录制VB脚本程序
|
||||
- 20241005
|
||||
- 新增邮箱和Bark推送
|
||||
- 新增直播注释停止录制
|
||||
- 优化分段录制
|
||||
- 重构部分代码
|
||||
- 20240928
|
||||
- 新增知乎直播、CHZZK直播录制
|
||||
- 修复音播直播录制
|
||||
- 20240903
|
||||
- 新增抖音双屏录制、音播直播录制
|
||||
- 修复PandaTV、bigo直播录制
|
||||
- 20240713
|
||||
- 新增映客直播录制
|
||||
- 20240705
|
||||
- 新增时光直播录制
|
||||
- 20240701
|
||||
- 修复虎牙直播录制2分钟断流问题
|
||||
- 新增自定义直播推送内容
|
||||
- 20240621
|
||||
- 新增Acfun、ShowRoom直播录制
|
||||
- 修复微博录制、新增直播源线路
|
||||
- 修复斗鱼直播60帧录制
|
||||
- 修复酷狗直播录制
|
||||
- 修复TikTok部分无法解析直播源
|
||||
- 修复抖音无法录制连麦直播
|
||||
- 20240510
|
||||
- 修复部分虎牙直播间录制错误
|
||||
- 20240508
|
||||
- 修复花椒直播录制
|
||||
- 更改文件路径解析方式 [@kaine1973](https://github.com/kaine1973)
|
||||
- 20240506
|
||||
- 修复抖音录制画质解析bug
|
||||
- 修复虎牙录制 60帧最高画质问题
|
||||
- 新增流星直播录制
|
||||
- 20240427
|
||||
- 新增LiveMe、花椒直播录制
|
||||
- 20240425
|
||||
- 新增TwitchTV直播录制
|
||||
- 20240424
|
||||
- 新增酷狗直播录制、优化PopkonTV直播录制
|
||||
- 20240423
|
||||
- 新增百度直播录制、微博直播录制
|
||||
- 修复斗鱼录制直播回放的问题
|
||||
- 新增直播源地址显示以及输出到日志文件设置
|
||||
- 20240311
|
||||
- 修复海外平台录制bug,增加画质选择,增强录制稳定性
|
||||
- 修复虎牙录制bug (虎牙`一起看`频道 有特殊限制,有时无法录制)
|
||||
- 20240309
|
||||
- 修复虎牙直播、小红书直播和B站直播录制
|
||||
- 新增5个直播平台录制,包括winktv、flextv、look、popkontv、twitcasting
|
||||
- 新增部分海外平台账号密码配置,实现自动登录并更新配置文件中的cookie
|
||||
- 新增自定义配置需要使用代理录制的平台
|
||||
- 新增只推送开播消息不进行录制设置
|
||||
- 修复了一些bug
|
||||
- 20240209
|
||||
- 优化AfreecaTV录制,新增账号密码登录获取cookie以及持久保存
|
||||
- 修复了小红书直播因官方更新直播域名,导致无法录制直播的问题
|
||||
- 修复了更新URL配置文件的bug
|
||||
- 最后,祝大家新年快乐!
|
||||
|
||||
<details><summary>点击展开更多提交日志</summary>
|
||||
|
||||
- 20240129
|
||||
- 新增猫耳FM直播录制
|
||||
- 20240127
|
||||
- 新增千度热播直播录制、新增pandaTV(韩国)直播录制
|
||||
- 新增telegram直播状态消息推送,修复了某些bug
|
||||
- 新增自定义设置不同直播间的录制画质(即每个直播间录制画质可不同)
|
||||
- 修改录制视频保存路径为 `downloads` 文件夹,并且分平台进行保存。
|
||||
- 20240114
|
||||
- 新增网易cc直播录制,优化ffmpeg参数,修改AfreecaTV输入直播地址格式
|
||||
- 修改日志记录器 @[iridescentGray](https://github.com/iridescentGray)
|
||||
- 20240102
|
||||
- 修复Linux上运行,新增docker配置文件
|
||||
- 20231210
|
||||
- 修复录制分段bug,修复bigo录制检测bug
|
||||
- 新增自定义修改录制主播名
|
||||
- 新增AfreecaTV直播录制,修复某些可能会发生的bug
|
||||
- 20231207
|
||||
- 新增blued直播录制,修复YY直播录制,新增直播结束消息推送
|
||||
- 20231206
|
||||
- 新增bigo直播录制
|
||||
- 20231203
|
||||
- 新增小红书直播录制(全网首发),目前小红书官方没有切换清晰度功能,因此直播录制也只有默认画质
|
||||
- 小红书录制暂时无法循环监测,每次主播开启直播,都要重新获取一次链接
|
||||
- 获取链接的方式为 将直播间转发到微信,在微信中打开后,复制页面的链接。
|
||||
- 20231030
|
||||
- 本次更新只是进行修复,没时间新增功能。
|
||||
- 欢迎各位大佬提pr 帮忙更新维护
|
||||
- 20230930
|
||||
- 新增抖音从接口获取直播流,增强稳定性
|
||||
- 修改快手获取直播流的方式,改用从官方接口获取
|
||||
- 祝大家中秋节快乐!
|
||||
- 20230919
|
||||
- 修复了快手版本更新后录制出错的问题,增加了其自动获取cookie(~~稳定性未知~~)
|
||||
- 修复了TikTok显示正在直播但不进行录制的问题
|
||||
- 20230907
|
||||
- 修复了因抖音官方更新了版本导致的录制出错以及短链接转换出错
|
||||
- 修复B站无法录制原画视频的bug
|
||||
- 修改了配置文件字段,新增各平台自定义设置Cookie
|
||||
- 20230903
|
||||
- 修复了TikTok录制时报644无法录制的问题
|
||||
- 新增直播状态推送到钉钉和微信的功能,如有需要请看 [设置推送教程](https://d04vqdiqwr3.feishu.cn/docx/XFPwdDDvfobbzlxhmMYcvouynDh?from=from_copylink)
|
||||
- 最近比较忙,其他问题有时间再更新
|
||||
- 20230816
|
||||
- 修复斗鱼直播(官方更新了字段)和快手直播录制出错的问题
|
||||
- 20230814
|
||||
- 新增B站直播录制
|
||||
- 写了一个在线播放M3U8和FLV视频的网页源码,打开即可食用
|
||||
- 20230812
|
||||
- 新增YY直播录制
|
||||
- 20230808
|
||||
- 修复主播重新开播无法再次录制的问题
|
||||
- 20230807
|
||||
- 新增了斗鱼直播录制
|
||||
- 修复显示录制完成之后会重新开始录制的问题
|
||||
- 20230805
|
||||
- 新增了虎牙直播录制,其暂时只能用flv视频流进行录制
|
||||
- Web API 新增了快手和虎牙这两个平台的直播流解析(TikTok要代理)
|
||||
- 20230804
|
||||
- 新增了快手直播录制,优化了部分代码
|
||||
- 上传了一个自动化获取抖音直播间页面Cookie的代码,可以用于录制
|
||||
- 20230803
|
||||
- 通宵更新
|
||||
- 新增了国际版抖音TikTok的直播录制,去除冗余 简化了部分代码
|
||||
- 20230724
|
||||
- 新增了一个通过抖音直播间地址获取直播视频流链接的API接口,上传即可用
|
||||
</details>
|
||||
 
|
||||
|
||||
## 有问题可以提issue, 我会在这里持续添加更多直播平台的录制 欢迎Star
|
||||
####
|
||||
MIT License
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
[youtube]
|
||||
# 只写 stream key(推荐方式)
|
||||
key = x04z-564w-aks7-embw-30y4
|
||||
key = x04z-564w-aks7-embw-30y4
|
||||
|
||||
# ===== 优先:推流画质 =====
|
||||
# 视频码率 Mbps(YouTube 推荐 2.5,可设 4-4.5 追求更高画质)
|
||||
video_bitrate_mbps = 2.5
|
||||
|
||||
# 推流状态日志间隔(秒),每 N 秒打印帧率/码率/网络等
|
||||
stats_interval_sec = 10
|
||||
|
||||
# ===== 其次:音频版权处理 =====
|
||||
# 是否启用 Demucs 人声分离(防 YouTube Content ID 版权识别)
|
||||
# 若推流几十秒后断流(YouTube 显示 no data),可设为 否 使用原始音频
|
||||
# 是 = 启用,只保留人声;否 = 使用原始音频
|
||||
enable_demucs = 是
|
||||
|
||||
# Demucs 是否优先使用 NVIDIA GPU 加速
|
||||
# 是 = 有显卡则用 GPU;否 = 强制 CPU;无显卡时自动回退到 CPU
|
||||
demucs_use_gpu = 是
|
||||
|
||||
# 人声混合比例(0.5-1.0),改善自然度、减少尖锐
|
||||
# 0.75 = 75%人声+25%混合(推荐);1.0 = 纯人声
|
||||
demucs_vocal_mix = 0.75
|
||||
|
||||
# 混合来源:bass=低音(无混响无重复);other=其他(可能带混响导致重复感)
|
||||
demucs_mix_source = bass
|
||||
456
demucs_pipeline.py
Normal file
456
demucs_pipeline.py
Normal file
@@ -0,0 +1,456 @@
|
||||
# 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 或 other:bass 无混响、无重复感;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_param;libx264 用 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 输出是 stdout,conn 是 socket。需要把 proc_a.stdout 的数据通过 socket 发到 FFmpeg。
|
||||
# FFmpeg_out 的第二个输入是 tcp,已经连接了 conn。所以我们从 proc_a 读,Demucs 处理,写 conn。
|
||||
# 但 proc_a.stdout 是 pipe,conn 是 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
|
||||
@@ -11,6 +11,23 @@ import re
|
||||
import os
|
||||
import configparser
|
||||
|
||||
# Demucs 管道(可选,用于防版权)
|
||||
try:
|
||||
from demucs_pipeline import (
|
||||
_check_demucs_available,
|
||||
run_demucs_pipeline,
|
||||
)
|
||||
_DEMUCS_MODULE_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DEMUCS_MODULE_AVAILABLE = 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):
|
||||
"""主播真实下播的专用异常"""
|
||||
@@ -85,19 +102,58 @@ def start_douyin_youtube_ffplay(
|
||||
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
|
||||
youtube_rtmp = rtmp_base + default_key # 默认值
|
||||
|
||||
enable_demucs = False
|
||||
demucs_use_gpu = True
|
||||
demucs_vocal_mix = 0.75 # 0.75=更自然少尖锐;1.0=纯人声
|
||||
demucs_mix_source = "bass" # bass=无混响无重复;other=可能带混响
|
||||
video_bitrate_mbps = 2.5 # YouTube 推荐 2.5,可设 4-4.5 追求更高画质
|
||||
stats_interval_sec = 10 # 推流状态日志间隔
|
||||
if os.path.exists(config_file):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_file, encoding="utf-8")
|
||||
if config.has_section("youtube"):
|
||||
for key_name, value in config.items("youtube"):
|
||||
value = value.strip()
|
||||
if value:
|
||||
if value.startswith("rtmp://"):
|
||||
youtube_rtmp = value
|
||||
else:
|
||||
youtube_rtmp = rtmp_base + value
|
||||
key_lower = key_name.strip().lower()
|
||||
val = value.strip()
|
||||
if key_lower == "enable_demucs":
|
||||
enable_demucs = val.lower() in ("1", "true", "yes", "是")
|
||||
elif key_lower == "demucs_use_gpu":
|
||||
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 == "demucs_mix_source":
|
||||
if val.lower() in ("bass", "other"):
|
||||
demucs_mix_source = val.lower()
|
||||
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"):
|
||||
youtube_rtmp = val if val.startswith("rtmp://") else rtmp_base + val
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||
break # 只取第一个有效 key,保持单路推流(不加多路逻辑)
|
||||
# 兼容:若上面未匹配到 key,取第一个非配置项的值作为 stream key
|
||||
if youtube_rtmp == rtmp_base + default_key:
|
||||
for k, v in config.items("youtube"):
|
||||
if k.strip().lower() not in ("enable_demucs", "demucs_use_gpu", "demucs_vocal_mix", "demucs_mix_source", "video_bitrate_mbps", "stats_interval_sec") and v.strip():
|
||||
v = v.strip()
|
||||
youtube_rtmp = v if v.startswith("rtmp://") else rtmp_base + v
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||
break
|
||||
|
||||
# ====================== 常量配置 ======================
|
||||
MAX_RETRY_DELAY = 90
|
||||
@@ -135,6 +191,7 @@ def start_douyin_youtube_ffplay(
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ====================== NVENC 检测 ======================
|
||||
# FFmpeg NVENC 需驱动 570+,否则会报 "Driver does not support the required nvenc API version"
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
@@ -144,13 +201,40 @@ def start_douyin_youtube_ffplay(
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
# 检查驱动版本,NVENC API 13.0 需 570+
|
||||
driver_ok = False
|
||||
try:
|
||||
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:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
|
||||
br_k = int(video_bitrate_mbps * 1000)
|
||||
nvenc_params = {
|
||||
"codec_v": codec_v,
|
||||
"preset_v": preset_v,
|
||||
"tune_param": tune_param,
|
||||
"demucs_use_gpu": demucs_use_gpu,
|
||||
"video_bitrate_k": br_k,
|
||||
}
|
||||
use_demucs = enable_demucs and _DEMUCS_MODULE_AVAILABLE and _check_demucs_available()
|
||||
|
||||
# 720p (最原始)
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
@@ -172,10 +256,9 @@ def start_douyin_youtube_ffplay(
|
||||
"-preset", "fast",
|
||||
# "-tune", "zerolatency",
|
||||
"-profile:v", "high",
|
||||
# 720p
|
||||
# "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate,保持真 CBR
|
||||
"-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
|
||||
"-vsync", "cfr",
|
||||
# 720p,画质优先,YouTube 推荐 3-8 Mbps
|
||||
"-b:v", f"{br_k}k", "-maxrate", f"{br_k+500}k", "-bufsize", f"{br_k*2}k",
|
||||
"-fps_mode", "cfr",
|
||||
"-g", "60", "-keyint_min", "60",
|
||||
"-r", "30",
|
||||
"-bf", "0",
|
||||
@@ -435,22 +518,55 @@ def start_douyin_youtube_ffplay(
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
proc = None
|
||||
if use_demucs:
|
||||
try:
|
||||
run_demucs_pipeline(
|
||||
real_url=real_url,
|
||||
youtube_rtmp=youtube_rtmp,
|
||||
douyin_headers=douyin_headers,
|
||||
nvenc_params=nvenc_params,
|
||||
end_keywords=END_KEYWORDS,
|
||||
stderr_queue_timeout=STDERR_QUEUE_TIMEOUT,
|
||||
no_frame_timeout=NO_FRAME_TIMEOUT,
|
||||
start_check_after=START_CHECK_AFTER,
|
||||
stale_output_seconds=STALE_OUTPUT_SECONDS,
|
||||
stream_ended_exception=StreamEnded,
|
||||
cleanup_proc_fn=cleanup_proc,
|
||||
stderr_reader_fn=_stderr_reader_thread,
|
||||
vocal_mix_ratio=demucs_vocal_mix,
|
||||
mix_source=demucs_mix_source,
|
||||
stats_interval_sec=stats_interval_sec,
|
||||
)
|
||||
except StreamEnded:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[WARN] Demucs 管道异常,回退原始推流: {e}")
|
||||
use_demucs = False
|
||||
|
||||
if not use_demucs:
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
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:
|
||||
try:
|
||||
@@ -468,15 +584,22 @@ def start_douyin_youtube_ffplay(
|
||||
break
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
line_str = line.strip() if isinstance(line, str) else line.decode("utf-8", errors="replace").strip()
|
||||
if line_str.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line_str}")
|
||||
continue
|
||||
|
||||
if line:
|
||||
if line_str:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
if stream_stats:
|
||||
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):
|
||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||
@@ -500,6 +623,9 @@ def start_douyin_youtube_ffplay(
|
||||
cleanup_proc(proc)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break
|
||||
finally:
|
||||
if stream_stats is not None:
|
||||
stream_stats.stop()
|
||||
|
||||
continue
|
||||
|
||||
|
||||
12
requirements-youtube.txt
Normal file
12
requirements-youtube.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
# YouTube 转播 + Demucs 人声分离(防版权)所需依赖
|
||||
# 在 requirements.txt 基础上额外安装:
|
||||
# pip install -r requirements.txt -r requirements-youtube.txt
|
||||
|
||||
# CPU 版本
|
||||
demucs>=4.0.0
|
||||
torch>=2.0.0
|
||||
torchaudio>=2.0.0
|
||||
|
||||
# 若使用 NVIDIA GPU,请安装 CUDA 版 PyTorch 后覆盖:
|
||||
# pip uninstall torch torchaudio
|
||||
# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
@@ -4,4 +4,11 @@ pycryptodome>=3.20.0
|
||||
distro>=1.9.0
|
||||
tqdm>=4.67.1
|
||||
httpx[http2]>=0.28.1
|
||||
PyExecJS>=1.5.1
|
||||
PyExecJS>=1.5.1
|
||||
|
||||
# 可选:YouTube 转播时 Demucs 人声分离(防版权识别)
|
||||
# pip install demucs torch torchaudio
|
||||
# GPU 版本: pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
# demucs>=4.0.0
|
||||
# torch>=2.0.0
|
||||
# torchaudio>=2.0.0
|
||||
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