diff --git a/.gitignore b/.gitignore index 6d53234..169b3f4 100644 --- a/.gitignore +++ b/.gitignore @@ -127,5 +127,8 @@ dmypy.json # Pyre type checker .pyre/ +# Ignore voicepack files +voicepacks/* + # End of https://www.gitignore.io/api/python diff --git a/README.md b/README.md index c27e4ad..5562723 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # LinVAM Linux Voice Activated Macro + ## Status This project is currently a work-in-progress and is minimally functional only for english. @@ -10,6 +11,7 @@ Known bugs and planned additions - Remember last loaded profile and load on start - Log window showing spoken words the V2T recognises with ability to right click and assign voice command and actions to current profile - Support for joysticks and gaming devices + ## Requirements - python3 - PyQt5 @@ -17,20 +19,33 @@ Known bugs and planned additions - pyaudio - pocketsphinx - swig3.0 + +## Optional requirements +- xdotool +- ffplay (part of ffmpeg, usually already installed) +- HCS voicepacks + ## Install - $ pip3 install PyQt5 - $ pip3 install python3-xlib - $ pip3 install pyaudio - $ pip3 install pocketsphinx - $ sudo apt-get install swig3.0 +- $ (optional) sudo apt install xdotool +- $ (optional) sudo apt install ffmpeg - $ sudo ln -s /usr/bin/swig3.0 /usr/bin/swig - $ git clone https://github.com/aidygus/LinVAM.git + ## Usage This script must be run with root privilege because it must hook and simulate input devices such as keyboard, mouse etc. - $ cd LinVAM - $ xhost + - $ sudo ./main.py +As an alternative, if you use the X window manager and have xdotool installed, you can run the script like this: +- $ cd LinVAM +- $ ./main.py -noroot + ### Profiles Multiple profiles are supported. To create a new profile for a specific task/game click new and the main profile editor window will be displayed @@ -46,3 +61,21 @@ You can also assign mouse movements and system commands if you require (eg openi ![Main GUI](https://raw.githubusercontent.com/aidygus/LinVAM/master/.img/complex.png) ### Threshold As a rough guide use a value of 10 for each syllable of a word then tweak it down for better accuracy. + +### Output audio +In the Command Edit Dialog, chose 'Other' and then 'Play sound'. Pick the sound you would like to play. +For this to work you need to copy any audio file you would like to use to the folder 'voicepacks'. +You are required to create a subfolder to hold all your audio files (voicepack folder), then within that subfolder, create as many folders as you like to group your audio files (category folders). +Place the audio file into these category folders or in any subfolder within a category folder. +In theory any audio file should work, but tested only with MP3 files. + +Example: +/voicepacks/my voicepack/custom commands/hello.mp3 +/voicepacks/my voicepack/other/thank you.mp3 + +If you own a HCS voicepack, copy the whole voicepack folder (like 'hcspack', 'hcspack-eden', ...) to the 'voicepacks' folder, so it reads like this: +/voicepacks/hcspack/... + +### Improve voice recognition accuracy +Please see this resource on how to train the acoustic model of pocketsphinx to match your voice: +https://cmusphinx.github.io/wiki/tutorialadapt/ \ No newline at end of file diff --git a/changes.md b/changes.md new file mode 100644 index 0000000..6b8f9fd --- /dev/null +++ b/changes.md @@ -0,0 +1,66 @@ +# Changelog smirgol + +### Issues: + - (fixed) voice recognition autostarts, there is no way to disable it. the flag "self.m_listening" does nothing + - (fixed) when profile is changed, the new commands will not be loaded, as the voice recognition is already running - by default with the Elite Dangerous settings. You cannot use any other profile. + - (fixed) "When I say" commands need to be all lower case, otherwise it won't be recognized. + +### Known/New Issues: + - When using external speakers it will pick up a playing audio sample and might trigger commands. Use headphones! :) + - On a Valve Index headset, the earphones, while being good, will feed the Index microphone causing troubles with the voice recognition. + There's probably nothing that can be done about it, maybe some sort of audio filter could be introduced to cut off low-volume sounds? + Lower voice audio for now to minimize the issue. + - There sometimes is a window error in the console when clicking on a 'cancel' button, does not seem to do harm, but should fix that if possible. + - Sometimes keypress signals are not recognized by game, this is the case with xdotool, don't know if this also happens with standard keyboard library. + It seems to solve the issue if you at least once manually press any key while the game window is focused + +### Added: + - hacked profileexcutor to reload command list when changing it. it did not do that, + so changing the profile had no effect at all. + - hacked profileexecutor to somewhat make use of "Enable listening". + It was an option without functionality at all until now. + - created new directory 'voicepacks', copy HCS voicepacks here + - new command to play a sound + - new gui to select sounds from voicepacks + - created playsound class that reads voicepack files and plays audio files with ffplay + - added alternate keypress handling using xdotool, which won't require root privileges. + might not work for any key existing in profiles, as some need to be remapped. added some remappings to profileexecutor.py pressKey() + - added basic command line argument reading to set some configs. right now there: + -noroot - will enable xdotool usage for keypresses + -xdowindowid - will send keypresses to this window only, makes it more relyable when window is not focused for any reason + - added auto-detection of Elite Dangerous client window id if -noroot is used and no -xdowindowid is supplied. + for this to work, start this script AFTER the client is already running. + - monkey-wrenched a volume slider to the main window + - added confirmation dialog for removing a profile (!) + - copy existing profile. added a copy button to the main menu + - properly shutdown audio recording stream + - updated gitignore to ignore audio files + +### Dependencies + - ffplay + - xdotool (optional, won't need root privileges for keypresses) + + Not sure about these, I had them installed already: + - import subprocess + - import shlex + - import signal + +### TODO + - select multiple files (from same category) to play one of them randomly + +### FUTURE IDEAS + - remember settings and load on start (nice to have) + - change key assignment logic to record a keypress / buttonpress combo + - will definitely break existing profiles, as we need to record keycodes and stuff + - maybe add a state machine like voice attack does + - look into reading log files from elite dangerous to trigger certain actions. + this would be a very game specific addition though... + - make connection to a specific voice pack more loose, so you can easily change the voice pack, + without re-editing all the commands. maybe even a different voice pack for certain actions, + like in voice attack (crewmen) + - reorganize main window to hold more settings and a log window for displaying actions + - look into other text2speech engines + i gave julius-speech a quick try. its pretty easy to setup and looks promising, but it + also has its problems with detecting some "exotic" words. as not being a native + english speaker, my pronounciation is not the best sometimes, which doesn't help. :) + - look into make recognized words using regex expressions \ No newline at end of file diff --git a/commandeditwnd.py b/commandeditwnd.py index f520451..931f470 100644 --- a/commandeditwnd.py +++ b/commandeditwnd.py @@ -5,6 +5,7 @@ from ui_commandeditwnd import Ui_CommandEditDialog from keyactioneditwnd import KeyActionEditWnd from mouseactioneditwnd import MouseActionEditWnd from pauseactioneditwnd import PauseActionEditWnd +from soundactioneditwnd import SoundActionEditWnd import json class CommandEditWnd(QDialog): @@ -12,6 +13,7 @@ class CommandEditWnd(QDialog): super().__init__(p_parent) self.ui = Ui_CommandEditDialog() self.ui.setupUi(self) + self.m_parent = p_parent self.ui.deleteBut.clicked.connect(self.slotDelete) self.ui.ok.clicked.connect(self.slotOK) @@ -27,6 +29,7 @@ class CommandEditWnd(QDialog): w_otherMenu = QMenu() w_otherMenu.addAction('Stop Another Command', self.slotStopAnotherCommand) w_otherMenu.addAction('Execute Another Command', self.slotDoAnotherCommand) + w_otherMenu.addAction('Play Sound', self.slotNewSoundEdit) self.ui.otherBut.setMenu(w_otherMenu) self.m_command = {} @@ -73,6 +76,14 @@ class CommandEditWnd(QDialog): w_commandDoAction['command name'] = text self.addAction(w_commandDoAction) + def slotDoPlaySound(self): + text, okPressed = QInputDialog.getItem(self, "Set sound to play", "Enter sound file:", list(self.m_parent.m_parent.m_sound.m_sounds), 0, False) + if okPressed and text != '': + w_commandDoAction = {} + w_commandDoAction['name'] = 'command play sound' + w_commandDoAction['command name'] = text + self.addAction(w_commandDoAction) + def slotNewKeyEdit(self): w_keyEditWnd = KeyActionEditWnd(None, self) if w_keyEditWnd.exec() == QDialog.Accepted: @@ -88,6 +99,11 @@ class CommandEditWnd(QDialog): if w_pauseEditWnd.exec() == QDialog.Accepted: self.addAction(w_pauseEditWnd.m_pauseAction) + def slotNewSoundEdit(self): + w_soundEditWnd = SoundActionEditWnd(self.m_parent.m_parent.m_sound, None, self) + if w_soundEditWnd.exec() == QDialog.Accepted: + self.addAction(w_soundEditWnd.m_soundAction) + def slotActionUp(self): currentIndex = self.ui.actionsListWidget.currentRow() currentItem = self.ui.actionsListWidget.takeItem(currentIndex); @@ -132,6 +148,10 @@ class CommandEditWnd(QDialog): if okPressed and text != '': w_action['command name'] = text w_jsonAction = json.dumps(w_action) + elif w_action['name'] == 'play sound': + w_soundEditWnd = SoundActionEditWnd(self.m_parent.m_parent.m_sound, w_action, self) + if w_soundEditWnd.exec() == QDialog.Accepted: + w_jsonAction = json.dumps(w_soundEditWnd.m_soundAction) w_item.setText(w_jsonAction) w_item.setData(Qt.UserRole, w_jsonAction) diff --git a/main.py b/main.py index d965974..b307f12 100755 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/python3 from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import * @@ -11,252 +11,324 @@ import pickle import signal import os import shutil +import subprocess +import shlex +from soundfiles import SoundFiles class MainWnd(QWidget): - def __init__(self, p_parent = None): - super().__init__(p_parent) - self.ui = Ui_MainWidget() - self.ui.setupUi(self) - self.m_profileExecutor = ProfileExecutor() + def __init__(self, p_parent = None): + super().__init__(p_parent) - self.ui.profileCbx.currentIndexChanged.connect(self.slotProfileChanged) - self.ui.addBut.clicked.connect(self.slotAddNewProfile) - self.ui.editBut.clicked.connect(self.slotEditProfile) - self.ui.removeBut.clicked.connect(self.slotRemoveProfile) - self.ui.listeningChk.stateChanged.connect(self.slotListeningEnabled) - self.ui.ok.clicked.connect(self.slotOK) - self.ui.cancel.clicked.connect(self.slotCancel) - signal.signal(signal.SIGINT, signal.SIG_DFL) + self.ui = Ui_MainWidget() + self.ui.setupUi(self) + self.handleArgs() + self.m_sound = SoundFiles() + self.m_profileExecutor = ProfileExecutor(None, self) - if self.loadFromDatabase() > 0 : - # if self.loadTestProfiles() > 0: - w_jsonProfile = self.ui.profileCbx.itemData(0) - if w_jsonProfile != None: - self.m_activeProfile = json.loads(w_jsonProfile) - self.m_profileExecutor.setProfile(self.m_activeProfile) - self.m_profileExecutor.start() + if not os.geteuid() == 0 and not self.m_config['noroot'] == 1: + print("\033[93m\nWARNING: no root privileges, unable to send key strokes to the system.\nConsider running this as root.\nFor that you might need to install some python modules for the root user. \n\033[0m") - def saveToDatabase(self): - w_profiles = [] - w_profileCnt = self.ui.profileCbx.count() - for w_idx in range(w_profileCnt): - w_jsonProfile = self.ui.profileCbx.itemData(w_idx) - if w_jsonProfile == None: - continue + self.ui.profileCbx.currentIndexChanged.connect(self.slotProfileChanged) + self.ui.addBut.clicked.connect(self.slotAddNewProfile) + self.ui.editBut.clicked.connect(self.slotEditProfile) + self.ui.copyBut.clicked.connect(self.slotCopyProfile) + self.ui.removeBut.clicked.connect(self.slotRemoveProfile) + self.ui.listeningChk.stateChanged.connect(self.slotListeningEnabled) + self.ui.ok.clicked.connect(self.slotOK) + self.ui.cancel.clicked.connect(self.slotCancel) + self.ui.sliderVolume.valueChanged.connect(lambda: self.m_sound.setVolume(self.ui.sliderVolume.value())) + signal.signal(signal.SIGINT, signal.SIG_DFL) - w_profile = json.loads(w_jsonProfile) - w_profiles.append(w_profile) + if self.loadFromDatabase() > 0 : + # if self.loadTestProfiles() > 0: + w_jsonProfile = self.ui.profileCbx.itemData(0) + if w_jsonProfile != None: + self.m_activeProfile = json.loads(w_jsonProfile) + self.m_profileExecutor.setProfile(self.m_activeProfile) + #self.m_profileExecutor.start() - with open(self.getSettingsPath("profiles.dat"), "wb") as f: - pickle.dump(w_profiles, f, pickle.HIGHEST_PROTOCOL) - def loadFromDatabase(self): - w_profiles = [] - with open(self.getSettingsPath("profiles.dat"), "rb") as f: - w_profiles = pickle.load(f) + def saveToDatabase(self): + w_profiles = [] + w_profileCnt = self.ui.profileCbx.count() + for w_idx in range(w_profileCnt): + w_jsonProfile = self.ui.profileCbx.itemData(w_idx) + if w_jsonProfile == None: + continue - for w_profile in w_profiles: - self.ui.profileCbx.addItem(w_profile['name']) - w_jsonProfile = json.dumps(w_profile) - self.ui.profileCbx.setItemData(self.ui.profileCbx.count() - 1, w_jsonProfile) + w_profile = json.loads(w_jsonProfile) + w_profiles.append(w_profile) - return len(w_profiles) + with open(self.getSettingsPath("profiles.dat"), "wb") as f: + pickle.dump(w_profiles, f, pickle.HIGHEST_PROTOCOL) - def getSettingsPath(self, setting): - home = os.path.expanduser("~") + '/.linvam/' - if not os.path.exists(home): - os.mkdir(home) - if not os.path.exists(home + setting): - shutil.copyfile(setting, home + setting) + def loadFromDatabase(self): + w_profiles = [] + with open(self.getSettingsPath("profiles.dat"), "rb") as f: + w_profiles = pickle.load(f) - return home + setting + for w_profile in w_profiles: + self.ui.profileCbx.addItem(w_profile['name']) + w_jsonProfile = json.dumps(w_profile) + self.ui.profileCbx.setItemData(self.ui.profileCbx.count() - 1, w_jsonProfile) - def loadTestProfiles(self): + return len(w_profiles) - w_carProfileDict = { - "name": "car game", - "commands": [ - {'name': 'forward', - 'actions': [ - {'name': 'key action', 'key': 'up', 'type': 1} - ], - 'repeat': 1, - 'async': False, - 'threshold': 12 - }, - {'name': 'back', - 'actions': [ - {'name': 'key action', 'key': 'down', 'type': 1} - ], - 'repeat': 1, - 'async': False, - 'threshold': 3 - }, - {'name': 'left', - 'actions': [{'name': 'key action', 'key': 'right', 'type': 0}, - {'name': 'key action', 'key': 'left', 'type': 1}, - ], - 'repeat': 1, - 'async': False, - 'threshold': 10 - }, - {'name': 'right', - 'actions': [{'name': 'key action', 'key': 'left', 'type': 0}, - {'name': 'key action', 'key': 'right', 'type': 1}, - ], - 'repeat': 1, - 'async': False, - 'threshold': 3 - }, - {'name': 'stop', - 'actions': [ - {'name': 'key action', 'key': 'left', 'type': 0}, - {'name': 'key action', 'key': 'right', 'type': 0}, - {'name': 'key action', 'key': 'up', 'type': 0}, - {'name': 'key action', 'key': 'down', 'type': 0} - ], - 'repeat': 1, - 'async': False, - 'threshold': 8 - } - ] - } + def getSettingsPath(self, setting): + home = os.path.expanduser("~") + '/.linvam/' + if not os.path.exists(home): + os.mkdir(home) + if not os.path.exists(home + setting): + shutil.copyfile(setting, home + setting) - w_airplaneProfileDict = { - "name": "airplane game", - "commands": [ - {'name': 'up', - 'actions': [ - {'name': 'command stop action', 'command name': 'down'}, - {'name': 'mouse move action', 'x': 0, 'y': -5, 'absolute': False}, - {'name': 'pause action', 'time': 0.01} - ], - 'repeat': -1, - 'async': True, - 'threshold': 3 - }, - {'name': 'left', - 'actions': [ - {'name': 'command stop action', 'command name': 'right'}, - {'name': 'mouse move action', 'x': -5, 'y': 0, 'absolute': False}, - {'name': 'pause action', 'time': 0.005} - ], - 'repeat': -1, - 'async': True, - 'threshold': 3 - }, - {'name': 'right', - 'actions': [ - {'name': 'command stop action', 'command name': 'left'}, - {'name': 'mouse move action', 'x': 5, 'y': 0, 'absolute': False}, - {'name': 'pause action', 'time': 0.005} - ], - 'repeat': -1, - 'async': True, - 'threshold': 3 - }, - {'name': 'down', - 'actions': [ - {'name': 'command stop action', 'command name': 'up'}, - {'name': 'mouse move action', 'x': 0, 'y': 5, 'absolute': False}, - {'name': 'pause action', 'time': 0.005} - ], - 'repeat': -1, - 'async': True, - 'threshold': 3 - }, - {'name': 'shoot', - 'actions': [ - {'name': 'mouse click action', 'button': 'left', 'type': 1}, - {'name': 'pause action', 'time': 0.03} - ], - 'repeat': 1, - 'async': False, - 'threshold': 3 - }, - {'name': 'stop', - 'actions': [ - {'name': 'command stop action', 'command name': 'up'}, - {'name': 'command stop action', 'command name': 'left'}, - {'name': 'command stop action', 'command name': 'right'}, - {'name': 'command stop action', 'command name': 'down'}, - {'name': 'mouse click action', 'button': 'left', 'type': 0} - ], - 'repeat': 1, - 'async': False, - 'threshold': 3 - } - ] - } - w_profiles = [] - w_profiles.append(w_carProfileDict) - w_profiles.append(w_airplaneProfileDict) + return home + setting - i = 0 - for w_profile in w_profiles: - self.ui.profileCbx.addItem(w_profile['name']) - w_jsonProfile = json.dumps(w_profile) - self.ui.profileCbx.setItemData(i, w_jsonProfile) - i = i + 1 + def loadTestProfiles(self): - return i + w_carProfileDict = { + "name": "car game", + "commands": [ + {'name': 'forward', + 'actions': [ + {'name': 'key action', 'key': 'up', 'type': 1} + ], + 'repeat': 1, + 'async': False, + 'threshold': 12 + }, + {'name': 'back', + 'actions': [ + {'name': 'key action', 'key': 'down', 'type': 1} + ], + 'repeat': 1, + 'async': False, + 'threshold': 3 + }, + {'name': 'left', + 'actions': [{'name': 'key action', 'key': 'right', 'type': 0}, + {'name': 'key action', 'key': 'left', 'type': 1}, + ], + 'repeat': 1, + 'async': False, + 'threshold': 10 + }, + {'name': 'right', + 'actions': [{'name': 'key action', 'key': 'left', 'type': 0}, + {'name': 'key action', 'key': 'right', 'type': 1}, + ], + 'repeat': 1, + 'async': False, + 'threshold': 3 + }, + {'name': 'stop', + 'actions': [ + {'name': 'key action', 'key': 'left', 'type': 0}, + {'name': 'key action', 'key': 'right', 'type': 0}, + {'name': 'key action', 'key': 'up', 'type': 0}, + {'name': 'key action', 'key': 'down', 'type': 0} + ], + 'repeat': 1, + 'async': False, + 'threshold': 8 + } + ] + } - def slotProfileChanged(self, p_idx): - w_jsonProfile = self.ui.profileCbx.itemData(p_idx) - if w_jsonProfile != None: - self.m_activeProfile = json.loads(w_jsonProfile) - self.m_profileExecutor.setProfile(self.m_activeProfile) + w_airplaneProfileDict = { + "name": "airplane game", + "commands": [ + {'name': 'up', + 'actions': [ + {'name': 'command stop action', 'command name': 'down'}, + {'name': 'mouse move action', 'x': 0, 'y': -5, 'absolute': False}, + {'name': 'pause action', 'time': 0.01} + ], + 'repeat': -1, + 'async': True, + 'threshold': 3 + }, + {'name': 'left', + 'actions': [ + {'name': 'command stop action', 'command name': 'right'}, + {'name': 'mouse move action', 'x': -5, 'y': 0, 'absolute': False}, + {'name': 'pause action', 'time': 0.005} + ], + 'repeat': -1, + 'async': True, + 'threshold': 3 + }, + {'name': 'right', + 'actions': [ + {'name': 'command stop action', 'command name': 'left'}, + {'name': 'mouse move action', 'x': 5, 'y': 0, 'absolute': False}, + {'name': 'pause action', 'time': 0.005} + ], + 'repeat': -1, + 'async': True, + 'threshold': 3 + }, + {'name': 'down', + 'actions': [ + {'name': 'command stop action', 'command name': 'up'}, + {'name': 'mouse move action', 'x': 0, 'y': 5, 'absolute': False}, + {'name': 'pause action', 'time': 0.005} + ], + 'repeat': -1, + 'async': True, + 'threshold': 3 + }, + {'name': 'shoot', + 'actions': [ + {'name': 'mouse click action', 'button': 'left', 'type': 1}, + {'name': 'pause action', 'time': 0.03} + ], + 'repeat': 1, + 'async': False, + 'threshold': 3 + }, + {'name': 'stop', + 'actions': [ + {'name': 'command stop action', 'command name': 'up'}, + {'name': 'command stop action', 'command name': 'left'}, + {'name': 'command stop action', 'command name': 'right'}, + {'name': 'command stop action', 'command name': 'down'}, + {'name': 'mouse click action', 'button': 'left', 'type': 0} + ], + 'repeat': 1, + 'async': False, + 'threshold': 3 + } + ] + } + w_profiles = [] + w_profiles.append(w_carProfileDict) + w_profiles.append(w_airplaneProfileDict) - def slotAddNewProfile(self): - w_profileEditWnd = ProfileEditWnd(None, self) - if w_profileEditWnd.exec() == QDialog.Accepted: - w_profile = w_profileEditWnd.m_profile - self.m_profileExecutor.setProfile(w_profile) - self.ui.profileCbx.addItem(w_profile['name']) - w_jsonProfile = json.dumps(w_profile) - self.ui.profileCbx.setItemData(self.ui.profileCbx.count()-1, w_jsonProfile) + i = 0 + for w_profile in w_profiles: + self.ui.profileCbx.addItem(w_profile['name']) + w_jsonProfile = json.dumps(w_profile) + self.ui.profileCbx.setItemData(i, w_jsonProfile) + i = i + 1 - def slotEditProfile(self): - w_idx = self.ui.profileCbx.currentIndex() - w_jsonProfile = self.ui.profileCbx.itemData(w_idx) - w_profile = json.loads(w_jsonProfile) - w_profileEditWnd = ProfileEditWnd(w_profile, self) - if w_profileEditWnd.exec() == QDialog.Accepted: - w_profile = w_profileEditWnd.m_profile - self.m_profileExecutor.setProfile(w_profile) - self.ui.profileCbx.setItemText(w_idx, w_profile['name']) - w_jsonProfile = json.dumps(w_profile) - self.ui.profileCbx.setItemData(w_idx, w_jsonProfile) + return i - def slotRemoveProfile(self): - w_curIdx = self.ui.profileCbx.currentIndex() - if w_curIdx >= 0: - self.ui.profileCbx.removeItem(w_curIdx) + def slotProfileChanged(self, p_idx): + w_jsonProfile = self.ui.profileCbx.itemData(p_idx) + if w_jsonProfile != None: + self.m_activeProfile = json.loads(w_jsonProfile) + self.m_profileExecutor.setProfile(self.m_activeProfile) - w_curIdx = self.ui.profileCbx.currentIndex() - if w_curIdx >= 0: - w_jsonProfile = self.ui.profileCbx.itemData(w_curIdx) - w_profile = json.loads(w_jsonProfile) - self.m_profileExecutor.setProfile(w_profile) + def slotAddNewProfile(self): + w_profileEditWnd = ProfileEditWnd(None, self) + if w_profileEditWnd.exec() == QDialog.Accepted: + w_profile = w_profileEditWnd.m_profile + self.m_profileExecutor.setProfile(w_profile) + self.ui.profileCbx.addItem(w_profile['name']) + w_jsonProfile = json.dumps(w_profile) + self.ui.profileCbx.setItemData(self.ui.profileCbx.count()-1, w_jsonProfile) - def slotListeningEnabled(self, p_enabled): - if p_enabled: - self.m_profileExecutor.setEnableListening(True) - else: - self.m_profileExecutor.setEnableListening(False) + def slotEditProfile(self): + w_idx = self.ui.profileCbx.currentIndex() + w_jsonProfile = self.ui.profileCbx.itemData(w_idx) + w_profile = json.loads(w_jsonProfile) + w_profileEditWnd = ProfileEditWnd(w_profile, self) + if w_profileEditWnd.exec() == QDialog.Accepted: + w_profile = w_profileEditWnd.m_profile + self.m_profileExecutor.setProfile(w_profile) + self.ui.profileCbx.setItemText(w_idx, w_profile['name']) + w_jsonProfile = json.dumps(w_profile) + self.ui.profileCbx.setItemData(w_idx, w_jsonProfile) + + def slotCopyProfile(self): + text, okPressed = QInputDialog.getText(self, "Copy profile", "Enter new profile name:", QLineEdit.Normal, "") + if okPressed and text != '': + # todo: check if name not already in use + w_idx = self.ui.profileCbx.currentIndex() + w_jsonProfile = self.ui.profileCbx.itemData(w_idx) + w_profile = json.loads(w_jsonProfile) + w_profile_copy = w_profile + w_profile_copy['name'] = text + self.ui.profileCbx.addItem(w_profile_copy['name']) + w_jsonProfile = json.dumps(w_profile_copy) + self.ui.profileCbx.setItemData(self.ui.profileCbx.count()-1, w_jsonProfile) + + def slotRemoveProfile(self): + + buttonReply = QMessageBox.question(self, 'Remove Profile', "Do you really want to delete this profile?", QMessageBox.No | QMessageBox.Yes, QMessageBox.No) + if buttonReply == QMessageBox.No: + return + + w_curIdx = self.ui.profileCbx.currentIndex() + if w_curIdx >= 0: + self.ui.profileCbx.removeItem(w_curIdx) + + w_curIdx = self.ui.profileCbx.currentIndex() + if w_curIdx >= 0: + w_jsonProfile = self.ui.profileCbx.itemData(w_curIdx) + w_profile = json.loads(w_jsonProfile) + self.m_profileExecutor.setProfile(w_profile) + + def slotListeningEnabled(self, p_enabled): + if p_enabled: + self.m_profileExecutor.setEnableListening(True) + else: + self.m_profileExecutor.setEnableListening(False) + + def slotOK(self): + self.saveToDatabase() + self.m_profileExecutor.shutdown() + self.close() + + def slotCancel(self): + self.m_profileExecutor.shutdown() + self.close() + exit() + + def handleArgs(self): + self.m_config = { + 'noroot' : 0, + 'xdowindowid' : None + } + + if len(sys.argv) == 1: + return + + for i in range(1,len(sys.argv)): + if sys.argv[i] == '-noroot': + self.m_config['noroot'] = 1 + elif sys.argv[i] == '-xdowindowid' and (i+1 < len(sys.argv)): + self.m_config['xdowindowid'] = sys.argv[i+1] + i = i+1 + + # try to help: if -noroot is supplied, but no xdowindowid, try to determine the id + # Elite Dangerous only + try: + args = shlex.split('xdotool search --name "\(CLIENT\)"') + window_id = str(subprocess.check_output(args)) + window_id = window_id.replace('b\'', '') + window_id = window_id.replace('\\n\'','') + except subprocess.CalledProcessError: + window_id = None + pass + + if not window_id == None: + # check if it's really the Client we want + try: + window_name = subprocess.check_output(['xdotool', 'getwindowname', str(window_id)]) + except subprocess.CalledProcessError: + window_name = None + pass + if not window_name == None: + if not str(window_name).find('Elite - Dangerous') == -1: + print("Window ID: ", str(window_id), ", window name: ", window_name) + print("Auto-detected window id of ED Client: ", window_id) + self.m_config['xdowindowid'] = window_id - def slotOK(self): - self.saveToDatabase() - self.m_profileExecutor.stop() - self.close() - def slotCancel(self): - self.m_profileExecutor.stop() - self.close() - exit() if __name__ == "__main__": - app = QApplication(sys.argv) - mainWnd = MainWnd() - mainWnd.show() - sys.exit(app.exec_()) + app = QApplication(sys.argv) + mainWnd = MainWnd() + mainWnd.show() + sys.exit(app.exec_()) diff --git a/mainwnd.ui b/mainwnd.ui index 790a116..b0dbda2 100644 --- a/mainwnd.ui +++ b/mainwnd.ui @@ -25,7 +25,7 @@ 20 - + 20 @@ -38,7 +38,7 @@ - + Qt::Horizontal @@ -50,6 +50,57 @@ + + + + + 200 + 0 + + + + Voice Volume + + + Qt::AlignCenter + + + + + 10 + 20 + 181 + 21 + + + + Change voice volume + + + 100 + + + 100 + + + Qt::Horizontal + + + + + + + + Qt::Horizontal + + + + 20 + 20 + + + + @@ -78,13 +129,19 @@ - + 20 + + + 0 + 0 + + 60 @@ -100,7 +157,7 @@ - 250 + 300 0 @@ -110,7 +167,7 @@ - 130 + 75 0 @@ -123,7 +180,7 @@ - 130 + 75 0 @@ -132,11 +189,27 @@ + + + + + 75 + 0 + + + + Copy currently selected profile + + + Copy + + + - 130 + 75 0 @@ -145,19 +218,6 @@ - - - - Qt::Horizontal - - - - 40 - 20 - - - - diff --git a/profileeditwnd.py b/profileeditwnd.py index 701c139..af04fe5 100644 --- a/profileeditwnd.py +++ b/profileeditwnd.py @@ -13,6 +13,7 @@ class ProfileEditWnd(QDialog): self.ui = Ui_ProfileEditDialog() self.ui.setupUi(self) self.m_profile = {} + self.m_parent = p_parent self.ui.cmdTable.setHorizontalHeaderLabels(('Spoken command', 'Actions')) self.ui.cmdTable.setSelectionBehavior(QAbstractItemView.SelectRows) diff --git a/profileexecutor.py b/profileexecutor.py index a89de52..a70c140 100644 --- a/profileexecutor.py +++ b/profileexecutor.py @@ -4,31 +4,44 @@ import time import threading import os, pyaudio import shutil +import re from pocketsphinx.pocketsphinx import * from sphinxbase.sphinxbase import * +from soundfiles import SoundFiles + class ProfileExecutor(threading.Thread): mouse = Controller() - def __init__(self, p_profile = None): - threading.Thread.__init__(self) + def __init__(self, p_profile = None, p_parent = None): + # threading.Thread.__init__(self) + + # does nothing? self.setProfile(p_profile) self.m_stop = False - self.m_listening = True + self.m_listening = False self.m_cmdThreads = {} self.m_config = Decoder.default_config() self.m_config.set_string('-hmm', os.path.join('model', 'en-us/en-us')) self.m_config.set_string('-dict', os.path.join('model', 'en-us/cmudict-en-us.dict')) self.m_config.set_string('-kws', 'command.list') + # you usually don't want all this info stuff as a regular user. it just covers up init messages + self.m_config.set_string('-logfn', '/dev/null') - p = pyaudio.PyAudio() - self.m_stream = p.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True) - self.m_stream.start_stream() + self.m_pyaudio = pyaudio.PyAudio() + self.m_stream = self.m_pyaudio.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True) # Process audio chunk by chunk. On keyword detected perform action and restart search self.m_decoder = Decoder(self.m_config) + self.m_thread = False + + self.p_parent = p_parent + if not self.p_parent == None: + self.m_sound = self.p_parent.m_sound + + def getSettingsPath(self, setting): home = os.path.expanduser("~") + '/.linvam/' if not os.path.exists(home): @@ -39,25 +52,46 @@ class ProfileExecutor(threading.Thread): return home + setting def setProfile(self, p_profile): + #print("setProfile") self.m_profile = p_profile if self.m_profile == None: return - + #print ("writing command list") w_commandWordFile = open(self.getSettingsPath('command.list'), 'w') w_commands = self.m_profile['commands'] i = 0 for w_command in w_commands: if i != 0: w_commandWordFile.write('\n') - w_commandWordFile.write(w_command['name'] + ' /1e-%d/' % w_command['threshold']) + w_commandWordFile.write(w_command['name'].lower() + ' /1e-%d/' % w_command['threshold']) i = i + 1 w_commandWordFile.close() self.m_config.set_string('-kws', self.getSettingsPath('command.list')) + # load new command list into decoder and restart it + if self.m_listening == True: + self.stop() + self.m_stream.start_stream() + # a self.m_decoder.reinit(self.config) will segfault? + self.m_decoder = Decoder(self.m_config) + self.m_stop = False + self.m_thread = threading.Thread(target=self.doListen, args=()) + self.m_thread.start() + else: + self.m_decoder.reinit(self.m_config) def setEnableListening(self, p_enable): - self.m_listening = p_enable + if self.m_listening == False and p_enable == True: + self.m_stream.start_stream() + self.m_listening = p_enable + self.m_stop = False + self.m_thread = threading.Thread(target=self.doListen, args=()) + self.m_thread.start() + elif self.m_listening == True and p_enable == False: + self.stop() - def run(self): + def doListen(self): + print("Detection started") + self.m_listening = True self.m_decoder.start_utt() while self.m_stop != True: buf = self.m_stream.read(1024) @@ -65,8 +99,14 @@ class ProfileExecutor(threading.Thread): self.m_decoder.process_raw(buf, False, False) if self.m_decoder.hyp() != None: - # print([(seg.word, seg.prob, seg.start_frame, seg.end_frame) for seg in decoder.seg()]) - # print("Detected keyword, restarting search") + #print([(seg.word, seg.prob, seg.start_frame, seg.end_frame) for seg in self.m_decoder.seg()]) + #print("Detected keyword, restarting search") + + # hack :) + for seg in self.m_decoder.seg(): + print("Detected: ",seg.word) + break + # # Here you run the code you want based on keyword # @@ -76,9 +116,21 @@ class ProfileExecutor(threading.Thread): self.m_decoder.end_utt() self.m_decoder.start_utt() + # def run(self): + def stop(self): - self.m_stop = True - threading.Thread.join(self) + if self.m_listening == True: + self.m_stop = True + self.m_listening = False + self.m_decoder.end_utt() + self.m_thread.join() + self.m_stream.stop_stream() + + def shutdown(self): + self.stop() + self.m_stream.close() + self.m_pyaudio.terminate() + def doAction(self, p_action): # {'name': 'key action', 'key': 'left', 'type': 0} @@ -91,20 +143,14 @@ class ProfileExecutor(threading.Thread): if w_actionName == 'key action': w_key = p_action['key'] w_type = p_action['type'] - if w_type == 1: - keyboard.press(w_key) - print("pressed key: ", w_key) - elif w_type == 0: - keyboard.release(w_key) - print("released key: ", w_key) - elif w_type == 10: - keyboard.press(w_key) - keyboard.release(w_key) - print("pressed and released key: ", w_key) + self.pressKey(w_key, w_type) elif w_actionName == 'pause action': + print("Sleep ", p_action['time']) time.sleep(p_action['time']) elif w_actionName == 'command stop action': self.stopCommand(p_action['command name']) + elif w_actionName == 'command play sound' or w_actionName == 'play sound': + self.playSound(p_action) elif w_actionName == 'mouse move action': if p_action['absolute']: ProfileExecutor.mouse.position([p_action['x'], p_action['y']]) @@ -167,7 +213,7 @@ class ProfileExecutor(threading.Thread): w_commands = self.m_profile['commands'] flag = False for w_command in w_commands: - if w_command['name'] == p_cmdName: + if w_command['name'].lower() == p_cmdName: flag = True break if flag == False: @@ -195,3 +241,66 @@ class ProfileExecutor(threading.Thread): if p_cmdName in self.m_cmdThreads.keys(): self.m_cmdThreads[p_cmdName].stop() del self.m_cmdThreads[p_cmdName] + + def playSound(self, p_cmdName): + sound_file = './voicepacks/' + p_cmdName['pack'] + '/' + p_cmdName['cat'] + '/' + p_cmdName['file'] + self.m_sound.play(sound_file) + + + def pressKey(self, w_key, w_type): + if self.p_parent.m_config['noroot'] == 1: + # xdotool has a different key mapping. translate old existing mappings of special keys + # use this to find key name: xev -event keyboard + w_key = re.sub('left ctrl', 'Control_L', w_key, flags=re.IGNORECASE) + w_key = re.sub('right ctrl', 'Control_R', w_key, flags=re.IGNORECASE) + w_key = re.sub('left shift', 'Shift_L', w_key, flags=re.IGNORECASE) + w_key = re.sub('right shift', 'Shift_R', w_key, flags=re.IGNORECASE) + w_key = re.sub('left alt', 'Alt_L', w_key, flags=re.IGNORECASE) + w_key = re.sub('right alt', 'Alt_R', w_key, flags=re.IGNORECASE) + w_key = re.sub('left windows', 'Super_L', w_key, flags=re.IGNORECASE) + w_key = re.sub('right windows', 'Super_R', w_key, flags=re.IGNORECASE) + w_key = re.sub('tab', 'Tab', w_key, flags=re.IGNORECASE) + w_key = re.sub('esc', 'Escape', w_key, flags=re.IGNORECASE) + + w_key = re.sub('left', 'Left', w_key, flags=re.IGNORECASE) + w_key = re.sub('right', 'Right', w_key, flags=re.IGNORECASE) + w_key = re.sub('up', 'Up', w_key, flags=re.IGNORECASE) + w_key = re.sub('down', 'Down', w_key, flags=re.IGNORECASE) + + w_key = re.sub('ins$', 'Insert', w_key, flags=re.IGNORECASE) + w_key = re.sub('del$', 'Delete', w_key, flags=re.IGNORECASE) + w_key = re.sub('home', 'Home', w_key, flags=re.IGNORECASE) + w_key = re.sub('end', 'End', w_key, flags=re.IGNORECASE) + w_key = re.sub('Page\s?up', 'Prior', w_key, flags=re.IGNORECASE) + w_key = re.sub('Page\s?down', 'Next', w_key, flags=re.IGNORECASE) + w_key = re.sub('return', 'Return', w_key, flags=re.IGNORECASE) + w_key = re.sub('enter', 'Return', w_key, flags=re.IGNORECASE) + w_key = re.sub('backspace', 'BackSpace', w_key, flags=re.IGNORECASE) + + w_key = w_key.replace('insert', 'Insert') + w_key = w_key.replace('delete', 'Delete') + + window_cmd = "" + if not self.p_parent.m_config['xdowindowid'] == None: + window_cmd = " windowactivate --sync " + str(self.p_parent.m_config['xdowindowid']) + + if w_type == 1: + os.system('xdotool ' + window_cmd + ' keydown ' + str(w_key) ) + print("pressed key: ", w_key) + elif w_type == 0: + os.system('xdotool' + window_cmd + ' keyup ' + str(w_key)) + print("released key: ", w_key) + elif w_type == 10: + os.system('xdotool' + window_cmd + ' key ' + str(w_key)) + print("pressed and released key: ", w_key) + else: + if w_type == 1: + keyboard.press(w_key) + print("pressed key: ", w_key) + elif w_type == 0: + keyboard.release(w_key) + print("released key: ", w_key) + elif w_type == 10: + keyboard.press(w_key) + keyboard.release(w_key) + print("pressed and released key: ", w_key) \ No newline at end of file diff --git a/soundactioneditwnd.py b/soundactioneditwnd.py new file mode 100644 index 0000000..7ff81a0 --- /dev/null +++ b/soundactioneditwnd.py @@ -0,0 +1,164 @@ +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +import re +import os +from ui_soundactioneditwnd import Ui_SoundSelect + +class SoundActionEditWnd(QDialog): + def __init__(self, p_sounds, p_soundAction = None, p_parent = None): + super().__init__(p_parent) + self.ui = Ui_SoundSelect() + self.ui.setupUi(self) + + if p_sounds == None: + return + + self.p_sounds = p_sounds + self.selectedVoicepack = False + self.selectedCategory = False + self.selectedFile = False + + self.ui.buttonOkay.clicked.connect(self.slotOK) + self.ui.buttonCancel.clicked.connect(super().reject) + self.ui.buttonPlaySound.clicked.connect(self.playSound) + self.ui.buttonStopSound.clicked.connect(self.stopSound) + self.ui.buttonOkay.setEnabled(False) + + # restore stuff when editing + if not p_soundAction == None: + self.selectedVoicepack = p_soundAction['pack'] + self.selectedCategory = p_soundAction['cat'] + self.selectedFile = p_soundAction['file'] + self.ui.buttonOkay.setEnabled(True) + + self.listVoicepacks_model = QStandardItemModel() + self.ui.listVoicepacks.setModel(self.listVoicepacks_model) + self.ui.listVoicepacks.clicked.connect(self.onVoicepackSelect) + + self.listCategories_model = QStandardItemModel() + self.ui.listCategories.setModel(self.listCategories_model) + self.ui.listCategories.clicked.connect(self.onCategorySelect) + + self.listFiles_model = QStandardItemModel() + self.ui.listFiles.setModel(self.listFiles_model) + self.ui.listFiles.clicked.connect(self.onFileSelect) + self.ui.listFiles.doubleClicked.connect(self.selectAndPlay) + + s = sorted(p_sounds.m_sounds) + for v in s: + item = QStandardItem(v) + self.listVoicepacks_model.appendRow(item) + + self.ui.filterCategories.textChanged.connect(self.populateCategories) + self.ui.filterFiles.textChanged.connect(self.populateFiles) + + + self.populateCategories(False) + self.populateFiles(False) + + # when editing, select old entries + if not self.selectedVoicepack == False: + item = self.listVoicepacks_model.findItems(self.selectedVoicepack) + if len(item) > 0: + index = self.listVoicepacks_model.indexFromItem(item[0]) + self.ui.listVoicepacks.setCurrentIndex(index) + + if not self.selectedCategory == False: + item = self.listCategories_model.findItems(self.selectedCategory) + if len(item) > 0: + index = self.listCategories_model.indexFromItem(item[0]) + self.ui.listCategories.setCurrentIndex(index) + + if not self.selectedFile == False: + item = self.listFiles_model.findItems(self.selectedFile) + if len(item) > 0: + index = self.listFiles_model.indexFromItem(item[0]) + self.ui.listFiles.setCurrentIndex(index) + + + + + def slotOK(self): + self.m_soundAction = {'name': 'play sound', 'pack': self.selectedVoicepack, 'cat' : self.selectedCategory, 'file' : self.selectedFile} + super().accept() + + def slotCancel(self): + super().reject() + + def onVoicepackSelect(self): + index = self.ui.listVoicepacks.currentIndex() + itemText = index.data() + self.selectedVoicepack = itemText + self.populateCategories() + self.ui.buttonOkay.setEnabled(False) + self.ui.buttonPlaySound.setEnabled(False) + + def onCategorySelect(self): + index = self.ui.listCategories.currentIndex() + itemText = index.data() + self.selectedCategory = itemText + self.populateFiles() + self.ui.buttonOkay.setEnabled(False) + self.ui.buttonPlaySound.setEnabled(False) + + + def onFileSelect(self): + index = self.ui.listFiles.currentIndex() + itemText = index.data() + self.selectedFile = itemText + self.ui.buttonOkay.setEnabled(True) + self.ui.buttonPlaySound.setEnabled(True) + + def selectAndPlay(self): + self.onFileSelect() + self.playSound() + + def populateCategories(self, reset = True): + if self.selectedVoicepack == False: + return + + if reset == True: + self.listCategories_model.removeRows( 0, self.listCategories_model.rowCount() ) + self.listFiles_model.removeRows( 0, self.listFiles_model.rowCount() ) + self.selectedCategory = False + self.selectedFile = False + + filter_categories = self.ui.filterCategories.toPlainText() + if len(filter_categories) == 0: + filter_categories = None + + s = sorted(self.p_sounds.m_sounds[self.selectedVoicepack]) + for v in s: + if not filter_categories == None: + if not re.search(filter_categories, v, re.IGNORECASE): + continue + item = QStandardItem(v) + self.listCategories_model.appendRow(item) + + def populateFiles(self, reset = True): + if self.selectedVoicepack == False or self.selectedCategory == False: + return + + if reset == True: + self.listFiles_model.removeRows( 0, self.listFiles_model.rowCount() ) + self.selectedFile = False + + filter_files = self.ui.filterFiles.toPlainText() + if len(filter_files) == 0: + filter_files = None + + s = sorted(self.p_sounds.m_sounds[self.selectedVoicepack][self.selectedCategory]) + for v in s: + if not filter_files == None: + if not re.search(filter_files, v, re.IGNORECASE): + continue + item = QStandardItem(v) + self.listFiles_model.appendRow(item) + + def playSound(self): + sound_file = './voicepacks/' + self.selectedVoicepack + '/' + self.selectedCategory + '/' + self.selectedFile + self.p_sounds.play(sound_file) + + def stopSound(self): + self.p_sounds.stop() \ No newline at end of file diff --git a/soundactioneditwnd.ui b/soundactioneditwnd.ui new file mode 100644 index 0000000..56fed6d --- /dev/null +++ b/soundactioneditwnd.ui @@ -0,0 +1,241 @@ + + + SoundSelect + + + + 0 + 0 + 1119 + 362 + + + + Sound selection + + + + + + + + 10 + 30 + 111 + 17 + + + + + + + VoicePacks: + + + + + + 160 + 30 + 111 + 17 + + + + + + + Categories: + + + + + + 660 + 90 + 451 + 231 + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + + + true + + + + 160 + 90 + 491 + 231 + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + + + + 10 + 50 + 141 + 271 + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + + + + 660 + 30 + 111 + 17 + + + + + + + Voice files: + + + + + true + + + + 940 + 330 + 80 + 25 + + + + + + + Ok + + + + + + 1030 + 330 + 80 + 25 + + + + + + + Cancel + + + + + + 160 + 50 + 491 + 31 + + + + Filter categories + + + Filter + + + + + + 660 + 50 + 451 + 31 + + + + Filter voice files + + + Filter + + + + + + + + + 410 + 330 + 80 + 25 + + + + + + + Play Sound + + + + + + 500 + 330 + 80 + 25 + + + + + + + Stop Sound + + + + + + diff --git a/soundfiles.py b/soundfiles.py new file mode 100644 index 0000000..3b0cfe8 --- /dev/null +++ b/soundfiles.py @@ -0,0 +1,87 @@ +import time +import threading +import os +import shutil +from os import path +import subprocess +import shlex +import signal + +# I've tried a couple of libs that are capable of playing mp3: +# pysound - offers no way to stop sounds. can't play files with whitespace in path +# pygame - has problems with certain mp3 files from voice packs +# mpg123 - does not offer volume control. need to be threaded as it's a system binary +# +# ffplay - need to be threaded as it's a system binary. i picked this one as it uses ffmpeg which +# is an excellent tool and should be installed on any system already +# I needed some process stuff to be able to stop an already playing sound (kill ffplay subprocess) + + +class SoundFiles(): + def __init__(self): + print("SoundFiles: init") + self.m_sounds = {} + self.scanSoundFiles() + self.thread_play = False + self.volume = 100 + + def scanSoundFiles(self): + print("SoundFiles: scanning") + if not path.exists('./voicepacks'): + print("No folder 'voicepacks' found. Please create one and copy all your voicepacks in there.") + return + + for root, dirs, files in os.walk("./voicepacks"): + for file in files: + if file.endswith(".mp3"): + # we expect a path like this: + # voicepacks/VOICEPACKNAME/COMMANDGROUP/(FURTHER_OPTIONAL_FOLDERS/)FILE + path_parts = root.split('/') + + if len(path_parts) < 4: + continue + + if not path_parts[2] in self.m_sounds: + self.m_sounds[path_parts[2]] = {} + + category = path_parts[3] + # there might be subfolders, so we have more than just 4 split results... + # for the ease of my mind, we concat the voicepack subfolders to 1 category name + # like voicepacks/hcspack/Characters/Astra/blah.mp3 will become: + # + # voicepack = hcspack + # category = Characters/Astra + # file = blah.mp4 + + if len(path_parts) > 4: + for i in range(4, len(path_parts)): + category = category + '/' + path_parts[i] + + if not category in self.m_sounds[path_parts[2]]: + self.m_sounds[path_parts[2]][category] = [] + + self.m_sounds[path_parts[2]][category].append(file) + + def play(self, sound_file): + if not os.path.isfile(sound_file): + print("ERROR - Sound file not found: ", sound_file) + return + + self.stop() + + # construct shell command. use shlex to split it up into valid args for Popen. + cmd = "ffplay -nodisp -autoexit -loglevel quiet -volume " + str(self.volume) + " \"" + sound_file + "\""; + args = shlex.split(cmd) + self.thread_play = subprocess.Popen(args) + + def stop(self): + if not self.thread_play == False: + # that aint no nice, but it's the only way i got the subprocess reliably killed. + # self.thread_play.terminate() or kill() should do the trick, but it won't + try: + os.kill(self.thread_play.pid, signal.SIGKILL) + except OSError: + pass + + def setVolume(self, volume): + self.volume = volume; \ No newline at end of file diff --git a/ui_mainwnd.py b/ui_mainwnd.py index 84b0c0f..215b46d 100644 --- a/ui_mainwnd.py +++ b/ui_mainwnd.py @@ -2,10 +2,11 @@ # Form implementation generated from reading ui file 'mainwnd.ui' # -# Created by: PyQt5 UI code generator 5.12.1 +# Created by: PyQt5 UI code generator 5.14.2 # # WARNING! All changes made in this file will be lost! + from PyQt5 import QtCore, QtGui, QtWidgets @@ -28,6 +29,19 @@ class Ui_MainWidget(object): self.horizontalLayout_2.addWidget(self.listeningChk) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_2.addItem(spacerItem) + self.groupBox = QtWidgets.QGroupBox(MainWidget) + self.groupBox.setMinimumSize(QtCore.QSize(200, 42)) + self.groupBox.setAlignment(QtCore.Qt.AlignCenter) + self.groupBox.setObjectName("groupBox") + self.sliderVolume = QtWidgets.QSlider(self.groupBox) + self.sliderVolume.setGeometry(QtCore.QRect(10, 20, 181, 21)) + self.sliderVolume.setMaximum(100) + self.sliderVolume.setProperty("value", 100) + self.sliderVolume.setOrientation(QtCore.Qt.Horizontal) + self.sliderVolume.setObjectName("sliderVolume") + self.horizontalLayout_2.addWidget(self.groupBox) + spacerItem1 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_2.addItem(spacerItem1) self.ok = QtWidgets.QPushButton(MainWidget) self.ok.setMinimumSize(QtCore.QSize(130, 0)) self.ok.setObjectName("ok") @@ -36,33 +50,40 @@ class Ui_MainWidget(object): self.cancel.setMinimumSize(QtCore.QSize(130, 0)) self.cancel.setObjectName("cancel") self.horizontalLayout_2.addWidget(self.cancel) - self.gridLayout_2.addLayout(self.horizontalLayout_2, 1, 0, 1, 1) + self.gridLayout_2.addLayout(self.horizontalLayout_2, 2, 0, 1, 1) self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setSpacing(20) self.horizontalLayout.setObjectName("horizontalLayout") self.label = QtWidgets.QLabel(MainWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label.sizePolicy().hasHeightForWidth()) + self.label.setSizePolicy(sizePolicy) self.label.setMinimumSize(QtCore.QSize(60, 0)) self.label.setObjectName("label") self.horizontalLayout.addWidget(self.label) self.profileCbx = QtWidgets.QComboBox(MainWidget) - self.profileCbx.setMinimumSize(QtCore.QSize(250, 0)) + self.profileCbx.setMinimumSize(QtCore.QSize(300, 0)) self.profileCbx.setObjectName("profileCbx") self.horizontalLayout.addWidget(self.profileCbx) self.addBut = QtWidgets.QPushButton(MainWidget) - self.addBut.setMinimumSize(QtCore.QSize(130, 0)) + self.addBut.setMinimumSize(QtCore.QSize(75, 0)) self.addBut.setObjectName("addBut") self.horizontalLayout.addWidget(self.addBut) self.editBut = QtWidgets.QPushButton(MainWidget) - self.editBut.setMinimumSize(QtCore.QSize(130, 0)) + self.editBut.setMinimumSize(QtCore.QSize(75, 0)) self.editBut.setObjectName("editBut") self.horizontalLayout.addWidget(self.editBut) + self.copyBut = QtWidgets.QPushButton(MainWidget) + self.copyBut.setMinimumSize(QtCore.QSize(75, 0)) + self.copyBut.setObjectName("copyBut") + self.horizontalLayout.addWidget(self.copyBut) self.removeBut = QtWidgets.QPushButton(MainWidget) - self.removeBut.setMinimumSize(QtCore.QSize(130, 0)) + self.removeBut.setMinimumSize(QtCore.QSize(75, 0)) self.removeBut.setObjectName("removeBut") self.horizontalLayout.addWidget(self.removeBut) - spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem1) - self.gridLayout_2.addLayout(self.horizontalLayout, 0, 0, 1, 1) + self.gridLayout_2.addLayout(self.horizontalLayout, 1, 0, 1, 1) self.retranslateUi(MainWidget) QtCore.QMetaObject.connectSlotsByName(MainWidget) @@ -71,21 +92,13 @@ class Ui_MainWidget(object): _translate = QtCore.QCoreApplication.translate MainWidget.setWindowTitle(_translate("MainWidget", "LinVAM")) self.listeningChk.setText(_translate("MainWidget", "Enable Listening")) + self.groupBox.setTitle(_translate("MainWidget", "Voice Volume")) + self.sliderVolume.setToolTip(_translate("MainWidget", "Change voice volume")) self.ok.setText(_translate("MainWidget", "OK")) self.cancel.setText(_translate("MainWidget", "Cancel")) self.label.setText(_translate("MainWidget", "Profile:")) self.addBut.setText(_translate("MainWidget", "Add")) self.editBut.setText(_translate("MainWidget", "Edit")) + self.copyBut.setToolTip(_translate("MainWidget", "Copy currently selected profile")) + self.copyBut.setText(_translate("MainWidget", "Copy")) self.removeBut.setText(_translate("MainWidget", "Remove")) - - - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - MainWidget = QtWidgets.QWidget() - ui = Ui_MainWidget() - ui.setupUi(MainWidget) - MainWidget.show() - sys.exit(app.exec_()) diff --git a/ui_soundactioneditwnd.py b/ui_soundactioneditwnd.py new file mode 100644 index 0000000..50d2d35 --- /dev/null +++ b/ui_soundactioneditwnd.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'soundactioneditwnd.ui' +# +# Created by: PyQt5 UI code generator 5.14.2 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_SoundSelect(object): + def setupUi(self, SoundSelect): + SoundSelect.setObjectName("SoundSelect") + SoundSelect.resize(1119, 362) + SoundSelect.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.labelVoicepacks = QtWidgets.QLabel(SoundSelect) + self.labelVoicepacks.setGeometry(QtCore.QRect(10, 30, 111, 17)) + self.labelVoicepacks.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.labelVoicepacks.setObjectName("labelVoicepacks") + self.labelCategories = QtWidgets.QLabel(SoundSelect) + self.labelCategories.setGeometry(QtCore.QRect(160, 30, 111, 17)) + self.labelCategories.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.labelCategories.setObjectName("labelCategories") + self.listFiles = QtWidgets.QListView(SoundSelect) + self.listFiles.setGeometry(QtCore.QRect(660, 90, 451, 231)) + self.listFiles.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.listFiles.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.listFiles.setProperty("showDropIndicator", False) + self.listFiles.setAlternatingRowColors(True) + self.listFiles.setObjectName("listFiles") + self.listCategories = QtWidgets.QListView(SoundSelect) + self.listCategories.setEnabled(True) + self.listCategories.setGeometry(QtCore.QRect(160, 90, 491, 231)) + self.listCategories.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.listCategories.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.listCategories.setProperty("showDropIndicator", False) + self.listCategories.setAlternatingRowColors(True) + self.listCategories.setObjectName("listCategories") + self.listVoicepacks = QtWidgets.QListView(SoundSelect) + self.listVoicepacks.setGeometry(QtCore.QRect(10, 50, 141, 271)) + self.listVoicepacks.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.listVoicepacks.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.listVoicepacks.setProperty("showDropIndicator", False) + self.listVoicepacks.setAlternatingRowColors(True) + self.listVoicepacks.setObjectName("listVoicepacks") + self.labelFiles = QtWidgets.QLabel(SoundSelect) + self.labelFiles.setGeometry(QtCore.QRect(660, 30, 111, 17)) + self.labelFiles.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.labelFiles.setObjectName("labelFiles") + self.buttonOkay = QtWidgets.QPushButton(SoundSelect) + self.buttonOkay.setEnabled(True) + self.buttonOkay.setGeometry(QtCore.QRect(940, 330, 80, 25)) + self.buttonOkay.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.buttonOkay.setObjectName("buttonOkay") + self.buttonCancel = QtWidgets.QPushButton(SoundSelect) + self.buttonCancel.setGeometry(QtCore.QRect(1030, 330, 80, 25)) + self.buttonCancel.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.buttonCancel.setObjectName("buttonCancel") + self.filterCategories = QtWidgets.QPlainTextEdit(SoundSelect) + self.filterCategories.setGeometry(QtCore.QRect(160, 50, 491, 31)) + self.filterCategories.setObjectName("filterCategories") + self.filterFiles = QtWidgets.QPlainTextEdit(SoundSelect) + self.filterFiles.setGeometry(QtCore.QRect(660, 50, 451, 31)) + self.filterFiles.setPlainText("") + self.filterFiles.setObjectName("filterFiles") + self.buttonPlaySound = QtWidgets.QPushButton(SoundSelect) + self.buttonPlaySound.setGeometry(QtCore.QRect(410, 330, 80, 25)) + self.buttonPlaySound.setToolTip("") + self.buttonPlaySound.setObjectName("buttonPlaySound") + self.buttonStopSound = QtWidgets.QPushButton(SoundSelect) + self.buttonStopSound.setGeometry(QtCore.QRect(500, 330, 80, 25)) + self.buttonStopSound.setToolTip("") + self.buttonStopSound.setObjectName("buttonStopSound") + + self.retranslateUi(SoundSelect) + QtCore.QMetaObject.connectSlotsByName(SoundSelect) + + def retranslateUi(self, SoundSelect): + _translate = QtCore.QCoreApplication.translate + SoundSelect.setWindowTitle(_translate("SoundSelect", "Sound selection")) + self.labelVoicepacks.setText(_translate("SoundSelect", "VoicePacks:")) + self.labelCategories.setText(_translate("SoundSelect", "Categories:")) + self.labelFiles.setText(_translate("SoundSelect", "Voice files:")) + self.buttonOkay.setText(_translate("SoundSelect", "Ok")) + self.buttonCancel.setText(_translate("SoundSelect", "Cancel")) + self.filterCategories.setToolTip(_translate("SoundSelect", "Filter categories")) + self.filterCategories.setStatusTip(_translate("SoundSelect", "Filter")) + self.filterFiles.setToolTip(_translate("SoundSelect", "Filter voice files")) + self.filterFiles.setStatusTip(_translate("SoundSelect", "Filter")) + self.buttonPlaySound.setText(_translate("SoundSelect", "Play Sound")) + self.buttonStopSound.setText(_translate("SoundSelect", "Stop Sound"))