From 7b4ae4d3e7c7aaf65315b217c00b5d6bc65dd61c Mon Sep 17 00:00:00 2001 From: smirgol Date: Sat, 16 May 2020 20:51:24 +0200 Subject: [PATCH] Fixes & implementation of playing audio files. See changes.md for details. - 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 --- .gitignore | 3 + README.md | 33 +++ changes.md | 64 +++++ commandeditwnd.py | 20 ++ main.py | 518 ++++++++++++++++++++++----------------- mainwnd.ui | 100 ++++++-- profileeditwnd.py | 1 + profileexecutor.py | 160 ++++++++++-- soundactioneditwnd.py | 164 +++++++++++++ soundactioneditwnd.ui | 241 ++++++++++++++++++ soundfiles.py | 87 +++++++ ui_mainwnd.py | 55 +++-- ui_soundactioneditwnd.py | 93 +++++++ 13 files changed, 1250 insertions(+), 289 deletions(-) create mode 100644 changes.md create mode 100644 soundactioneditwnd.py create mode 100644 soundactioneditwnd.ui create mode 100644 soundfiles.py create mode 100644 ui_soundactioneditwnd.py 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..3ecec0f 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 ressource 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..a27e3e4 --- /dev/null +++ b/changes.md @@ -0,0 +1,64 @@ +# 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 + +### 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. :) \ 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..2ad6d0f 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..762e677 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,67 @@ 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: + print('xdotool ' + window_cmd + ' key ' + str(w_key)) + 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..30776d5 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, 0)) + 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"))