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.
Files changed (113) hide show
  1. sunoauxtool/__init__.py +7 -0
  2. sunoauxtool/__main__.py +6 -0
  3. sunoauxtool/aggregate.py +247 -0
  4. sunoauxtool/ai/__init__.py +32 -0
  5. sunoauxtool/ai/audiosr.py +174 -0
  6. sunoauxtool/ai/base.py +28 -0
  7. sunoauxtool/ai/basicpitch.py +151 -0
  8. sunoauxtool/ai/diffrhythm.py +357 -0
  9. sunoauxtool/ai/musicgen.py +243 -0
  10. sunoauxtool/analysis/__init__.py +50 -0
  11. sunoauxtool/analysis/chords.py +148 -0
  12. sunoauxtool/analysis/key.py +148 -0
  13. sunoauxtool/analysis/spectral.py +59 -0
  14. sunoauxtool/analysis/structure.py +160 -0
  15. sunoauxtool/analysis/tempo.py +299 -0
  16. sunoauxtool/analysis/transcribe.py +358 -0
  17. sunoauxtool/batch.py +523 -0
  18. sunoauxtool/cli.py +1648 -0
  19. sunoauxtool/commands/__init__.py +1 -0
  20. sunoauxtool/commands/helpers.py +305 -0
  21. sunoauxtool/config.py +653 -0
  22. sunoauxtool/download/__init__.py +75 -0
  23. sunoauxtool/download/__main__.py +8 -0
  24. sunoauxtool/download/cli.py +195 -0
  25. sunoauxtool/download/convert/__init__.py +19 -0
  26. sunoauxtool/download/convert/audio.py +110 -0
  27. sunoauxtool/download/convert/engine.py +96 -0
  28. sunoauxtool/download/convert/profiles.py +51 -0
  29. sunoauxtool/download/convert/video_extract.py +135 -0
  30. sunoauxtool/download/exceptions.py +68 -0
  31. sunoauxtool/download/fmp4.py +103 -0
  32. sunoauxtool/download/forensics.py +220 -0
  33. sunoauxtool/download/sources/__init__.py +21 -0
  34. sunoauxtool/download/sources/api.py +144 -0
  35. sunoauxtool/download/sources/base.py +67 -0
  36. sunoauxtool/download/sources/catcatch.py +82 -0
  37. sunoauxtool/download/transcoder.py +255 -0
  38. sunoauxtool/dsp/__init__.py +10 -0
  39. sunoauxtool/dsp/filters.py +253 -0
  40. sunoauxtool/dsp/loudness.py +138 -0
  41. sunoauxtool/dsp/ops.py +450 -0
  42. sunoauxtool/dsp/processor.py +103 -0
  43. sunoauxtool/dsp/reverb.py +111 -0
  44. sunoauxtool/env.py +392 -0
  45. sunoauxtool/exceptions.py +160 -0
  46. sunoauxtool/export/__init__.py +13 -0
  47. sunoauxtool/export/audio.py +143 -0
  48. sunoauxtool/export/suno.py +205 -0
  49. sunoauxtool/generators/__init__.py +25 -0
  50. sunoauxtool/generators/base.py +121 -0
  51. sunoauxtool/generators/music21_melody.py +233 -0
  52. sunoauxtool/generators/procedural.py +532 -0
  53. sunoauxtool/inspire.py +255 -0
  54. sunoauxtool/logging_setup.py +57 -0
  55. sunoauxtool/models/__init__.py +7 -0
  56. sunoauxtool/models/chords.py +143 -0
  57. sunoauxtool/models/midi.py +144 -0
  58. sunoauxtool/models/notes.py +116 -0
  59. sunoauxtool/music_theory/__init__.py +19 -0
  60. sunoauxtool/music_theory/counterpoint.py +99 -0
  61. sunoauxtool/music_theory/inversion.py +106 -0
  62. sunoauxtool/music_theory/postprocess.py +40 -0
  63. sunoauxtool/music_theory/rhythm_patterns.py +104 -0
  64. sunoauxtool/music_theory/voice_leading.py +132 -0
  65. sunoauxtool/output_manager.py +172 -0
  66. sunoauxtool/pipeline.py +355 -0
  67. sunoauxtool/platform_paths.py +56 -0
  68. sunoauxtool/plugins.py +113 -0
  69. sunoauxtool/preview.py +826 -0
  70. sunoauxtool/render/__init__.py +19 -0
  71. sunoauxtool/render/fluidsynth.py +270 -0
  72. sunoauxtool/score/__init__.py +98 -0
  73. sunoauxtool/score/drawing.py +607 -0
  74. sunoauxtool/score/jianpu.py +852 -0
  75. sunoauxtool/score/layout.py +1108 -0
  76. sunoauxtool/score/model.py +645 -0
  77. sunoauxtool/score/musicxml.py +621 -0
  78. sunoauxtool/score/png.py +70 -0
  79. sunoauxtool/score/svg.py +768 -0
  80. sunoauxtool/score/theory.py +394 -0
  81. sunoauxtool/score_export.py +368 -0
  82. sunoauxtool/styles/__init__.py +5 -0
  83. sunoauxtool/styles/registry.py +143 -0
  84. sunoauxtool/sunopack.py +160 -0
  85. sunoauxtool/video/__init__.py +18 -0
  86. sunoauxtool/video/__main__.py +6 -0
  87. sunoauxtool/video/analysis/__init__.py +22 -0
  88. sunoauxtool/video/analysis/audio_analysis.py +338 -0
  89. sunoauxtool/video/audio_io.py +182 -0
  90. sunoauxtool/video/cli.py +231 -0
  91. sunoauxtool/video/compositor.py +163 -0
  92. sunoauxtool/video/config.py +256 -0
  93. sunoauxtool/video/engines/__init__.py +20 -0
  94. sunoauxtool/video/engines/ffmpeg_engine.py +283 -0
  95. sunoauxtool/video/engines/frame_engine.py +292 -0
  96. sunoauxtool/video/exceptions.py +55 -0
  97. sunoauxtool/video/mixer.py +218 -0
  98. sunoauxtool/video/output_manager.py +149 -0
  99. sunoauxtool/video/presets/__init__.py +72 -0
  100. sunoauxtool/video/text.py +56 -0
  101. sunoauxtool/video/videomaker.py +361 -0
  102. sunoauxtool/video/visuals/__init__.py +87 -0
  103. sunoauxtool/video/visuals/bars.py +106 -0
  104. sunoauxtool/video/visuals/base.py +57 -0
  105. sunoauxtool/video/visuals/reactive.py +80 -0
  106. sunoauxtool/video/visuals/score.py +458 -0
  107. sunoauxtool/video/visuals/spectrum.py +74 -0
  108. sunoauxtool/video/visuals/tracks.py +190 -0
  109. sunoauxtool-1.6.0.dist-info/METADATA +320 -0
  110. sunoauxtool-1.6.0.dist-info/RECORD +113 -0
  111. sunoauxtool-1.6.0.dist-info/WHEEL +5 -0
  112. sunoauxtool-1.6.0.dist-info/entry_points.txt +4 -0
  113. sunoauxtool-1.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """SmartNoteGen - 本地 AI 音乐生成 CLI。
2
+
3
+ 定位:程序化生成多轨 MIDI → 渲染 WAV → Suno 合规导出(10–30s 纯器乐),
4
+ 供用户上传 Suno Pro 合成成品。
5
+ """
6
+
7
+ __version__ = "1.6.0"
@@ -0,0 +1,6 @@
1
+ """python -m sunoauxtool 入口。"""
2
+
3
+ from sunoauxtool.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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)