"""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-deprecated(1.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_length:sr / 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)、Frequency(0-100)、Intensity(0-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()