Files
2026-05-19 16:46:58 +08:00

452 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""music2tap: 将音频文件的 RMS 音量包络转换为 RichTap 标准 .he 触觉文件。
支持批量模式(扫描 music/ 目录)与单文件模式(--input/--output)。
"""
from __future__ import annotations
import argparse
import json
import sys
import warnings
from datetime import datetime, timezone
from pathlib import Path
# 抑制 librosa 解码 mp3 时回退 audioread 引发的两条已知告警:
# - PySoundFile 对部分 mp3 头部有问题会失败回退(结果正确,不影响包络)
# - audioread 后端在 librosa 0.10 标记为 future-deprecated1.0 才移除)
warnings.filterwarnings("ignore", message="PySoundFile failed.*")
warnings.filterwarnings("ignore", category=FutureWarning, module="librosa.*")
import librosa # noqa: E402 warnings 过滤需在 librosa 导入前生效
import numpy as np # noqa: E402
# 支持的音频扩展名集合,用于批量模式过滤
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a"}
# 默认参数:22050Hz 单声道、100Hz 帧率(10ms 一帧)——触觉反馈常用粒度
DEFAULT_SR = 22050
DEFAULT_FRAME_RATE = 100
# RichTap continuous 事件的固定中频,dense 模式整曲使用统一频率
DEFAULT_FREQUENCY = 50
# 谱心映射上限:人耳对 0-8kHz 最敏感、流行乐谱心绝大多数落在 200-6000Hz,
# 取 8000Hz 作上界后线性映射到 0-100,能避免少数尖锐高频把整体压扁
SPECTRAL_CENTROID_MAX_HZ = 8000.0
# 默认关键帧阈值:相对上一关键帧的振幅/频率最小变化(0-100 单位)
DEFAULT_INTENSITY_THRESHOLD = 2
DEFAULT_FREQUENCY_THRESHOLD = 2
def extract_envelope(
audio_path: Path,
sr: int = DEFAULT_SR,
frame_rate: int = DEFAULT_FRAME_RATE,
) -> tuple[np.ndarray, int, int]:
"""提取音频文件的归一化 RMS 包络。
使用 librosa 加载音频并计算逐帧 RMS,再按全曲最大值线性归一化到 0-100
整数区间,匹配 RichTap .he 格式对 ``Intensity`` 字段的取值要求。
:param audio_path: 输入音频路径,支持 mp3/wav/flac/ogg/m4a
:param sr: 目标采样率(Hz),统一重采样以保证 hop_length 推导一致
:param frame_rate: 期望的输出帧率(Hz),决定时间分辨率
:returns: ``(intensities, frame_interval_ms, duration_ms)``
intensities 为 ``np.uint8`` 数组,每元素是 0-100 的整数振幅;
frame_interval_ms 为相邻两帧的时间间隔(毫秒);
duration_ms 为音频总时长(毫秒)。
:raises FileNotFoundError: 音频文件不存在
"""
if not audio_path.exists():
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
# mono=True 以避免立体声两通道导致的 RMS 数值偏差;统一 sr 以稳定 hop_length
y, sr = librosa.load(str(audio_path), sr=sr, mono=True)
# 由采样率与目标帧率推导 hop_lengthsr / frame_rate ≈ 每帧样本数
# 例如 22050Hz、100Hz → hop_length=220,对应 10ms 一帧
hop_length = max(1, int(round(sr / frame_rate)))
frame_length = 2048 # librosa 默认值,提供稳定的能量估计窗口
rms = librosa.feature.rms(y=y, frame_length=frame_length, hop_length=hop_length)[0]
# 按全曲最大 RMS 归一化:保证最响段映射到 100,弱段保持比例关系
# 防 0 除:极端情况下整段静音则全部输出 0
peak = float(rms.max()) if rms.size > 0 else 0.0
if peak > 0:
normalized = np.clip(rms / peak * 100.0, 0, 100)
else:
normalized = np.zeros_like(rms)
intensities = np.round(normalized).astype(np.uint8)
# frame_interval_ms 用 hop_length 反推真实间隔,避免与目标帧率有舍入误差时漂移
frame_interval_ms = int(round(hop_length * 1000 / sr))
duration_ms = int(round(len(y) * 1000 / sr))
return intensities, frame_interval_ms, duration_ms
def extract_envelope_with_freq(
audio_path: Path,
sr: int = DEFAULT_SR,
frame_rate: int = DEFAULT_FRAME_RATE,
) -> tuple[np.ndarray, np.ndarray, int, int]:
"""同时提取归一化 RMS 振幅与谱心频率序列。
与 :func:`extract_envelope` 共用 hop_length 推导逻辑,因此返回的
intensities / frequencies 等长且时间戳一致,可直接用于 keyframe 抽稀。
:param audio_path: 输入音频路径
:param sr: 目标采样率(Hz
:param frame_rate: 期望帧率(Hz
:returns: ``(intensities, frequencies, frame_interval_ms, duration_ms)``
intensities/frequencies 都是 ``np.uint8`` 数组,取值 0-100。
:raises FileNotFoundError: 音频文件不存在
"""
if not audio_path.exists():
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
y, sr = librosa.load(str(audio_path), sr=sr, mono=True)
hop_length = max(1, int(round(sr / frame_rate)))
n_fft = 2048
rms = librosa.feature.rms(y=y, frame_length=n_fft, hop_length=hop_length)[0]
centroid = librosa.feature.spectral_centroid(
y=y, sr=sr, n_fft=n_fft, hop_length=hop_length
)[0]
# RMS 按全曲峰值归一化;静音段全 0,避免除零
peak = float(rms.max()) if rms.size > 0 else 0.0
if peak > 0:
intensity_norm = np.clip(rms / peak * 100.0, 0, 100)
else:
intensity_norm = np.zeros_like(rms)
intensities = np.round(intensity_norm).astype(np.uint8)
# 谱心按 0-8kHz 上界做硬截断映射,越界值压到 100
freq_norm = np.clip(centroid / SPECTRAL_CENTROID_MAX_HZ * 100.0, 0, 100)
frequencies = np.round(freq_norm).astype(np.uint8)
# rms 与 spectral_centroid 在 librosa 里默认 center=True,长度一致;
# 不一致时按较短者截断,避免下游索引越界
n = min(len(intensities), len(frequencies))
intensities = intensities[:n]
frequencies = frequencies[:n]
frame_interval_ms = int(round(hop_length * 1000 / sr))
duration_ms = int(round(len(y) * 1000 / sr))
return intensities, frequencies, frame_interval_ms, duration_ms
def select_keyframes(
intensities: np.ndarray,
frequencies: np.ndarray,
intensity_threshold: int = DEFAULT_INTENSITY_THRESHOLD,
frequency_threshold: int = DEFAULT_FREQUENCY_THRESHOLD,
) -> list[int]:
"""贪心抽稀,返回保留的关键帧索引列表。
规则:相对上一个已保留关键帧,若 ``|ΔI| ≥ intensity_threshold`` 或
``|ΔF| ≥ frequency_threshold`` 则记录当前帧为新关键帧。
首末必留——首点决定起始状态、末点决定 Duration 边界,缺失会让下位机
的线性插值跨越整曲终点。
选用贪心而非 RDP:下位机已对 continuous 事件做线性插值,贪心算法的
“对上一关键帧最大偏差”语义恰好等于下位机插值误差的上界,与播放行为一致。
:param intensities: 0-100 整数振幅序列
:param frequencies: 0-100 整数频率序列,长度需与 intensities 一致
:param intensity_threshold: 振幅变化阈值(含等号)
:param frequency_threshold: 频率变化阈值(含等号)
:returns: 保留索引升序列表(含首末)
"""
n = len(intensities)
if n == 0:
return []
if n == 1:
return [0]
kept: list[int] = [0]
last_i = int(intensities[0])
last_f = int(frequencies[0])
# 仅遍历中间点,末点单独追加,保证 Duration 对齐
for idx in range(1, n - 1):
cur_i = int(intensities[idx])
cur_f = int(frequencies[idx])
if (
abs(cur_i - last_i) >= intensity_threshold
or abs(cur_f - last_f) >= frequency_threshold
):
kept.append(idx)
last_i, last_f = cur_i, cur_f
if kept[-1] != n - 1:
kept.append(n - 1)
return kept
def build_he(
intensities: np.ndarray,
frame_interval_ms: int,
duration_ms: int,
source_name: str,
frequencies: np.ndarray | None = None,
indices: list[int] | None = None,
) -> dict:
"""根据归一化包络构造 RichTap 标准 .he 字典。
使用单个 continuous 事件承载整曲包络,``Curve`` 数组的每点包含
Time(绝对 ms)、Frequency0-100)、Intensity0-100)。
:param intensities: 0-100 的整数振幅数组
:param frame_interval_ms: 单帧时间步长(毫秒),用于把序号换算成 Time
:param duration_ms: 整曲时长(毫秒),写入 Event.Duration
:param source_name: 源文件名,用于 Metadata.Description
:param frequencies: 可选,与 intensities 等长的 0-100 频率数组;
为 None 时整曲使用 :data:`DEFAULT_FREQUENCY`dense 模式行为)
:param indices: 可选,要写入 Curve 的索引列表(关键帧抽稀结果);
为 None 时写入全部点
:returns: 可直接 json.dump 的 dict
"""
sel = indices if indices is not None else range(len(intensities))
# Time 以 frame_interval_ms 为步长换算:dense 是等间距,keyframe 仅在选中索引上落点
curve = [
{
"Time": int(i * frame_interval_ms),
"Frequency": int(frequencies[i])
if frequencies is not None
else DEFAULT_FREQUENCY,
"Intensity": int(intensities[i]),
}
for i in sel
]
# librosa 的 center=True 会让最后一帧中心略超过音频长度,
# Duration 必须 ≥ 最后关键帧的 Time,否则下位机会把末点视为越界
if curve:
duration_ms = max(duration_ms, curve[-1]["Time"])
return {
"Metadata": {
"Version": 1,
"Created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"Description": f"Generated from {source_name} by music2tap",
"ChannelNumber": 1,
},
"PatternList": [
{
"AbsoluteTime": 0,
"Pattern": [
{
"Event": {
"Type": "continuous",
"RelativeTime": 0,
"Duration": duration_ms,
"Parameters": {
"Intensity": 100,
"Frequency": DEFAULT_FREQUENCY,
"Curve": curve,
},
}
}
],
}
],
}
def write_he(he_dict: dict, output_path: Path) -> None:
"""将 .he 字典以 UTF-8 JSON 形式写入磁盘。
:param he_dict: 由 :func:`build_he` 生成的字典
:param output_path: 输出路径,父目录会自动创建
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as f:
# ensure_ascii=False 保留 Description 中可能的中文;indent=2 便于人工排查
json.dump(he_dict, f, ensure_ascii=False, indent=2)
def process_file(
input_path: Path,
output_path: Path,
sr: int = DEFAULT_SR,
frame_rate: int = DEFAULT_FRAME_RATE,
method: str = "dense",
intensity_threshold: int = DEFAULT_INTENSITY_THRESHOLD,
frequency_threshold: int = DEFAULT_FREQUENCY_THRESHOLD,
) -> None:
"""单文件转换流水线:提包络 → 构造 .he → 写盘。
:param input_path: 输入音频文件
:param output_path: 输出 .he 文件路径
:param sr: 目标采样率
:param frame_rate: 期望帧率
:param method: ``"dense"`` 等间距逐帧;``"keyframe"`` 仅写振幅/频率拐点
:param intensity_threshold: keyframe 模式下的 |ΔI| 阈值
:param frequency_threshold: keyframe 模式下的 |ΔF| 阈值
"""
if method == "dense":
intensities, frame_interval_ms, duration_ms = extract_envelope(
input_path, sr=sr, frame_rate=frame_rate
)
he_dict = build_he(intensities, frame_interval_ms, duration_ms, input_path.name)
write_he(he_dict, output_path)
print(
f"[OK] {input_path.name} -> {output_path} "
f"(dense, {len(intensities)} points, {duration_ms} ms, "
f"step {frame_interval_ms} ms)"
)
return
if method == "keyframe":
intensities, frequencies, frame_interval_ms, duration_ms = (
extract_envelope_with_freq(input_path, sr=sr, frame_rate=frame_rate)
)
kept = select_keyframes(
intensities,
frequencies,
intensity_threshold=intensity_threshold,
frequency_threshold=frequency_threshold,
)
he_dict = build_he(
intensities,
frame_interval_ms,
duration_ms,
input_path.name,
frequencies=frequencies,
indices=kept,
)
write_he(he_dict, output_path)
ratio = (len(kept) / len(intensities) * 100.0) if len(intensities) else 0.0
print(
f"[OK] {input_path.name} -> {output_path} "
f"(keyframe, kept {len(kept)}/{len(intensities)} = {ratio:.1f}%, "
f"{duration_ms} ms, step {frame_interval_ms} ms, "
f"I_thr={intensity_threshold}, F_thr={frequency_threshold})"
)
return
raise ValueError(f"未知 method: {method!r}(应为 'dense''keyframe'")
def process_directory(
input_dir: Path,
output_dir: Path,
sr: int = DEFAULT_SR,
frame_rate: int = DEFAULT_FRAME_RATE,
method: str = "dense",
intensity_threshold: int = DEFAULT_INTENSITY_THRESHOLD,
frequency_threshold: int = DEFAULT_FREQUENCY_THRESHOLD,
) -> int:
"""批量转换目录下所有音频文件。
:param input_dir: 待扫描的音频目录
:param output_dir: 输出 .he 文件目录
:param sr: 目标采样率
:param frame_rate: 期望帧率
:param method: ``"dense"`` 或 ``"keyframe"``
:param intensity_threshold: keyframe 模式下的 |ΔI| 阈值
:param frequency_threshold: keyframe 模式下的 |ΔF| 阈值
:returns: 成功处理的文件数
"""
if not input_dir.is_dir():
print(f"[ERR] 输入目录不存在: {input_dir}", file=sys.stderr)
return 0
count = 0
for audio_file in sorted(input_dir.iterdir()):
if audio_file.suffix.lower() not in AUDIO_EXTS:
continue
out_file = output_dir / (audio_file.stem + ".he")
try:
process_file(
audio_file,
out_file,
sr=sr,
frame_rate=frame_rate,
method=method,
intensity_threshold=intensity_threshold,
frequency_threshold=frequency_threshold,
)
count += 1
except Exception as exc:
# 单个文件失败不阻断批量任务,但需明确报错以便排查
print(f"[ERR] {audio_file.name}: {exc}", file=sys.stderr)
return count
def main() -> None:
"""CLI 入口:解析参数并分发到批量或单文件流水线。"""
parser = argparse.ArgumentParser(
description="提取音频 RMS 包络并生成 RichTap 标准 .he 触觉文件"
)
parser.add_argument("--input", type=Path, help="单文件模式:输入音频路径")
parser.add_argument("--output", type=Path, help="单文件模式:输出 .he 路径")
parser.add_argument(
"--input-dir",
type=Path,
default=Path("music"),
help="批量模式:输入目录(默认 music/)",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path("output"),
help="批量模式:输出目录(默认 output/)",
)
parser.add_argument("--sr", type=int, default=DEFAULT_SR, help="目标采样率 (Hz)")
parser.add_argument(
"--frame-rate",
type=int,
default=DEFAULT_FRAME_RATE,
help="输出帧率 (Hz)10ms→100",
)
parser.add_argument(
"--method",
choices=("dense", "keyframe"),
default="dense",
help="dense=逐帧;keyframe=只写振幅/频率拐点(下位机线性插值)",
)
parser.add_argument(
"--intensity-threshold",
type=int,
default=DEFAULT_INTENSITY_THRESHOLD,
help="keyframe 模式下的 |ΔIntensity| 阈值(0-100",
)
parser.add_argument(
"--frequency-threshold",
type=int,
default=DEFAULT_FREQUENCY_THRESHOLD,
help="keyframe 模式下的 |ΔFrequency| 阈值(0-100",
)
args = parser.parse_args()
common = dict(
sr=args.sr,
frame_rate=args.frame_rate,
method=args.method,
intensity_threshold=args.intensity_threshold,
frequency_threshold=args.frequency_threshold,
)
# 单文件模式优先:只要给了 --input 就走单文件分支
if args.input is not None:
out = args.output or (args.output_dir / (args.input.stem + ".he"))
process_file(args.input, out, **common)
return
# 批量模式:扫描 input-dir
n = process_directory(args.input_dir, args.output_dir, **common)
print(f"[DONE] processed {n} file(s) into {args.output_dir}")
if __name__ == "__main__":
main()