ApolloTab 0.2.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.
- ApolloTab/__init__.py +79 -0
- ApolloTab/player.py +1113 -0
- ApolloTab/py.typed +2 -0
- apollotab-0.2.0.dist-info/METADATA +934 -0
- apollotab-0.2.0.dist-info/RECORD +8 -0
- apollotab-0.2.0.dist-info/WHEEL +5 -0
- apollotab-0.2.0.dist-info/licenses/LICENSE +373 -0
- apollotab-0.2.0.dist-info/top_level.txt +1 -0
ApolloTab/player.py
ADDED
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
============================================================
|
|
4
|
+
文件名: player.py
|
|
5
|
+
功能描述: GTP播放器高级封装类 - 整合解析/渲染/音频/时间线的完整流程
|
|
6
|
+
|
|
7
|
+
原理:
|
|
8
|
+
将 GTP 文件的完整生命周期(加载→解析→渲染→音频初始化→播放控制→时间线)
|
|
9
|
+
封装为单一的高层 API 类 GTPPlayer,
|
|
10
|
+
使主程序只需调用简单方法即可实现完整的 GTP 播放功能。
|
|
11
|
+
|
|
12
|
+
核心职责:
|
|
13
|
+
1. 文件加载与解析 (parse_gtp)
|
|
14
|
+
2. 音轨渲染 (TabRenderer.render_from_file)
|
|
15
|
+
3. 音频引擎管理 (SynthEngine + MidiConverter)
|
|
16
|
+
4. 播放光标时间线构建 (time_ms ↔ scroll_y 映射)
|
|
17
|
+
5. 时间↔位置双向转换 (二分查找+线性插值)
|
|
18
|
+
|
|
19
|
+
设计原则:
|
|
20
|
+
- 高内聚低耦合: 所有GTP相关逻辑集中在此类中
|
|
21
|
+
- 最小化依赖: 仅依赖 gtp_engine 内部模块和 PyQt5
|
|
22
|
+
- 线程安全: 音频操作在独立线程中执行
|
|
23
|
+
- 优雅降级: 缺少依赖时提供有意义的错误信息
|
|
24
|
+
|
|
25
|
+
使用示例:
|
|
26
|
+
from gtp_engine.player import GTPPlayer
|
|
27
|
+
|
|
28
|
+
# 创建播放器实例
|
|
29
|
+
player = GTPPlayer()
|
|
30
|
+
|
|
31
|
+
# 加载并渲染
|
|
32
|
+
player.load("song.gp5")
|
|
33
|
+
images = player.render_track(0) # 渲染第1轨
|
|
34
|
+
|
|
35
|
+
# 初始化音频(可选)
|
|
36
|
+
if player.init_audio():
|
|
37
|
+
player.play()
|
|
38
|
+
|
|
39
|
+
# 获取时间线数据(用于播放光标)
|
|
40
|
+
timeline = player.build_timeline(page_layouts, images, display_width)
|
|
41
|
+
|
|
42
|
+
# 时间↔位置转换
|
|
43
|
+
scroll_y = player.time_to_scroll_pos(5000) # 5000ms → 像素位置
|
|
44
|
+
time_ms = player.scroll_pos_to_time(scroll_y) # 像素位置 → ms
|
|
45
|
+
|
|
46
|
+
# 清理资源
|
|
47
|
+
player.shutdown()
|
|
48
|
+
|
|
49
|
+
依赖库:
|
|
50
|
+
- gtp_engine.parser (parse_gtp, GTPParser)
|
|
51
|
+
- gtp_engine.renderer (TabRenderer, RenderConfig)
|
|
52
|
+
- gtp_engine.audio (MidiConverter, SynthEngine)
|
|
53
|
+
- gtp_engine.models (GTPSong, GTPTrack)
|
|
54
|
+
- PyQt5 (QPixmap, 用于图像渲染)
|
|
55
|
+
|
|
56
|
+
创建日期: 2026-06-12
|
|
57
|
+
最后更新: 2026-06-12 (v0.2.0 - Phase 4 库化重构)
|
|
58
|
+
============================================================
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
import bisect
|
|
62
|
+
from typing import List, Optional, Tuple, Dict, Callable
|
|
63
|
+
|
|
64
|
+
from PyQt5.QtGui import QPixmap, QPainter, QColor, QFont
|
|
65
|
+
|
|
66
|
+
# 内部模块导入
|
|
67
|
+
from .parser import parse_gtp, GTPParser
|
|
68
|
+
from .renderer import TabRenderer
|
|
69
|
+
from .utils import RenderConfig
|
|
70
|
+
from .audio import MidiConverter, MidiEvent, SynthEngine
|
|
71
|
+
from .models import GTPSong, GTPTrack
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GTPPlayer:
|
|
75
|
+
"""
|
|
76
|
+
GTP文件播放器 - 高级封装类
|
|
77
|
+
|
|
78
|
+
功能概述:
|
|
79
|
+
提供 Guitar Pro 文件的完整播放解决方案,包括:
|
|
80
|
+
- 文件解析与元数据提取
|
|
81
|
+
- 六线谱渲染为 QPixmap 图像
|
|
82
|
+
- FluidSynth 音频合成与播放
|
|
83
|
+
- 播放光标时间线构建
|
|
84
|
+
- 时间↔位置双向映射
|
|
85
|
+
|
|
86
|
+
设计模式:
|
|
87
|
+
- 门面模式(Facade): 将多个子系统的复杂操作封装为简单接口
|
|
88
|
+
- 状态模式(State): 管理加载/就绪/播放/暂停等状态转换
|
|
89
|
+
|
|
90
|
+
参数说明(初始化):
|
|
91
|
+
gain: 主音量(0.0-1.0), 调整效果: 1.0=最大音量, 0.7=推荐默认值
|
|
92
|
+
sample_rate: 采样率(Hz), 调整效果: 44100=CD音质, 48000=高清
|
|
93
|
+
buffer_size: 缓冲区大小, 调整效果: 256=低延迟, 512=稳定
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# ===== 音频模式常量 =====
|
|
97
|
+
MODE_ALL = "all" # 全轨并轨模式
|
|
98
|
+
MODE_CURRENT = "current" # 仅当前轨模式
|
|
99
|
+
MODE_OFF = "off" # 关闭音频模式
|
|
100
|
+
|
|
101
|
+
def __init__(self, gain: float = 0.7, sample_rate: int = 44100, buffer_size: int = 512):
|
|
102
|
+
"""
|
|
103
|
+
初始化 GTP 播放器
|
|
104
|
+
|
|
105
|
+
参数:
|
|
106
|
+
gain: 主音量增益(0.0-1.0), 调整效果: 0.7=适中音量
|
|
107
|
+
sample_rate: 音频采样率(Hz), 调整效果: 44100=CD标准
|
|
108
|
+
buffer_size: 音频缓冲区大小, 调整效果: 512=平衡延迟与稳定性
|
|
109
|
+
"""
|
|
110
|
+
# ===== 核心组件 =====
|
|
111
|
+
self._parser = GTPParser() # 解析器
|
|
112
|
+
self._renderer = TabRenderer() # 渲染器
|
|
113
|
+
self._midi_converter = MidiConverter() # MIDI转换器
|
|
114
|
+
self._synth_engine: Optional[SynthEngine] = None # 音频合成器(延迟初始化)
|
|
115
|
+
|
|
116
|
+
# ===== 数据状态 =====
|
|
117
|
+
self._song: Optional[GTPSong] = None # 当前加载的歌曲对象
|
|
118
|
+
self._file_path: str = "" # 当前文件路径
|
|
119
|
+
self._current_track: int = 0 # 当前选中的音轨索引
|
|
120
|
+
|
|
121
|
+
# ===== 音频状态 =====
|
|
122
|
+
self._audio_mode: str = self.MODE_ALL # 当前音频模式
|
|
123
|
+
self._audio_enabled: bool = True # 是否启用音频
|
|
124
|
+
self._audio_events: List[MidiEvent] = [] # 当前MIDI事件列表
|
|
125
|
+
self._track_channels: List[int] = [] # 通道映射(全轨模式)
|
|
126
|
+
|
|
127
|
+
# ===== 时间线数据 =====
|
|
128
|
+
self._playhead_timeline: List[dict] = [] # 播放光标时间线索引
|
|
129
|
+
self._timeline_times: List[float] = [] # 预提取的时间排序列表(性能优化)
|
|
130
|
+
self._timeline_scroll_ys: List[float] = [] # 预提取的scroll_y排序列表
|
|
131
|
+
self._total_audio_duration_ms: float = 0.0 # 总音频时长(ms)
|
|
132
|
+
|
|
133
|
+
# ===== 音频参数 =====
|
|
134
|
+
self._gain = gain
|
|
135
|
+
self._sample_rate = sample_rate
|
|
136
|
+
self._buffer_size = buffer_size
|
|
137
|
+
|
|
138
|
+
# ===== 回调函数 =====
|
|
139
|
+
self._note_callback: Optional[Callable] = None # 音符触发回调
|
|
140
|
+
|
|
141
|
+
# ================================================================
|
|
142
|
+
# 属性访问器
|
|
143
|
+
# ================================================================
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def song(self) -> Optional[GTPSong]:
|
|
147
|
+
"""当前加载的GTP歌曲对象"""
|
|
148
|
+
return self._song
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def file_path(self) -> str:
|
|
152
|
+
"""当前GTP文件路径"""
|
|
153
|
+
return self._file_path
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def current_track(self) -> int:
|
|
157
|
+
"""当前选中的音轨索引"""
|
|
158
|
+
return self._current_track
|
|
159
|
+
|
|
160
|
+
@current_track.setter
|
|
161
|
+
def current_track(self, value: int) -> None:
|
|
162
|
+
"""设置当前音轨索引"""
|
|
163
|
+
self._current_track = value
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def audio_mode(self) -> str:
|
|
167
|
+
"""当前音频模式 (all/current/off)"""
|
|
168
|
+
return self._audio_mode
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def is_audio_ready(self) -> bool:
|
|
172
|
+
"""音频引擎是否已初始化且可用"""
|
|
173
|
+
return self._synth_engine is not None and self._synth_engine.is_initialized
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def is_playing(self) -> bool:
|
|
177
|
+
"""是否正在播放"""
|
|
178
|
+
return self._synth_engine is not None and self._synth_engine.is_playing
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def is_paused(self) -> bool:
|
|
182
|
+
"""是否已暂停"""
|
|
183
|
+
return self._synth_engine is not None and self._synth_engine.is_paused
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def current_time_ms(self) -> float:
|
|
187
|
+
"""当前音频播放时间(毫秒)"""
|
|
188
|
+
if self._synth_engine:
|
|
189
|
+
return self._synth_engine.current_time_ms
|
|
190
|
+
return 0.0
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def total_duration_ms(self) -> float:
|
|
194
|
+
"""总音频时长(毫秒)"""
|
|
195
|
+
return self._total_audio_duration_ms
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def playhead_timeline(self) -> List[dict]:
|
|
199
|
+
"""播放光标时间线数据"""
|
|
200
|
+
return self._playhead_timeline
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def track_count(self) -> int:
|
|
204
|
+
"""当前文件的音轨数量"""
|
|
205
|
+
if self._song:
|
|
206
|
+
return len(self._song.tracks)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def tracks(self) -> List[GTPTrack]:
|
|
211
|
+
"""获取所有音轨列表"""
|
|
212
|
+
if self._song:
|
|
213
|
+
return self._song.tracks
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
# ================================================================
|
|
217
|
+
# 文件加载与解析
|
|
218
|
+
# ================================================================
|
|
219
|
+
|
|
220
|
+
def load(self, file_path: str) -> GTPSong:
|
|
221
|
+
"""
|
|
222
|
+
加载并解析 Guitar Pro 文件
|
|
223
|
+
|
|
224
|
+
参数:
|
|
225
|
+
file_path: .gp3/.gp4/.gp5/.gpx 文件路径
|
|
226
|
+
|
|
227
|
+
返回:
|
|
228
|
+
GTPSong 歌曲对象
|
|
229
|
+
|
|
230
|
+
异常:
|
|
231
|
+
GPException: 文件格式错误或无法解析时抛出
|
|
232
|
+
ImportError: 缺少 pyguitarpro 依赖时抛出
|
|
233
|
+
FileNotFoundError: 文件不存在时抛出
|
|
234
|
+
|
|
235
|
+
示例:
|
|
236
|
+
>>> player = GTPPlayer()
|
|
237
|
+
>>> song = player.load("my_song.gp5")
|
|
238
|
+
>>> print(f"标题: {song.title}, 音轨数: {player.track_count}")
|
|
239
|
+
"""
|
|
240
|
+
self._file_path = file_path
|
|
241
|
+
self._song = parse_gtp(file_path)
|
|
242
|
+
self._current_track = 0
|
|
243
|
+
return self._song
|
|
244
|
+
|
|
245
|
+
def get_track_info(self, track_index: int = None) -> Dict:
|
|
246
|
+
"""
|
|
247
|
+
获取指定音轨的详细信息
|
|
248
|
+
|
|
249
|
+
参数:
|
|
250
|
+
track_index: 音轨索引,None则使用当前音轨
|
|
251
|
+
|
|
252
|
+
返回:
|
|
253
|
+
包含音轨信息的字典:
|
|
254
|
+
{
|
|
255
|
+
'index': 音轨索引,
|
|
256
|
+
'name': 音轨名称,
|
|
257
|
+
'tuning': 调弦元组(MIDI音高),
|
|
258
|
+
'tuning_name': 调弦名称,
|
|
259
|
+
'fret_count': 品格数,
|
|
260
|
+
'measure_count': 小节数,
|
|
261
|
+
'instrument': MIDI乐器编号,
|
|
262
|
+
'is_visible': 是否可见,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
异常:
|
|
266
|
+
IndexError: 音轨索引超出范围时抛出
|
|
267
|
+
"""
|
|
268
|
+
if not self._song:
|
|
269
|
+
raise ValueError("尚未加载任何文件,请先调用 load()")
|
|
270
|
+
|
|
271
|
+
idx = track_index if track_index is not None else self._current_track
|
|
272
|
+
if idx >= len(self._song.tracks):
|
|
273
|
+
raise IndexError(f"音轨索引{idx}超出范围(0-{len(self._song.tracks)-1})")
|
|
274
|
+
|
|
275
|
+
track = self._song.tracks[idx]
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
'index': idx,
|
|
279
|
+
'name': track.name or f"音轨{idx + 1}",
|
|
280
|
+
'tuning': track.strings,
|
|
281
|
+
'tuning_name': track.get_tuning_name(),
|
|
282
|
+
'fret_count': track.fret_count,
|
|
283
|
+
'measure_count': len(track.measures),
|
|
284
|
+
'instrument': track.instrument,
|
|
285
|
+
'is_visible': track.is_visible,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def get_all_tracks_info(self) -> List[Dict]:
|
|
289
|
+
"""
|
|
290
|
+
获取所有音轨的信息列表
|
|
291
|
+
|
|
292
|
+
返回:
|
|
293
|
+
字典列表,每个元素包含一个音轨的详细信息
|
|
294
|
+
(格式同 get_track_info 的返回值)
|
|
295
|
+
"""
|
|
296
|
+
if not self._song:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
return [self.get_track_info(i) for i in range(len(self._song.tracks))]
|
|
300
|
+
|
|
301
|
+
# ================================================================
|
|
302
|
+
# 渲染功能
|
|
303
|
+
# ================================================================
|
|
304
|
+
|
|
305
|
+
def render_track(self, track_index: int = None,
|
|
306
|
+
config: RenderConfig = None) -> List[QPixmap]:
|
|
307
|
+
"""
|
|
308
|
+
渲染指定音轨的六线谱图像
|
|
309
|
+
|
|
310
|
+
参数:
|
|
311
|
+
track_index: 要渲染的音轨索引,None则使用当前音轨
|
|
312
|
+
config: 自定义渲染配置,None则使用默认配置
|
|
313
|
+
|
|
314
|
+
返回:
|
|
315
|
+
QPixmap列表,每元素对应一页乐谱图像
|
|
316
|
+
|
|
317
|
+
注意:
|
|
318
|
+
同时会更新 last_layouts 属性,可用于播放光标等功能。
|
|
319
|
+
如果尚未加载文件,会自动调用 load()。
|
|
320
|
+
|
|
321
|
+
示例:
|
|
322
|
+
>>> pages = player.render_track(0)
|
|
323
|
+
>>> print(f"共{len(pages)}页")
|
|
324
|
+
>>> pages[0].save("page1.png", "PNG")
|
|
325
|
+
"""
|
|
326
|
+
if not self._file_path:
|
|
327
|
+
raise ValueError("尚未加载文件,请先调用 load()")
|
|
328
|
+
|
|
329
|
+
idx = track_index if track_index is not None else self._current_track
|
|
330
|
+
|
|
331
|
+
if config:
|
|
332
|
+
self._renderer = TabRenderer(config)
|
|
333
|
+
|
|
334
|
+
pixmaps = self._renderer.render_from_file(self._file_path, track_index=idx)
|
|
335
|
+
|
|
336
|
+
# 更新当前音轨索引
|
|
337
|
+
self._current_track = idx
|
|
338
|
+
|
|
339
|
+
return pixmaps
|
|
340
|
+
|
|
341
|
+
def render_from_song(self, song: GTPSong = None,
|
|
342
|
+
track_index: int = None,
|
|
343
|
+
config: RenderConfig = None) -> List[QPixmap]:
|
|
344
|
+
"""
|
|
345
|
+
从已有的 GTPSong 对象渲染(不重新解析文件)
|
|
346
|
+
|
|
347
|
+
参数:
|
|
348
|
+
song: GTPSong 对象,None则使用已加载的song
|
|
349
|
+
track_index: 音轨索引
|
|
350
|
+
config: 渲染配置
|
|
351
|
+
|
|
352
|
+
返回:
|
|
353
|
+
QPixmap列表
|
|
354
|
+
"""
|
|
355
|
+
target_song = song or self._song
|
|
356
|
+
if not target_song:
|
|
357
|
+
raise ValueError("没有可用的歌曲数据")
|
|
358
|
+
|
|
359
|
+
idx = track_index if track_index is not None else self._current_track
|
|
360
|
+
|
|
361
|
+
if config:
|
|
362
|
+
renderer = TabRenderer(config)
|
|
363
|
+
return renderer.render(target_song, track_index=idx)
|
|
364
|
+
else:
|
|
365
|
+
return self._renderer.render(target_song, track_index=idx)
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def last_layouts(self) -> list:
|
|
369
|
+
"""
|
|
370
|
+
获取上次渲染的布局数据
|
|
371
|
+
|
|
372
|
+
返回:
|
|
373
|
+
List[PageLayout], 由 TabRenderer.render() 生成,
|
|
374
|
+
包含每页/行/小节/拍的精确坐标信息
|
|
375
|
+
"""
|
|
376
|
+
return getattr(self._renderer, 'last_layouts', [])
|
|
377
|
+
|
|
378
|
+
# ================================================================
|
|
379
|
+
# 音频引擎管理
|
|
380
|
+
# ================================================================
|
|
381
|
+
|
|
382
|
+
def init_audio(self, note_callback: Callable = None) -> bool:
|
|
383
|
+
"""
|
|
384
|
+
初始化音频播放引擎
|
|
385
|
+
|
|
386
|
+
功能:
|
|
387
|
+
1. 创建 SynthEngine 实例(FluidSynth 合成器)
|
|
388
|
+
2. 初始化音频输出驱动
|
|
389
|
+
3. 自动搜索并加载 SoundFont 音色文件
|
|
390
|
+
4. 设置音符回调(用于视觉高亮同步)
|
|
391
|
+
5. 根据 audio_mode 转换并加载 MIDI 事件
|
|
392
|
+
|
|
393
|
+
参数:
|
|
394
|
+
note_callback: 音符触发回调函数,签名为 (midi_pitch, velocity, time_ms) → None
|
|
395
|
+
用于在UI中高亮当前发声的音符
|
|
396
|
+
|
|
397
|
+
返回:
|
|
398
|
+
True 表示初始化成功,False 表示失败(缺少依赖或SoundFont)
|
|
399
|
+
|
|
400
|
+
注意:
|
|
401
|
+
此方法应在 load() 之后调用。
|
|
402
|
+
失败时不会抛出异常,而是返回 False 并打印警告信息。
|
|
403
|
+
|
|
404
|
+
错误处理:
|
|
405
|
+
- ImportError(pyfluidsynth未安装): 返回False,提示安装
|
|
406
|
+
- SoundFont未找到: 返回False,提示放置sf2文件
|
|
407
|
+
- 初始化失败: 返回False,打印详细错误
|
|
408
|
+
"""
|
|
409
|
+
if not self._song:
|
|
410
|
+
print("[GTPPlayer] 错误: 尚未加载文件,请先调用 load()")
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
# === Step 1: 创建 FluidSynth 合成器 ===
|
|
415
|
+
self._synth_engine = SynthEngine(
|
|
416
|
+
gain=self._gain,
|
|
417
|
+
sample_rate=self._sample_rate,
|
|
418
|
+
buffer_size=self._buffer_size
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# === Step 2: 初始化音频驱动 ===
|
|
422
|
+
if not self._synth_engine.initialize():
|
|
423
|
+
print("[GTPPlayer] 错误: FluidSynth 初始化失败")
|
|
424
|
+
print(" 可能原因: fluidsynth 库未正确安装或音频设备不可用")
|
|
425
|
+
print(" 解决方案:")
|
|
426
|
+
print(" Windows: 下载 libfluidsynth-3.dll 放到项目目录")
|
|
427
|
+
print(" Linux: sudo apt-get install fluidsynth")
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
# === Step 3: 加载 SoundFont ===
|
|
431
|
+
sf_path = self._synth_engine.load_soundfont()
|
|
432
|
+
if not sf_path:
|
|
433
|
+
print("[GTPPlayer] 警告: 未找到 SoundFont 文件")
|
|
434
|
+
print(" 将使用默认音色(可能效果不佳)")
|
|
435
|
+
print(" 建议: 下载 FluidR3_GM.sf2 放到 ./soundfont/ 目录")
|
|
436
|
+
else:
|
|
437
|
+
print(f"[GTPPlayer] ✓ 已加载 SoundFont: {sf_path}")
|
|
438
|
+
|
|
439
|
+
# === Step 4: 设置音符回调 ===
|
|
440
|
+
self._note_callback = note_callback
|
|
441
|
+
if note_callback:
|
|
442
|
+
self._synth_engine.set_note_callback(note_callback)
|
|
443
|
+
|
|
444
|
+
# === Step 5: 转换并加载 MIDI 事件 ===
|
|
445
|
+
self.rebuild_audio_events()
|
|
446
|
+
|
|
447
|
+
# 打印就绪信息
|
|
448
|
+
mode_label = "全轨并轨" if self._audio_mode == self.MODE_ALL else f"仅当前轨"
|
|
449
|
+
duration_ms = self.get_current_duration_ms()
|
|
450
|
+
print(f"[GTPPlayer] ✓ 引擎就绪[{mode_label}]: "
|
|
451
|
+
f"{len(self._audio_events)}个MIDI事件, "
|
|
452
|
+
f"{len(self._track_channels)}个通道, "
|
|
453
|
+
f"BPM={self._song.tempo}, "
|
|
454
|
+
f"时长={duration_ms / 1000:.1f}秒")
|
|
455
|
+
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
except ImportError as e:
|
|
459
|
+
print(f"[GTPPlayer] 错误: 依赖库缺失 - {e}")
|
|
460
|
+
print(" 请安装: pip install pyfluidsynth")
|
|
461
|
+
return False
|
|
462
|
+
except Exception as e:
|
|
463
|
+
print(f"[GTPPlayer] 错误: 音频初始化失败 - {e}")
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
def get_current_duration_ms(self) -> float:
|
|
467
|
+
"""
|
|
468
|
+
获取当前模式的音频总时长(ms)
|
|
469
|
+
|
|
470
|
+
返回:
|
|
471
|
+
全轨模式: 所有轨道的最长时长
|
|
472
|
+
单轨模式: 当前轨道的时长
|
|
473
|
+
"""
|
|
474
|
+
if not self._song or not self._midi_converter:
|
|
475
|
+
return 0.0
|
|
476
|
+
|
|
477
|
+
if self._audio_mode == self.MODE_ALL:
|
|
478
|
+
return self._midi_converter.get_all_tracks_duration_ms(self._song)
|
|
479
|
+
else:
|
|
480
|
+
return self._midi_converter.get_total_duration_ms(
|
|
481
|
+
self._song, self._current_track
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def set_audio_mode(self, mode: str) -> None:
|
|
485
|
+
"""
|
|
486
|
+
切换音频播放模式
|
|
487
|
+
|
|
488
|
+
参数:
|
|
489
|
+
mode: 目标模式
|
|
490
|
+
- "all"(MODE_ALL): 全轨并轨 - 所有音轨同时播放(默认)
|
|
491
|
+
- "current"(MODE_CURRENT): 仅当前轨 - 只播放当前选中音轨
|
|
492
|
+
- "off"(MODE_OFF): 关闭音频 - 仅滚动播放,不输出声音
|
|
493
|
+
|
|
494
|
+
原理:
|
|
495
|
+
切换模式时会自动停止当前播放、重建MIDI事件序列、重新加载到合成器。
|
|
496
|
+
如果引擎未初始化,仅更新模式标记(待后续 init_audio() 时生效)。
|
|
497
|
+
"""
|
|
498
|
+
if self._audio_mode == mode:
|
|
499
|
+
return # 模式未变,跳过
|
|
500
|
+
|
|
501
|
+
old_mode = self._audio_mode
|
|
502
|
+
self._audio_mode = mode
|
|
503
|
+
|
|
504
|
+
# 更新启用状态
|
|
505
|
+
self._audio_enabled = (mode != self.MODE_OFF)
|
|
506
|
+
|
|
507
|
+
# 如果是关闭模式,立即停止音频
|
|
508
|
+
if mode == self.MODE_OFF and self._synth_engine:
|
|
509
|
+
self._synth_engine.stop()
|
|
510
|
+
|
|
511
|
+
# 如果引擎已初始化,根据新模式重建事件
|
|
512
|
+
if self._synth_engine and mode != self.MODE_OFF:
|
|
513
|
+
self.rebuild_audio_events()
|
|
514
|
+
|
|
515
|
+
print(f"[GTPPlayer] 音频模式: {old_mode} → {mode}")
|
|
516
|
+
|
|
517
|
+
def rebuild_audio_events(self) -> None:
|
|
518
|
+
"""
|
|
519
|
+
重新构建 MIDI 事件序列(切换音轨/切换音频模式时调用)
|
|
520
|
+
|
|
521
|
+
功能:
|
|
522
|
+
根据 audio_mode 决定转换范围:
|
|
523
|
+
- 全轨模式: 转换所有音轨,每轨独立 MIDI 通道
|
|
524
|
+
- 单轨模式: 仅转换当前选中音轨
|
|
525
|
+
|
|
526
|
+
流程:
|
|
527
|
+
1. 停止当前播放(如有)
|
|
528
|
+
2. 根据模式选择转换方式
|
|
529
|
+
3. 为各通道设置乐器音色(吉他/鼓组)
|
|
530
|
+
4. 重新加载事件到合成器
|
|
531
|
+
"""
|
|
532
|
+
if not self._song or not self._midi_converter or not self._synth_engine:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
# 先停止正在播放的音频
|
|
536
|
+
self._synth_engine.stop()
|
|
537
|
+
|
|
538
|
+
# === 根据模式选择转换方式 ===
|
|
539
|
+
if self._audio_mode == self.MODE_ALL:
|
|
540
|
+
# 全轨并轨: 转换所有音轨
|
|
541
|
+
self._audio_events, self._track_channels = (
|
|
542
|
+
self._midi_converter.convert_all_tracks(self._song)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# 为每个通道设置合适的乐器音色
|
|
546
|
+
if self._track_channels:
|
|
547
|
+
for ch in set(self._track_channels):
|
|
548
|
+
if ch == 9:
|
|
549
|
+
# 通道9是MIDI打击乐保留通道,设置为鼓组
|
|
550
|
+
try:
|
|
551
|
+
self._synth_engine.set_drum_kit(ch, kit=0)
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
else:
|
|
555
|
+
# 其他通道设置为电吉他音色
|
|
556
|
+
try:
|
|
557
|
+
# 27 = Clean Electric Guitar (MIDI程序号)
|
|
558
|
+
self._synth_engine.set_instrument(ch, 27)
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
else:
|
|
562
|
+
# 仅当前轨: 只转换当前选中的音轨
|
|
563
|
+
self._track_channels = []
|
|
564
|
+
self._audio_events = self._midi_converter.convert(
|
|
565
|
+
self._song, track_index=self._current_track
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# 重新加载到合成器
|
|
569
|
+
if self._audio_events:
|
|
570
|
+
self._synth_engine.load_events(
|
|
571
|
+
self._audio_events,
|
|
572
|
+
bpm=self._song.tempo,
|
|
573
|
+
ticks_per_beat=480 # MIDI标准分辨率
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# 打印日志
|
|
577
|
+
mode_label = ("全轨并轨" if self._audio_mode == self.MODE_ALL
|
|
578
|
+
else f"仅当前轨(#{self._current_track + 1})")
|
|
579
|
+
print(f"[GTPPlayer] 事件重建[{mode_label}]: "
|
|
580
|
+
f"{len(self._audio_events)}个事件, "
|
|
581
|
+
f"{len(self._track_channels)}个通道")
|
|
582
|
+
|
|
583
|
+
# ================================================================
|
|
584
|
+
# 播放控制
|
|
585
|
+
# ================================================================
|
|
586
|
+
|
|
587
|
+
def play(self) -> None:
|
|
588
|
+
"""
|
|
589
|
+
开始播放音频
|
|
590
|
+
|
|
591
|
+
注意: 需要先调用 init_audio() 初始化引擎
|
|
592
|
+
"""
|
|
593
|
+
if self._synth_engine and self._audio_enabled:
|
|
594
|
+
self._synth_engine.play()
|
|
595
|
+
|
|
596
|
+
def pause(self) -> None:
|
|
597
|
+
"""
|
|
598
|
+
暂停播放(保持当前位置,可恢复)
|
|
599
|
+
"""
|
|
600
|
+
if self._synth_engine:
|
|
601
|
+
self._synth_engine.pause()
|
|
602
|
+
|
|
603
|
+
def resume(self) -> None:
|
|
604
|
+
"""
|
|
605
|
+
恢复播放(从暂停位置继续)
|
|
606
|
+
"""
|
|
607
|
+
if self._synth_engine:
|
|
608
|
+
self._synth_engine.resume()
|
|
609
|
+
|
|
610
|
+
def stop(self) -> None:
|
|
611
|
+
"""
|
|
612
|
+
停止播放(回到开头)
|
|
613
|
+
"""
|
|
614
|
+
if self._synth_engine:
|
|
615
|
+
self._synth_engine.stop()
|
|
616
|
+
|
|
617
|
+
def seek(self, time_ms: float) -> None:
|
|
618
|
+
"""
|
|
619
|
+
跳转到指定时间位置
|
|
620
|
+
|
|
621
|
+
参数:
|
|
622
|
+
time_ms: 目标时间(毫秒),0=开头
|
|
623
|
+
"""
|
|
624
|
+
if self._synth_engine:
|
|
625
|
+
self._synth_engine.seek(time_ms)
|
|
626
|
+
|
|
627
|
+
def shutdown(self) -> None:
|
|
628
|
+
"""
|
|
629
|
+
完全关闭音频引擎并释放所有资源
|
|
630
|
+
|
|
631
|
+
应在程序退出前调用,确保:
|
|
632
|
+
- 停止播放线程
|
|
633
|
+
- 释放音频设备
|
|
634
|
+
- 释放合成器内存
|
|
635
|
+
"""
|
|
636
|
+
if self._synth_engine:
|
|
637
|
+
try:
|
|
638
|
+
self._synth_engine.stop()
|
|
639
|
+
self._synth_engine.shutdown()
|
|
640
|
+
except Exception:
|
|
641
|
+
pass
|
|
642
|
+
finally:
|
|
643
|
+
self._synth_engine = None
|
|
644
|
+
|
|
645
|
+
# ================================================================
|
|
646
|
+
# 播放光标时间线
|
|
647
|
+
# ================================================================
|
|
648
|
+
|
|
649
|
+
def build_timeline(self, page_layouts: list, images: List[QPixmap],
|
|
650
|
+
display_width: int) -> List[dict]:
|
|
651
|
+
"""
|
|
652
|
+
构建播放光标时间线 - 将每个拍映射到其音频时间和屏幕位置
|
|
653
|
+
|
|
654
|
+
原理:
|
|
655
|
+
遍历所有页面的布局数据(PageLayout→SystemLayout→MeasureLayout→BeatLayout),
|
|
656
|
+
结合 GTP 歌曲的 BPM 和时间签名,计算每个拍对应的音频时间位置(ms),
|
|
657
|
+
生成一个按时间排序的时间线索引。
|
|
658
|
+
|
|
659
|
+
核心改进:
|
|
660
|
+
每个拍同时记录 scroll_y(在总内容中的Y偏移),
|
|
661
|
+
使滚动位置可以由音乐时间驱动,而非线性恒速滚动。
|
|
662
|
+
这样在音符密集区(16/32分音符)播放条自动加快,
|
|
663
|
+
在稀疏区(全/二分音符)自动减慢,与实际音乐节奏同步。
|
|
664
|
+
|
|
665
|
+
参数:
|
|
666
|
+
page_layouts: TabRenderer 生成的布局数据(List[PageLayout])
|
|
667
|
+
images: 渲染后的页面图像列表(List[QPixmap])
|
|
668
|
+
display_width: 显示区域宽度(px),用于计算缩放比例
|
|
669
|
+
|
|
670
|
+
返回:
|
|
671
|
+
List[dict], 每个元素包含:
|
|
672
|
+
- time_ms: 该拍的起始音频时间(毫秒)
|
|
673
|
+
- scroll_y: 该拍在总内容中的Y位置(像素)
|
|
674
|
+
- page_idx: 所在页面索引
|
|
675
|
+
- sys_idx: 所在系统(行)索引
|
|
676
|
+
- meas_idx: 所在小节索引
|
|
677
|
+
- beat_idx: 该小节内的拍索引
|
|
678
|
+
- x_center: 该拍的中心X坐标
|
|
679
|
+
- x_start: 该拍的起始X坐标
|
|
680
|
+
- x_end: 该拍的结束X坐标
|
|
681
|
+
- y_top: 系统顶部Y坐标
|
|
682
|
+
- y_bottom: 系统底部Y坐标
|
|
683
|
+
|
|
684
|
+
性能优化:
|
|
685
|
+
- 预提取 _timeline_times 和 _timeline_scroll_ys 排序列表
|
|
686
|
+
- 后续 _update_playhead/time_to_scroll_pos 直接复用,避免每帧O(n)分配
|
|
687
|
+
"""
|
|
688
|
+
self._playhead_timeline = []
|
|
689
|
+
self._timeline_times = []
|
|
690
|
+
self._timeline_scroll_ys = []
|
|
691
|
+
|
|
692
|
+
if not page_layouts or not self._song:
|
|
693
|
+
return self._playhead_timeline
|
|
694
|
+
|
|
695
|
+
# 获取BPM(取第一个tempo标记, 默认120)
|
|
696
|
+
bpm = 120
|
|
697
|
+
if hasattr(self._song, 'tempo_changes') and self._song.tempo_changes:
|
|
698
|
+
bpm = self._song.tempo_changes[0].value
|
|
699
|
+
if bpm <= 0:
|
|
700
|
+
bpm = 120
|
|
701
|
+
elif hasattr(self._song, 'tempo') and self._song.tempo > 0:
|
|
702
|
+
bpm = self._song.tempo
|
|
703
|
+
|
|
704
|
+
# MIDI 时间参数
|
|
705
|
+
ticks_per_beat = 480 # MIDI 标准分辨率
|
|
706
|
+
ms_per_tick = 60000.0 / (bpm * ticks_per_beat)
|
|
707
|
+
|
|
708
|
+
current_time_ticks = 0
|
|
709
|
+
current_time_ms = 0.0
|
|
710
|
+
|
|
711
|
+
# 计算每页的缩放高度和缩放比(用于累计scroll_y,与paintEvent一致)
|
|
712
|
+
draw_w = display_width - 20 # 减去左右边距
|
|
713
|
+
page_heights = [] # 每页缩放后的高度
|
|
714
|
+
page_scales = [] # 每页的缩放比(width_ratio)
|
|
715
|
+
|
|
716
|
+
for img in images:
|
|
717
|
+
if img and not img.isNull():
|
|
718
|
+
ratio = draw_w / img.width() if img.width() > 0 else 1
|
|
719
|
+
page_heights.append(img.height() * ratio + 5)
|
|
720
|
+
page_scales.append(ratio)
|
|
721
|
+
else:
|
|
722
|
+
page_heights.append(0)
|
|
723
|
+
page_scales.append(1)
|
|
724
|
+
|
|
725
|
+
cumulative_y = 0.0 # 累计Y偏移(所有之前页面的总高度)
|
|
726
|
+
|
|
727
|
+
# 遍历所有页面布局
|
|
728
|
+
for page_idx, page in enumerate(page_layouts):
|
|
729
|
+
page_base_y = cumulative_y # 当前页的起始Y
|
|
730
|
+
page_scale_ratio = page_scales[page_idx] if page_idx < len(page_scales) else 1
|
|
731
|
+
|
|
732
|
+
if page_idx < len(page_heights):
|
|
733
|
+
cumulative_y += page_heights[page_idx]
|
|
734
|
+
|
|
735
|
+
# 遍历该页的所有系统(行)
|
|
736
|
+
for sys_idx, system in enumerate(page.systems):
|
|
737
|
+
# 遍历该系统的所有小节
|
|
738
|
+
for meas_idx, m_layout in enumerate(system.measures):
|
|
739
|
+
measure = m_layout.measure
|
|
740
|
+
|
|
741
|
+
# 获取该小节的拍号信息
|
|
742
|
+
if hasattr(measure, 'time_signature'):
|
|
743
|
+
ts = measure.time_signature
|
|
744
|
+
numerator = getattr(ts, 'numerator', 4)
|
|
745
|
+
denominator = getattr(ts, 'denominator', 4)
|
|
746
|
+
else:
|
|
747
|
+
numerator, denominator = 4, 4
|
|
748
|
+
|
|
749
|
+
# 计算该小节的总tick数
|
|
750
|
+
measure_ticks = int(numerator * ticks_per_beat * 4 / max(denominator, 1))
|
|
751
|
+
|
|
752
|
+
beats_in_measure = m_layout.beats
|
|
753
|
+
if not beats_in_measure:
|
|
754
|
+
# 无拍的空小节:只累加时间
|
|
755
|
+
current_time_ticks += measure_ticks
|
|
756
|
+
current_time_ms = current_time_ticks * ms_per_tick
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
n_beats = len(beats_in_measure)
|
|
760
|
+
tick_per_beat = measure_ticks // max(n_beats, 1)
|
|
761
|
+
|
|
762
|
+
# 遍历该小节的所有拍
|
|
763
|
+
for beat_idx, b_layout in enumerate(beats_in_measure):
|
|
764
|
+
# === 计算 scroll_y: 每个拍有独立递增的Y位置 ===
|
|
765
|
+
sys_beats_count = sum(len(m.beats) for m in system.measures)
|
|
766
|
+
beats_before = (
|
|
767
|
+
sum(len(m2.beats) for m2 in system.measures[:meas_idx])
|
|
768
|
+
+ beat_idx
|
|
769
|
+
)
|
|
770
|
+
rel_pos = beats_before / max(sys_beats_count, 1)
|
|
771
|
+
sys_h_render = max(system.y_tab_bottom - system.y_tab_top, 1)
|
|
772
|
+
|
|
773
|
+
scroll_y = (
|
|
774
|
+
page_base_y
|
|
775
|
+
+ (system.y_tab_top + rel_pos * sys_h_render) * page_scale_ratio
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# 构建时间线索引条目
|
|
779
|
+
entry = {
|
|
780
|
+
'time_ms': current_time_ms,
|
|
781
|
+
'scroll_y': scroll_y,
|
|
782
|
+
'page_idx': page_idx,
|
|
783
|
+
'sys_idx': sys_idx,
|
|
784
|
+
'meas_idx': meas_idx,
|
|
785
|
+
'beat_idx': beat_idx,
|
|
786
|
+
'x_center': b_layout.x_center,
|
|
787
|
+
'x_start': b_layout.x_start,
|
|
788
|
+
'x_end': b_layout.x_end,
|
|
789
|
+
'y_top': system.y_tab_top,
|
|
790
|
+
'y_bottom': system.y_tab_bottom,
|
|
791
|
+
}
|
|
792
|
+
self._playhead_timeline.append(entry)
|
|
793
|
+
|
|
794
|
+
# 累加时间和tick
|
|
795
|
+
current_time_ticks += tick_per_beat
|
|
796
|
+
current_time_ms = current_time_ticks * ms_per_tick
|
|
797
|
+
|
|
798
|
+
# === 后处理: 确保scroll_y严格单调递增 + 可选哨兵 ===
|
|
799
|
+
if self._playhead_timeline:
|
|
800
|
+
# 强制单调递增(防止布局数据异常导致回退)
|
|
801
|
+
prev_y = -1.0
|
|
802
|
+
for entry in self._playhead_timeline:
|
|
803
|
+
if entry['scroll_y'] <= prev_y:
|
|
804
|
+
entry['scroll_y'] = prev_y + 0.5 # 最小增量保证递增
|
|
805
|
+
prev_y = entry['scroll_y']
|
|
806
|
+
|
|
807
|
+
# 更新总时长
|
|
808
|
+
self._total_audio_duration_ms = self._playhead_timeline[-1]['time_ms']
|
|
809
|
+
|
|
810
|
+
# 总内容高度(与 _calculate_total_distance 一致)
|
|
811
|
+
total_content_h = max(cumulative_y - 5, 0)
|
|
812
|
+
|
|
813
|
+
last_entry = self._playhead_timeline[-1]
|
|
814
|
+
last_scroll_y = last_entry['scroll_y']
|
|
815
|
+
last_time_ms = last_entry['time_ms']
|
|
816
|
+
remaining_h = total_content_h - last_scroll_y
|
|
817
|
+
|
|
818
|
+
# 仅当剩余空白区域超过50px时才添加哨兵点
|
|
819
|
+
if remaining_h > 50:
|
|
820
|
+
first = self._playhead_timeline[0]
|
|
821
|
+
elapsed_y = last_scroll_y - first['scroll_y']
|
|
822
|
+
elapsed_t = max(last_time_ms - first['time_ms'], 1)
|
|
823
|
+
avg_speed = elapsed_y / elapsed_t
|
|
824
|
+
estimated_remaining_ms = remaining_h / max(avg_speed, 0.01)
|
|
825
|
+
|
|
826
|
+
sentinel = {
|
|
827
|
+
'time_ms': last_time_ms + estimated_remaining_ms,
|
|
828
|
+
'scroll_y': float(total_content_h),
|
|
829
|
+
'page_idx': len(page_layouts) - 1,
|
|
830
|
+
'sys_idx': 0, 'meas_idx': 0, 'beat_idx': 0,
|
|
831
|
+
'x_center': 0, 'x_start': 0, 'x_end': 0,
|
|
832
|
+
'y_top': 0, 'y_bottom': 0,
|
|
833
|
+
}
|
|
834
|
+
self._playhead_timeline.append(sentinel)
|
|
835
|
+
self._total_audio_duration_ms = sentinel['time_ms']
|
|
836
|
+
|
|
837
|
+
# === 性能优化: 预提取bisect关键排序列表 ===
|
|
838
|
+
self._timeline_times = [e['time_ms'] for e in self._playhead_timeline]
|
|
839
|
+
self._timeline_scroll_ys = [e['scroll_y'] for e in self._playhead_timeline]
|
|
840
|
+
else:
|
|
841
|
+
self._total_audio_duration_ms = 0.0
|
|
842
|
+
self._timeline_times = []
|
|
843
|
+
self._timeline_scroll_ys = []
|
|
844
|
+
|
|
845
|
+
return self._playhead_timeline
|
|
846
|
+
|
|
847
|
+
# ================================================================
|
|
848
|
+
# 时间 ↔ 位置 双向映射
|
|
849
|
+
# ================================================================
|
|
850
|
+
|
|
851
|
+
def update_playhead(self, time_ms: float = None) -> Optional[tuple]:
|
|
852
|
+
"""
|
|
853
|
+
根据当前播放时间更新光标位置
|
|
854
|
+
|
|
855
|
+
参数:
|
|
856
|
+
time_ms: 当前音频时间(毫秒)。None则从 synth_engine 获取。
|
|
857
|
+
|
|
858
|
+
返回:
|
|
859
|
+
光标信息元组或None:
|
|
860
|
+
(page_idx, sys_idx, meas_idx, beat_idx, x_in_page, progress_in_beat)
|
|
861
|
+
- progress_in_beat ∈ [0,1) 表示当前拍内的进度
|
|
862
|
+
- beat_idx=-1 表示还未到第一个拍
|
|
863
|
+
"""
|
|
864
|
+
if not self._playhead_timeline:
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
# 获取当前时间
|
|
868
|
+
if time_ms is None and self._synth_engine:
|
|
869
|
+
time_ms = self._synth_engine.current_time_ms
|
|
870
|
+
if time_ms is None:
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
# 二分查找: 找到 time_ms 对应的拍(使用预提取的排序列表)
|
|
874
|
+
idx = bisect.bisect_right(self._timeline_times, time_ms) - 1
|
|
875
|
+
|
|
876
|
+
if idx < 0:
|
|
877
|
+
# 还没到第一个拍
|
|
878
|
+
entry = self._playhead_timeline[0]
|
|
879
|
+
return (
|
|
880
|
+
entry['page_idx'], entry['sys_idx'], entry['meas_idx'],
|
|
881
|
+
-1, entry['x_start'], 0.0
|
|
882
|
+
)
|
|
883
|
+
elif idx >= len(self._playhead_timeline) - 1:
|
|
884
|
+
# 已超过最后一个拍
|
|
885
|
+
entry = self._playhead_timeline[-1]
|
|
886
|
+
return (
|
|
887
|
+
entry['page_idx'], entry['sys_idx'], entry['meas_idx'],
|
|
888
|
+
entry['beat_idx'], entry['x_end'], 1.0
|
|
889
|
+
)
|
|
890
|
+
else:
|
|
891
|
+
# 在两个拍之间 → 插值计算精确X坐标
|
|
892
|
+
curr = self._playhead_timeline[idx]
|
|
893
|
+
next_e = self._playhead_timeline[idx + 1]
|
|
894
|
+
|
|
895
|
+
dt = next_e['time_ms'] - curr['time_ms']
|
|
896
|
+
progress = (time_ms - curr['time_ms']) / dt if dt > 0 else 0.0
|
|
897
|
+
|
|
898
|
+
# X坐标线性插值
|
|
899
|
+
x_pos = curr['x_center'] + progress * (next_e['x_center'] - curr['x_center'])
|
|
900
|
+
|
|
901
|
+
return (
|
|
902
|
+
curr['page_idx'], curr['sys_idx'], curr['meas_idx'],
|
|
903
|
+
curr['beat_idx'], x_pos, progress
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
def time_to_scroll_pos(self, time_ms: float,
|
|
907
|
+
total_scroll_distance: float,
|
|
908
|
+
display_height: int) -> float:
|
|
909
|
+
"""
|
|
910
|
+
根据音频时间计算对应的滚动Y位置
|
|
911
|
+
|
|
912
|
+
原理:
|
|
913
|
+
在 playhead_timeline 中二分查找当前时间对应的拍,
|
|
914
|
+
通过线性插值获取精确的 scroll_y 值。
|
|
915
|
+
这使得滚动位置随音符时值自动变化:
|
|
916
|
+
- 音符密集区(16/32分音符): 相同时间→更大scroll_y变化 → 滚动更快
|
|
917
|
+
- 音符稀疏区(全/二分音符): 相同时间→更小scroll_y变化 → 滚动更慢
|
|
918
|
+
|
|
919
|
+
参数:
|
|
920
|
+
time_ms: 当前音频播放时间(毫秒)
|
|
921
|
+
total_scroll_distance: 总滚动距离(像素)
|
|
922
|
+
display_height: 显示区域高度(像素),用于居中计算
|
|
923
|
+
|
|
924
|
+
返回:
|
|
925
|
+
对应的滚动Y位置(像素),范围 [0, total_scroll_distance]
|
|
926
|
+
"""
|
|
927
|
+
if not self._playhead_timeline or total_scroll_distance <= 0:
|
|
928
|
+
return 0.0
|
|
929
|
+
|
|
930
|
+
# 边界处理
|
|
931
|
+
if time_ms <= 0:
|
|
932
|
+
return 0.0
|
|
933
|
+
if time_ms >= self._playhead_timeline[-1]['time_ms']:
|
|
934
|
+
return float(total_scroll_distance)
|
|
935
|
+
|
|
936
|
+
# 二分查找时间位置(使用预提取的排序列表)
|
|
937
|
+
idx = bisect.bisect_right(self._timeline_times, time_ms) - 1
|
|
938
|
+
|
|
939
|
+
if idx < 0:
|
|
940
|
+
return 0.0
|
|
941
|
+
if idx >= len(self._playhead_timeline) - 1:
|
|
942
|
+
return float(total_scroll_distance)
|
|
943
|
+
|
|
944
|
+
# 线性插值
|
|
945
|
+
curr = self._playhead_timeline[idx]
|
|
946
|
+
next_e = self._playhead_timeline[idx + 1]
|
|
947
|
+
dt = next_e['time_ms'] - curr['time_ms']
|
|
948
|
+
if dt <= 0:
|
|
949
|
+
scroll_y = curr['scroll_y']
|
|
950
|
+
else:
|
|
951
|
+
t = (time_ms - curr['time_ms']) / dt
|
|
952
|
+
scroll_y = curr['scroll_y'] + t * (next_e['scroll_y'] - curr['scroll_y'])
|
|
953
|
+
|
|
954
|
+
# 映射到显示区域(减去可视区域高度的一半,让光标居中)
|
|
955
|
+
centered_pos = max(0, scroll_y - display_height / 2)
|
|
956
|
+
|
|
957
|
+
return min(centered_pos, float(total_scroll_distance))
|
|
958
|
+
|
|
959
|
+
def scroll_pos_to_time(self, scroll_pos: float,
|
|
960
|
+
total_scroll_distance: float,
|
|
961
|
+
display_height: int) -> float:
|
|
962
|
+
"""
|
|
963
|
+
根据滚动Y位置反推对应的音频时间 — time_to_scroll_pos 的逆运算
|
|
964
|
+
|
|
965
|
+
原理:
|
|
966
|
+
在 playhead_timeline 中对 scroll_y 做二分查找,
|
|
967
|
+
通过线性插值获取精确的 time_ms 值。
|
|
968
|
+
这是 time_to_scroll_pos() 的完全对称逆操作。
|
|
969
|
+
|
|
970
|
+
为什么不能用线性比例?
|
|
971
|
+
因为 scroll_y 与 time_ms 的关系是非线性的:
|
|
972
|
+
- 密集区: 相同时间内scroll_y变化大 → 每像素对应少时间
|
|
973
|
+
- 稀疏区: 相同时间内scroll_y变化小 → 每像素对应多时间
|
|
974
|
+
用线性比例会在密集区高估时间(音频跳到后面),导致"提前"感
|
|
975
|
+
|
|
976
|
+
参数:
|
|
977
|
+
scroll_pos: 滚动位置(像素),已减去display_h/2的居中值
|
|
978
|
+
total_scroll_distance: 总滚动距离(像素)
|
|
979
|
+
display_height: 显示区域高度(像素)
|
|
980
|
+
|
|
981
|
+
返回:
|
|
982
|
+
对应的音频时间(毫秒)
|
|
983
|
+
"""
|
|
984
|
+
if not self._playhead_timeline or total_scroll_distance <= 0:
|
|
985
|
+
return 0.0
|
|
986
|
+
|
|
987
|
+
# 边界处理
|
|
988
|
+
if scroll_pos <= 0:
|
|
989
|
+
return 0.0
|
|
990
|
+
if scroll_pos >= total_scroll_distance:
|
|
991
|
+
return self._total_audio_duration_ms
|
|
992
|
+
|
|
993
|
+
# 将居中位置还原为原始scroll_y(与time_to_scroll_pos中的操作相反)
|
|
994
|
+
raw_scroll_y = scroll_pos + display_height / 2
|
|
995
|
+
|
|
996
|
+
# 对scroll_y做二分查找(使用预提取的排序列表,与time_to_scroll_pos对称)
|
|
997
|
+
idx = bisect.bisect_right(self._timeline_scroll_ys, raw_scroll_y) - 1
|
|
998
|
+
|
|
999
|
+
if idx < 0:
|
|
1000
|
+
return self._playhead_timeline[0]['time_ms']
|
|
1001
|
+
if idx >= len(self._playhead_timeline) - 1:
|
|
1002
|
+
return self._playhead_timeline[-1]['time_ms']
|
|
1003
|
+
|
|
1004
|
+
# 线性插值(与time_to_scroll_pos对称)
|
|
1005
|
+
curr = self._playhead_timeline[idx]
|
|
1006
|
+
next_e = self._playhead_timeline[idx + 1]
|
|
1007
|
+
dy = next_e['scroll_y'] - curr['scroll_y']
|
|
1008
|
+
if dy <= 0:
|
|
1009
|
+
return curr['time_ms']
|
|
1010
|
+
|
|
1011
|
+
t = (raw_scroll_y - curr['scroll_y']) / dy
|
|
1012
|
+
time_ms = curr['time_ms'] + t * (next_e['time_ms'] - curr['time_ms'])
|
|
1013
|
+
|
|
1014
|
+
return max(0.0, time_ms)
|
|
1015
|
+
|
|
1016
|
+
# ================================================================
|
|
1017
|
+
# 工具方法
|
|
1018
|
+
# ================================================================
|
|
1019
|
+
|
|
1020
|
+
def create_error_image(self, message: str, width: int = 800,
|
|
1021
|
+
height: int = 500) -> QPixmap:
|
|
1022
|
+
"""
|
|
1023
|
+
创建错误/信息展示图(当GTP引擎不可用时回退显示)
|
|
1024
|
+
|
|
1025
|
+
参数:
|
|
1026
|
+
message: 显示的错误/信息文本
|
|
1027
|
+
width: 图片宽度(px)
|
|
1028
|
+
height: 图片高度(px)
|
|
1029
|
+
|
|
1030
|
+
返回:
|
|
1031
|
+
包含错误信息的 QPixmap 图像
|
|
1032
|
+
"""
|
|
1033
|
+
pixmap = QPixmap(width, height)
|
|
1034
|
+
pixmap.fill(QColor('#252536')) # 使用深色主题背景
|
|
1035
|
+
painter = QPainter(pixmap)
|
|
1036
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
1037
|
+
|
|
1038
|
+
# 标题
|
|
1039
|
+
painter.setPen(QColor('#3B82F6'))
|
|
1040
|
+
title_font = QFont("Microsoft YaHei", 26, QFont.Bold)
|
|
1041
|
+
painter.setFont(title_font)
|
|
1042
|
+
filename = "" if not self._file_path else self._file_path
|
|
1043
|
+
painter.drawText(QRect(50, 40, width - 100, 60),
|
|
1044
|
+
Qt.AlignCenter, f"Guitar Pro 文件: {filename}")
|
|
1045
|
+
|
|
1046
|
+
# 信息文本
|
|
1047
|
+
painter.setPen(QColor('#E2E8F0'))
|
|
1048
|
+
info_font = QFont("Microsoft YaHei", 13)
|
|
1049
|
+
painter.setFont(info_font)
|
|
1050
|
+
|
|
1051
|
+
lines = message.split('\n')
|
|
1052
|
+
y = 130
|
|
1053
|
+
for line in lines:
|
|
1054
|
+
if line.strip():
|
|
1055
|
+
painter.drawText(QRect(50, y, width - 100, 32), Qt.AlignLeft, line)
|
|
1056
|
+
y += 30
|
|
1057
|
+
|
|
1058
|
+
# 边框
|
|
1059
|
+
painter.setPen(QPen(QColor('#3B82F6'), 2))
|
|
1060
|
+
painter.setBrush(Qt.NoBrush)
|
|
1061
|
+
painter.drawRoundedRect(30, 30, width - 60, height - 60, 15, 15)
|
|
1062
|
+
painter.end()
|
|
1063
|
+
|
|
1064
|
+
return pixmap
|
|
1065
|
+
|
|
1066
|
+
def __del__(self):
|
|
1067
|
+
"""析构时确保释放音频资源"""
|
|
1068
|
+
self.shutdown()
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
# ============================================================
|
|
1072
|
+
# 便捷函数
|
|
1073
|
+
# ============================================================
|
|
1074
|
+
|
|
1075
|
+
def create_gtp_player(gain: float = 0.7) -> GTPPlayer:
|
|
1076
|
+
"""
|
|
1077
|
+
便捷工厂函数:创建并返回 GTPPlayer 实例
|
|
1078
|
+
|
|
1079
|
+
参数:
|
|
1080
|
+
gain: 主音量(0.0-1.0), 调整效果: 0.7=适中音量
|
|
1081
|
+
|
|
1082
|
+
返回:
|
|
1083
|
+
GTPPlayer 实例
|
|
1084
|
+
|
|
1085
|
+
示例:
|
|
1086
|
+
>>> from gtp_engine.player import create_gtp_player
|
|
1087
|
+
>>> player = create_gtp_player(gain=0.8)
|
|
1088
|
+
>>> player.load("song.gp5")
|
|
1089
|
+
>>> images = player.render_track(0)
|
|
1090
|
+
"""
|
|
1091
|
+
return GTPPlayer(gain=gain)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def render_gtp_to_images(file_path: str, track_index: int = 0,
|
|
1095
|
+
config: RenderConfig = None) -> List[QPixmap]:
|
|
1096
|
+
"""
|
|
1097
|
+
便捷函数:一键渲染GTP文件为图像列表
|
|
1098
|
+
|
|
1099
|
+
参数:
|
|
1100
|
+
file_path: .gp3/.gp4/.gp5/.gpx 文件路径
|
|
1101
|
+
track_index: 音轨索引(默认第1条)
|
|
1102
|
+
config: 自定义渲染配置(可选)
|
|
1103
|
+
|
|
1104
|
+
返回:
|
|
1105
|
+
QPixmap列表,每元素对应一页乐谱图像
|
|
1106
|
+
|
|
1107
|
+
示例:
|
|
1108
|
+
>>> from gtp_engine.player import render_gtp_to_images
|
|
1109
|
+
>>> pages = render_gtp_to_images("my_song.gp5", track_index=0)
|
|
1110
|
+
>>> pages[0].save("output.png", "PNG")
|
|
1111
|
+
"""
|
|
1112
|
+
player = GTPPlayer()
|
|
1113
|
+
return player.render_track(track_index, config)
|