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:
2026-06-06 11:50:40 +08:00
parent 258cf81b25
commit 6ef9e082a6
91 changed files with 4091 additions and 3929 deletions
+100
View File
@@ -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()
+73
View File
@@ -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
}'
+175
View File
@@ -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()
+125
View File
@@ -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()
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
# ============================================================
# 昔涟语音提取管线
#
# 步骤:
# 1. 从 HSR 音频包提取 .wem (本脚本)
# 2. 用 ww2ogg/vgmstream 转换 .wem → .wav (需手动安装工具)
# 3. 用 ffmpeg 标准化音频格式
# ============================================================
set -e
HSR_AUDIO_DIR="D:/MeowG/HonkaiStar_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 "=== 管线完成 ==="
+99
View File
@@ -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()
+23
View File
@@ -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.
+102
View File
@@ -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.
+296
View File
@@ -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()