完成音乐合成插件之迁移

This commit is contained in:
Eilles
2026-07-08 02:40:43 +08:00
parent 2919722c52
commit 026c529182
8 changed files with 479 additions and 37 deletions
+7 -2
View File
@@ -6,8 +6,6 @@
# mystuff
/*.zip
/.vscode
/*.mid
/*.midi
/*.mcpack
/*.bdx
/*.msq
@@ -28,6 +26,13 @@ RES.txt
/fcwslib
test_lyric-mido.py
test_doing_things.py
TEST-*.*
# Copyrighted
vanilla_assets/
/*.mid
/*.midi
# Byte-compiled / optimized
__pycache__/
@@ -87,7 +87,7 @@ class McstructureExportConfig(PluginConfig):
class MusicExportToMcstructureWithDelayPlayerPlugin(MusicOutputPluginBase):
metainfo = PluginMetaInformation(
name="导出全曲结构插件mcstructure结构、延迟播放器)",
name="导出全曲结构(mcstructure结构、延迟播放器)",
author="金羿、玉衡Alioth",
description="将音·创 v3 的整首音乐数据,以指令方块延迟的播放形式,导出为基岩版 MCSTRUCTURE 结构",
version=(0, 0, 1),
@@ -177,7 +177,7 @@ class MidiImport2MusicPlugin(MusicInputPluginBase):
"""Midi 音乐数据导入插件"""
metainfo = PluginMetaInformation(
name="Midi 导入插件",
name="Midi 导入全曲",
author="金羿、玉衡Alioth",
description="从 Midi 文件导入音乐数据",
version=(0, 0, 1),
@@ -31,3 +31,119 @@ limitations under the License.
# 本插件依照 Apache 2.0 协议开放源代码,若需转载或借鉴
# 许可声明请查看 http://www.apache.org/licenses/LICENSE-2.0
from io import BytesIO
from pathlib import Path
from time import perf_counter_ns
from dataclasses import dataclass
from typing import BinaryIO, Optional, Iterator, Generator, Any, Tuple, Literal
from Musicreater import SingleMusic, SingleTrack, SingleNote, SoundAtmos, MineNote
from Musicreater.plugins import (
PluginConfig,
PluginMetaInformation,
PluginTypes,
music_output_plugin,
MusicOutputPluginBase,
track_output_plugin,
TrackOutputPluginBase,
)
from .main import MusicPreview
@dataclass
class PcmConversionConfig(PluginConfig):
assets_path: Path
"""
生成音频文件所使用的资源文件路径
"""
synthesis_mode: Literal[0, 1, 2, 3, 4] = 1
"""
音频合成模式
- 0 原始长度,不变化时长
- 1 拉伸至 mc 播放器定义(我的世界效果)
- 2 根据 midi 音符长度裁剪
- 3 混音预留
- 4 匹配 midi 音符长度(最佳效果)
"""
overlay_mode: Literal[1, 2] = 1
"""
没看懂这个参数的意思
"""
sample_rate: int = 44100
"""
目标输出的采样率
"""
value_get_method: Literal[0, 1] = 1
"""
采样取值方法,没看懂什么意思
- 0 均值化
- 1 钳制位
"""
pitch_accuracy_decimals: int = 0
"""
音调处理精度,小数点后位数
"""
global_deviation: float = 0
"""
全曲音调偏移
"""
@music_output_plugin("music_to_pcm_plugin")
class NoteDataConvert2PcmPlugin(MusicOutputPluginBase):
metainfo = PluginMetaInformation(
name="全曲预览播放·PCM",
author="金羿、鱼旧梦",
description="从全曲的音符数据转换为可以用于播放的 PCM 编码数据",
version=(0, 0, 1),
type=PluginTypes.FUNCTION_MUSIC_EXPORT,
license="Apache 2.0",
)
def dump(self, data: SingleMusic, file_path: Path, config: PcmConversionConfig):
pr = perf_counter_ns()
music_preview = MusicPreview(
resource_folder=config.assets_path,
synthesis_mode=config.synthesis_mode,
overlay_mode=config.overlay_mode,
sample_rate=config.sample_rate,
value_get_method=config.value_get_method,
pitch_accuracy_decimals=config.pitch_accuracy_decimals,
music_deviation=config.global_deviation,
)
music_preview.to_wav_file(data, file_path)
af = perf_counter_ns()
print("合成用时", af - pr, "纳秒,即", (af - pr) / 1000_000_000, "")
def stream_dump(
self, data: SingleMusic, config: PcmConversionConfig
) -> Iterator[bytes]:
pr = perf_counter_ns()
music_preview = MusicPreview(
resource_folder=config.assets_path,
synthesis_mode=config.synthesis_mode,
overlay_mode=config.overlay_mode,
sample_rate=config.sample_rate,
value_get_method=config.value_get_method,
pitch_accuracy_decimals=config.pitch_accuracy_decimals,
music_deviation=config.global_deviation,
)
b_out = BytesIO()
music_preview.to_wav_file_byte(data, b_out)
af = perf_counter_ns()
print("合成用时", af - pr, "纳秒,即", (af - pr) / 1000_000_000, "")
b_out.seek(0)
yield from b_out
+338 -22
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""
音·创 v3 内置的 音乐预览插件
音·创 v3 内置的 音乐合成预览功能
"""
"""
@@ -33,8 +33,10 @@ limitations under the License.
import os
import pathlib
from typing import Any, Union
from math import inf
from typing import BinaryIO, Optional, Iterator, Generator, Any, Tuple, Literal
from pathlib import Path
import soundfile as sf
import librosa
@@ -42,31 +44,345 @@ import numpy as np
from Musicreater import SingleMusic, SingleTrack, SingleNote, SoundAtmos, MineNote
from Musicreater.plugins import (
library_plugin,
PluginConfig,
PluginMetaInformation,
PluginTypes,
LibraryPluginBase,
)
from Musicreater.exceptions import ZeroSpeedError, IllegalMinimumVolumeError
from Musicreater._utils import enumerated_stuffcopy_dictionary
from Musicreater.constants import MM_INSTRUMENT_DEVIATION_TABLE
class MusicPreview:
"""
原始资源存储规范:
Dict[str"ID1": Dict[str"SoundID": ndarray[]]]
转换资源存储规范:
Dict[str"ID1": Dict[str"SoundID": Dict[int"Pitch": ndarray]]]
测试阶段,请直接vanilla作为默认传入,不考虑载入
"""
res_raw_data: dict = {} # 原始资源存储
# res_raw_index: list = [] # 资源转换情况,key: [88] 留着优化
res_data: dict = {} # 转换后资源存储
res_data_sr: int # 转换后采样率,和输出采样率一致
@staticmethod
def res_pitch_shift(y: np.ndarray, *, sr: float, n_steps: float, **kwargs: Any):
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
@staticmethod
def res_time_stretch(y: np.ndarray, *, rate: float, **kwargs: Any):
return librosa.effects.time_stretch(y, rate=rate)
@staticmethod
def res_resample(
y: np.ndarray,
*,
orig_sr: float,
target_sr: float,
fix: bool = True,
**kwargs: Any,
):
return librosa.resample(y, orig_sr=orig_sr, target_sr=target_sr, fix=fix)
@library_plugin("notedata_to_pcm_plugin")
class NoteDataConvert2PcmPlugin(LibraryPluginBase):
metainfo = PluginMetaInformation(
name="音符数据预览播放支持插件·PCM",
author="金羿、鱼旧梦",
description="从音符数据转换为可以用于播放的 PCM 编码数据",
version=(0, 0, 1),
type=PluginTypes.LIBRARY,
license="Apache 2.0",
)
def __init__(
self,
resource_folder: Path,
synthesis_mode: Literal[0, 1, 2, 3, 4] = 1,
overlay_mode: Literal[1, 2] = 1,
sample_rate: int = 44100,
value_get_method: Literal[0, 1] = 1,
pitch_accuracy_decimals: int = 0,
music_deviation: float = 0,
):
self.mode = synthesis_mode
self.overlay_mode_c = overlay_mode
self.output_sample_rate = sample_rate
self.get_value_method = value_get_method
self.resources_path = resource_folder
self.pitch_precision_decimals = pitch_accuracy_decimals
self.pitch_deviation = music_deviation
for file in self.resources_path.iterdir():
if file.is_file():
self.res_raw_data[file.stem] = librosa.load(file, sr=None)
def res_shift(
self,
sound_name: str,
percussive: bool,
pitch: int,
duration: int,
# rate: int
):
y_orig, sr_orig = self.res_raw_data[sound_name]
if not percussive:
if self.mode == 1:
# 变调, 时域压扩, 重采样 mc方法
self.res_data[sound_name][pitch] = librosa.resample(
librosa.effects.time_stretch(
librosa.effects.pitch_shift(y_orig, sr=sr_orig, n_steps=pitch),
rate=2 ** (pitch / 12),
),
orig_sr=sr_orig,
target_sr=self.output_sample_rate,
fix=False,
)
elif self.mode == 0:
# 重采样, 变调
self.res_data[sound_name][pitch] = librosa.resample(
librosa.effects.pitch_shift(y_orig, sr=sr_orig, n_steps=pitch),
orig_sr=sr_orig,
target_sr=self.output_sample_rate,
fix=False,
)
elif self.mode == 4:
# 变调, 时域压扩, 重采样 MIDI-FFT
if self.overlay_mode_c == 2:
rate = duration / 20 / (len(y_orig[0]) / sr_orig)
else:
rate = duration / 20 / (len(y_orig) / sr_orig)
rate = rate if rate != 0 else 1
self.res_data[sound_name][pitch] = librosa.resample(
librosa.effects.time_stretch(
librosa.effects.pitch_shift(y_orig, sr=sr_orig, n_steps=pitch),
rate=rate,
),
orig_sr=sr_orig,
target_sr=self.output_sample_rate,
fix=False,
)
elif self.mode == 2:
# 变调, 时域压扩, 重采样 MIDI-cut
if self.overlay_mode_c == 2:
deal = librosa.effects.pitch_shift(
y_orig, sr=sr_orig, n_steps=pitch
)[
...,
: (
int(duration / 20 * sr_orig)
if duration / 20 * sr_orig > len(y_orig[0])
else len(y_orig[0])
),
]
else:
deal = librosa.effects.pitch_shift(
y_orig, sr=sr_orig, n_steps=pitch
)[
: (
int(duration / 20 * sr_orig)
if duration / 20 * sr_orig > len(y_orig)
else len(y_orig)
)
]
self.res_data[sound_name][pitch] = librosa.resample(
deal, orig_sr=sr_orig, target_sr=self.output_sample_rate, fix=False
)
else:
# if self.mode == 1:
# 重采样, 不变调
print(">>", sound_name, pitch)
self.res_data[sound_name][pitch] = librosa.resample(
y_orig,
orig_sr=sr_orig,
target_sr=self.output_sample_rate,
fix=False, # 尽管这样不变调,但是也不应将 pitch 与打击乐器联系在一起
)
"""elif self.mode == 0:
# 重采样, 不变调, 衰弱
self.cache_dict[raw_name] = librosa_resample(
y_orig,
orig_sr=sr_orig,
target_sr=self.out_sr,
fix=False
)"""
def make_pitch_accurate(self, pitch: float) -> int:
"""
相信你知道的,小数并不准确
"""
return int((pitch + self.pitch_deviation) * (10**self.pitch_precision_decimals))
# 获取资源方法,需要指定采样率,采样率变了即全局重载
# 资源存储类里面有就取,没就写入并标识已有数据
def get_res(
self,
sound_name: str,
pitch: float,
percussive: bool,
duration: int,
):
print(
sound_name,
pitch,
">>",
(pitch := self.make_pitch_accurate(pitch)),
percussive,
duration,
)
if sound_name in self.res_data:
if pitch in self.res_data[sound_name]:
return self.res_data[sound_name][pitch]
else:
self.res_shift(
sound_name=sound_name,
percussive=percussive,
pitch=pitch,
duration=duration,
)
# print(2, self.res_data[packageID][soundID][pitch])
return self.res_data[sound_name][pitch]
else:
self.res_data[sound_name] = {}
self.res_shift(
sound_name=sound_name,
percussive=percussive,
pitch=pitch,
duration=duration,
)
# print(2, self.res_data[packageID][soundID][pitch])
return self.res_data[sound_name][pitch]
def convert(self, music: SingleMusic, start_tick: float = 0, end_tick: float = inf):
if self.overlay_mode_c == 1:
def overlay(seg_overlay: np.ndarray, pos_tick: int):
pos_ = int(out_sr * pos_tick * 0.05)
wav_model[pos_ : seg_overlay.size + pos_] += seg_overlay
# print(0)
wav_model = np.zeros(
int(
max(
[
(
i.start_tick * 0.05 * self.output_sample_rate
+ len(
self.get_res(
sound_name=i.instrument,
pitch=i.pitch
- 60
- MM_INSTRUMENT_DEVIATION_TABLE.get(
i.instrument, 6
),
percussive=i.percussive,
duration=i.duration_tick,
)
)
)
for i in music.get_minenotes(
start_time=start_tick, end_time=end_tick
)
]
)
),
dtype=np.float32,
)
elif self.overlay_mode_c == 2:
def overlay(seg_overlay: np.ndarray, pos_tick: int):
pos_ = int(out_sr * pos_tick * 0.05)
wav_model[..., pos_ : len(seg_overlay[0]) + pos_] += seg_overlay
wav_model = np.zeros(
(
2,
int(
max(
[
(
i.start_tick * 0.05 * self.output_sample_rate
+ len(
self.get_res(
sound_name=i.instrument,
pitch=i.pitch
- 60
- MM_INSTRUMENT_DEVIATION_TABLE.get(
i.instrument, 6
),
percussive=i.percussive,
duration=i.duration_tick,
)
)
)
for i in music.get_minenotes(
start_time=start_tick, end_time=end_tick
)
]
)
),
),
dtype=np.float32,
)
else:
raise ValueError("错误的 overlay_mode 参数")
out_sr = self.output_sample_rate
for note in music.get_minenotes(start_time=start_tick, end_time=end_tick):
accurate_pitch = self.make_pitch_accurate(
note.pitch
- 60
- (
0
if note.percussive
else MM_INSTRUMENT_DEVIATION_TABLE.get(note.instrument, 6)
)
)
print(
":",
note.instrument,
note.pitch,
">>",
accurate_pitch,
note.percussive,
note.duration_tick,
)
if not note.percussive:
overlay(
self.res_data[note.instrument][accurate_pitch] * note.volume / 127,
note.start_tick,
)
else:
overlay(
self.res_data[note.instrument][accurate_pitch] * note.volume / 127,
note.start_tick,
)
if self.get_value_method == 0:
# 归一化,抚摸耳朵 (bushi
max_val = np.max(np.abs(wav_model))
if not max_val == 0:
wav_model = wav_model / max_val
elif self.get_value_method == 1:
wav_model[wav_model > 1] = 1
wav_model[wav_model < -1] = -1
if self.overlay_mode_c == 2:
return wav_model.T
else:
return wav_model[:, np.newaxis]
def stream(self, music: SingleMusic):
pass
# debug
def to_wav_file(self, single_music: SingleMusic, out_file_path: Path):
sf.write(
out_file_path,
self.convert(single_music),
samplerate=self.output_sample_rate,
format="wav",
)
return out_file_path
def to_wav_file_byte(self, single_music: SingleMusic, output_file: BinaryIO):
sf.write(
output_file,
self.convert(single_music),
samplerate=self.output_sample_rate,
format="wav",
)
@@ -96,7 +96,7 @@ class MineCommand:
@library_plugin("notedata_to_command_plugin")
class NoteDataConvert2CommandPlugin(LibraryPluginBase):
metainfo = PluginMetaInformation(
name="音符数据指令支持插件",
name="音符数据的基岩版指令支持",
author="金羿、玉衡Alioth",
description="从音符数据转换为我的世界指令相关格式",
version=(0, 0, 1),
+15 -10
View File
@@ -174,7 +174,7 @@ class SingleNote:
midi_pitch: int
Midi 音高
note_volume: int
响度/力度(百廿七分比, 0~127)
响度或曰力度(百廿七分比, 0~127)
start_time: int
开始之时(命令刻)
注:此处的时间是用从乐曲开始到当前的刻数
@@ -377,9 +377,9 @@ class MineNote:
pitch: float
"""Midi 音高"""
instrument: str
"""乐器 ID"""
"""乐器标识"""
volume: float
"""力度/播放音量 0~127 百廿七分比"""
"""力度或曰播放音量、响度 0~127 百廿七分比"""
start_tick: int
"""开始之时 命令刻"""
duration_tick: int
@@ -387,7 +387,7 @@ class MineNote:
start_time_offset: int
"""高精度开始时间偏量 1/5000 秒"""
percussive: bool
"""是否作为打击乐器"""
"""是否作为打击乐器使"""
position: SoundAtmos
"""声像方位"""
@@ -619,7 +619,7 @@ class SingleTrack(List[SingleNote]):
return (x for x in self if x.precise_start_time == time)
def get_notes(
self, start_time: float, end_time: float = inf
self, start_time: float = 0, end_time: float = inf
) -> Iterator[SingleNote]:
"""通过开始时间和结束时间来获取音符"""
if end_time < start_time:
@@ -791,7 +791,12 @@ class SingleMusic(List[SingleTrack]):
# 没想到真的会用到全曲复制……
return self.from_track_list(
track_list=[tr.copy(with_argument_curve=True,) for tr in self],
track_list=[
tr.copy(
with_argument_curve=True,
)
for tr in self
],
name=self.music_name,
creator=self.music_creator,
original_author=self.music_original_author,
@@ -875,13 +880,13 @@ class SingleMusic(List[SingleTrack]):
# yield _remain[1]
def get_tracked_notes(
self, start_time: float, end_time: float = inf
self, start_time: float = 0, end_time: float = inf
) -> Generator[Iterator[SingleNote], Any, None]:
"""获取指定时间段的各个音轨的音符数据"""
return (track.get_notes(start_time, end_time) for track in self.music_tracks)
def get_tracked_minenotes(
self, start_time: float, end_time: float = inf
self, start_time: float = 0, end_time: float = inf
) -> Generator[Iterator[MineNote], Any, None]:
"""获取指定时间段的各个音轨的,供我的世界播放的音符数据类"""
return (
@@ -889,7 +894,7 @@ class SingleMusic(List[SingleTrack]):
)
def get_notes(
self, start_time: float, end_time: float = inf
self, start_time: float = 0, end_time: float = inf
) -> Iterator[SingleNote]:
"""获取指定时间段的所有音符数据,按照时间顺序"""
if self.track_amount == 0:
@@ -900,7 +905,7 @@ class SingleMusic(List[SingleTrack]):
)
def get_minenotes(
self, start_time: float, end_time: float = inf
self, start_time: float = 0, end_time: float = inf
) -> Iterator[MineNote]:
"""获取指定时间段所有的,供我的世界播放的音符数据类,按照时间顺序"""
if self.track_amount == 0: