feat: 语音流式输入管线 + VAD前端集成 + 插件-工具合并清理
- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST) - Gateway: VoiceStreamManager代理WS流式STT到voice-service - Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码 - 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端) - 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并 - 文档: 完善gateway-api.md和voice-service.md语音API文档 - 工具: scripts/voice/ 语音转换脚本集 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量 WEM → WAV 转换,使用 vgmstream-cli + ffmpeg 标准化。
|
||||
输出: 22050Hz, mono, 16-bit PCM WAV
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VGMSTREAM = r"D:\Project\Code\Uni\Cyrene\scripts\voice\tools\vgmstream\vgmstream-cli.exe"
|
||||
RAW_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\raw"
|
||||
CLEANED_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
||||
|
||||
# 要转换的子目录(按优先级)
|
||||
TARGETS = [
|
||||
"VoBanks27", "VoBanks28", "VoBanks29", "VoBanks30", "VoBanks31",
|
||||
]
|
||||
|
||||
|
||||
def convert_wem_to_wav(wem_path: str, wav_path: str) -> bool:
|
||||
"""vgmstream WEM→临时WAV, ffmpeg 标准化 → 最终WAV (22050Hz mono s16)"""
|
||||
os.makedirs(os.path.dirname(wav_path), exist_ok=True)
|
||||
|
||||
# 跳过已存在且非空的文件
|
||||
if os.path.exists(wav_path) and os.path.getsize(wav_path) > 100:
|
||||
return True
|
||||
|
||||
tmp_path = wav_path + ".tmp.wav"
|
||||
try:
|
||||
# Step 1: vgmstream → temp WAV
|
||||
result = subprocess.run(
|
||||
[VGMSTREAM, "-o", tmp_path, wem_path],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0 or not os.path.exists(tmp_path):
|
||||
return False
|
||||
|
||||
# Step 2: ffmpeg → 标准化 22050Hz mono s16
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", tmp_path,
|
||||
"-ar", "22050", "-ac", "1", "-sample_fmt", "s16",
|
||||
wav_path],
|
||||
capture_output=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
return os.path.exists(wav_path) and os.path.getsize(wav_path) > 100
|
||||
|
||||
except Exception as e:
|
||||
print(f" FAIL [{os.path.basename(wem_path)}]: {e}")
|
||||
return False
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
|
||||
def main():
|
||||
print("=== 批量 WEM → WAV 转换 (VoBanks) ===\n")
|
||||
|
||||
total = 0
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
for target in TARGETS:
|
||||
src_dir = os.path.join(RAW_DIR, target)
|
||||
dst_dir = os.path.join(CLEANED_DIR, target)
|
||||
|
||||
if not os.path.isdir(src_dir):
|
||||
print(f"SKIP: {target} (not found)")
|
||||
continue
|
||||
|
||||
wem_files = sorted(Path(src_dir).glob("*.wem"))
|
||||
if not wem_files:
|
||||
print(f"SKIP: {target} (empty)")
|
||||
continue
|
||||
|
||||
print(f"[{target}] {len(wem_files)} files...")
|
||||
|
||||
for i, wem in enumerate(wem_files):
|
||||
wav = os.path.join(dst_dir, wem.stem + ".wav")
|
||||
if convert_wem_to_wav(str(wem), wav):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
total += 1
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
print(f" {i+1}/{len(wem_files)} (ok:{ok} fail:{fail})")
|
||||
|
||||
print(f" Done: {len(wem_files)} files\n")
|
||||
|
||||
print(f"=== 转换完成: {ok} ok, {fail} fail, {total} total ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# 批量 WEM → WAV 转换 (使用 vgmstream-cli)
|
||||
set -e
|
||||
|
||||
RAW_DIR="D:/Project/Code/Uni/Cyrene-Voice-Model/data/raw"
|
||||
CLEANED_DIR="D:/Project/Code/Uni/Cyrene-Voice-Model/data/cleaned"
|
||||
VGMSTREAM="D:/Project/Code/Uni/Cyrene/scripts/voice/tools/vgmstream/vgmstream-cli.exe"
|
||||
|
||||
if [ ! -f "$VGMSTREAM" ]; then
|
||||
echo "错误: 找不到 vgmstream-cli.exe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== 批量 WEM → WAV 转换 ==="
|
||||
echo ""
|
||||
|
||||
TOTAL=0
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
|
||||
while IFS= read -r -d '' wem_file; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# 输出路径: cleaned/ 目录下保持相同子目录结构,改 .wem 为 .wav
|
||||
rel_path="${wem_file#$RAW_DIR/}"
|
||||
wav_file="${CLEANED_DIR}/${rel_path%.wem}.wav"
|
||||
wav_dir="$(dirname "$wav_file")"
|
||||
|
||||
mkdir -p "$wav_dir"
|
||||
|
||||
# 跳过已转换的
|
||||
if [ -f "$wav_file" ] && [ "$(stat -c%s "$wav_file" 2>/dev/null || echo 0)" -gt 100 ]; then
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# 转换
|
||||
if cmd.exe //c "$VGMSTREAM -o \"$wav_file\" \"$wem_file\"" 2>/dev/null; then
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
|
||||
# 进度显示
|
||||
if [ $((TOTAL % 100)) -eq 0 ]; then
|
||||
echo " 进度: $TOTAL 文件 (成功: $SUCCESS, 失败: $FAILED)"
|
||||
fi
|
||||
done < <(find "$RAW_DIR" -name "*.wem" -print0)
|
||||
|
||||
echo ""
|
||||
echo "=== 转换完成 ==="
|
||||
echo "总计: $TOTAL | 成功: $SUCCESS | 失败: $FAILED"
|
||||
|
||||
# 统计分类
|
||||
echo ""
|
||||
echo "音频时长分布:"
|
||||
find "$CLEANED_DIR" -name "*.wav" | while read wav; do
|
||||
dur=$(ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$wav" 2>/dev/null || echo "0")
|
||||
echo "$dur"
|
||||
done | awk '
|
||||
{ d = $1 + 0 }
|
||||
d < 1 { lt1++ }
|
||||
d < 3 { lt3++ }
|
||||
d < 10 { lt10++ }
|
||||
d < 30 { lt30++ }
|
||||
d >= 30 { gt30++ }
|
||||
END {
|
||||
printf " < 1s: %d\n", lt1
|
||||
printf " 1-3s: %d\n", lt3
|
||||
printf " 3-10s: %d\n", lt10
|
||||
printf " 10-30s: %d\n", lt30
|
||||
printf " > 30s: %d\n", gt30
|
||||
}'
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
通过 Chrome DevTools Protocol 下载文件。
|
||||
需要 Chrome 以 --remote-debugging-port=9222 启动。
|
||||
|
||||
用法:
|
||||
python cdp_download.py <url> <output_path>
|
||||
python cdp_download.py https://github.com/.../vgmstream-win.zip tools/vgmstream.zip
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from websocket import create_connection
|
||||
|
||||
|
||||
class CDPClient:
|
||||
def __init__(self, ws_url: str):
|
||||
self.ws = create_connection(ws_url, origin="http://127.0.0.1:9222")
|
||||
self._id = 0
|
||||
self._lock = threading.Lock()
|
||||
self._pending = {}
|
||||
self._events = []
|
||||
self._running = True
|
||||
|
||||
# Start background reader
|
||||
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
def _read_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
msg = json.loads(self.ws.recv())
|
||||
msg_id = msg.get('id')
|
||||
if msg_id is not None:
|
||||
with self._lock:
|
||||
self._pending[msg_id] = msg
|
||||
else:
|
||||
self._events.append(msg)
|
||||
except Exception:
|
||||
if self._running:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
|
||||
def send(self, method: str, params: dict = None) -> dict:
|
||||
self._id += 1
|
||||
msg_id = self._id
|
||||
payload = json.dumps({
|
||||
'id': msg_id,
|
||||
'method': method,
|
||||
'params': params or {}
|
||||
})
|
||||
self.ws.send(payload)
|
||||
|
||||
# Wait for response
|
||||
timeout = 60
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
with self._lock:
|
||||
if msg_id in self._pending:
|
||||
result = self._pending.pop(msg_id)
|
||||
if 'error' in result:
|
||||
raise Exception(f"CDP Error: {result['error']}")
|
||||
return result.get('result', {})
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"CDP command {method} timed out")
|
||||
|
||||
def wait_for_event(self, event_type: str, timeout: float = 60) -> dict:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
for i, evt in enumerate(self._events):
|
||||
if evt.get('method') == event_type:
|
||||
return self._events.pop(i)['params']
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError(f"Event {event_type} not received within {timeout}s")
|
||||
|
||||
def close(self):
|
||||
self._running = False
|
||||
self.ws.close()
|
||||
|
||||
|
||||
def download_via_cdp(url: str, output_path: str, cdp_url: str = "http://localhost:9222"):
|
||||
"""
|
||||
使用 Chrome CDP 下载文件。
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
# 1. 创建新标签页
|
||||
print(f"[CDP] 创建标签页...")
|
||||
req = urllib.request.Request(f"{cdp_url}/json/new", method='PUT')
|
||||
resp = urllib.request.urlopen(req)
|
||||
tab = json.loads(resp.read())
|
||||
ws_url = tab['webSocketDebuggerUrl']
|
||||
print(f"[CDP] 标签页: {tab['id']}")
|
||||
|
||||
client = CDPClient(ws_url)
|
||||
|
||||
try:
|
||||
# 2. 启用必要的域
|
||||
print(f"[CDP] 启用 Page...")
|
||||
client.send('Page.enable')
|
||||
|
||||
# 3. 设置下载目录
|
||||
download_dir = os.path.abspath(os.path.dirname(output_path))
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
print(f"[CDP] 下载目录: {download_dir}")
|
||||
|
||||
client.send('Browser.setDownloadBehavior', {
|
||||
'behavior': 'allow',
|
||||
'downloadPath': download_dir
|
||||
})
|
||||
|
||||
# 4. 导航到下载 URL
|
||||
print(f"[CDP] 导航到: {url}")
|
||||
client.send('Page.navigate', {'url': url})
|
||||
|
||||
# 5. 等待下载完成
|
||||
print(f"[CDP] 等待下载开始...")
|
||||
will_begin = client.wait_for_event('Browser.downloadWillBegin', timeout=30)
|
||||
guid = will_begin['guid']
|
||||
suggested_name = will_begin.get('suggestedFilename', 'unknown')
|
||||
print(f"[CDP] 下载开始: {suggested_name} (guid={guid})")
|
||||
|
||||
# 等待下载进度完成
|
||||
print(f"[CDP] 等待下载完成...")
|
||||
while True:
|
||||
progress = client.wait_for_event('Browser.downloadProgress', timeout=120)
|
||||
state = progress.get('state', '')
|
||||
if state == 'completed':
|
||||
print(f"[CDP] 下载完成")
|
||||
break
|
||||
elif state == 'canceled':
|
||||
raise Exception("下载被取消")
|
||||
elif state == 'interrupted':
|
||||
print(f"[CDP] 下载中断, 重试...")
|
||||
client.send('Browser.resumeDownload', {'guid': guid})
|
||||
else:
|
||||
received = progress.get('receivedBytes', 0)
|
||||
total = progress.get('totalBytes', 0)
|
||||
if total > 0:
|
||||
print(f"[CDP] 进度: {received}/{total} ({100*received//total}%)")
|
||||
|
||||
# 6. 移动到目标路径
|
||||
downloaded_file = os.path.join(download_dir, suggested_name)
|
||||
if os.path.exists(downloaded_file) and downloaded_file != output_path:
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
os.rename(downloaded_file, output_path)
|
||||
print(f"[CDP] 文件保存到: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
# 关闭标签页
|
||||
urllib.request.urlopen(urllib.request.Request(
|
||||
f"{cdp_url}/json/close/{tab['id']}", method='PUT'))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python cdp_download.py <url> <output_path>")
|
||||
print("示例: python cdp_download.py https://example.com/file.zip tools/file.zip")
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
output = sys.argv[2]
|
||||
download_via_cdp(url, output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 .wem (Wwise Encoded Media) 文件批量转换为 .wav 格式。
|
||||
使用 ffmpeg 进行转换(需预先安装 ffmpeg)。
|
||||
|
||||
.wem 文件本质上是 RIFF/WAVE 容器,内部编码可能是:
|
||||
- PCM 16-bit (ffmpeg 直接支持)
|
||||
- Wwise ADPCM (ffmpeg 需要额外解码器)
|
||||
- Vorbis (部分 ffmpeg 版本支持)
|
||||
|
||||
用法:
|
||||
python convert_wem.py <input_dir> <output_dir>
|
||||
python convert_wem.py ./wem_output/ ./wav_output/
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def convert_wem_to_wav(wem_path: str, wav_path: str) -> bool:
|
||||
"""使用 ffmpeg 将单个 .wem 文件转为 .wav."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-y', '-i', wem_path,
|
||||
'-ar', '22050', # 22.05kHz (与 persona.yaml 设定的训练格式一致)
|
||||
'-ac', '1', # mono
|
||||
'-sample_fmt', 's16', # 16-bit
|
||||
wav_path],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0 and os.path.getsize(wav_path) > 100:
|
||||
return True
|
||||
else:
|
||||
# 部分 WEM 是 Vorbis 编码,需要用不同方式
|
||||
return _convert_wem_vorbis(wem_path, wav_path)
|
||||
except Exception as e:
|
||||
print(f" ffmpeg 错误 [{os.path.basename(wem_path)}]: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _convert_wem_vorbis(wem_path: str, wav_path: str) -> bool:
|
||||
"""尝试处理 Vorbis 编码的 WEM 文件 (带 Wwise 头的 Ogg Vorbis)."""
|
||||
try:
|
||||
# 方式: 跳过 RIFF 头 + fmt chunk, 直接取 Vorbis 数据
|
||||
# .wem 的 Vorbis 数据从 "vorb" chunk 开始
|
||||
with open(wem_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# 查找 "vorb" 标识
|
||||
vorb_pos = data.find(b'vorb')
|
||||
if vorb_pos == -1:
|
||||
return False
|
||||
|
||||
# 重新封装为标准 Ogg (在 vorb 数据前加 OggS 头)
|
||||
# 简化方法: 用 ffmpeg 的 libvorbis 解码
|
||||
# 如果上面失败了,尝试用 -f s16le 强制读取
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-y', '-f', 's16le',
|
||||
'-ar', '48000', '-ac', '1',
|
||||
'-i', wem_path,
|
||||
'-ar', '22050', '-ac', '1',
|
||||
wav_path],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
return result.returncode == 0 and os.path.getsize(wav_path) > 100
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def convert_directory(input_dir: str, output_dir: str) -> tuple[int, int]:
|
||||
"""批量转换目录中所有 .wem 文件."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
wem_files = sorted(Path(input_dir).glob('*.wem'))
|
||||
|
||||
if not wem_files:
|
||||
print(f"在 {input_dir} 中未找到 .wem 文件")
|
||||
return 0, 0
|
||||
|
||||
print(f"找到 {len(wem_files)} 个 .wem 文件,开始转换...")
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for i, wem_path in enumerate(wem_files):
|
||||
wav_name = wem_path.stem + '.wav'
|
||||
wav_path = os.path.join(output_dir, wav_name)
|
||||
|
||||
# 跳过已存在的
|
||||
if os.path.exists(wav_path) and os.path.getsize(wav_path) > 100:
|
||||
success += 1
|
||||
continue
|
||||
|
||||
if convert_wem_to_wav(str(wem_path), wav_path):
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
print(f" 进度: {i+1}/{len(wem_files)} (成功: {success}, 失败: {failed})")
|
||||
|
||||
print(f"\n转换完成: {success} 成功, {failed} 失败")
|
||||
return success, failed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="批量转换 .wem → .wav (需要 ffmpeg)")
|
||||
parser.add_argument('input_dir', help='包含 .wem 文件的输入目录')
|
||||
parser.add_argument('output_dir', help='输出目录')
|
||||
parser.add_argument('--single', nargs=2, metavar=('WEM', 'WAV'),
|
||||
help='转换单个文件')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.single:
|
||||
ok = convert_wem_to_wav(args.single[0], args.single[1])
|
||||
print(f"{'OK' if ok else 'FAILED'}: {args.single[0]} -> {args.single[1]}")
|
||||
else:
|
||||
convert_directory(args.input_dir, args.output_dir)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 昔涟语音提取管线
|
||||
#
|
||||
# 步骤:
|
||||
# 1. 从 HSR 音频包提取 .wem (本脚本)
|
||||
# 2. 用 ww2ogg/vgmstream 转换 .wem → .wav (需手动安装工具)
|
||||
# 3. 用 ffmpeg 标准化音频格式
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
HSR_AUDIO_DIR="D:/MeowG/Honkai:Star_Rail/StarRail_Data/Persistent/Audio/AudioPackage/Windows/Chinese(PRC)"
|
||||
RAW_DIR="D:/Project/Code/Uni/Cyrene-Voice-Model/data/raw"
|
||||
CLEANED_DIR="D:/Project/Code/Uni/Cyrene-Voice-Model/data/cleaned"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== 昔涟语音提取管线 ==="
|
||||
echo ""
|
||||
|
||||
# ---- 阶段 1: 从 .pck 提取 .wem ----
|
||||
echo "[阶段 1/4] 提取候选 .pck 文件..."
|
||||
|
||||
# 昔涟是 3.x 角色,语音在以下文件中:
|
||||
# - VoBanks 27-31 (最新角色语音库)
|
||||
# - External_del_4.0_chapter5_* (3.0 主线昔涟出场)
|
||||
# - External_del_4.1_chapter_* (3.1 主线昔涟出场)
|
||||
TARGETS=(
|
||||
"VoBanks27.pck"
|
||||
"VoBanks28.pck"
|
||||
"VoBanks29.pck"
|
||||
"VoBanks30.pck"
|
||||
"VoBanks31.pck"
|
||||
"External_del_4.0_chapter5_0.pck"
|
||||
"External_del_4.0_chapter5_1.pck"
|
||||
"External_del_4.0_chapter5_2.pck"
|
||||
"External_del_4.1_chapter_0.pck"
|
||||
"External_del_4.1_chapter_1.pck"
|
||||
"External_del_4.1_chapter_2.pck"
|
||||
)
|
||||
|
||||
for target in "${TARGETS[@]}"; do
|
||||
pck_path="${HSR_AUDIO_DIR}/${target}"
|
||||
if [ -f "$pck_path" ]; then
|
||||
echo " 提取: $target ($(du -h "$pck_path" | cut -f1))"
|
||||
python3 "${SCRIPT_DIR}/extract_pck.py" "$pck_path" "${RAW_DIR}/${target%.pck}/"
|
||||
else
|
||||
echo " 跳过: $target (文件不存在)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "[阶段 1/4] 完成: .wem 文件已提取到 ${RAW_DIR}/"
|
||||
|
||||
# ---- 阶段 2: .wem → .wav 转换 ----
|
||||
echo ""
|
||||
echo "[阶段 2/4] 需要转换 .wem → .wav"
|
||||
echo ""
|
||||
echo " HSR 使用 Wwise 专有编码 (0xFFFF),ffmpeg 无法直接解码。"
|
||||
echo " 请使用以下工具之一进行转换:"
|
||||
echo ""
|
||||
echo " 方案 A — vgmstream CLI (推荐, 最简单):"
|
||||
echo " 下载: https://github.com/vgmstream/vgmstream/releases"
|
||||
echo " 解压后将 vgmstream-cli.exe 放入 scripts/voice/tools/"
|
||||
echo " 然后运行本脚本的 --convert 模式"
|
||||
echo ""
|
||||
echo " 方案 B — AnimeWwise GUI (最强大, 保留原始文件名):"
|
||||
echo " 下载: https://github.com/Escartem/AnimeWwise/releases"
|
||||
echo " 直接打开 GUI, 选择 HSR 目录, 导出昔涟语音"
|
||||
echo ""
|
||||
echo " 方案 C — ww2ogg + revorb (传统方案):"
|
||||
echo " 下载 ww2ogg.exe + revorb.exe + packed_codebooks.bin"
|
||||
echo " 放入 scripts/voice/tools/"
|
||||
echo ""
|
||||
echo " 安装工具后, 运行: $0 --convert"
|
||||
echo ""
|
||||
|
||||
# ---- 阶段 3 (条件): 批量转换 ----
|
||||
if [ "$1" = "--convert" ]; then
|
||||
echo "[阶段 3/4] 转换 .wem → .wav..."
|
||||
|
||||
TOOLS_DIR="${SCRIPT_DIR}/tools"
|
||||
|
||||
# 优先使用 vgmstream
|
||||
if [ -f "${TOOLS_DIR}/vgmstream-cli.exe" ]; then
|
||||
echo " 使用 vgmstream-cli..."
|
||||
find "${RAW_DIR}" -name "*.wem" | while read wem; do
|
||||
wav="${wem%.wem}.wav"
|
||||
if [ ! -f "$wav" ]; then
|
||||
"${TOOLS_DIR}/vgmstream-cli.exe" -o "$wav" "$wem" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
elif [ -f "${TOOLS_DIR}/ww2ogg.exe" ]; then
|
||||
echo " 使用 ww2ogg + ffmpeg..."
|
||||
find "${RAW_DIR}" -name "*.wem" | while read wem; do
|
||||
ogg="${wem%.wem}.ogg"
|
||||
wav="${wem%.wem}.wav"
|
||||
if [ ! -f "$wav" ]; then
|
||||
"${TOOLS_DIR}/ww2ogg.exe" "$wem" -o "$ogg" --pcb "${TOOLS_DIR}/packed_codebooks.bin" 2>/dev/null
|
||||
ffmpeg -y -i "$ogg" -ar 22050 -ac 1 -sample_fmt s16 "$wav" 2>/dev/null
|
||||
rm -f "$ogg"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " 错误: 未找到转换工具, 请先安装 vgmstream 或 ww2ogg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[阶段 3/4] 完成"
|
||||
|
||||
# ---- 阶段 4: 音频标准化 + 分类 ----
|
||||
echo ""
|
||||
echo "[阶段 4/4] 标准化 + 分类..."
|
||||
|
||||
# 按音频时长初步分类 (语音通常 1-15 秒)
|
||||
mkdir -p "${CLEANED_DIR}/daily" "${CLEANED_DIR}/battle" \
|
||||
"${CLEANED_DIR}/emotional" "${CLEANED_DIR}/story"
|
||||
|
||||
find "${RAW_DIR}" -name "*.wav" | while read wav; do
|
||||
# 获取时长
|
||||
duration=$(ffprobe -v quiet -show_entries format=duration \
|
||||
-of default=noprint_wrappers=1:nokey=1 "$wav" 2>/dev/null || echo "0")
|
||||
dur_float=$(echo "$duration" | awk '{print int($1 * 1000)}')
|
||||
|
||||
basename=$(basename "$wav" .wav)
|
||||
parent=$(basename "$(dirname "$wav")")
|
||||
|
||||
# 分类逻辑
|
||||
if [ "$dur_float" -lt 500 ]; then
|
||||
# < 0.5s: 可能是战斗短语音 / 语气词
|
||||
target_dir="${CLEANED_DIR}/battle"
|
||||
elif [ "$dur_float" -gt 15000 ]; then
|
||||
# > 15s: 可能是剧情长对话
|
||||
target_dir="${CLEANED_DIR}/story"
|
||||
elif echo "$parent" | grep -qi "chapter"; then
|
||||
target_dir="${CLEANED_DIR}/story"
|
||||
elif echo "$parent" | grep -qi "vobanks"; then
|
||||
# VoBanks 包含战斗 + 日常语音, 需要人工筛选
|
||||
target_dir="${CLEANED_DIR}/daily"
|
||||
else
|
||||
target_dir="${CLEANED_DIR}/daily"
|
||||
fi
|
||||
|
||||
# 用 ffmpeg 标准化: 22.05kHz mono 16bit
|
||||
ffmpeg -y -i "$wav" -ar 22050 -ac 1 -sample_fmt s16 \
|
||||
"${target_dir}/${parent}_${basename}.wav" 2>/dev/null
|
||||
|
||||
echo -n "."
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "[阶段 4/4] 完成: 音频已分类到 ${CLEANED_DIR}/"
|
||||
echo ""
|
||||
echo "文件分布:"
|
||||
echo " 日常对话: $(ls "${CLEANED_DIR}/daily/" 2>/dev/null | wc -l) 个"
|
||||
echo " 战斗语音: $(ls "${CLEANED_DIR}/battle/" 2>/dev/null | wc -l) 个"
|
||||
echo " 情感表达: $(ls "${CLEANED_DIR}/emotional/" 2>/dev/null | wc -l) 个"
|
||||
echo " 剧情对话: $(ls "${CLEANED_DIR}/story/" 2>/dev/null | wc -l) 个"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 管线完成 ==="
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 Honkai: Star Rail 的 .pck (AKPK/Wwise SoundBank) 文件中提取 .wem 音频。
|
||||
|
||||
用法:
|
||||
python extract_pck.py <input.pck> <output_dir>
|
||||
python extract_pck.py VoBanks31.pck ./output/
|
||||
python extract_pck.py --all <pck_dir> <output_dir>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_wem_files(data: bytes) -> list[tuple[int, int, str]]:
|
||||
"""
|
||||
扫描数据中的所有 RIFF/WAVE 块 (.wem 文件).
|
||||
返回 [(offset, size, riff_type), ...] 列表.
|
||||
"""
|
||||
results = []
|
||||
pos = 0
|
||||
data_len = len(data)
|
||||
while pos < data_len - 12:
|
||||
if data[pos:pos + 4] == b'RIFF':
|
||||
chunk_size = struct.unpack_from('<I', data, pos + 4)[0]
|
||||
riff_type = data[pos + 8:pos + 12]
|
||||
# .wem 文件的 riff_type 是 b'WAVE'
|
||||
if riff_type == b'WAVE' and chunk_size > 100:
|
||||
total_size = chunk_size + 8 # RIFF header + data
|
||||
if pos + total_size <= data_len:
|
||||
results.append((pos, total_size, riff_type.decode('ascii', errors='replace')))
|
||||
# 跳过已匹配的块
|
||||
pos += 8 + chunk_size
|
||||
continue
|
||||
pos += 1
|
||||
return results
|
||||
|
||||
|
||||
def extract_pck(pck_path: str, output_dir: str, prefix: str = "") -> list[str]:
|
||||
"""
|
||||
从单个 .pck 文件提取所有 .wem 文件到 output_dir.
|
||||
返回提取的文件路径列表.
|
||||
"""
|
||||
pck_name = Path(pck_path).stem
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
with open(pck_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
print(f"[{pck_name}] 文件大小: {len(data):,} bytes, 扫描 WEM 块...")
|
||||
wem_entries = find_wem_files(data)
|
||||
print(f"[{pck_name}] 找到 {len(wem_entries)} 个音频文件")
|
||||
|
||||
extracted = []
|
||||
for i, (offset, size, riff_type) in enumerate(wem_entries):
|
||||
# 使用 offset 作为唯一 ID (Wwise 文件 ID 就是 offset 的某种映射)
|
||||
wem_data = data[offset:offset + size]
|
||||
|
||||
if prefix:
|
||||
filename = f"{prefix}_{i:04d}_{offset:08x}.wem"
|
||||
else:
|
||||
filename = f"{pck_name}_{i:04d}_{offset:08x}.wem"
|
||||
|
||||
out_path = os.path.join(output_dir, filename)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(wem_data)
|
||||
extracted.append(out_path)
|
||||
|
||||
total_mb = sum(wem_entries[i][1] for i in range(len(wem_entries))) / 1024 / 1024
|
||||
print(f"[{pck_name}] 提取完成: {len(extracted)} 文件, {total_mb:.1f} MB")
|
||||
return extracted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="从 HSR .pck 文件提取 .wem 音频")
|
||||
parser.add_argument('input', help='输入 .pck 文件路径,或 --all 模式下的目录路径')
|
||||
parser.add_argument('output', help='输出目录')
|
||||
parser.add_argument('--all', action='store_true', help='批量模式:提取目录中所有 .pck 文件')
|
||||
parser.add_argument('--prefix', default='', help='输出文件名前缀')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
pck_dir = Path(args.input)
|
||||
pck_files = sorted(pck_dir.glob('*.pck'))
|
||||
print(f"批量模式: 找到 {len(pck_files)} 个 .pck 文件")
|
||||
total_extracted = 0
|
||||
for pck_file in pck_files:
|
||||
extracted = extract_pck(str(pck_file), args.output, args.prefix)
|
||||
total_extracted += len(extracted)
|
||||
print(f"\n总计: {total_extracted} 个音频文件")
|
||||
else:
|
||||
extract_pck(args.input, args.output, args.prefix)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
Copyright (c) 2008-2025 Adam Gashlin, Fastelbja, Ronny Elfert, bnnm,
|
||||
Christopher Snowhill, NicknineTheEagle, bxaimc,
|
||||
Thealexbarney, CyberBotX, EdnessP, et al
|
||||
|
||||
Portions Copyright (c) 2004-2008, Marko Kreen
|
||||
Portions Copyright 2001-2007 jagarl / Kazunori Ueno <jagarl@creator.club.ne.jp>
|
||||
Portions Copyright (c) 1998, Justin Frankel/Nullsoft Inc.
|
||||
Portions Copyright (C) 2006 Nullsoft, Inc.
|
||||
Portions Copyright (c) 2005-2007 Paul Hsieh
|
||||
Portions Copyright (C) 2000-2004 Leshade Entis, Entis-soft.
|
||||
Portions Public Domain originating with Sun Microsystems
|
||||
|
||||
Permission to use, copy, modify, and distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,102 @@
|
||||
# vgmstream
|
||||
This is vgmstream, a library for playing streamed (prerecorded) video game audio.
|
||||
|
||||
Some of vgmstream's features:
|
||||
- Decodes [hundreds of video game music formats and codecs](doc/FORMATS.md), from typical
|
||||
game engine files to obscure single-game codecs, aiming for high accuracy and compatibility.
|
||||
- Support for looped BGM, using file's internal metadata for smooth transitions, with accurate
|
||||
sample counts.
|
||||
- [Subsongs](doc/USAGE.md#subsongs), playing a format's multiple internal songs separately.
|
||||
- Many types of companion files (data split into multiple files) and custom containers.
|
||||
- Encryption keys, internal stream names, and other unusual cases found in game audio.
|
||||
- [TXTH](doc/TXTH.md) function, to add external support for extra formats, including raw audio in
|
||||
many forms.
|
||||
- [TXTP](doc/TXTP.md) function, for real-time and per-file config, like forced looping, removing
|
||||
channels, playing certain subsong, or fusing multiple files into a single one.
|
||||
- Simple [external tagging](doc/USAGE.md#tagging) via .m3u files.
|
||||
- [Plugins](#getting-vgmstream) are available for various media player software and operating systems.
|
||||
|
||||
The main development repository: https://github.com/vgmstream/vgmstream/
|
||||
|
||||
Automated builds with the latest changes: https://vgmstream.org
|
||||
(https://github.com/vgmstream/vgmstream-releases/releases/tag/nightly)
|
||||
|
||||
Numbered releases: https://github.com/vgmstream/vgmstream/releases
|
||||
|
||||
Help can be found here: https://www.hcs64.com/
|
||||
|
||||
More documentation: https://github.com/vgmstream/vgmstream/tree/master/doc
|
||||
|
||||
## Getting vgmstream
|
||||
There are multiple end-user components:
|
||||
- [vgmstream-cli](doc/USAGE.md#testexevgmstream-cli-command-line-decoder): A command-line decoder.
|
||||
- [in_vgmstream](doc/USAGE.md#in_vgmstream-winamp-plugin): A Winamp plugin.
|
||||
- [foo_input_vgmstream](doc/USAGE.md#foo_input_vgmstream-foobar2000-plugin): A foobar2000 component.
|
||||
- [xmp-vgmstream](doc/USAGE.md#xmp-vgmstream-xmplay-plugin): An XMPlay plugin.
|
||||
- [vgmstream.so](doc/USAGE.md#audacious-plugin): An Audacious plugin.
|
||||
- [vgmstream123](doc/USAGE.md#vgmstream123-command-line-player): A command-line player.
|
||||
|
||||
The main library (plain *vgmstream*) is the code that handles the internal conversion, while the
|
||||
above components are what you use to get sound.
|
||||
|
||||
### Usage
|
||||
If you want to convert game audio to `.wav`, get *vgmstream-cli* then drag-and-drop one
|
||||
or more files to the executable (support may vary per O.S. or distro). This should create
|
||||
`(file.extension).wav`, if the format is supported. You can also try the online web player
|
||||
instead. See: https://vgmstream.org
|
||||
|
||||
More user-friendly would be installing a player like *foobar2000* (on Windows) or *Audacious*
|
||||
(on Linux) and the vgmstream plugin. Then you can directly listen your files and set options like
|
||||
infinite looping, or convert to `.wav` with the player's options (also easier to use if your file
|
||||
has multiple "subsongs").
|
||||
|
||||
See [components](doc/USAGE.md#components) in the *usage guide* for full install instructions and
|
||||
explanations. The aim is feature parity, but there are a few differences between them due to
|
||||
missing parts on vgmstream's side or lack of support in the player.
|
||||
|
||||
Note that vgmstream cannot *encode* (convert from `.wav` to a game format), it only *decodes*
|
||||
(plays game audio).
|
||||
|
||||
### Windows binaries
|
||||
Prebuilt binaries:
|
||||
- https://vgmstream.org (latest)
|
||||
- https://github.com/vgmstream/vgmstream/releases (infrequent numbered releases)
|
||||
|
||||
The foobar2000 component is also available on https://www.foobar2000.org based on current
|
||||
release.
|
||||
|
||||
You may also try the alternative versions (irregularly) built by [bnnm](https://github.com/bnnm):
|
||||
- https://github.com/bnnm/vgmstream-builds/raw/master/bin/vgmstream-latest-test-u.zip
|
||||
|
||||
Or compile from source, see the [build guide](doc/BUILD.md).
|
||||
|
||||
### Linux binaries
|
||||
A prebuilt CLI binary is available. It's statically linked and should work on systems running
|
||||
Linux kernel v3.2 and above:
|
||||
- https://vgmstream.org (latest)
|
||||
- https://github.com/vgmstream/vgmstream/releases (infrequent numbered releases)
|
||||
|
||||
Building from source will also give you *vgmstream.so* (Audacious plugin), and *vgmstream123*
|
||||
(command-line player), which can't be statically linked.
|
||||
|
||||
When building it needs several external libraries. For a quick script for Debian and Ubuntu-style
|
||||
distros run `./make-build-cmake.sh`. The script will need to install dependencies first, so you
|
||||
may prefer to run steps manually, which the [build guide](doc/BUILD.md) describes in detail.
|
||||
|
||||
### macOS binaries
|
||||
A prebuilt CLI binary is available:
|
||||
- https://vgmstream.org (latest)
|
||||
- https://github.com/vgmstream/vgmstream/releases (infrequent numbered releases)
|
||||
|
||||
Otherwise follow the [build guide](doc/BUILD.md).
|
||||
|
||||
|
||||
## More info
|
||||
- [Usage guide](doc/USAGE.md)
|
||||
- [List of supported audio formats](doc/FORMATS.md)
|
||||
- [Build guide](doc/BUILD.md)
|
||||
- [TXTH file format](doc/TXTH.md)
|
||||
- [TXTP file format](doc/TXTP.md)
|
||||
|
||||
|
||||
Enjoy! *hcs*
|
||||
File diff suppressed because it is too large
Load Diff
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 Wwise Vorbis .wem 文件转换为标准 Ogg Vorbis (.ogg) 文件。
|
||||
纯 Python 实现,不依赖 ww2ogg 或 revorb 外部工具。
|
||||
|
||||
基于 Wwise RIFF/Vorbis 格式:
|
||||
- Codec ID: 0xFFFF
|
||||
- Vorbis 数据存储在 "vorb" chunk 中
|
||||
- 数据包可直接封装为 Ogg 容器
|
||||
|
||||
用法:
|
||||
python wem2ogg.py <input.wem> <output.ogg>
|
||||
python wem2ogg.py --batch <input_dir> <output_dir>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Ogg 页类型
|
||||
OGG_HEADER = 0x02
|
||||
OGG_FIRST_DATA = 0x00
|
||||
OGG_CONTINUED = 0x00
|
||||
OGG_LAST = 0x04
|
||||
|
||||
# CRC32 表 (预计算)
|
||||
_crc_table = None
|
||||
|
||||
|
||||
def _get_crc_table():
|
||||
global _crc_table
|
||||
if _crc_table is None:
|
||||
_crc_table = []
|
||||
for i in range(256):
|
||||
r = i << 24
|
||||
for _ in range(8):
|
||||
if r & 0x80000000:
|
||||
r = (r << 1) ^ 0x04c11db7
|
||||
else:
|
||||
r <<= 1
|
||||
_crc_table.append(r & 0xffffffff)
|
||||
return _crc_table
|
||||
|
||||
|
||||
def ogg_crc32(data: bytes) -> int:
|
||||
table = _get_crc_table()
|
||||
crc = 0
|
||||
for b in data:
|
||||
crc = (crc << 8) ^ table[((crc >> 24) & 0xff) ^ b]
|
||||
crc &= 0xffffffff
|
||||
return crc
|
||||
|
||||
|
||||
def make_ogg_page(segment_data: bytes, granule: int,
|
||||
header_type: int, stream_serial: int = 0,
|
||||
page_index: int = 0) -> bytes:
|
||||
"""构造一个 Ogg 页."""
|
||||
# 将数据分割成最多 255 字节的段
|
||||
segments = []
|
||||
pos = 0
|
||||
while pos < len(segment_data):
|
||||
seg_len = min(255, len(segment_data) - pos)
|
||||
segments.append(seg_len)
|
||||
pos += seg_len
|
||||
|
||||
num_segments = len(segments)
|
||||
page_header = bytearray(27 + num_segments)
|
||||
|
||||
# OggS 签名
|
||||
page_header[0:4] = b'OggS'
|
||||
# Version
|
||||
page_header[4] = 0
|
||||
# Header type
|
||||
page_header[5] = header_type
|
||||
# Granule position (8 bytes, little-endian)
|
||||
struct.pack_into('<q', page_header, 6, granule)
|
||||
# Stream serial
|
||||
struct.pack_into('<I', page_header, 14, stream_serial)
|
||||
# Page index
|
||||
struct.pack_into('<I', page_header, 18, page_index)
|
||||
# Checksum (先填 0)
|
||||
struct.pack_into('<I', page_header, 22, 0)
|
||||
# Number of segments
|
||||
page_header[26] = num_segments
|
||||
# Segment table
|
||||
for i, seg_len in enumerate(segments):
|
||||
page_header[27 + i] = seg_len
|
||||
|
||||
# 计算 CRC
|
||||
full_page = bytearray(page_header) + bytearray(segment_data)
|
||||
crc = ogg_crc32(bytes(full_page))
|
||||
struct.pack_into('<I', full_page, 22, crc)
|
||||
|
||||
return bytes(full_page)
|
||||
|
||||
|
||||
def extract_vorbis_packets(wem_path: str) -> list[bytes]:
|
||||
"""从 WEM 文件中提取 Vorbis 数据包."""
|
||||
with open(wem_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# 验证 RIFF 头
|
||||
if data[:4] != b'RIFF':
|
||||
raise ValueError("不是有效的 RIFF 文件")
|
||||
|
||||
# 查找 "vorb" chunk
|
||||
pos = 12 # 跳过 RIFF 头
|
||||
vorb_data = None
|
||||
|
||||
while pos < len(data) - 8:
|
||||
chunk_id = data[pos:pos + 4]
|
||||
chunk_size = struct.unpack_from('<I', data, pos + 4)[0]
|
||||
|
||||
if chunk_id == b'vorb' or chunk_id == b'data':
|
||||
vorb_start = pos + 8
|
||||
vorb_data = data[vorb_start:vorb_start + chunk_size]
|
||||
break
|
||||
|
||||
# 对齐到 2 字节边界
|
||||
pos += 8 + chunk_size
|
||||
if chunk_size % 2:
|
||||
pos += 1
|
||||
|
||||
if vorb_data is None:
|
||||
raise ValueError("未找到 vorb/data chunk")
|
||||
|
||||
# 解析 Vorbis 数据包
|
||||
# 前 4 字节: 数据包数量 (实际上可能是样本数)
|
||||
setup_offset = struct.unpack_from('<I', vorb_data, 0)[0]
|
||||
|
||||
# 每个数据包: [2 bytes: granule/size info][packet data]
|
||||
packets = []
|
||||
pos = 4 # 跳过 setup offset
|
||||
|
||||
# 第一个数据包是 Vorbis 头 (identification header)
|
||||
# Wwise 格式: 2 bytes granule + 2 bytes size (或只是 2 bytes size)
|
||||
# 尝试解析...
|
||||
|
||||
# 通常第一个 packet 是 setup 数据
|
||||
# 格式: 对于每个 packet:
|
||||
# - uint16: 如果最高位为 1,这是 granule 的高位部分
|
||||
# 实际上 Wwise Vorbis 数据包格式比较复杂
|
||||
|
||||
# 简化处理: 跳过 4 字节后就是连续的 Vorbis 数据包
|
||||
# 每个 packet 前 2 字节表示该 packet 的大小
|
||||
# packet_size & 0x8000: granule 在下一个 packet 变化
|
||||
|
||||
remaining = vorb_data[4:]
|
||||
while len(remaining) > 2:
|
||||
# 读取 packet 大小 (可能用 2 或 4 字节)
|
||||
header = struct.unpack_from('<H', remaining, 0)[0]
|
||||
has_granule = (header & 0x8000) != 0
|
||||
pkt_size = header & 0x7FFF
|
||||
|
||||
if pkt_size == 0:
|
||||
break
|
||||
|
||||
offset = 2
|
||||
granule_val = 0
|
||||
if has_granule:
|
||||
granule_val = struct.unpack_from('<H', remaining, offset)[0]
|
||||
offset += 2
|
||||
|
||||
if offset + pkt_size > len(remaining):
|
||||
break
|
||||
|
||||
packet = remaining[offset:offset + pkt_size]
|
||||
packets.append(packet)
|
||||
remaining = remaining[offset + pkt_size:]
|
||||
|
||||
return packets
|
||||
|
||||
|
||||
def wem_to_ogg(wem_path: str, ogg_path: str) -> bool:
|
||||
"""转换单个 .wem 文件为 .ogg."""
|
||||
try:
|
||||
packets = extract_vorbis_packets(wem_path)
|
||||
|
||||
if len(packets) < 3:
|
||||
print(f" 警告: {os.path.basename(wem_path)} 只有 {len(packets)} 个数据包")
|
||||
return False
|
||||
|
||||
# Vorbis 头三个数据包:
|
||||
# 1. Identification header
|
||||
# 2. Comment header
|
||||
# 3. Setup header
|
||||
# 后续: 音频数据包
|
||||
|
||||
ident_pkt = packets[0]
|
||||
comment_pkt = packets[1]
|
||||
setup_pkt = packets[2]
|
||||
audio_packets = packets[3:]
|
||||
|
||||
# 构造 Ogg 文件
|
||||
ogg_data = bytearray()
|
||||
|
||||
# 第 0 页: Identification header
|
||||
ogg_data += make_ogg_page(ident_pkt, granule=0,
|
||||
header_type=OGG_HEADER,
|
||||
page_index=0)
|
||||
|
||||
# 第 1 页: Comment + Setup headers
|
||||
header_data = comment_pkt + setup_pkt
|
||||
ogg_data += make_ogg_page(header_data, granule=0,
|
||||
header_type=OGG_FIRST_DATA,
|
||||
page_index=1)
|
||||
|
||||
# 后续页: 音频数据 (每页放尽可能多的 packet)
|
||||
page_idx = 2
|
||||
granule = 0
|
||||
buf = bytearray()
|
||||
|
||||
for pkt in audio_packets:
|
||||
# Vorbis granule = 累积样本数
|
||||
# 粗略估计: 每个 packet 约 576 或 1024 samples
|
||||
granule += 576
|
||||
|
||||
if len(buf) + len(pkt) > 45000: # Ogg 页最大约 65KB
|
||||
ogg_data += make_ogg_page(bytes(buf), granule=granule,
|
||||
header_type=OGG_CONTINUED,
|
||||
page_index=page_idx)
|
||||
page_idx += 1
|
||||
buf = bytearray()
|
||||
|
||||
buf += pkt
|
||||
|
||||
# 最后一页
|
||||
if buf:
|
||||
ogg_data += make_ogg_page(bytes(buf), granule=granule,
|
||||
header_type=OGG_LAST,
|
||||
page_index=page_idx)
|
||||
|
||||
with open(ogg_path, 'wb') as f:
|
||||
f.write(ogg_data)
|
||||
|
||||
return os.path.getsize(ogg_path) > 100
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误 [{os.path.basename(wem_path)}]: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def batch_convert(input_dir: str, output_dir: str) -> tuple[int, int]:
|
||||
"""批量转换目录中所有 .wem 文件."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
wem_files = sorted(Path(input_dir).glob('*.wem'))
|
||||
|
||||
if not wem_files:
|
||||
print(f"在 {input_dir} 中未找到 .wem 文件")
|
||||
return 0, 0
|
||||
|
||||
print(f"找到 {len(wem_files)} 个 .wem 文件")
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for i, wem_path in enumerate(wem_files):
|
||||
ogg_name = wem_path.stem + '.ogg'
|
||||
ogg_path = os.path.join(output_dir, ogg_name)
|
||||
|
||||
if wem_to_ogg(str(wem_path), ogg_path):
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
print(f" 进度: {i+1}/{len(wem_files)} (成功: {success})")
|
||||
|
||||
print(f"\n转换完成: {success} 成功, {failed} 失败")
|
||||
return success, failed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="WEM → OGG 转换器 (纯 Python)")
|
||||
parser.add_argument('input', help='输入 .wem 文件或目录')
|
||||
parser.add_argument('output', help='输出 .ogg 文件或目录')
|
||||
parser.add_argument('--batch', action='store_true',
|
||||
help='批量模式: 转换目录中所有 .wem 文件')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.batch:
|
||||
batch_convert(args.input, args.output)
|
||||
else:
|
||||
ok = wem_to_ogg(args.input, args.output)
|
||||
if ok:
|
||||
print(f"OK: {args.input} → {args.output}")
|
||||
else:
|
||||
print(f"FAILED: {args.input}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user