backup 2026-05-21-23-10-40 exclude party materials
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
待完善内容/
|
待完善内容/
|
||||||
个人简历构建/
|
个人简历构建/
|
||||||
|
党支部相关材料汇总/
|
||||||
|
语音生成相关/
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
大家好,未来技术学院师生联合党支部成立于2022年4月,现有党员104人。
|
|
||||||
以学科交叉为牵引,支部组织合理,建设完备。
|
|
||||||
我们在医工交叉领域取得许多成果。我们在智能制造中大展拳脚。
|
|
||||||
未来技术学院师生联合党支部始终坚持党建引领,不断深化学科交叉、产教融合、协同育人。
|
|
||||||
未来我们将持续推进党建和业务深入融合,以党建促发展,推动学校人才培养模式改革取得新的成效。
|
|
||||||
不负青春跟党走,砥砺奋进向未来。
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 889 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 431 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,38 +0,0 @@
|
|||||||
# Tools_scripts_XunFei-Ubuntu
|
|
||||||
|
|
||||||
Ubuntu 版配音工具,使用 Bash + Python + ffmpeg 替代 PowerShell。
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y python3 python3-pip ffmpeg
|
|
||||||
python3 -m pip install -r Tools_scripts_XunFei-Ubuntu/requirements-ubuntu.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export XF_APPID="your_app_id"
|
|
||||||
export XF_APIKEY="your_api_key"
|
|
||||||
export XF_APISECRET="your_api_secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generate Voice
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./Tools_scripts_XunFei-Ubuntu/synthesize_xfyun_super_tts.sh \
|
|
||||||
--script 配音稿.md \
|
|
||||||
--output-dir 02_audio/super_tts \
|
|
||||||
--voice x5_lingfeiyi_flow \
|
|
||||||
--speed 50
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build Final Video
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 Tools_scripts_XunFei-Ubuntu/build_final_video_ubuntu.py \
|
|
||||||
--video input.mp4 \
|
|
||||||
--audio-dir 02_audio/super_tts \
|
|
||||||
--output 05_outputs/final_voiceover.mp4
|
|
||||||
```
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Build a final voice-over video on Ubuntu with ffmpeg."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str]) -> None:
|
|
||||||
print("+ " + " ".join(cmd))
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def require_tool(name: str) -> str:
|
|
||||||
path = shutil.which(name)
|
|
||||||
if not path:
|
|
||||||
raise SystemExit(f"{name} is required. Install it with: sudo apt install -y ffmpeg")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def media_duration(path: Path) -> float:
|
|
||||||
result = subprocess.check_output(
|
|
||||||
[
|
|
||||||
"ffprobe",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_entries",
|
|
||||||
"format=duration",
|
|
||||||
"-of",
|
|
||||||
"default=nw=1:nk=1",
|
|
||||||
str(path),
|
|
||||||
],
|
|
||||||
text=True,
|
|
||||||
).strip()
|
|
||||||
return float(result)
|
|
||||||
|
|
||||||
|
|
||||||
def audio_files(audio_dir: Path) -> list[Path]:
|
|
||||||
files = [
|
|
||||||
path
|
|
||||||
for path in sorted(audio_dir.iterdir())
|
|
||||||
if path.is_file() and path.suffix.lower() in AUDIO_EXTS
|
|
||||||
]
|
|
||||||
if not files:
|
|
||||||
raise FileNotFoundError(f"No audio files found in {audio_dir}")
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def concat_audio_dir(audio_dir: Path, work_dir: Path, silence: float) -> Path:
|
|
||||||
work_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
normalized: list[Path] = []
|
|
||||||
silence_path = work_dir / "silence.wav"
|
|
||||||
run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-t",
|
|
||||||
f"{silence:.3f}",
|
|
||||||
"-i",
|
|
||||||
"anullsrc=channel_layout=stereo:sample_rate=48000",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
str(silence_path),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
for index, src in enumerate(audio_files(audio_dir), start=1):
|
|
||||||
dst = work_dir / f"audio_{index:02d}.wav"
|
|
||||||
run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
str(src),
|
|
||||||
"-vn",
|
|
||||||
"-ar",
|
|
||||||
"48000",
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
str(dst),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
normalized.append(dst)
|
|
||||||
|
|
||||||
concat_items: list[Path] = []
|
|
||||||
for index, item in enumerate(normalized):
|
|
||||||
concat_items.append(item)
|
|
||||||
if index != len(normalized) - 1 and silence > 0:
|
|
||||||
concat_items.append(silence_path)
|
|
||||||
|
|
||||||
list_path = work_dir / "audio_concat.txt"
|
|
||||||
with list_path.open("w", encoding="utf-8") as handle:
|
|
||||||
for item in concat_items:
|
|
||||||
escaped = item.resolve().as_posix().replace("'", "'\\''")
|
|
||||||
handle.write(f"file '{escaped}'\n")
|
|
||||||
|
|
||||||
out_audio = work_dir / "combined_voice.wav"
|
|
||||||
run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"concat",
|
|
||||||
"-safe",
|
|
||||||
"0",
|
|
||||||
"-i",
|
|
||||||
str(list_path),
|
|
||||||
"-c:a",
|
|
||||||
"pcm_s16le",
|
|
||||||
str(out_audio),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return out_audio
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description="Combine one video with voice-over audio.")
|
|
||||||
parser.add_argument("--video", type=Path, required=True, help="Source video path.")
|
|
||||||
parser.add_argument("--audio", type=Path, default=None, help="Single voice-over audio file.")
|
|
||||||
parser.add_argument("--audio-dir", type=Path, default=None, help="Directory of ordered audio files.")
|
|
||||||
parser.add_argument("--output", type=Path, default=Path("05_outputs/final_voiceover.mp4"))
|
|
||||||
parser.add_argument("--work-dir", type=Path, default=Path("04_intermediate/ubuntu_voiceover"))
|
|
||||||
parser.add_argument("--silence", type=float, default=0.35, help="Gap seconds between audio files.")
|
|
||||||
parser.add_argument("--width", type=int, default=1920)
|
|
||||||
parser.add_argument("--height", type=int, default=1080)
|
|
||||||
parser.add_argument("--fps", type=int, default=30)
|
|
||||||
parser.add_argument("--crf", type=int, default=20)
|
|
||||||
parser.add_argument("--preset", default="medium")
|
|
||||||
parser.add_argument("--video-speed", type=float, default=None, help="Override automatic speed.")
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
require_tool("ffmpeg")
|
|
||||||
require_tool("ffprobe")
|
|
||||||
|
|
||||||
if not args.video.exists():
|
|
||||||
raise FileNotFoundError(args.video)
|
|
||||||
if bool(args.audio) == bool(args.audio_dir):
|
|
||||||
raise SystemExit("Use exactly one of --audio or --audio-dir.")
|
|
||||||
|
|
||||||
args.work_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
audio_path = args.audio if args.audio else concat_audio_dir(args.audio_dir, args.work_dir, args.silence)
|
|
||||||
if not audio_path or not audio_path.exists():
|
|
||||||
raise FileNotFoundError(audio_path)
|
|
||||||
|
|
||||||
video_duration = media_duration(args.video)
|
|
||||||
audio_duration = media_duration(audio_path)
|
|
||||||
if video_duration <= 0 or audio_duration <= 0:
|
|
||||||
raise RuntimeError("Invalid media duration.")
|
|
||||||
|
|
||||||
speed = args.video_speed if args.video_speed else video_duration / audio_duration
|
|
||||||
if speed <= 0:
|
|
||||||
raise ValueError("--video-speed must be greater than 0.")
|
|
||||||
|
|
||||||
print(f"video_duration={video_duration:.3f}s")
|
|
||||||
print(f"audio_duration={audio_duration:.3f}s")
|
|
||||||
print(f"video_speed={speed:.6f}x")
|
|
||||||
|
|
||||||
vf = (
|
|
||||||
f"[0:v]setpts=PTS/{speed:.8f},fps={args.fps},"
|
|
||||||
f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease,"
|
|
||||||
f"pad={args.width}:{args.height}:(ow-iw)/2:(oh-ih)/2:black,"
|
|
||||||
"setsar=1,format=yuv420p[v];"
|
|
||||||
"[1:a]aresample=48000,apad[a]"
|
|
||||||
)
|
|
||||||
run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-hide_banner",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
str(args.video),
|
|
||||||
"-i",
|
|
||||||
str(audio_path),
|
|
||||||
"-filter_complex",
|
|
||||||
vf,
|
|
||||||
"-map",
|
|
||||||
"[v]",
|
|
||||||
"-map",
|
|
||||||
"[a]",
|
|
||||||
"-t",
|
|
||||||
f"{audio_duration:.3f}",
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
args.preset,
|
|
||||||
"-crf",
|
|
||||||
str(args.crf),
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
"-ar",
|
|
||||||
"48000",
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart",
|
|
||||||
str(args.output),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
final_duration = media_duration(args.output)
|
|
||||||
print(f"output={args.output}")
|
|
||||||
print(f"final_duration={final_duration:.3f}s")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
TARGET="${1:-02_audio}"
|
|
||||||
|
|
||||||
if ! command -v ffprobe >/dev/null 2>&1; then
|
|
||||||
echo "ffprobe is required. Install it with: sudo apt install -y ffmpeg" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -e "$TARGET" ]]; then
|
|
||||||
echo "Path not found: $TARGET" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
find "$TARGET" -type f \( -iname '*.mp3' -o -iname '*.wav' -o -iname '*.m4a' \) -print0 |
|
|
||||||
sort -z |
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
duration="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$file")"
|
|
||||||
printf '%8.3fs %s\n' "$duration" "$file"
|
|
||||||
done
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
websocket-client>=1.8.0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
python3 "$SCRIPT_DIR/xfyun_tts_ubuntu.py" --mode super "$@"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
python3 "$SCRIPT_DIR/xfyun_tts_ubuntu.py" --mode normal "$@"
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate XFYUN TTS voice files on Ubuntu.
|
|
||||||
|
|
||||||
This script supports both the normal XFYUN online TTS endpoint and the
|
|
||||||
super-realistic TTS endpoint used by the PowerShell workflow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import email.utils
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote, urlparse
|
|
||||||
|
|
||||||
|
|
||||||
NORMAL_TTS_URL = "wss://tts-api.xfyun.cn/v2/tts"
|
|
||||||
SUPER_TTS_URL = "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScriptSegment:
|
|
||||||
index: str
|
|
||||||
title: str
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
def safe_filename(value: str) -> str:
|
|
||||||
cleaned = re.sub(r'[\\/:*?"<>|]', "", value).strip()
|
|
||||||
return cleaned or "segment"
|
|
||||||
|
|
||||||
|
|
||||||
def find_default_script(cwd: Path) -> Path:
|
|
||||||
candidates = sorted(cwd.glob("*.md"))
|
|
||||||
preferred = [
|
|
||||||
path
|
|
||||||
for path in candidates
|
|
||||||
if path.name.startswith("配音稿") or path.name.lower().startswith("voice")
|
|
||||||
]
|
|
||||||
fallback = [
|
|
||||||
path
|
|
||||||
for path in candidates
|
|
||||||
if not path.name.startswith("配音生成工作流") and path.name.lower() != "readme.md"
|
|
||||||
]
|
|
||||||
selected = (preferred or fallback or candidates)
|
|
||||||
if not selected:
|
|
||||||
raise FileNotFoundError("Cannot find script Markdown file. Use --script to specify one.")
|
|
||||||
return selected[0]
|
|
||||||
|
|
||||||
|
|
||||||
def load_segments(script_path: Path) -> list[ScriptSegment]:
|
|
||||||
content = script_path.read_text(encoding="utf-8-sig")
|
|
||||||
pattern = re.compile(
|
|
||||||
r"(?ms)^##\s+([1-9])\.\s+(.+?)\r?\n(.*?)(?=^##\s+[1-9]\.\s+|\Z)"
|
|
||||||
)
|
|
||||||
matches = pattern.findall(content)
|
|
||||||
if not matches:
|
|
||||||
raise ValueError("Cannot find sections like '## 1. title' in script Markdown.")
|
|
||||||
|
|
||||||
segments: list[ScriptSegment] = []
|
|
||||||
metadata = re.compile(r"^(说明|时长|备注|镜头|画面|音色|语速|输出|提示)[::]")
|
|
||||||
for index, title, body in matches:
|
|
||||||
lines = []
|
|
||||||
for raw_line in body.splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line or line.startswith("#") or metadata.match(line):
|
|
||||||
continue
|
|
||||||
lines.append(line)
|
|
||||||
text = "\n".join(lines).replace("\t", " ").strip()
|
|
||||||
if not text:
|
|
||||||
raise ValueError(f"Section {index} has no readable text.")
|
|
||||||
segments.append(ScriptSegment(index=index, title=title.strip(), text=text))
|
|
||||||
return segments
|
|
||||||
|
|
||||||
|
|
||||||
def build_auth_url(request_url: str, api_key: str, api_secret: str) -> str:
|
|
||||||
uri = urlparse(request_url)
|
|
||||||
host_name = uri.hostname or ""
|
|
||||||
path = uri.path or "/"
|
|
||||||
date = email.utils.formatdate(usegmt=True)
|
|
||||||
signature_origin = f"host: {host_name}\ndate: {date}\nGET {path} HTTP/1.1"
|
|
||||||
digest = hmac.new(
|
|
||||||
api_secret.encode("utf-8"),
|
|
||||||
signature_origin.encode("utf-8"),
|
|
||||||
hashlib.sha256,
|
|
||||||
).digest()
|
|
||||||
signature = base64.b64encode(digest).decode("ascii")
|
|
||||||
authorization_origin = (
|
|
||||||
f'api_key="{api_key}", algorithm="hmac-sha256", '
|
|
||||||
f'headers="host date request-line", signature="{signature}"'
|
|
||||||
)
|
|
||||||
authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode("ascii")
|
|
||||||
return (
|
|
||||||
f"{request_url}?authorization={quote(authorization)}"
|
|
||||||
f"&date={quote(date)}&host={quote(host_name)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_websocket():
|
|
||||||
try:
|
|
||||||
import websocket # type: ignore
|
|
||||||
except ImportError as exc:
|
|
||||||
raise SystemExit(
|
|
||||||
"Missing dependency: websocket-client. Install it with:\n"
|
|
||||||
" python3 -m pip install -r Tools_scripts_XunFei-Ubuntu/requirements-ubuntu.txt"
|
|
||||||
) from exc
|
|
||||||
return websocket
|
|
||||||
|
|
||||||
|
|
||||||
def recv_json(socket: Any) -> dict[str, Any]:
|
|
||||||
message = socket.recv()
|
|
||||||
if isinstance(message, bytes):
|
|
||||||
message = message.decode("utf-8")
|
|
||||||
return json.loads(message)
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_normal(
|
|
||||||
*,
|
|
||||||
text: str,
|
|
||||||
out_file: Path,
|
|
||||||
app_id: str,
|
|
||||||
api_key: str,
|
|
||||||
api_secret: str,
|
|
||||||
voice: str,
|
|
||||||
speed: int,
|
|
||||||
volume: int,
|
|
||||||
pitch: int,
|
|
||||||
) -> None:
|
|
||||||
websocket = require_websocket()
|
|
||||||
url = build_auth_url(NORMAL_TTS_URL, api_key, api_secret)
|
|
||||||
socket = websocket.create_connection(url, timeout=30)
|
|
||||||
audio = bytearray()
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"common": {"app_id": app_id},
|
|
||||||
"business": {
|
|
||||||
"aue": "lame",
|
|
||||||
"sfl": 1,
|
|
||||||
"auf": "audio/L16;rate=16000",
|
|
||||||
"vcn": voice,
|
|
||||||
"speed": speed,
|
|
||||||
"volume": volume,
|
|
||||||
"pitch": pitch,
|
|
||||||
"bgs": 0,
|
|
||||||
"tte": "UTF8",
|
|
||||||
"reg": "2",
|
|
||||||
"rdn": "0",
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"status": 2,
|
|
||||||
"text": base64.b64encode(text.encode("utf-8")).decode("ascii"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
socket.send(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
||||||
while True:
|
|
||||||
response = recv_json(socket)
|
|
||||||
if response.get("code", 0) != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"XFYUN normal TTS failed: code={response.get('code')}, "
|
|
||||||
f"message={response.get('message')}"
|
|
||||||
)
|
|
||||||
data = response.get("data") or {}
|
|
||||||
if data.get("audio"):
|
|
||||||
audio.extend(base64.b64decode(data["audio"]))
|
|
||||||
if data.get("status") == 2:
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
socket.close()
|
|
||||||
|
|
||||||
if not audio:
|
|
||||||
raise RuntimeError("No audio data returned by XFYUN normal TTS.")
|
|
||||||
out_file.write_bytes(audio)
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_super(
|
|
||||||
*,
|
|
||||||
text: str,
|
|
||||||
out_file: Path,
|
|
||||||
app_id: str,
|
|
||||||
api_key: str,
|
|
||||||
api_secret: str,
|
|
||||||
voice: str,
|
|
||||||
speed: int,
|
|
||||||
volume: int,
|
|
||||||
pitch: int,
|
|
||||||
raw_text: bool,
|
|
||||||
) -> None:
|
|
||||||
websocket = require_websocket()
|
|
||||||
url = build_auth_url(SUPER_TTS_URL, api_key, api_secret)
|
|
||||||
socket = websocket.create_connection(url, timeout=30)
|
|
||||||
audio = bytearray()
|
|
||||||
request_text = text if raw_text else base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"header": {"app_id": app_id, "status": 2},
|
|
||||||
"parameter": {
|
|
||||||
"oral": {
|
|
||||||
"oral_level": "mid",
|
|
||||||
"spark_assist": 1,
|
|
||||||
"remain": 1,
|
|
||||||
},
|
|
||||||
"tts": {
|
|
||||||
"vcn": voice,
|
|
||||||
"speed": speed,
|
|
||||||
"volume": volume,
|
|
||||||
"pitch": pitch,
|
|
||||||
"bgs": 0,
|
|
||||||
"reg": 0,
|
|
||||||
"rdn": 0,
|
|
||||||
"rhy": 0,
|
|
||||||
"watermask": 0,
|
|
||||||
"implicit_watermark": False,
|
|
||||||
"audio": {
|
|
||||||
"encoding": "lame",
|
|
||||||
"sample_rate": 24000,
|
|
||||||
"channels": 1,
|
|
||||||
"bit_depth": 16,
|
|
||||||
"frame_size": 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"payload": {
|
|
||||||
"text": {
|
|
||||||
"encoding": "utf8",
|
|
||||||
"compress": "raw",
|
|
||||||
"format": "plain",
|
|
||||||
"status": 2,
|
|
||||||
"seq": 0,
|
|
||||||
"text": request_text,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
socket.send(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
||||||
while True:
|
|
||||||
response = recv_json(socket)
|
|
||||||
header = response.get("header") or {}
|
|
||||||
if header and header.get("code", 0) != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"XFYUN super TTS failed: code={header.get('code')}, "
|
|
||||||
f"message={header.get('message')}, sid={header.get('sid')}"
|
|
||||||
)
|
|
||||||
if response.get("code", 0) != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"XFYUN super TTS failed: code={response.get('code')}, "
|
|
||||||
f"message={response.get('message')}"
|
|
||||||
)
|
|
||||||
payload_audio = ((response.get("payload") or {}).get("audio") or {})
|
|
||||||
if payload_audio.get("audio"):
|
|
||||||
audio.extend(base64.b64decode(payload_audio["audio"]))
|
|
||||||
if header.get("status") == 2 or payload_audio.get("status") == 2:
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
socket.close()
|
|
||||||
|
|
||||||
if not audio:
|
|
||||||
raise RuntimeError("No audio data returned by XFYUN super TTS.")
|
|
||||||
out_file.write_bytes(audio)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_range(name: str, value: int) -> None:
|
|
||||||
if value < 0 or value > 100:
|
|
||||||
raise ValueError(f"{name} must be between 0 and 100.")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description="Generate XFYUN TTS audio on Ubuntu.")
|
|
||||||
parser.add_argument("--script", type=Path, default=None, help="Markdown script path.")
|
|
||||||
parser.add_argument("--output-dir", type=Path, default=Path("02_audio/xfyun_tts"))
|
|
||||||
parser.add_argument("--mode", choices=["normal", "super"], default="super")
|
|
||||||
parser.add_argument("--voice", default=None, help="XFYUN vcn voice name.")
|
|
||||||
parser.add_argument("--speed", type=int, default=50)
|
|
||||||
parser.add_argument("--volume", type=int, default=70)
|
|
||||||
parser.add_argument("--pitch", type=int, default=50)
|
|
||||||
parser.add_argument("--raw-text", action="store_true", help="Use raw text for super TTS.")
|
|
||||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing mp3 files.")
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Only parse script and print plan.")
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
validate_range("speed", args.speed)
|
|
||||||
validate_range("volume", args.volume)
|
|
||||||
validate_range("pitch", args.pitch)
|
|
||||||
|
|
||||||
script_path = args.script or find_default_script(Path.cwd())
|
|
||||||
segments = load_segments(script_path)
|
|
||||||
voice = args.voice or ("xiaoyan" if args.mode == "normal" else "x5_lingfeiyi_flow")
|
|
||||||
|
|
||||||
print(f"script={script_path}")
|
|
||||||
print(f"mode={args.mode}")
|
|
||||||
print(f"voice={voice}")
|
|
||||||
print(f"segments={len(segments)}")
|
|
||||||
for segment in segments:
|
|
||||||
print(f" {segment.index}. {segment.title} ({len(segment.text)} chars)")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
app_id = os.environ.get("XF_APPID")
|
|
||||||
api_key = os.environ.get("XF_APIKEY")
|
|
||||||
api_secret = os.environ.get("XF_APISECRET")
|
|
||||||
if not app_id or not api_key or not api_secret:
|
|
||||||
raise SystemExit("Please set XF_APPID, XF_APIKEY and XF_APISECRET first.")
|
|
||||||
|
|
||||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for segment in segments:
|
|
||||||
out_file = args.output_dir / f"{segment.index}-{safe_filename(segment.title)}.mp3"
|
|
||||||
if out_file.exists() and not args.overwrite:
|
|
||||||
print(f"skip existing: {out_file}")
|
|
||||||
continue
|
|
||||||
print(f"synthesizing {segment.index}: {segment.title}")
|
|
||||||
if args.mode == "normal":
|
|
||||||
synthesize_normal(
|
|
||||||
text=segment.text,
|
|
||||||
out_file=out_file,
|
|
||||||
app_id=app_id,
|
|
||||||
api_key=api_key,
|
|
||||||
api_secret=api_secret,
|
|
||||||
voice=voice,
|
|
||||||
speed=args.speed,
|
|
||||||
volume=args.volume,
|
|
||||||
pitch=args.pitch,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
synthesize_super(
|
|
||||||
text=segment.text,
|
|
||||||
out_file=out_file,
|
|
||||||
app_id=app_id,
|
|
||||||
api_key=api_key,
|
|
||||||
api_secret=api_secret,
|
|
||||||
voice=voice,
|
|
||||||
speed=args.speed,
|
|
||||||
volume=args.volume,
|
|
||||||
pitch=args.pitch,
|
|
||||||
raw_text=args.raw_text,
|
|
||||||
)
|
|
||||||
print(f"generated: {out_file}")
|
|
||||||
|
|
||||||
print("all voice files generated")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
raise SystemExit(main())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
raise SystemExit(130)
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# 配音生成工作流 Ubuntu Agent
|
|
||||||
|
|
||||||
本文档用于指导 Agent 在 Ubuntu 环境中使用 `Tools_scripts_XunFei-Ubuntu`,将配音稿文字转为讯飞配音音频,并与视频合成为最终介绍视频。
|
|
||||||
|
|
||||||
## 1. 目录约定
|
|
||||||
|
|
||||||
建议保持以下结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
项目目录/
|
|
||||||
配音稿.md
|
|
||||||
Tools_scripts_XunFei-Ubuntu/
|
|
||||||
requirements-ubuntu.txt
|
|
||||||
xfyun_tts_ubuntu.py
|
|
||||||
synthesize_xfyun_tts.sh
|
|
||||||
synthesize_xfyun_super_tts.sh
|
|
||||||
check_audio_duration.sh
|
|
||||||
build_final_video_ubuntu.py
|
|
||||||
```
|
|
||||||
|
|
||||||
其中:
|
|
||||||
|
|
||||||
- `xfyun_tts_ubuntu.py`:核心 Python 脚本,支持普通 TTS 和超拟人 TTS。
|
|
||||||
- `synthesize_xfyun_tts.sh`:普通讯飞 TTS 入口,默认声音 `xiaoyan`。
|
|
||||||
- `synthesize_xfyun_super_tts.sh`:讯飞超拟人 TTS 入口,默认声音 `x5_lingfeiyi_flow`。
|
|
||||||
- `check_audio_duration.sh`:批量查看 mp3、wav、m4a 等音频时长。
|
|
||||||
- `build_final_video_ubuntu.py`:将单个视频与配音音频合成为最终视频,并按旁白时长自动调整画面速度。
|
|
||||||
|
|
||||||
## 2. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y python3 python3-pip ffmpeg
|
|
||||||
python3 -m pip install -r Tools_scripts_XunFei-Ubuntu/requirements-ubuntu.txt
|
|
||||||
chmod +x Tools_scripts_XunFei-Ubuntu/*.sh Tools_scripts_XunFei-Ubuntu/*.py
|
|
||||||
```
|
|
||||||
|
|
||||||
如果使用虚拟环境:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m venv .venv-tts
|
|
||||||
source .venv-tts/bin/activate
|
|
||||||
python -m pip install -r Tools_scripts_XunFei-Ubuntu/requirements-ubuntu.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 配置讯飞环境变量
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export XF_APPID="你的AppId"
|
|
||||||
export XF_APIKEY="你的ApiKey"
|
|
||||||
export XF_APISECRET="你的ApiSecret"
|
|
||||||
```
|
|
||||||
|
|
||||||
如需长期生效,可以写入 `~/.bashrc`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat >> ~/.bashrc <<'EOF'
|
|
||||||
export XF_APPID="你的AppId"
|
|
||||||
export XF_APIKEY="你的ApiKey"
|
|
||||||
export XF_APISECRET="你的ApiSecret"
|
|
||||||
EOF
|
|
||||||
source ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本不会保存密钥,也不要把真实密钥写入仓库。
|
|
||||||
|
|
||||||
## 4. 配音稿格式
|
|
||||||
|
|
||||||
脚本识别 Markdown 中的分段标题:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 1. 第一段标题
|
|
||||||
第一段配音正文。
|
|
||||||
|
|
||||||
## 2. 第二段标题
|
|
||||||
第二段配音正文。
|
|
||||||
|
|
||||||
## 3. 第三段标题
|
|
||||||
第三段配音正文。
|
|
||||||
|
|
||||||
## 4. 第四段标题
|
|
||||||
第四段配音正文。
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- 标题建议保持 `## 1.` 到 `## 4.`。
|
|
||||||
- 输出文件名会使用段号和标题,例如 `1-第一段标题.mp3`。
|
|
||||||
- `说明:`、`时长:`、`备注:`、`镜头:`、`画面:` 等元信息行会被忽略。
|
|
||||||
- 正文只放最终朗读内容,不要放内部提示词。
|
|
||||||
|
|
||||||
可先做干跑检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 Tools_scripts_XunFei-Ubuntu/xfyun_tts_ubuntu.py \
|
|
||||||
--script 配音稿.md \
|
|
||||||
--dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 普通 TTS 合成
|
|
||||||
|
|
||||||
普通 TTS 适合快速生成清晰稳定的中文配音。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./Tools_scripts_XunFei-Ubuntu/synthesize_xfyun_tts.sh \
|
|
||||||
--script 配音稿.md \
|
|
||||||
--output-dir 02_audio/tts_audio_xiaoyan \
|
|
||||||
--voice xiaoyan \
|
|
||||||
--speed 50 \
|
|
||||||
--volume 70 \
|
|
||||||
--pitch 50
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 超拟人 TTS 合成
|
|
||||||
|
|
||||||
超拟人 TTS 更适合项目汇报、系统介绍和宣传片。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./Tools_scripts_XunFei-Ubuntu/synthesize_xfyun_super_tts.sh \
|
|
||||||
--script 配音稿.md \
|
|
||||||
--output-dir 02_audio/super_tts_x5_lingfeiyi \
|
|
||||||
--voice x5_lingfeiyi_flow \
|
|
||||||
--speed 50 \
|
|
||||||
--volume 70 \
|
|
||||||
--pitch 50
|
|
||||||
```
|
|
||||||
|
|
||||||
如果接口要求明文文本模式,可加:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./Tools_scripts_XunFei-Ubuntu/synthesize_xfyun_super_tts.sh \
|
|
||||||
--script 配音稿.md \
|
|
||||||
--output-dir 02_audio/super_tts_raw \
|
|
||||||
--raw-text
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 声音和语速选择
|
|
||||||
|
|
||||||
- `--voice`:讯飞发音人,也就是 `vcn`。
|
|
||||||
- `--speed`:语速,通常 `0-100`,默认 `50`。
|
|
||||||
- `--volume`:音量,通常 `0-100`,默认 `70`。
|
|
||||||
- `--pitch`:音调,通常 `0-100`,默认 `50`。
|
|
||||||
- `--overwrite`:覆盖已存在的音频文件。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 系统介绍:优先用超拟人 TTS,例如 `x5_lingfeiyi_flow`。
|
|
||||||
- 快速校稿:使用普通 TTS,例如 `xiaoyan`。
|
|
||||||
- 需要缩短成片时长:先压缩文案,再把 `--speed` 调到 `55-60`。
|
|
||||||
|
|
||||||
## 8. 检查音频时长
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./Tools_scripts_XunFei-Ubuntu/check_audio_duration.sh 02_audio/super_tts_x5_lingfeiyi
|
|
||||||
```
|
|
||||||
|
|
||||||
如果总时长过长:
|
|
||||||
|
|
||||||
- 优先删减配音稿。
|
|
||||||
- 其次略微提高 `--speed`。
|
|
||||||
- 最后再调整视频变速。
|
|
||||||
|
|
||||||
不建议为了追赶过长旁白而大幅加速视频,否则画面会不自然。
|
|
||||||
|
|
||||||
## 9. 合成最终视频
|
|
||||||
|
|
||||||
如果已有一个完整录屏和一组分段配音:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 Tools_scripts_XunFei-Ubuntu/build_final_video_ubuntu.py \
|
|
||||||
--video 待配音视频/ISISeg-介入导丝视频分割工作台-使用展示.mp4 \
|
|
||||||
--audio-dir 02_audio/super_tts_x5_lingfeiyi \
|
|
||||||
--output 05_outputs/ISISeg-系统使用视频-配音版.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
如果已经有合并好的单个旁白音频:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 Tools_scripts_XunFei-Ubuntu/build_final_video_ubuntu.py \
|
|
||||||
--video input.mp4 \
|
|
||||||
--audio voiceover.mp3 \
|
|
||||||
--output 05_outputs/final_voiceover.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会:
|
|
||||||
|
|
||||||
- 用 `ffprobe` 读取视频和旁白时长。
|
|
||||||
- 自动计算画面变速系数。
|
|
||||||
- 静音原视频音轨,只保留新旁白。
|
|
||||||
- 输出 H.264/AAC、yuv420p、faststart MP4。
|
|
||||||
|
|
||||||
常用参数:
|
|
||||||
|
|
||||||
- `--width 1920 --height 1080`:输出分辨率。
|
|
||||||
- `--fps 30`:输出帧率。
|
|
||||||
- `--silence 0.35`:多段配音之间插入的静音秒数。
|
|
||||||
- `--video-speed 1.25`:手动指定画面速度,覆盖自动计算。
|
|
||||||
|
|
||||||
## 10. Agent 执行清单
|
|
||||||
|
|
||||||
1. 确认 `Tools_scripts_XunFei-Ubuntu` 存在。
|
|
||||||
2. 检查 `ffmpeg`、`ffprobe`、`python3` 是否可用。
|
|
||||||
3. 安装 `requirements-ubuntu.txt` 中的依赖。
|
|
||||||
4. 检查 `XF_APPID`、`XF_APIKEY`、`XF_APISECRET`。
|
|
||||||
5. 创建或读取配音稿,并用 `--dry-run` 校验分段。
|
|
||||||
6. 根据场景选择普通 TTS 或超拟人 TTS。
|
|
||||||
7. 设置独立输出目录,避免覆盖不同声音和语速的试听结果。
|
|
||||||
8. 生成音频后运行 `check_audio_duration.sh`。
|
|
||||||
9. 用 `build_final_video_ubuntu.py` 合成最终视频。
|
|
||||||
10. 用 `ffprobe` 检查最终视频时长、编码和音频流。
|
|
||||||
|
|
||||||
## 11. 常见问题
|
|
||||||
|
|
||||||
### Missing dependency: websocket-client
|
|
||||||
|
|
||||||
运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m pip install -r Tools_scripts_XunFei-Ubuntu/requirements-ubuntu.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Please set XF_APPID, XF_APIKEY and XF_APISECRET first
|
|
||||||
|
|
||||||
说明当前终端没有讯飞凭证环境变量。设置后重新执行脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export XF_APPID="你的AppId"
|
|
||||||
export XF_APIKEY="你的ApiKey"
|
|
||||||
export XF_APISECRET="你的ApiSecret"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cannot find script Markdown file
|
|
||||||
|
|
||||||
请使用 `--script 配音稿.md` 显式指定配音稿。
|
|
||||||
|
|
||||||
### ffmpeg 或 ffprobe 不存在
|
|
||||||
|
|
||||||
运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install -y ffmpeg
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user