From 583ca04ac9f4aee21c5440c8c10a340aa853bcd0 Mon Sep 17 00:00:00 2001 From: EillesWan Date: Thu, 22 Jan 2026 09:23:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Musicreater/data.py | 163 ++++++++--- Musicreater/paramcurve.py | 580 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 704 insertions(+), 40 deletions(-) create mode 100644 Musicreater/paramcurve.py diff --git a/Musicreater/data.py b/Musicreater/data.py index 73d4df7..48bfc2c 100644 --- a/Musicreater/data.py +++ b/Musicreater/data.py @@ -4,8 +4,6 @@ 存储音·创新数据存储类 """ -# WARNING 本文件中使用之功能尚未启用 - """ 版权所有 © 2025 金羿 Copyright © 2025 Eilles @@ -18,42 +16,21 @@ Terms & Conditions: License.md in the root directory # Email TriM-Organization@hotmail.com # 若需转载或借鉴 许可声明请查看仓库目录下的 License.md +# WARNING 本文件中使用之功能尚未启用 -from math import sin, cos, asin, radians, degrees, sqrt, atan, inf +from math import sin, cos, asin, radians, degrees, sqrt, atan, inf, ceil from dataclasses import dataclass -from typing import Optional, Any, List, Tuple, Union, Dict, Sequence, Callable -import bisect +from typing import Optional, Any, List, Tuple, Union, Dict, Sequence, Callable, Generator +from enum import Enum from .types import FittingFunctionType from .constants import MC_PITCHED_INSTRUMENT_LIST - -class ArgumentCurve: - - base_line: float = 0 - """基线/默认值""" - - default_curve: Callable[[float], float] - """默认曲线""" - - defined_curves: Dict[float, "ArgumentCurve"] = {} - """调整后的曲线集合""" - - left_border: float = 0 - """定义域左边界""" - - right_border: float = inf - """定义域右边界""" - - def __init__(self, baseline: float = 0, default_function: Callable[[float], float] = lambda x: 0, function_set: Dict = {}) -> None: - pass - - def __call__(self, *args: Any, **kwds: Any) -> Any: - pass - +from .paramcurve import ParamCurve class SoundAtmos: + """声源方位类""" sound_distance: float """声源距离 方块""" @@ -66,6 +43,20 @@ class SoundAtmos: distance: Optional[float] = None, azimuth: Optional[Tuple[float, float]] = None, ) -> None: + """ + 定义一个发声方位 + + Parameters + ------------ + distance: float + 发声源距离玩家的距离(半径 `r`) + 注:距离越近,音量越高,默认为 0。此参数可以与音量成某种函数关系。 + azimuth: tuple[float, float] + 声源方位 + 注:此参数为tuple,包含两个元素,分别表示: + `rV` 发声源在竖直(上下)轴上,从玩家视角正前方开始,向顺时针旋转的角度 + `rH` 发声源在水平(左右)轴上,从玩家视角正前方开始,向上(到达玩家正上方顶点后变为向下,以此类推的旋转)旋转的角度 + """ self.sound_azimuth = (azimuth[0] % 360, azimuth[1] % 360) if azimuth else (0, 0) """声源方位""" @@ -103,7 +94,7 @@ class SoundAtmos: @property def position_displacement(self) -> Tuple[float, float, float]: - """声像位移""" + """声像位移,直接可应用于我的世界的相对视角的坐标参考系中(^x ^y ^z)""" dk1 = self.sound_distance * round(cos(radians(self.sound_azimuth[1])), 8) return ( -dk1 * round(sin(radians(self.sound_azimuth[0])), 8), @@ -161,14 +152,6 @@ class SingleNote: 高精度的开始时间偏移量(1/1250秒) is_percussion: bool 是否作为打击乐器 - distance: float - 发声源距离玩家的距离(半径 `r`) - 注:距离越近,音量越高,默认为 0。此参数可以与音量成某种函数关系。 - azimuth: tuple[float, float] - 声源方位 - 注:此参数为tuple,包含两个元素,分别表示: - `rV` 发声源在竖直(上下)轴上,从玩家视角正前方开始,向顺时针旋转的角度 - `rH` 发声源在水平(左右)轴上,从玩家视角正前方开始,向上(到达玩家正上方顶点后变为向下,以此类推的旋转)旋转的角度 extra_information: Dict[str, Any] 附加信息,尽量存储为字典 @@ -332,6 +315,17 @@ class SingleNote: return self.start_tick > other.start_tick +class CurvableParam(str, Enum): + """可曲线化的参数枚举类""" + PITCH = "note-pitch" + VELOCITY = "note-velocity" + VOLUME = "note-volume" + DISTANCE = "note-sound-distance" + HORIZONTAL_PANNING = "note-H-panning-degree" + VERTICAL_PANNING = "note-V-panning-degree" + + + class SingleTrack(list): """存储单个轨道的类""" @@ -353,7 +347,7 @@ class SingleTrack(list): sound_position: SoundAtmos """声像方位""" - argument_curves: Dict[str, FittingFunctionType] + argument_curves: Dict[CurvableParam, ParamCurve] """参数曲线""" extra_info: Dict[str, Any] @@ -397,6 +391,11 @@ class SingleTrack(list): """音符数""" return len(self) + @property + def track_notes(self) -> List[SingleNote]: + """音符列表""" + return self + def set_info(self, key: Union[str, Sequence[str]], value: Any): """设置附加信息""" if isinstance(key, str): @@ -416,6 +415,90 @@ class SingleTrack(list): """获取附加信息""" return self.extra_info.get(key, default) -class SingleMusic(object): + + def get_notes(self) -> Generator[SingleNote, Any, None]: + + # TODO : 添加其他参数以及参数曲线到这里来 + for note in self: + yield note + + +class SingleMusic(list): """存储单个曲子的类""" + music_name: str + """乐曲名称""" + + music_creator: str + """本我的世界曲目的制作者""" + + music_original_author: str + """曲目的原作者""" + + music_description: str + """当前曲目的简介""" + + music_credits: str + """曲目的版权信息""" + + # 感叹一下什么交冗余设计啊!(叉腰) + extra_info: Dict[str, Any] + """这还得放东西?""" + + def __init__( + self, + name: str = "未命名乐曲", + creator: str = "未命名制作者", + original_author: str = "未命名原作者", + description: str = "未命名简介", + credits: str = "未命名版权信息", + *args: SingleTrack, + extra_information: Dict[str, Any] = {}, + ): + self.music_name = name + """乐曲名称""" + + self.music_creator = creator + """曲目制作者""" + + self.music_original_author = original_author + """乐曲原作者""" + + self.music_description = description + """简介""" + + self.music_credits = credits + """版权信息""" + + self.extra_info = extra_information if extra_information else {} + + super().__init__(*args) + + @property + def track_amount(self) -> int: + """音轨数""" + return len(self) + + @property + def music_tracks(self) -> List[SingleTrack]: + """音轨列表""" + return self + + def set_info(self, key: Union[str, Sequence[str]], value: Any): + """设置附加信息""" + if isinstance(key, str): + self.extra_info[key] = value + elif ( + isinstance(key, Sequence) + and isinstance(value, Sequence) + and (k := len(key)) == len(value) + ): + for i in range(k): + self.extra_info[key[i]] = value[i] + else: + # 提供简单报错就行了,如果放一堆 if 语句,降低处理速度 + raise TypeError("参数类型错误;键:`{}` 值:`{}`".format(key, value)) + + def get_info(self, key: str, default: Any = None) -> Any: + """获取附加信息""" + return self.extra_info.get(key, default) diff --git a/Musicreater/paramcurve.py b/Musicreater/paramcurve.py new file mode 100644 index 0000000..3cc0f3f --- /dev/null +++ b/Musicreater/paramcurve.py @@ -0,0 +1,580 @@ +# -*- coding: utf-8 -*- + +""" +存储音·创音轨所需的参数曲线 +""" + +""" +版权所有 © 2025 金羿 +Copyright © 2025 Eilles + +开源相关声明请见 仓库根目录下的 License.md +Terms & Conditions: License.md in the root directory +""" + +# 睿乐组织 开发交流群 861684859 +# Email TriM-Organization@hotmail.com +# 若需转载或借鉴 许可声明请查看仓库目录下的 License.md + + +# WARNING 本文件中使用之功能尚未启用 +# 鉴于白谭若佬给出的建议:本功能应是处于低优先级开发的 +# 因此暂时用处不大,可以稍微放一会再进行开发 +# 目前用人工智能生成了部分代码,只经过简单的测试 +# 可以等伶伦工作站开发出来后再进行完整的测试 + + +from math import ceil +from dataclasses import dataclass +from typing import Optional, Any, List, Tuple +from enum import Enum +import bisect + +from .types import FittingFunctionType + + +def _evaluate_bezier_segment( + t0: float, + v0: float, + t1: float, + v1: float, + out_tangent: Optional[Tuple[float, float]], + in_tangent: Optional[Tuple[float, float]], + u: float, +) -> float: + """ + 计算贝塞尔区间 [t0, t1] 在归一化参数 u ∈ [0,1] 处的 y 值。 + + 控制点: + P0 = (t0, v0) + P1 = (t0 + out_dt, v0 + out_dv) + P2 = (t1 - in_dt, v1 - in_dv) ← 注意:in_tangent 是相对于 t1 的偏移 + P3 = (t1, v1) + """ + # 默认控制点:退化为线性 + p0 = (t0, v0) + p3 = (t1, v1) + + if out_tangent is not None: + p1 = (t0 + out_tangent[0], v0 + out_tangent[1]) + else: + p1 = p0 # 无出手柄 → 与起点重合 + + if in_tangent is not None: + p2 = (t1 - in_tangent[0], v1 - in_tangent[1]) + else: + p2 = p3 # 无入手柄 → 与终点重合 + + # 三次贝塞尔 y(t) + mt = 1.0 - u + return mt**3 * p0[1] + 3 * mt**2 * u * p1[1] + 3 * mt * u**2 * p2[1] + u**3 * p3[1] + + +class InterpolationMethod: + """ + 预定义的标准化插值函数集合。所有函数接受归一化输入 u ∈ [0,1],返回 v ∈ [0,1]。 + """ + + @staticmethod + def linear(u: float) -> float: + """ + 线性插值。 + + Parameters + ---------- + u : float + 归一化时间,范围 [0, 1]。 + + Returns + ------- + float + 插值权重,范围 [0, 1]。 + """ + return u + + @staticmethod + def ease_in_quad(u: float) -> float: + """ + 二次缓入(慢进快出)。 + + Parameters + ---------- + u : float + 归一化时间,范围 [0, 1]。 + + Returns + ------- + float + 插值权重。 + """ + return u * u + + @staticmethod + def ease_out_quad(u: float) -> float: + """ + 二次缓出(快进慢出)。 + + Parameters + ---------- + u : float + 归一化时间,范围 [0, 1]。 + + Returns + ------- + float + 插值权重。 + """ + return 1 - (1 - u) ** 2 + + @staticmethod + def ease_in_out_quad(u: float) -> float: + """ + 二次缓入缓出。 + + Parameters + ---------- + u : float + 归一化时间,范围 [0, 1]。 + + Returns + ------- + float + 插值权重。 + """ + if u < 0.5: + return 2 * u * u + else: + return 1 - pow(-2 * u + 2, 2) / 2 + + @staticmethod + def hold(u: float) -> float: + """ + 阶梯保持模式占位函数。实际插值逻辑在 ParamCurve.value_at 中特殊处理。 + + Parameters + ---------- + u : float + 归一化时间(忽略)。 + + Returns + ------- + float + 无意义,仅作标识。 + """ + return 0.0 + + +@dataclass +class Keyframe: + """ + 参数曲线上的一个关键帧,支持完整的入/出切线控制。 + + 插值优先级: + 1. 若 use_bezier=True → 使用贝塞尔模式(需 in_tangent / out_tangent) + 2. 否则 → 使用 out_interp 函数(in_interp 被忽略) + """ + + time: float + value: float + + # 函数插值模式 + out_interp: Optional[FittingFunctionType] = None + + # 贝塞尔模式 + in_tangent: Optional[Tuple[float, float]] = ( + None # (dt, dv) ← 相对于自身(负 dt 表示左侧) + ) + out_tangent: Optional[Tuple[float, float]] = ( + None # (dt, dv) → 相对于自身(正 dt 表示右侧) + ) + use_bezier: bool = False + + +class BoundaryBehaviour(str, Enum): + """ + 边界行为枚举。 + """ + + CONSTANT = "constant" + """返回默认基线值""" + HOLD = "hold" + """保持首/尾关键帧的值""" + + +class ParamCurve: + """ + 参数曲线类 + """ + + """ + 支持动态节点编辑 + 用户通过添加/修改关键帧(时间-值对)来定义曲线,类自动在相邻关键帧之间生成插值段。 + 支持多种插值模式:线性('linear')、平滑缓动('smooth')、保持('hold')或自定义函数。 + """ + + base_line: float = 0.0 + """基线/默认值""" + + base_interpolation_function: FittingFunctionType + """默认(未指定区间时的)关键帧插值模式""" + + boundary_behaviour: BoundaryBehaviour + """边界行为,控制参数曲线在已定义的范围外的返回值""" + + _keys: List[Keyframe] + """关键帧列表""" + + def __init__( + self, + base_value: float = 0.0, + default_interpolation_function: FittingFunctionType = InterpolationMethod.linear, + boundary_mode: BoundaryBehaviour = BoundaryBehaviour.CONSTANT, + ): + """ + 初始化参数曲线。 + + Parameters + ---------- + base_value : float + 边界外默认值(当 boundary_mode 为 BoundaryBehaviour.CONSTANT 时使用)。 + default_interp : FittingFunctionType + 新关键帧的默认 out_interp。 + boundary_mode : BoundaryBehaviour + 范围外行为: + - BoundaryBehaviour.CONSTANT: 返回 base_value + - BoundaryBehaviour.HOLD: 保持首/尾关键帧值 + """ + self.base_line = base_value + self.base_interpolation_function = default_interpolation_function + self.boundary_behaviour = boundary_mode + + self._keys: List[Keyframe] = [] + + def add_key( + self, + time: float, + value: float, + out_interp: Optional[FittingFunctionType] = None, + in_tangent: Optional[Tuple[float, float]] = None, + out_tangent: Optional[Tuple[float, float]] = None, + use_bezier: bool = False, + ): + """ + 添加或更新关键帧。 + + Parameters + ---------- + time : float + 关键帧时间。 + value : float + 参数值。 + out_interp : Optional[Callable] + 出插值函数(若 use_bezier=False)。 + in_tangent : Optional[Tuple[float, float]] + 入切线偏移 (dt, dv)。dt 通常为负(表示左侧),但存储为绝对偏移。 + out_tangent : Optional[Tuple[float, float]] + 出切线偏移 (dt, dv)。dt 通常为正。 + use_bezier : bool + 是否使用贝塞尔插值。 + + Returns + ------- + None + + Notes + ----- + 若时间已存在,更新该关键帧的所有属性。 + """ + interp = ( + out_interp if out_interp is not None else self.base_interpolation_function + ) + new_key = Keyframe(time, value, interp, in_tangent, out_tangent, use_bezier) + + idx = bisect.bisect_left(self._keys, time, key=lambda k: k.time) + if idx < len(self._keys) and self._keys[idx].time == time: + self._keys[idx] = new_key + else: + self._keys.insert(idx, new_key) + + def remove_key(self, time: float): + """ + 移除指定时间的关键帧。 + + Parameters + ---------- + time : float + 要移除的关键帧时间。 + + Returns + ------- + None + """ + idx = bisect.bisect_left(self._keys, time, key=lambda k: k.time) + if idx < len(self._keys) and self._keys[idx].time == time: + del self._keys[idx] + + def update_key_value(self, time: float, new_value: float): + """更新关键帧值,保留其他属性。""" + idx = bisect.bisect_left(self._keys, time, key=lambda k: k.time) + if idx < len(self._keys) and self._keys[idx].time == time: + k = self._keys[idx] + self._keys[idx] = Keyframe( + time, new_value, k.out_interp, k.in_tangent, k.out_tangent, k.use_bezier + ) + + def update_key_interp( + self, + time: float, + out_interp: Optional[FittingFunctionType] = None, + in_tangent: Optional[Tuple[float, float]] = None, + out_tangent: Optional[Tuple[float, float]] = None, + use_bezier: bool = False, + ): + """更新关键帧的插值属性。""" + idx = bisect.bisect_left(self._keys, time, key=lambda k: k.time) + if idx < len(self._keys) and self._keys[idx].time == time: + k = self._keys[idx] + new_value = k.value + interp = out_interp if out_interp is not None else k.out_interp + self._keys[idx] = Keyframe( + time, new_value, interp, in_tangent, out_tangent, use_bezier + ) + + def set_key_tangents( + self, + time: float, + in_tangent: Optional[Tuple[float, float]] = None, + out_tangent: Optional[Tuple[float, float]] = None, + use_bezier: bool = True, + ): + """单独设置关键帧的切线,不改变值。""" + idx = bisect.bisect_left(self._keys, time, key=lambda k: k.time) + if idx < len(self._keys) and self._keys[idx].time == time: + k = self._keys[idx] + self._keys[idx] = Keyframe( + time, + k.value, + out_interp=k.out_interp, + in_tangent=in_tangent, + out_tangent=out_tangent, + use_bezier=use_bezier, + ) + + def make_key_smooth(self, time: float): + """ + 将关键帧设为“平滑”模式(自动对称切线,并设为贝塞尔模式)。 + 切线长度基于相邻关键帧的时间和值差。 + """ + idx = bisect.bisect_left(self._keys, time, key=lambda k: k.time) + if idx < len(self._keys) and self._keys[idx].time == time: + k = self._keys[idx] + prev_k = self._keys[idx - 1] if idx > 0 else None + next_k = self._keys[idx + 1] if idx + 1 < len(self._keys) else None + + # 默认切线长度:时间差的 1/3,值差按比例 + dt_in = dt_out = 0.1 + dv_in = dv_out = 0.0 + + if prev_k and next_k: + dt_total = next_k.time - prev_k.time + dv_total = next_k.value - prev_k.value + dt_in = dt_out = dt_total / 3.0 + dv_in = dv_out = dv_total / 3.0 + elif prev_k: + dt_out = (k.time - prev_k.time) / 2.0 + dv_out = (k.value - prev_k.value) / 2.0 + dt_in = dt_out + dv_in = dv_out + elif next_k: + dt_in = (next_k.time - k.time) / 2.0 + dv_in = (next_k.value - k.value) / 2.0 + dt_out = dt_in + dv_out = dv_in + + self.set_key_tangents( + time, + in_tangent=(-dt_in, -dv_in), # in_tangent 存储为偏移,使用时做减法 + out_tangent=(dt_out, dv_out), + use_bezier=True, + ) + + def _get_boundary_value(self, t: float) -> float: + """根据 boundary_mode 获取范围外的值。""" + if not self._keys: + return self.base_line + if self.boundary_behaviour == BoundaryBehaviour.CONSTANT: + return self.base_line + elif self.boundary_behaviour == BoundaryBehaviour.HOLD: + if t < self._keys[0].time: + return self._keys[0].value + else: + return self._keys[-1].value + else: # 可能会有别的模式吗? + return self.base_line + + def value_at(self, t: float) -> float: + """ + 计算时间 t 处的曲线值。 + + Parameters + ---------- + t : float + 查询时间。 + + Returns + ------- + float + 插值结果。 + """ + keys = self._keys + if not keys: + return self._get_boundary_value(t) + + if t < keys[0].time or t > keys[-1].time: + return self._get_boundary_value(t) + + times = [k.time for k in keys] + idx = bisect.bisect_right(times, t) - 1 + + if idx < 0: + return self._get_boundary_value(t) + if idx >= len(keys) - 1: + return keys[-1].value + + k0 = keys[idx] + k1 = keys[idx + 1] + + if k0.time == k1.time: + return k0.value + if k0.time == t: + return k0.value + if k1.time == t: + return k1.value + + t0, v0 = k0.time, k0.value + t1, v1 = k1.time, k1.value + u = (t - t0) / (t1 - t0) + u = max(0.0, min(1.0, u)) + + # 贝塞尔模式(高优先级) + if k0.use_bezier or k1.use_bezier: + return _evaluate_bezier_segment( + t0, + v0, + t1, + v1, + out_tangent=k0.out_tangent, + in_tangent=k1.in_tangent, # ← 关键:使用下一帧的 in_tangent! + u=u, + ) + + # 函数插值模式,优先处理阶梯保持模式 + elif k0.out_interp is InterpolationMethod.hold: + return v0 + + interp_func = k0.out_interp or self.base_interpolation_function + v_norm = interp_func(u) + return v0 + v_norm * (v1 - v0) + + def __call__(self, t: float) -> float: + return self.value_at(t) + + def get_all_keys(self) -> List[Tuple[float, float]]: + """返回 (time, value) 列表。""" + return [(k.time, k.value) for k in self._keys] + + def set_default_interpolation_function(self, interp_func: FittingFunctionType): + """设置默认插值函数。""" + self.base_interpolation_function = interp_func + + def set_boundary_mode( + self, mode: BoundaryBehaviour, base_value: Optional[float] = None + ): + """ + 设置边界行为。 + + Parameters + ---------- + mode : BoundaryBehaviour + 边界行为设定 + base_value : Optional[float] + 当 mode=BoundaryBehaviour.CONSTANT 时,指定新的默认值。 + """ + self.boundary_behaviour = mode + if base_value is not None: + self.base_line = base_value + + def bake( + self, + start: float, + end: float, + sample_rate: Optional[float] = None, + num_samples: Optional[int] = None, + dtype: Any = None, + ) -> "np.ndarray": # type: ignore 这里这样用会报错吗?不知道,但是人工智能这样写了都,大抵是能用的吧 + """ + 将参数曲线在指定时间范围内烘焙为 NumPy 数组,用于高性能实时查询或音频渲染。 + + Parameters + ---------- + start : float + 烘焙起始时间(包含)。 + end : float + 烘焙结束时间(不包含)。 + sample_rate : Optional[float] + 采样率(单位:样本/时间单位)。例如,若时间单位为秒,sample_rate=48000 表示每秒 48k 样本。 + 必须与 `num_samples` 二选一提供。 + num_samples : Optional[int] + 输出数组的总样本数。若提供,则忽略 `sample_rate`。 + dtype : Any, optional + 输出数组的数据类型(如 np.float32)。默认为 np.float64。 + + Returns + ------- + np.ndarray + 一维 NumPy 数组,长度为 `num_samples`,`arr[i] ≈ curve(start + i / sample_rate)`。 + + Exceptions + ---------- + ValueError + - 若 `start >= end` + - 若未提供 `sample_rate` 且未提供 `num_samples` + - 若 `num_samples <= 0` + + Notes + ----- + - 内部使用 `np.linspace` 生成时间轴,然后逐点调用 `self.value_at(t)`。 + - 虽然目前是 Python 循环,但对于典型自动化曲线(<1000 关键帧),NumPy 向量化优势主要体现在内存布局和后续处理。 + - 如需极致性能(如 >1M 样本),可未来优化为 C++/Numba 加速,但当前已满足 DAW 自动化需求。 + """ + if start >= end: + raise ValueError("起始值须小于结束值。") + + if num_samples is not None: + if num_samples <= 0: + raise ValueError("烘焙的采样数须为非零自然数。") + n = num_samples + elif sample_rate is not None: + if sample_rate <= 0: + raise ValueError("烘焙的采样率须为正值。") + duration = end - start + n = int(ceil(duration * sample_rate)) + # 别因为小数数值会产生的问题而越界了来着 + if n == 0: + n = 1 + else: + raise ValueError("烘焙参数时,须提供采样率或采样数。") + + import numpy as np + + # 生成对应时间的节点:[start, ..., end - dt] + times = np.linspace(start, end, n, endpoint=False) + + # 计算每个时间节点上的参数值 + # 我们认为在数字音频工作站的环境里,此值可能最多到 ~1e6 的样子,因此这样 for 一下应当可以接受 + # WARNING: 人工智能是这样理解的,如果有问题的话后续可能需要更改 + values = np.empty(n, dtype=dtype or np.float64) + for i in range(n): + values[i] = self.value_at(float(times[i])) + + return values diff --git a/pyproject.toml b/pyproject.toml index 01288bd..a5312e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ full = [ "TrimMCStruct <= 0.0.5.9", "brotli >= 1.0.0", + "numpy" ] dev = [ "TrimMCStruct <= 0.0.5.9",