Created
March 7, 2025 07:09
-
-
Save moksamedia/3bdd7736fee8ba16786133a06b9af2e3 to your computer and use it in GitHub Desktop.
Audacity edit audios addon (fixed so that the file dialog works)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
''' | |
This addon opens one or many audios in an editor (the default is Audacity). | |
It supports 3 modes to choose which audios it opens. The config is entered in a text box and the searching mode will automatically detected. | |
If users enter "first_field,second_field,third_field", this addon will open every audios in "first_field" and "second_field" | |
If users enter "1,2:1", this addon will opens the first and second audios in the front card and the first audio in the back card. | |
If users enter "<div id=“editable”>[sound: … ]</div>" and tick the regex box, it will search audios that are surrounded by <div id="editable"> tag. | |
On main window -> only show change path button | |
On review mode, only show search fields | |
First, implement search box | |
Which information do we need to save? | |
searchCriteria = {} | |
searchCriteria["search_by_fields] = [] | |
searchCriteria["search_by_number] = ([number in the front card], [number in the back card]) | |
searhcCriteria["search_by_regex] = regex | |
''' | |
import os | |
import sys | |
import re | |
import anki | |
import aqt | |
from aqt.qt import * | |
from aqt import utils, mw | |
import subprocess | |
import shlex | |
from pickle import load, dump | |
from aqt import gui_hooks | |
dialog = None | |
soundRegex = re.compile("\[sound:(.*?\.(?:mp3|m4a|wav))\]") | |
class AddonDialog(QDialog): | |
"""Main Options dialog""" | |
def __init__(self, config, configFile): | |
QDialog.__init__(self, parent=mw) | |
self.configFile = configFile | |
self.config = config | |
#self.loadConfig() | |
self.setupUi() | |
def setupUi(self): | |
"""Set up widgets and layouts""" | |
deckLabel = QLabel("Choose deck") | |
fieldLabel = QLabel("Fields in this deck") | |
searchboxLabel = QLabel("Criteria search") | |
editorPathLabel = QLabel("Editor path") | |
deck = mw.col.decks.current()['name'] | |
self.criteriaBox = QLineEdit(self) | |
self.fieldInfo = QLabel("\n".join(self.selectFields(deck))) | |
self.searchByRebexCheckbox = QCheckBox("By regex") | |
self.editorPath = QLabel(self.config["editor_path"]) | |
self.changeEditorPath = QPushButton("Change editor path") | |
self.changeEditorPath.clicked.connect(self.handleChangePath) | |
self.deckSelection = QComboBox() | |
self.deckSelection.addItems(self.getDeckList()) | |
self.deckSelection.currentIndexChanged.connect(self.handleSelectDeck) | |
self.criteriaBox.setText(self.getConfigValue()) | |
self.grid = QGridLayout() | |
self.grid.setSpacing(10) | |
self.grid.addWidget(deckLabel, 1, 0) | |
self.grid.addWidget(self.deckSelection, 1, 1) | |
self.grid.addWidget(fieldLabel, 2, 0) | |
self.grid.addWidget(self.fieldInfo, 2, 1) | |
self.grid.addWidget(searchboxLabel, 3, 0) | |
self.grid.addWidget(self.criteriaBox, 3, 1) | |
self.grid.addWidget(self.searchByRebexCheckbox, 3, 2) | |
self.grid.addWidget(editorPathLabel, 4, 0) | |
self.grid.addWidget(self.editorPath, 4, 1) | |
self.grid.addWidget(self.changeEditorPath) | |
self.saveConfig = QCheckBox("Save config") | |
# Main button box | |
buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Help) | |
buttonBox.accepted.connect(self.onAccept) | |
buttonBox.rejected.connect(self.onReject) | |
buttonBox.helpRequested.connect(self.handleShowHelp) | |
# Main layout | |
mainLayout = QVBoxLayout() | |
mainLayout.addLayout(self.grid) | |
mainLayout.addWidget(buttonBox) | |
mainLayout.addWidget(self.saveConfig) | |
self.setLayout(mainLayout) | |
self.setMinimumWidth(360) | |
self.setWindowTitle('Config edit audios in external editor') | |
def getConfigValue(self): | |
deck = self.deckSelection.currentText() | |
if self.config is None or deck not in self.config: | |
return "" | |
config = self.config[deck] | |
if "search_by_regex" in config: | |
return config["search_by_regex"] | |
if "search_by_number" in config: | |
front, back = config["search_by_number"] | |
return ','.join(front) + ':' + ','.join(back) | |
if "search_by_fields" in config: | |
fields = config["search_by_fields"] | |
return ','.join(fields) | |
return "" | |
def handleSelectDeck(self): | |
deck = self.deckSelection.currentText() | |
self.criteriaBox.setText(self.getConfigValue()) | |
fields = self.selectFields(deck) | |
self.fieldInfo.setText("\n".join(fields)) | |
def getDeckList(self): | |
deckNames = sorted(mw.col.decks.allNames()) | |
currentDeck = mw.col.decks.current()['name'] | |
deckNames.insert(0, currentDeck) | |
for i in range(len(deckNames)): | |
if deckNames[i] == 'Default': | |
deckNames.pop(i) | |
break | |
return deckNames | |
def handleChangePath(self): | |
dialog = OpenFileDialog() | |
if isinstance(dialog.filename, list): | |
path = dialog.filename[0] | |
else: | |
path = dialog.filename | |
if path: | |
if not anki.utils.isWin and '/' in path: | |
path = path.split('/')[-1] | |
print(path) | |
utils.showInfo("Choose editor successful.") | |
self.editorPath.setText(path) | |
self.config["editor_path"] = path | |
with open(configFile, 'wb') as f: | |
dump(self.config, f) | |
def handleShowHelp(self): | |
utils.showText(""" | |
This addon opens one or many audios in an editor (the default is Audacity). | |
It supports 3 modes to choose which audios it opens. The config is entered in a text box and the searching mode will automatically detected. | |
If users enter "first_field,second_field,third_field", this addon will open every audios in "first_field" and "second_field" | |
If users enter "1,2:1", this addon will opens the first and second audios in the front card and the first audio in the back card. | |
If users enter "<div id=“editable”>[sound: … ]</div>" and tick the regex box, it will search audios that are surrounded by <div id="editable"> tag. | |
You can also change the default audio editor. Now it is having a subtle bug with MacOS that crashes the editor (other than Audacity) when we edit the audios. In that case, you need to reopen the editor and crash Shift+G again. | |
""") | |
def selectFields(self, deck): | |
query = 'deck:"{}"'.format(deck) | |
cardId = mw.col.findCards(query=query)[0] | |
card = mw.col.getCard(cardId) | |
note = card.note() | |
model = note.model() | |
fields = card.note().keys() | |
return fields | |
def onAccept(self): | |
## 1. Get search field | |
criteria = self.criteriaBox.text() | |
if len(criteria) > 0: | |
parsedCriteria = self.parseCriteria(criteria, self.searchByRebexCheckbox.isChecked()) | |
if parsedCriteria is None: | |
utils.showInfo("Your input has an invalid form. Check the help for more detail about the input pattern.") | |
return | |
## 2. Setup the config | |
deck = self.deckSelection.currentText() | |
self.config[deck] = parsedCriteria | |
## 3. If the save to default is checked, save the config to the config file | |
if self.saveConfig.isChecked(): | |
with open(self.configFile, 'wb') as f: | |
dump(self.config, f) | |
self.close() | |
def parseCriteria(self, criteria, searchByRegex=False): | |
if searchByRegex: | |
return self.parseByRegex(criteria) | |
searchByNumberRegex = "^([1-9]+,)*[1-9]*:([1-9]+,)*[1-9]*$" | |
if re.match(searchByNumberRegex, criteria): | |
return self.parseByNumber(criteria) | |
searchByFieldRegex = "^(.+,)*.+$" | |
if re.match(searchByFieldRegex, criteria): | |
return self.parseByField(criteria) | |
return None | |
def parseByNumber(self, criteria): | |
frontCard, backCard = criteria.split(":") | |
frontCardAudios, backCardAudios = frontCard.split(','), backCard.split(',') | |
searchCriteria = {} | |
searchCriteria["search_by_number"] = (frontCardAudios, backCardAudios) | |
return searchCriteria | |
def parseByRegex(self, criteria): | |
searchCriteria = {} | |
searchCriteria["search_by_regex"] = criteria | |
return searchCriteria | |
def parseByField(self, criteria): | |
fields = criteria.split(',') | |
searchCriteria = {} | |
searchCriteria["search_by_fields"] = fields | |
return searchCriteria | |
def onReject(self): | |
self.close() | |
class OpenFileDialog(QFileDialog): | |
def __init__(self): | |
QDialog.__init__(self, mw) | |
self.title = 'Open file' | |
self.left = 10 | |
self.top = 10 | |
self.width = 640 | |
self.height = 480 | |
self.filename = None | |
self._init_ui() | |
def _init_ui(self): | |
self.setWindowTitle(self.title) | |
self.setGeometry(self.left, self.top, self.width, self.height) | |
self.filename = self._get_file() | |
# self.exec_() | |
def _get_file(self): | |
if anki.utils.is_win: | |
directory = os.path.expanduser("~/Desktop") | |
else: | |
directory = "/Applications" | |
try: | |
if anki.utils.is_win: | |
dialog = QFileDialog(self) | |
dialog.setWindowTitle("Open File") | |
dialog.setDirectory(directory) | |
dialog.setFileMode(QFileDialog.FileMode.ExistingFile) | |
if dialog.exec(): | |
path = dialog.selectedFiles()[0] | |
if path: | |
return path | |
else: | |
utils.showInfo("Cannot open this file.") | |
else: | |
dialog = QFileDialog(self) | |
dialog.setWindowTitle("Open File") | |
dialog.setDirectory(directory) | |
dialog.setFileMode(QFileDialog.FileMode.Directory) | |
dialog.setOptions(QFileDialog.Option.DontUseNativeDialog | QFileDialog.Option.ShowDirsOnly | QFileDialog.Option.DontResolveSymlinks) | |
if dialog.exec(): | |
path = dialog.selectedFiles()[0] | |
#path, _ = QFileDialog.getOpenFileName(self, "Open Application", directory) | |
if path: | |
print("path="+path) | |
return path | |
else: | |
utils.showInfo("Cannot open this file.") | |
except Exception as e: | |
utils.showInfo(str(e)) | |
return None | |
def findFieldAudios(card): | |
global soundRegex | |
fldAudios = {} | |
for field, value in card.note().items(): | |
matchAudios = soundRegex.findall(value) | |
if matchAudios: | |
fldAudios[field] = [] | |
for audio in matchAudios: | |
fldAudios[field].append(audio) | |
return fldAudios | |
def findSearchCriteria(): | |
global config | |
## current deck | |
deck = mw.col.decks.current()['name'] | |
## default mode | |
searchCriteria = {"search_by_number": (['1',],['1',])} | |
if (config is not None) and (deck in config): | |
searchCriteria = config[deck] | |
return searchCriteria | |
def findEditorPath(): | |
global config | |
editorPath = config["editor_path"] | |
return editorPath | |
def getFieldInTemplate(tmpl): | |
fields = [] | |
start = 0 | |
while True: | |
s = tmpl.find('{{', start) | |
if s == -1: break | |
e = tmpl.find('}}', s) | |
if e != -1: | |
fields.append(tmpl[s + 2:e][:]) | |
start = e + 2 | |
else: break | |
return fields | |
def filterAudios(fields, fieldAudios, keptAudios): | |
audios = [] | |
for field in fields: | |
if field in fieldAudios: | |
audios.extend(fieldAudios[field]) | |
for i in range(len(audios),0,-1): | |
if str(i) not in keptAudios: | |
del audios[i-1] | |
return audios | |
def searchByNumber(numbers, card): | |
fieldAudios = findFieldAudios(card) | |
t = card.note().model()['tmpls'][card.ord] | |
if aqt.mw.reviewer.state == 'question': | |
## audios for front card | |
frontFields = getFieldInTemplate(t.get('qfmt')) | |
return filterAudios(frontFields, fieldAudios, numbers[0]) | |
else: | |
## audios for back card | |
backFields = getFieldInTemplate(t.get('afmt')) | |
return filterAudios(backFields, fieldAudios, numbers[1]) | |
def searchByFields(fields, card): | |
fieldAudios = findFieldAudios(card) | |
audios = [] | |
for field in fields: | |
if field in fieldAudios: | |
audios.extend(fieldAudios[field]) | |
return audios | |
def searchByRegex(regex, card): | |
## find by specified regex -> find by sound regex | |
compiledRe = re.compile(regex) | |
audios = [] | |
for field, value in card.note().items(): | |
matches = compiledRe.findall(value) | |
if matches: | |
for match in matches: | |
matchedAudios = soundRegex.findall(match) | |
for audio in matchedAudios: | |
audios.append(audio) | |
return audios | |
def findAudiosToOpen(searchCriteria, card): | |
if "search_by_regex" in searchCriteria: | |
return searchByRegex(searchCriteria["search_by_regex"], card) | |
if "search_by_number" in searchCriteria: | |
return searchByNumber(searchCriteria["search_by_number"], card) | |
if "search_by_fields" in searchCriteria: | |
return searchByFields(searchCriteria["search_by_fields"], card) | |
def openAudios(editorPath, audios): | |
global config, configFile | |
## use path to open editor and audios | |
audioPaths = ['%s' % os.path.join(aqt.mw.col.media.dir(), audio) for audio in audios] | |
for path in audioPaths: | |
if not os.path.exists(path): | |
utils.showInfo("file %s does not exist" % path) | |
return | |
if anki.utils.isWin: | |
#Windows_SND = u'''start "" "%s" "%s"''' | |
params = ["start", "", editorPath] | |
params.extend(audioPaths) | |
subprocess.call(params, shell=True) | |
else: | |
params = ["open", "-a", editorPath] | |
params.extend(audioPaths) | |
code = subprocess.call(params) | |
if code > 0: | |
utils.showInfo("Cannot open %s. Choose another editor" % editorPath) | |
dialog = OpenFileDialog() | |
if isinstance(dialog, list): | |
path = dialog.filename[0] | |
else: | |
path = dialog.filename | |
if path: | |
if not anki.utils.isWin and '/' in path: | |
path = path.split('/')[-1] | |
utils.showInfo("Choose editor successful.") | |
config["editor_path"] = path | |
with open(configFile, 'wb') as f: | |
dump(config, f) | |
def handleOpenAudiosInEditor(editor): | |
loadConfig() | |
note = editor.note | |
if note is None: | |
utils.showInfo("Please open a note with audio to edit.") | |
return | |
# Extract audio files for editing | |
searchCriteria = findSearchCriteria() | |
editorPath = findEditorPath() | |
audiosToOpen = findAudiosToOpen(searchCriteria, note) | |
openAudios(editorPath, audiosToOpen) | |
def setupEditorButton(self, editor): | |
btn = editor.addButton( | |
icon="audio-volume-high", # Use the speaker icon | |
cmd="edit_audio", | |
func=lambda: handleOpenAudiosInEditor(editor), | |
tip="Edit Audios in External Editor", | |
keys="Ctrl+G", # Change the shortcut to Control + G | |
) | |
# Connect button setup to editor initialization hook | |
gui_hooks.editor_did_init_buttons.append(setupEditorButton) | |
def handleOpenAudios(): | |
## if not review mode -> do nothing | |
## if review mode | |
## -> check if dialog is None or dialog.config has config[deck] | |
## if have -> select audio based on that config | |
## else use default config 1:1 | |
loadConfig() | |
card = aqt.mw.reviewer.card | |
if card is None: | |
utils.showInfo("You are only able to open audios when you are reviewing cards.") | |
return | |
searchCriteria = findSearchCriteria() | |
editorPath = findEditorPath() | |
audiosToOpen = findAudiosToOpen(searchCriteria, card) | |
openAudios(editorPath, audiosToOpen) | |
def handleConfig(): | |
global config, configFile | |
loadConfig() | |
dialog = AddonDialog(config, configFile) | |
config = dialog.config | |
dialog.exec() | |
config = {} | |
configFile = "" | |
def loadConfig(): | |
global config, configFile | |
if len(configFile) > 0: | |
return | |
configFile = os.path.join(aqt.mw.col.media.dir(), "editAudio.cfg") | |
config["editor_path"] = "/Applications/Audacity.app" | |
if anki.utils.isWin: | |
config["editor_path"] = "C:\\Program Files\\Audacity\\audacity.exe" | |
if os.path.exists(configFile): | |
with open(configFile, 'rb') as f: | |
try: | |
config = load(f) | |
except: | |
utils.showInfo("cannot load the config") | |
menu = mw.form.menuTools.addMenu("Edit audios in editor") | |
action = QAction("Edit audios", aqt.mw) | |
action.setShortcut(QKeySequence('g')) | |
action.triggered.connect(handleOpenAudios) | |
menu.addAction(action) | |
action = QAction("Config", aqt.mw) | |
action.setShortcut(QKeySequence('shift+g')) | |
action.triggered.connect(handleConfig) | |
menu.addAction(action) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment