sunoauxtool 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sunoauxtool/__init__.py +7 -0
- sunoauxtool/__main__.py +6 -0
- sunoauxtool/aggregate.py +247 -0
- sunoauxtool/ai/__init__.py +32 -0
- sunoauxtool/ai/audiosr.py +174 -0
- sunoauxtool/ai/base.py +28 -0
- sunoauxtool/ai/basicpitch.py +151 -0
- sunoauxtool/ai/diffrhythm.py +357 -0
- sunoauxtool/ai/musicgen.py +243 -0
- sunoauxtool/analysis/__init__.py +50 -0
- sunoauxtool/analysis/chords.py +148 -0
- sunoauxtool/analysis/key.py +148 -0
- sunoauxtool/analysis/spectral.py +59 -0
- sunoauxtool/analysis/structure.py +160 -0
- sunoauxtool/analysis/tempo.py +299 -0
- sunoauxtool/analysis/transcribe.py +358 -0
- sunoauxtool/batch.py +523 -0
- sunoauxtool/cli.py +1648 -0
- sunoauxtool/commands/__init__.py +1 -0
- sunoauxtool/commands/helpers.py +305 -0
- sunoauxtool/config.py +653 -0
- sunoauxtool/download/__init__.py +75 -0
- sunoauxtool/download/__main__.py +8 -0
- sunoauxtool/download/cli.py +195 -0
- sunoauxtool/download/convert/__init__.py +19 -0
- sunoauxtool/download/convert/audio.py +110 -0
- sunoauxtool/download/convert/engine.py +96 -0
- sunoauxtool/download/convert/profiles.py +51 -0
- sunoauxtool/download/convert/video_extract.py +135 -0
- sunoauxtool/download/exceptions.py +68 -0
- sunoauxtool/download/fmp4.py +103 -0
- sunoauxtool/download/forensics.py +220 -0
- sunoauxtool/download/sources/__init__.py +21 -0
- sunoauxtool/download/sources/api.py +144 -0
- sunoauxtool/download/sources/base.py +67 -0
- sunoauxtool/download/sources/catcatch.py +82 -0
- sunoauxtool/download/transcoder.py +255 -0
- sunoauxtool/dsp/__init__.py +10 -0
- sunoauxtool/dsp/filters.py +253 -0
- sunoauxtool/dsp/loudness.py +138 -0
- sunoauxtool/dsp/ops.py +450 -0
- sunoauxtool/dsp/processor.py +103 -0
- sunoauxtool/dsp/reverb.py +111 -0
- sunoauxtool/env.py +392 -0
- sunoauxtool/exceptions.py +160 -0
- sunoauxtool/export/__init__.py +13 -0
- sunoauxtool/export/audio.py +143 -0
- sunoauxtool/export/suno.py +205 -0
- sunoauxtool/generators/__init__.py +25 -0
- sunoauxtool/generators/base.py +121 -0
- sunoauxtool/generators/music21_melody.py +233 -0
- sunoauxtool/generators/procedural.py +532 -0
- sunoauxtool/inspire.py +255 -0
- sunoauxtool/logging_setup.py +57 -0
- sunoauxtool/models/__init__.py +7 -0
- sunoauxtool/models/chords.py +143 -0
- sunoauxtool/models/midi.py +144 -0
- sunoauxtool/models/notes.py +116 -0
- sunoauxtool/music_theory/__init__.py +19 -0
- sunoauxtool/music_theory/counterpoint.py +99 -0
- sunoauxtool/music_theory/inversion.py +106 -0
- sunoauxtool/music_theory/postprocess.py +40 -0
- sunoauxtool/music_theory/rhythm_patterns.py +104 -0
- sunoauxtool/music_theory/voice_leading.py +132 -0
- sunoauxtool/output_manager.py +172 -0
- sunoauxtool/pipeline.py +355 -0
- sunoauxtool/platform_paths.py +56 -0
- sunoauxtool/plugins.py +113 -0
- sunoauxtool/preview.py +826 -0
- sunoauxtool/render/__init__.py +19 -0
- sunoauxtool/render/fluidsynth.py +270 -0
- sunoauxtool/score/__init__.py +98 -0
- sunoauxtool/score/drawing.py +607 -0
- sunoauxtool/score/jianpu.py +852 -0
- sunoauxtool/score/layout.py +1108 -0
- sunoauxtool/score/model.py +645 -0
- sunoauxtool/score/musicxml.py +621 -0
- sunoauxtool/score/png.py +70 -0
- sunoauxtool/score/svg.py +768 -0
- sunoauxtool/score/theory.py +394 -0
- sunoauxtool/score_export.py +368 -0
- sunoauxtool/styles/__init__.py +5 -0
- sunoauxtool/styles/registry.py +143 -0
- sunoauxtool/sunopack.py +160 -0
- sunoauxtool/video/__init__.py +18 -0
- sunoauxtool/video/__main__.py +6 -0
- sunoauxtool/video/analysis/__init__.py +22 -0
- sunoauxtool/video/analysis/audio_analysis.py +338 -0
- sunoauxtool/video/audio_io.py +182 -0
- sunoauxtool/video/cli.py +231 -0
- sunoauxtool/video/compositor.py +163 -0
- sunoauxtool/video/config.py +256 -0
- sunoauxtool/video/engines/__init__.py +20 -0
- sunoauxtool/video/engines/ffmpeg_engine.py +283 -0
- sunoauxtool/video/engines/frame_engine.py +292 -0
- sunoauxtool/video/exceptions.py +55 -0
- sunoauxtool/video/mixer.py +218 -0
- sunoauxtool/video/output_manager.py +149 -0
- sunoauxtool/video/presets/__init__.py +72 -0
- sunoauxtool/video/text.py +56 -0
- sunoauxtool/video/videomaker.py +361 -0
- sunoauxtool/video/visuals/__init__.py +87 -0
- sunoauxtool/video/visuals/bars.py +106 -0
- sunoauxtool/video/visuals/base.py +57 -0
- sunoauxtool/video/visuals/reactive.py +80 -0
- sunoauxtool/video/visuals/score.py +458 -0
- sunoauxtool/video/visuals/spectrum.py +74 -0
- sunoauxtool/video/visuals/tracks.py +190 -0
- sunoauxtool-1.6.0.dist-info/METADATA +320 -0
- sunoauxtool-1.6.0.dist-info/RECORD +113 -0
- sunoauxtool-1.6.0.dist-info/WHEEL +5 -0
- sunoauxtool-1.6.0.dist-info/entry_points.txt +4 -0
- sunoauxtool-1.6.0.dist-info/top_level.txt +1 -0
sunoauxtool/__init__.py
ADDED
sunoauxtool/__main__.py
ADDED
sunoauxtool/aggregate.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""sunoaux:顶层聚合 CLI(前期 pre / 后期 post 的统一入口)。
|
|
2
|
+
|
|
3
|
+
设计铁律(继承自重构计划 R3):
|
|
4
|
+
- **薄转发层**:所有子命令直接二次注册既有命令函数(Typer 的 command
|
|
5
|
+
装饰器返回原函数,可安全挂到新 app 上),零参数复制、零业务逻辑;
|
|
6
|
+
- 核心层不得反向依赖 CLI 层——本模块只 import,不被 import;
|
|
7
|
+
- 旧入口 ``sunoauxtool`` / ``videomaker`` / ``downloadhelper`` 保留(兼容期 >= 1 个版本)。
|
|
8
|
+
|
|
9
|
+
子命令结构:
|
|
10
|
+
sunoaux pre melody / midi / score / render / transcribe (前期:创作)
|
|
11
|
+
sunoaux post probe / convert / fetch (后期:取回与转码)
|
|
12
|
+
sunoaux post video render / multi (后期:音乐视频)
|
|
13
|
+
sunoaux post dsp (R6 已交付:DSP 算子链)
|
|
14
|
+
sunoaux post enhance (AudioSR 音质提升,R5;依赖可选装)
|
|
15
|
+
|
|
16
|
+
映射表(新 -> 旧):
|
|
17
|
+
pre melody -> sunoauxtool generate melody
|
|
18
|
+
pre midi -> sunoauxtool generate midi
|
|
19
|
+
pre score -> sunoauxtool score
|
|
20
|
+
pre render -> sunoauxtool render
|
|
21
|
+
pre transcribe -> sunoauxtool transcribe
|
|
22
|
+
post probe -> downloadhelper probe
|
|
23
|
+
post convert -> downloadhelper decode
|
|
24
|
+
post fetch -> downloadhelper batch
|
|
25
|
+
post video * -> videomaker render / multi
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
import typer
|
|
34
|
+
|
|
35
|
+
from sunoauxtool import __version__
|
|
36
|
+
from sunoauxtool import cli as core_cli
|
|
37
|
+
from sunoauxtool.ai.audiosr import AudioSRAdapter
|
|
38
|
+
from sunoauxtool.commands.helpers import _guard
|
|
39
|
+
from sunoauxtool.download import cli as download_cli
|
|
40
|
+
from sunoauxtool.exceptions import InputFileError, ParameterError
|
|
41
|
+
from sunoauxtool.video import cli as video_cli
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
name="sunoaux",
|
|
45
|
+
help="SunoAuxTool 聚合入口:pre(前期创作)+ post(后期处理)",
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
add_completion=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _version_callback(value: bool) -> None:
|
|
52
|
+
if value:
|
|
53
|
+
typer.echo(f"sunoaux {__version__}")
|
|
54
|
+
raise typer.Exit(0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.callback()
|
|
58
|
+
def main(
|
|
59
|
+
version: bool = typer.Option(
|
|
60
|
+
None, "--version", help="显示版本号", callback=_version_callback, is_eager=True
|
|
61
|
+
),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""sunoaux 聚合入口(薄转发层,业务逻辑在既有命令实现中)。"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# pre:前期创作(映射 sunoauxtool 既有命令)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
pre_app = typer.Typer(help="前期:旋律 / MIDI / 谱面 / 渲染 / 转谱", no_args_is_help=True)
|
|
71
|
+
|
|
72
|
+
pre_app.command("melody", help="(= generate melody) 生成旋律 MIDI")(core_cli.generate_melody)
|
|
73
|
+
pre_app.command("midi", help="(= generate midi) 程序化生成 MIDI")(core_cli.generate_midi)
|
|
74
|
+
pre_app.command("score", help="(= score) 从 MIDI 生成谱面")(core_cli.score_cmd)
|
|
75
|
+
pre_app.command("render", help="(= render) MIDI -> WAV 渲染")(core_cli.render_cmd)
|
|
76
|
+
pre_app.command("transcribe", help="(= transcribe) WAV -> MIDI 转谱")(core_cli.transcribe_cmd)
|
|
77
|
+
|
|
78
|
+
app.add_typer(pre_app, name="pre")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# post:后期处理(映射 downloadhelper / videomaker 既有命令)
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
post_app = typer.Typer(help="后期:取回 / 转码 / 视频 / DSP / 音质提升", no_args_is_help=True)
|
|
86
|
+
|
|
87
|
+
post_app.command("probe", help="(= downloadhelper probe) 取证判定:明文 / 加密密文")(
|
|
88
|
+
download_cli.probe
|
|
89
|
+
)
|
|
90
|
+
post_app.command("convert", help="(= downloadhelper decode) fMP4 -> Opus/MP3 转码")(
|
|
91
|
+
download_cli.decode
|
|
92
|
+
)
|
|
93
|
+
post_app.command("convert-audio", help="(= downloadhelper convert) 音频互转 mp3/wav/m4a/flac")(
|
|
94
|
+
download_cli.convert
|
|
95
|
+
)
|
|
96
|
+
post_app.command("extract-audio", help="(= downloadhelper extract-audio) 视频分离音轨(直通优先)")(
|
|
97
|
+
download_cli.extract_audio
|
|
98
|
+
)
|
|
99
|
+
@post_app.command(
|
|
100
|
+
"fetch",
|
|
101
|
+
help="取回音频(R7):catcatch=猫抓缓存扫描转码(默认);suno-api/haimeng/tianyin=API 源",
|
|
102
|
+
)
|
|
103
|
+
@_guard
|
|
104
|
+
def fetch_cmd(
|
|
105
|
+
query: str = typer.Argument(..., help="catcatch=缓存目录;API 源=歌曲/任务 ID"),
|
|
106
|
+
source: str = typer.Option(
|
|
107
|
+
"catcatch", "--source", "-s", help="源:catcatch | suno-api | haimeng | tianyin"
|
|
108
|
+
),
|
|
109
|
+
out: Path = typer.Option(None, "-o", "--out", help="输出目录(catcatch 默认原目录;API 默认 ./fetched/<source>)"),
|
|
110
|
+
fmt: str = typer.Option("both", "--fmt", help="[catcatch] 输出格式: opus | mp3 | both"),
|
|
111
|
+
bitrate: str = typer.Option("192k", "--bitrate", help="[catcatch] MP3 码率"),
|
|
112
|
+
ffmpeg: Optional[str] = typer.Option(None, "--ffmpeg-path", help="[catcatch] ffmpeg 绝对路径"),
|
|
113
|
+
dry_run: bool = typer.Option(
|
|
114
|
+
False, "--dry-run", help="[R11] 仅校验凭证/可用性:不实际取回;缺凭证报 25"
|
|
115
|
+
),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""统一取回入口(R7):错误码 25=凭证缺失、26=请求失败;猫抓沿用 20-24。"""
|
|
118
|
+
if dry_run:
|
|
119
|
+
from sunoauxtool.download.sources.base import list_sources
|
|
120
|
+
|
|
121
|
+
valid = {s.name: s for s in list_sources()}
|
|
122
|
+
if source not in valid:
|
|
123
|
+
known = ", ".join(sorted(valid))
|
|
124
|
+
raise ParameterError(f"未知下载源: {source}(可用: {known})", code=1)
|
|
125
|
+
typer.echo(f"🔎 dry-run(不取回): {valid[source].check(query)}")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if source == "catcatch":
|
|
129
|
+
# 直通既有 batch 实现(能力零复制)
|
|
130
|
+
download_cli.batch(
|
|
131
|
+
directory=Path(query),
|
|
132
|
+
out=out,
|
|
133
|
+
fmt=fmt,
|
|
134
|
+
bitrate=bitrate,
|
|
135
|
+
ffmpeg=ffmpeg,
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
from sunoauxtool.download.sources.base import list_sources
|
|
140
|
+
from sunoauxtool.download.sources.catcatch import CatCatchSource
|
|
141
|
+
|
|
142
|
+
valid = {s.name: s for s in list_sources()}
|
|
143
|
+
if source not in valid:
|
|
144
|
+
known = ", ".join(sorted(valid))
|
|
145
|
+
raise ParameterError(f"未知下载源: {source}(可用: {known})", code=1)
|
|
146
|
+
|
|
147
|
+
adapter = valid[source]
|
|
148
|
+
if isinstance(adapter, CatCatchSource):
|
|
149
|
+
adapter.fmt, adapter.bitrate, adapter.ffmpeg = fmt, bitrate, ffmpeg
|
|
150
|
+
target_out = out or Path("fetched") / source
|
|
151
|
+
files = adapter.fetch(query, target_out)
|
|
152
|
+
typer.echo(f"✅ 取回 {len(files)} 个文件(source={source}):")
|
|
153
|
+
for f in files:
|
|
154
|
+
typer.echo(f" {f.path}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@post_app.command(
|
|
158
|
+
"dsp",
|
|
159
|
+
help="DSP 算子链:norm/loudnorm/fade-in/fade-out/trim/resample/lowcut/compress/"
|
|
160
|
+
"expand/limiter/reverb/concat",
|
|
161
|
+
)
|
|
162
|
+
@_guard
|
|
163
|
+
def dsp_cmd(
|
|
164
|
+
audio: str = typer.Argument(..., help="输入 WAV 路径"),
|
|
165
|
+
ops_spec: str = typer.Option(
|
|
166
|
+
...,
|
|
167
|
+
"--ops",
|
|
168
|
+
help='算子串(逗号分隔),如 "norm -1, fade-in 0.5, trim 10-25, resample 32000"',
|
|
169
|
+
),
|
|
170
|
+
output: Path = typer.Option(None, "-o", "--output", help="输出 WAV(默认 <输入>_dsp.wav)"),
|
|
171
|
+
bit_depth: int = typer.Option(16, "--bit-depth", help="输出位深:16 | 24"),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""DSP 算子链(R6):错误码 15=处理失败 / 16=参数错误。"""
|
|
174
|
+
from sunoauxtool.dsp.ops import OPS, apply_ops, parse_ops
|
|
175
|
+
from sunoauxtool.export import audio as audio_ops
|
|
176
|
+
|
|
177
|
+
src = Path(audio)
|
|
178
|
+
if not src.is_file():
|
|
179
|
+
raise InputFileError(f"输入音频不存在: {src}", code=3)
|
|
180
|
+
if bit_depth not in (16, 24):
|
|
181
|
+
raise ParameterError(f"bit_depth 只能是 16 或 24: {bit_depth}", code=16)
|
|
182
|
+
|
|
183
|
+
ops = parse_ops(ops_spec)
|
|
184
|
+
unknown = [op.name for op in ops if op.name not in OPS]
|
|
185
|
+
if unknown:
|
|
186
|
+
raise ParameterError(
|
|
187
|
+
f"未知算子: {', '.join(unknown)}(可用: {', '.join(sorted(OPS))})", code=16
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
wave, sr = audio_ops.read_wav(src)
|
|
191
|
+
wave, sr = apply_ops(wave, sr, ops, base_dir=src.parent)
|
|
192
|
+
|
|
193
|
+
out = output or src.with_name(src.stem + "_dsp.wav")
|
|
194
|
+
written = audio_ops.write_wav(out, wave, sr, bit_depth=bit_depth)
|
|
195
|
+
typer.echo(f"✅ DSP 完成: {written}({wave.shape[0] / sr:.2f}s @ {sr}Hz, {bit_depth}bit)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@post_app.command(
|
|
199
|
+
"enhance",
|
|
200
|
+
help="AudioSR 音质提升/超分(R5;长音频自动分块交叉淡化;未装依赖 exit 6)",
|
|
201
|
+
)
|
|
202
|
+
@_guard
|
|
203
|
+
def enhance_cmd(
|
|
204
|
+
audio: str = typer.Argument(..., help="输入音频路径(WAV)"),
|
|
205
|
+
output: Path = typer.Option(None, "-o", "--output", help="输出 WAV(默认 <输入>_enhanced.wav)"),
|
|
206
|
+
model: str = typer.Option("basic", "--model", help="模型:basic(音乐/通用)| speech"),
|
|
207
|
+
seed: int = typer.Option(42, "--seed", help="随机种子"),
|
|
208
|
+
steps: int = typer.Option(50, "--steps", help="DDIM 步数(默认 50)"),
|
|
209
|
+
chunk: float = typer.Option(15.0, "--chunk", help="长音频分块秒数"),
|
|
210
|
+
overlap: float = typer.Option(2.0, "--overlap", help="分块重叠秒数"),
|
|
211
|
+
) -> None:
|
|
212
|
+
"""AudioSR 超分(R5):输出单声道 48kHz WAV(上游管线行为)。"""
|
|
213
|
+
src = Path(audio)
|
|
214
|
+
if not src.is_file():
|
|
215
|
+
raise InputFileError(f"输入音频不存在: {src}", code=3)
|
|
216
|
+
out = output or src.with_name(src.stem + "_enhanced.wav")
|
|
217
|
+
|
|
218
|
+
adapter = AudioSRAdapter(
|
|
219
|
+
model_name=model,
|
|
220
|
+
seed=seed,
|
|
221
|
+
ddim_steps=steps,
|
|
222
|
+
chunk_duration_s=chunk,
|
|
223
|
+
overlap_duration_s=overlap,
|
|
224
|
+
)
|
|
225
|
+
if not adapter.is_available():
|
|
226
|
+
from sunoauxtool.exceptions import AiDependencyError
|
|
227
|
+
|
|
228
|
+
raise AiDependencyError(
|
|
229
|
+
"audiosr 不可用:未找到 AudioSR 源码目录"
|
|
230
|
+
"(设 AUDIOSR_DIR 或克隆到 src/versatile_audio_super_resolution,"
|
|
231
|
+
"依赖见 requirements/vasr.txt)",
|
|
232
|
+
code=6,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
written = adapter.enhance(str(src), str(out))
|
|
236
|
+
typer.echo(f"✅ 音质提升完成: {written}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
video_app = typer.Typer(help="音乐视频(sunoauxtool.video,原 videomaker)", no_args_is_help=True)
|
|
240
|
+
video_app.command("render")(video_cli.render)
|
|
241
|
+
video_app.command("multi")(video_cli.multi)
|
|
242
|
+
video_app.command("presets")(video_cli.presets)
|
|
243
|
+
video_app.command("config")(video_cli.config)
|
|
244
|
+
video_app.command("version")(video_cli.version)
|
|
245
|
+
post_app.add_typer(video_app, name="video")
|
|
246
|
+
|
|
247
|
+
app.add_typer(post_app, name="post")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""AI 适配器包(P1)。模块顶部零重型 import。"""
|
|
2
|
+
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
from sunoauxtool.ai.base import AIGenerator
|
|
6
|
+
from sunoauxtool.ai.musicgen import MusicGenAdapter
|
|
7
|
+
from sunoauxtool.ai.diffrhythm import DiffRhythmAdapter
|
|
8
|
+
from sunoauxtool.ai.basicpitch import BasicPitchAdapter
|
|
9
|
+
|
|
10
|
+
def discover_ai_backends() -> Dict[str, type]:
|
|
11
|
+
"""AI 后端注册表 ``{name: AIGenerator 子类}`` = 内置 + entry point 插件。
|
|
12
|
+
|
|
13
|
+
扩展点:``sunoauxtool.ai_backends``(见 :mod:`sunoauxtool.plugins`)。
|
|
14
|
+
``basic-pitch`` 是**转谱**后端(不走 generate),见
|
|
15
|
+
:func:`sunoauxtool.analysis.discover_transcribe_backends`。
|
|
16
|
+
"""
|
|
17
|
+
from sunoauxtool.plugins import discover
|
|
18
|
+
|
|
19
|
+
builtins: Dict[str, type] = {
|
|
20
|
+
"musicgen": MusicGenAdapter,
|
|
21
|
+
"diffrhythm": DiffRhythmAdapter,
|
|
22
|
+
}
|
|
23
|
+
return discover("ai_backends", builtins, base=AIGenerator, instantiate=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AIGenerator",
|
|
28
|
+
"MusicGenAdapter",
|
|
29
|
+
"DiffRhythmAdapter",
|
|
30
|
+
"BasicPitchAdapter",
|
|
31
|
+
"discover_ai_backends",
|
|
32
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""AudioSR(VASR)音质提升适配器(R5)。
|
|
2
|
+
|
|
3
|
+
能力:音频超分 / 高频重建(**不是人声分离**——人声分离另行选型)。
|
|
4
|
+
上游:haoheliu/versatile_audio_super_resolution(AudioSR)本地克隆 @ d312fba,
|
|
5
|
+
源码位于 ``src/versatile_audio_super_resolution``(自带内嵌 .git,R5 入库时清理)。
|
|
6
|
+
|
|
7
|
+
设计要点:
|
|
8
|
+
- **零顶层重依赖**:本模块顶层只 import 标准库;audiosr/torch 全部在推理时
|
|
9
|
+
延迟导入(CI 零 torch 断言不受影响,未装依赖不影响主包任何功能)。
|
|
10
|
+
- **可选装目录模式**(与 diffrhythm 同构):目录定位顺序
|
|
11
|
+
环境变量 ``AUDIOSR_DIR`` > 默认 ``<repo>/src/versatile_audio_super_resolution``。
|
|
12
|
+
- 长音频走上游 ``super_resolution_long_audio``:15s 分块 / 2s 重叠 Hann 交叉淡化
|
|
13
|
+
/ 块级峰值还原 / overlap-add 归一化(2026-09-21 实测 32s 冒烟:时长精确对齐)。
|
|
14
|
+
- 输出:**单声道 48kHz** WAV(上游管线内部将立体声混为单声道;这是上游行为,
|
|
15
|
+
保留立体声需改上游,暂不做)。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from sunoauxtool.exceptions import AiDependencyError, InputFileError
|
|
26
|
+
|
|
27
|
+
_INSTALL_GUIDE = (
|
|
28
|
+
"audiosr 不可用:未找到 AudioSR 源码目录。\n"
|
|
29
|
+
"方式一:克隆上游仓库到 src/versatile_audio_super_resolution(默认探测路径)\n"
|
|
30
|
+
" git clone https://github.com/haoheliu/versatile_audio_super_resolution\n"
|
|
31
|
+
"方式二:设环境变量 AUDIOSR_DIR 指向任意 audiosr 源码目录\n"
|
|
32
|
+
"依赖(torch/torchaudio 等)见 requirements/vasr.txt;首次运行自动下载约 2.6GB 权重。\n"
|
|
33
|
+
"网络:直连 huggingface.co 不通时设 HF_ENDPOINT=https://hf-mirror.com(国内镜像)。"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_DEFAULT_DIR = Path(__file__).resolve().parents[2] / "versatile_audio_super_resolution"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_audiosr_dir() -> Optional[Path]:
|
|
40
|
+
"""定位 audiosr 源码目录;找不到返回 None。"""
|
|
41
|
+
env = os.environ.get("AUDIOSR_DIR")
|
|
42
|
+
cand = Path(env) if env else _DEFAULT_DIR
|
|
43
|
+
return cand if (cand / "audiosr").is_dir() else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ensure_importable() -> None:
|
|
47
|
+
"""把 audiosr 源码目录挂到 sys.path(幂等),失败抛 AiDependencyError。"""
|
|
48
|
+
d = resolve_audiosr_dir()
|
|
49
|
+
if d is None:
|
|
50
|
+
raise AiDependencyError(_INSTALL_GUIDE, code=6)
|
|
51
|
+
d_str = str(d)
|
|
52
|
+
if d_str not in sys.path:
|
|
53
|
+
sys.path.insert(0, d_str)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AudioSRAdapter:
|
|
57
|
+
"""AudioSR 超分适配器(可选 AI 后端,R5)。
|
|
58
|
+
|
|
59
|
+
用法::
|
|
60
|
+
|
|
61
|
+
adapter = AudioSRAdapter()
|
|
62
|
+
if adapter.is_available():
|
|
63
|
+
adapter.enhance("in.wav", "out.wav")
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
model_name: str = "basic",
|
|
69
|
+
device: Optional[str] = None,
|
|
70
|
+
seed: int = 42,
|
|
71
|
+
ddim_steps: int = 50,
|
|
72
|
+
guidance_scale: float = 3.5,
|
|
73
|
+
chunk_duration_s: float = 15.0,
|
|
74
|
+
overlap_duration_s: float = 2.0,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""初始化(不触发模型加载)。
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
model_name: ``basic``(音乐/通用)或 ``speech``。
|
|
80
|
+
device: ``cuda`` / ``cpu``;None 自动选(有 CUDA 用 CUDA)。
|
|
81
|
+
ddim_steps: DDIM 采样步数(默认 50,R0 实测 50 步质量/耗时均衡)。
|
|
82
|
+
chunk_duration_s / overlap_duration_s: 长音频分块参数(须 chunk > overlap)。
|
|
83
|
+
"""
|
|
84
|
+
if chunk_duration_s <= overlap_duration_s:
|
|
85
|
+
raise ValueError("chunk_duration_s 必须大于 overlap_duration_s")
|
|
86
|
+
self.model_name = model_name
|
|
87
|
+
self.device = device
|
|
88
|
+
self.seed = seed
|
|
89
|
+
self.ddim_steps = ddim_steps
|
|
90
|
+
self.guidance_scale = guidance_scale
|
|
91
|
+
self.chunk_duration_s = chunk_duration_s
|
|
92
|
+
self.overlap_duration_s = overlap_duration_s
|
|
93
|
+
self._model = None # 惰性加载
|
|
94
|
+
|
|
95
|
+
# -- 可用性 ------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def is_available(self) -> bool:
|
|
98
|
+
"""audiosr 源码目录是否就位(不触发实际 import,轻量检查)。"""
|
|
99
|
+
return resolve_audiosr_dir() is not None
|
|
100
|
+
|
|
101
|
+
def _load_model(self):
|
|
102
|
+
"""惰性加载 AudioSR 模型(权重缺失/下载失败转 AiDependencyError)。"""
|
|
103
|
+
if self._model is None:
|
|
104
|
+
_ensure_importable()
|
|
105
|
+
try:
|
|
106
|
+
import torch
|
|
107
|
+
|
|
108
|
+
import audiosr
|
|
109
|
+
|
|
110
|
+
if self.device is not None:
|
|
111
|
+
device = self.device
|
|
112
|
+
else:
|
|
113
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
114
|
+
self._model = audiosr.build_model(
|
|
115
|
+
model_name=self.model_name, device=device
|
|
116
|
+
)
|
|
117
|
+
except AiDependencyError:
|
|
118
|
+
raise
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
raise AiDependencyError(
|
|
121
|
+
f"AudioSR 模型加载失败(权重下载/损坏?依赖缺失?): {exc}", code=6
|
|
122
|
+
) from exc
|
|
123
|
+
return self._model
|
|
124
|
+
|
|
125
|
+
# -- 推理 --------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def enhance(self, input_path: str, output_path: str) -> str:
|
|
128
|
+
"""对整段音频做超分增强,写 48kHz PCM_24 WAV,返回输出路径。
|
|
129
|
+
|
|
130
|
+
长音频自动分块(``super_resolution_long_audio``);输出为
|
|
131
|
+
**单声道 48kHz**(上游行为)。
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
InputFileError: 输入文件不存在(退出码 3)。
|
|
135
|
+
AiDependencyError: 依赖/模型不可用(退出码 6)。
|
|
136
|
+
"""
|
|
137
|
+
src = Path(input_path)
|
|
138
|
+
if not src.is_file():
|
|
139
|
+
raise InputFileError(f"输入音频不存在: {src}", code=3)
|
|
140
|
+
|
|
141
|
+
import soundfile as sf
|
|
142
|
+
|
|
143
|
+
model = self._load_model()
|
|
144
|
+
try:
|
|
145
|
+
import audiosr
|
|
146
|
+
|
|
147
|
+
wave = audiosr.super_resolution_long_audio(
|
|
148
|
+
model,
|
|
149
|
+
str(src),
|
|
150
|
+
seed=int(self.seed),
|
|
151
|
+
ddim_steps=int(self.ddim_steps),
|
|
152
|
+
guidance_scale=float(self.guidance_scale),
|
|
153
|
+
chunk_duration_s=float(self.chunk_duration_s),
|
|
154
|
+
overlap_duration_s=float(self.overlap_duration_s),
|
|
155
|
+
)
|
|
156
|
+
except AiDependencyError:
|
|
157
|
+
raise
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
hint = ""
|
|
160
|
+
# huggingface.co 直连不通(本机被墙)时给出镜像指引;
|
|
161
|
+
# 否则用户只会看到一串 ConnectTimeout,不知道怎么修。
|
|
162
|
+
if "huggingface.co" in str(exc) or "timed out" in str(exc):
|
|
163
|
+
hint = (
|
|
164
|
+
";网络提示:直连 huggingface.co 超时,"
|
|
165
|
+
"设 HF_ENDPOINT=https://hf-mirror.com 走国内镜像;"
|
|
166
|
+
"权重已缓存后可加 HF_HUB_OFFLINE=1 跳过联网校验"
|
|
167
|
+
)
|
|
168
|
+
raise AiDependencyError(f"AudioSR 推理失败: {exc}{hint}", code=6) from exc
|
|
169
|
+
|
|
170
|
+
out = Path(output_path)
|
|
171
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
# wave: torch [1(ch), N] 48kHz -> numpy (N, 1)
|
|
173
|
+
sf.write(str(out), wave.squeeze(0).numpy().T, 48000, subtype="PCM_24")
|
|
174
|
+
return str(out)
|
sunoauxtool/ai/base.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""P1 AI 适配器抽象接口。
|
|
2
|
+
|
|
3
|
+
隔离原则(架构 §5.1):
|
|
4
|
+
- 本包所有重型 import 均发生在函数体内(延迟导入),模块顶部零 torch/audiocraft/diffrhythm
|
|
5
|
+
- AIGenerator 只暴露 generate(source_wav, prompt) -> str(输入输出均为文件路径)
|
|
6
|
+
- P0 管线完全不感知 AI 内部实现
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AIGenerator(ABC):
|
|
15
|
+
"""AI 生成器抽象接口(P1)。
|
|
16
|
+
|
|
17
|
+
generate 输入/输出均为音频文件路径字符串。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def generate(self, source_wav: str, prompt: str, **kw) -> str:
|
|
22
|
+
"""基于输入音频与提示词生成新音频,返回输出文件路径。"""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def is_available(self) -> bool:
|
|
27
|
+
"""当前环境是否可用(依赖已安装 / 显存满足)。"""
|
|
28
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Basic Pitch 适配器(#13 预留,多音轨复调转谱的可选 AI 后端)。
|
|
2
|
+
|
|
3
|
+
定位
|
|
4
|
+
--------
|
|
5
|
+
内置转谱(``analysis/transcribe.py``)只做单旋律/主导声部;多音轨复调(钢琴和弦、
|
|
6
|
+
多乐器混音)是研究级问题,交给 Spotify 的 basic-pitch(轻量 NMP 模型,CPU 可跑)。
|
|
7
|
+
|
|
8
|
+
隔离原则(与 ai/musicgen.py 同一模式):
|
|
9
|
+
- 模块顶部零 basic_pitch/torch import;``is_available()`` 用 ``find_spec`` 探测(不触发实际 import)
|
|
10
|
+
- 依赖未装 → ``AiDependencyError``(退出码 6),带安装指引
|
|
11
|
+
- basic-pitch **不在 base.txt**:走 requirements/ai.txt 可选装(见该文件「可选转谱后端」段)
|
|
12
|
+
- 首次运行 ``ICModel()`` 会自动下载约 100MB 权重到用户缓存目录
|
|
13
|
+
|
|
14
|
+
真实推理已实测(2026-09-22)
|
|
15
|
+
--------------------------------
|
|
16
|
+
在隔离环境 **Python 3.9 + basic-pitch 0.4.0(onnxruntime 后端)** 实跑通过,
|
|
17
|
+
据此修正了三处与真实 API 不符的写法:
|
|
18
|
+
|
|
19
|
+
1. **``ICModel`` 不存在**:``from basic_pitch import ICModel`` → ImportError
|
|
20
|
+
(0.4.0 顶层只有常量与子模块)。0.4.x 无需手动实例化模型,
|
|
21
|
+
``predict()`` 第二参默认就是随包安装的 ``saved_models/icassp_2022/nmp.onnx``。
|
|
22
|
+
2. **参数顺序是 ``(audio, model)`` 而非 ``(model, audio)``**:
|
|
23
|
+
``predict(audio_path, model_or_model_path=..., onset_threshold=0.5, ...)``。
|
|
24
|
+
3. **顺序写错抛的是 ``ValueError`` 不是 ``TypeError``**(模型路径被当音频去加载),
|
|
25
|
+
因此历史写法里的 ``except TypeError`` 兜不住,必须一开始就用对顺序。
|
|
26
|
+
|
|
27
|
+
返回值为三元组 ``(model_output: dict, midi_data: PrettyMIDI, note_events: list)``,
|
|
28
|
+
``midi_data`` 有 ``.write(path)``。实测 3 秒复调音频(C-E-G 和弦 → A-C)识别出 5 个音符。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import importlib.util
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
from sunoauxtool.ai.base import AIGenerator
|
|
38
|
+
from sunoauxtool.exceptions import AiDependencyError, InputFileError
|
|
39
|
+
|
|
40
|
+
_INSTALL_GUIDE = (
|
|
41
|
+
"basic-pitch 不可用:未安装可选转谱依赖。\n"
|
|
42
|
+
"请安装: pip install basic-pitch(首次运行会自动下载约 100MB 权重)\n"
|
|
43
|
+
"或继续使用内置单旋律转谱:transcribe --backend builtin"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BasicPitchAdapter(AIGenerator):
|
|
48
|
+
"""basic-pitch 复调转谱适配器(可选 AI 后端,#13 预留)。"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
device: str = "cpu",
|
|
53
|
+
model_path: Optional[str] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""初始化。
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
device: 推理设备(basic-pitch 主要面向 CPU,GPU 传 "cuda" 由其自行处理)。
|
|
59
|
+
model_path: 显式权重路径;None 用默认权重(首次运行自动下载)。
|
|
60
|
+
"""
|
|
61
|
+
self.device = device
|
|
62
|
+
self.model_path = model_path
|
|
63
|
+
|
|
64
|
+
# -- 可用性 ------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def is_available(self) -> bool:
|
|
67
|
+
"""检查 basic_pitch 是否已安装(find_spec 不触发实际 import)。"""
|
|
68
|
+
return importlib.util.find_spec("basic_pitch") is not None
|
|
69
|
+
|
|
70
|
+
def _resolve_model_path(self) -> Optional[str]:
|
|
71
|
+
"""解析模型路径(不实际实例化模型,0.4.x predict 内部自行加载)。"""
|
|
72
|
+
if self.model_path:
|
|
73
|
+
p = Path(self.model_path).expanduser().resolve()
|
|
74
|
+
if not p.is_file():
|
|
75
|
+
raise AiDependencyError(
|
|
76
|
+
f"basic-pitch 模型路径不存在: {p}", code=6
|
|
77
|
+
) from None
|
|
78
|
+
return str(p)
|
|
79
|
+
return None # None 表示使用 predict 内置默认模型路径
|
|
80
|
+
|
|
81
|
+
# -- 推理 --------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def transcribe(
|
|
84
|
+
self,
|
|
85
|
+
source_wav: str,
|
|
86
|
+
output_path: Optional[str] = None,
|
|
87
|
+
**_kw,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""把音频转成多音轨 MIDI 文件。
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
source_wav: 输入音频路径。
|
|
93
|
+
output_path: 输出 .mid 路径;None 时 ``<stem>_basicpitch.mid`` 落在输入旁。
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
输出 .mid 绝对路径字符串。
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
AiDependencyError: 依赖未装或模型加载失败(退出码 6)。
|
|
100
|
+
InputFileError: 输入文件不存在(退出码 3)。
|
|
101
|
+
"""
|
|
102
|
+
if not self.is_available():
|
|
103
|
+
raise AiDependencyError(_INSTALL_GUIDE, code=6)
|
|
104
|
+
|
|
105
|
+
src = Path(source_wav).expanduser().resolve()
|
|
106
|
+
if not src.is_file():
|
|
107
|
+
raise InputFileError(f"音频文件不存在: {src}", code=3)
|
|
108
|
+
|
|
109
|
+
# 延迟导入(P0 模块零重型 import 约束)
|
|
110
|
+
# 0.4.x 签名:predict(audio, model=...) -> (model_output, midi_data, note_events)。
|
|
111
|
+
# 实测官方顺序,此处直接使用;保留关键字序兜底兼容历史版本。
|
|
112
|
+
#
|
|
113
|
+
# 注意:basic_pitch/__init__.py 的后端选择链 if/elif **没有 else 分支**,
|
|
114
|
+
# 四个后端(tf/coreml/tflite/onnx)一个都没装时会在模块级抛
|
|
115
|
+
# `NameError: _default_model_type is not defined`。这种「装了包但没装后端」
|
|
116
|
+
# 的状态必须报成退出码 6 并给出可操作的指引,而不是漏成退出码 1 的 NameError。
|
|
117
|
+
try:
|
|
118
|
+
from basic_pitch.inference import predict
|
|
119
|
+
except Exception as exc: # pragma: no cover - 依赖真实环境
|
|
120
|
+
raise AiDependencyError(
|
|
121
|
+
f"basic-pitch 已安装但导入失败(多半是没装推理后端):{exc}。"
|
|
122
|
+
f"Windows/onnx 路线请执行 `pip install onnxruntime`;"
|
|
123
|
+
f"也可用 `pip install 'basic-pitch[tf]'` 走 TensorFlow 后端。",
|
|
124
|
+
code=6,
|
|
125
|
+
) from exc
|
|
126
|
+
|
|
127
|
+
model = self._resolve_model_path()
|
|
128
|
+
try:
|
|
129
|
+
output = predict(str(src), model) if model is not None else predict(str(src))
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
kw = {"audio": str(src)}
|
|
132
|
+
if model is not None:
|
|
133
|
+
kw["model"] = model
|
|
134
|
+
output = predict(**kw)
|
|
135
|
+
|
|
136
|
+
# 0.4.x 返回三元组 (dict, PrettyMIDI, list),不用 getattr 裸三元组兼容
|
|
137
|
+
midi_data = output[1]
|
|
138
|
+
|
|
139
|
+
out = (
|
|
140
|
+
Path(output_path).expanduser().resolve()
|
|
141
|
+
if output_path
|
|
142
|
+
else src.with_name(f"{src.stem}_basicpitch.mid")
|
|
143
|
+
)
|
|
144
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
midi_data.write(str(out))
|
|
146
|
+
return str(out)
|
|
147
|
+
|
|
148
|
+
# AIGenerator 接口适配:basic-pitch 是转谱不是生成,映射为「输入音频 → 输出 MIDI」
|
|
149
|
+
def generate(self, source_wav: str, prompt: str = "", **kw) -> str:
|
|
150
|
+
"""AIGenerator 接口兼容:prompt 忽略,行为同 :meth:`transcribe`。"""
|
|
151
|
+
return self.transcribe(source_wav, **kw)
|