-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanki_jam.py
More file actions
389 lines (310 loc) · 13.6 KB
/
Copy pathanki_jam.py
File metadata and controls
389 lines (310 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
from typing import TYPE_CHECKING, Any, Optional
import aqt
import aqt.gui_hooks
import aqt.sound
from anki.cards import Card
from anki.notes import Note
from aqt.utils import tooltip
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QMenu, QPushButton, QStatusBar, QWidget
from .riff_importer import RiffImporter
from .riffs_constants import (
JAM_DECK_NAME,
JAM_NOTE_FIELDS,
JAM_NOTETYPE_NAME,
JAM_PITCH_FIELD,
JAM_SPEED_FIELD,
JAM_TEMPLATE_NAME,
)
from .utils import cast_assert
if TYPE_CHECKING:
from aqt.browser.browser import Browser
def _float_field_or_default(note: Note, field_name: str, default: float) -> float:
try:
raw_value = note[field_name]
except KeyError:
return default
stripped = raw_value.strip()
if not stripped:
return default
try:
return float(stripped)
except ValueError:
print(f"Could not parse field '{field_name}' value '{raw_value}' as float.")
return default
class AudioControls:
_instance: Optional['AudioControls'] = None
def __init__(self) -> None:
self.audio_speed = 1.0
self.pitch_shift = 0.0 # semitones
self.show_notif = True
self.loop_enabled = True
self.audio_control_widget: Optional['AudioControlWidget'] = None
self._register_hooks()
self._register_menu_actions()
self.ensure_riffs_resources()
@classmethod
def instance(cls) -> 'AudioControls':
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _register_hooks(self) -> None:
aqt.gui_hooks.av_player_did_begin_playing.append(self.did_begin_playing)
aqt.gui_hooks.profile_did_open.append(self.ensure_riffs_resources)
aqt.gui_hooks.reviewer_did_init.append(self.setup_audio_controls)
aqt.gui_hooks.card_will_show.append(self.on_card_will_show)
aqt.gui_hooks.reviewer_will_end.append(self.hide_audio_controls)
aqt.gui_hooks.browser_will_show_context_menu.append(self.on_browser_will_show_context_menu)
def _register_menu_actions(self) -> None:
menu = cast_assert(aqt.mw.form.menuTools.addMenu('Anki Jam'), QMenu)
action = QAction('Decrease audio speed', aqt.mw)
action.setShortcut('[')
action.triggered.connect(self.decrease_audio_speed)
menu.addAction(action)
action = QAction('Increase audio speed', aqt.mw)
action.setShortcut(']')
action.triggered.connect(self.increase_audio_speed)
menu.addAction(action)
action = QAction('Stop audio', aqt.mw)
action.setShortcut('8')
action.triggered.connect(self.stop_audio)
menu.addAction(action)
action = QAction('Import Loops', aqt.mw)
action.triggered.connect(self.import_riffs)
menu.addAction(action)
def on_browser_will_show_context_menu(self, browser: 'Browser', menu: QMenu) -> None:
action = QAction('Reset riff speed', browser)
action.triggered.connect(
lambda _checked=False, b=browser: self.reset_speed_for_selection(b)
)
menu.addAction(action)
def ensure_riffs_resources(self) -> None:
self.ensure_riffs_deck_exists()
self.ensure_riffs_notetype_exists()
def import_riffs(self) -> None:
if aqt.mw is None:
print("Can't import loops, main window is not available")
return
if aqt.mw.col is None:
print("Can't import loops, collection is not available")
return
self.ensure_riffs_resources()
importer = RiffImporter(aqt.mw)
importer.prompt_and_import()
def ensure_riffs_deck_exists(self) -> None:
if aqt.mw is None:
print("Can't ensure Anki Jam deck exists, main window is not available")
return
if aqt.mw.col is None:
print("Can't ensure Anki Jam deck exists, collection is not available")
return
aqt.mw.col.decks.add_normal_deck_with_name(JAM_DECK_NAME)
def ensure_riffs_notetype_exists(self) -> None:
mw = aqt.mw
if mw is None:
print("Can't ensure Anki Jam note type exists, main window is not available")
return
collection = mw.col
if collection is None:
print("Can't ensure Anki Jam note type exists, collection is not available")
return
models = collection.models
notetype = models.by_name(JAM_NOTETYPE_NAME)
if notetype:
missing_fields = [
field for field in JAM_NOTE_FIELDS if field not in models.field_map(notetype)
]
if missing_fields:
for field in missing_fields:
models.add_field(notetype, models.new_field(field))
models.update_dict(notetype)
if not notetype.get('tmpls'):
template = models.new_template(JAM_TEMPLATE_NAME)
template['qfmt'] = '{{Artist}} - {{Title}}<br>{{Name}}<br>{{Audio}}'
template['afmt'] = '{{FrontSide}}'
models.add_template(notetype, template)
models.update_dict(notetype)
else:
notetype['tmpls'][0]['name'] = JAM_TEMPLATE_NAME
notetype['tmpls'][0]['qfmt'] = '{{Artist}} - {{Title}}<br>{{Name}}<br>{{Audio}}'
notetype['tmpls'][0]['afmt'] = '{{FrontSide}}'
models.update_dict(notetype)
return
notetype = models.new(JAM_NOTETYPE_NAME)
for field in JAM_NOTE_FIELDS:
models.add_field(notetype, models.new_field(field))
template = models.new_template(JAM_TEMPLATE_NAME)
template['qfmt'] = '{{Artist}} - {{Title}}<br>{{Name}}<br>{{Audio}}'
template['afmt'] = '{{FrontSide}}'
models.add_template(notetype, template)
notetype['sortf'] = 0
models.add(notetype)
def reset_speed_for_selection(self, browser: 'Browser') -> None:
mw = aqt.mw
if mw is None:
print("Can't reset riff speeds, main window is not available")
return
collection = mw.col
if collection is None:
print("Can't reset riff speeds, collection is not available")
return
note_ids = browser.selected_notes()
if not note_ids:
self.notify('No notes selected to reset speed')
return
updated = 0
skipped = 0
for note_id in note_ids:
note = collection.get_note(note_id)
notetype = note.note_type()
if not notetype or notetype.get('name') != JAM_NOTETYPE_NAME:
skipped += 1
continue
try:
note[JAM_SPEED_FIELD] = '1.000'
except KeyError:
skipped += 1
continue
try:
collection.update_note(note)
except AttributeError:
note.flush()
updated += 1
if updated:
try:
browser.model.reset()
except AttributeError:
browser.mw.reset()
self.notify(f'Reset speed to 1.0x for {updated} note(s)')
elif skipped:
self.notify('No Anki Jam notes in selection to reset speed')
def set_speed(self, player: Optional[aqt.sound.MpvManager], speed: float) -> None:
if player is None:
print("Couldn't set speed, player is None")
return
self.audio_speed = speed
player_ = cast_assert(player, aqt.sound.MpvManager)
player_.set_property('af', self._build_audio_filter_value())
player_.set_property('speed', self.audio_speed)
def apply_audio_speed(self, player: Any, speed: float) -> None:
self.set_speed(player, speed)
def apply_pitch_shift(self, player: Any, pitch_shift: float) -> None:
if player is None:
print("Couldn't set pitch shift, player is None")
return
self.pitch_shift = pitch_shift
player_ = cast_assert(player, aqt.sound.MpvManager)
player_.set_property('af', self._build_audio_filter_value())
player_.set_property('speed', self.audio_speed)
def apply_loop_setting(self, player: Any) -> None:
if player is None:
return
if isinstance(player, aqt.sound.MpvManager):
player.set_property('loop', 'inf' if self.loop_enabled else 'no') # type: ignore[no-untyped-call]
def toggle_loop(self, enabled: bool) -> None:
self.loop_enabled = enabled
self.apply_loop_setting(aqt.sound.av_player.current_player)
self.notify('Loop enabled' if enabled else 'Loop disabled')
self.try_update_displays()
def increase_audio_speed(self) -> None:
self.audio_speed = min(4.0, self.audio_speed + 0.1)
self.notify('Increase audio speed by 0.1. Current Speed: ' + f'{self.audio_speed:.1f}')
self.apply_audio_speed(aqt.sound.av_player.current_player, self.audio_speed)
self.try_update_displays()
def decrease_audio_speed(self) -> None:
self.audio_speed = max(0.1, self.audio_speed - 0.1)
self.notify('Decrease audio speed by 0.1. Current Speed: ' + f'{self.audio_speed: .1f}')
self.apply_audio_speed(aqt.sound.av_player.current_player, self.audio_speed)
self.try_update_displays()
def stop_audio(self) -> None:
self.notify('Stop audio.')
player = aqt.sound.av_player.current_player
if player is None:
print("Can't stop audio, player is None")
return
cast_assert(player, aqt.sound.MpvManager).stop()
def did_begin_playing(self, player: Any, _tag: Any) -> None:
print('BEGIN PLAYING')
player.set_property('audio-delay', '0')
player.set_property('gapless-audio', 'yes')
self.apply_pitch_shift(player, self.pitch_shift)
self.apply_audio_speed(player, self.audio_speed)
self.apply_loop_setting(player)
def on_card_will_show(self, text: str, card: Card, _kind: str) -> str:
self.apply_card_audio_settings(card)
return text
def apply_card_audio_settings(self, card: Card) -> None:
note = card.note()
notetype = note.note_type()
if not notetype or notetype.get('name') != JAM_NOTETYPE_NAME:
return
pitch_value = _float_field_or_default(note, JAM_PITCH_FIELD, 0.0)
speed_value = _float_field_or_default(note, JAM_SPEED_FIELD, 1.0)
self.pitch_shift = pitch_value
self.audio_speed = speed_value
player = aqt.sound.av_player.current_player
if player:
self.apply_pitch_shift(player, self.pitch_shift)
self.apply_audio_speed(player, self.audio_speed)
self.try_update_displays()
def _build_audio_filter_value(self) -> str:
pitch_scale = 2 ** (self.pitch_shift / 12)
return f'rubberband=window=long:formant=preserved:pitch-scale={pitch_scale:.6f}'
def setup_audio_controls(self, _reviewer: Any) -> None:
if self.audio_control_widget is None:
self.audio_control_widget = AudioControlWidget(self, aqt.mw)
status_bar = cast_assert(aqt.mw.statusBar(), QStatusBar)
status_bar.addPermanentWidget(self.audio_control_widget)
self.audio_control_widget.show()
self.try_update_displays()
def hide_audio_controls(self) -> None:
if self.audio_control_widget:
self.audio_control_widget.hide()
def notify(self, message: str) -> None:
if self.show_notif:
tooltip(message)
def try_update_displays(self) -> None:
if self.audio_control_widget:
self.audio_control_widget.update_displays()
class AudioControlWidget(QWidget):
def __init__(self, controller: AudioControls, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.controller = controller
self.setup_ui()
def setup_ui(self) -> None:
layout = QHBoxLayout()
layout.setContentsMargins(5, 2, 5, 2)
self.speed_label = QLabel('Speed: 1.0x')
self.speed_dec_btn = QPushButton('-')
self.speed_inc_btn = QPushButton('+')
self.speed_dec_btn.setMaximumWidth(30)
self.speed_inc_btn.setMaximumWidth(30)
self.pitch_label = QLabel('Pitch: 0.0')
self.pitch_dec_btn = QPushButton('-')
self.pitch_inc_btn = QPushButton('+')
self.pitch_dec_btn.setMaximumWidth(30)
self.pitch_inc_btn.setMaximumWidth(30)
self.loop_checkbox = QCheckBox('Loop')
self.loop_checkbox.setChecked(self.controller.loop_enabled)
self.loop_checkbox.toggled.connect(self.controller.toggle_loop)
self.speed_dec_btn.clicked.connect(self.controller.decrease_audio_speed)
self.speed_inc_btn.clicked.connect(self.controller.increase_audio_speed)
layout.addWidget(self.speed_label)
layout.addWidget(self.speed_dec_btn)
layout.addWidget(self.speed_inc_btn)
layout.addSpacing(20)
layout.addWidget(self.pitch_label)
layout.addWidget(self.pitch_dec_btn)
layout.addWidget(self.pitch_inc_btn)
layout.addSpacing(20)
layout.addWidget(self.loop_checkbox)
layout.addStretch()
self.setLayout(layout)
def update_displays(self) -> None:
self.speed_label.setText(f'Speed: {self.controller.audio_speed:.1f}x')
if abs(self.controller.pitch_shift) < 0.0001:
self.pitch_label.setText('Pitch: 0.0')
else:
self.pitch_label.setText(f'Pitch: {self.controller.pitch_shift:+.1f}')
self.loop_checkbox.setChecked(self.controller.loop_enabled)
audio_controls = AudioControls.instance()