From 88763a152624842077d91d136364d7fb5cc4a533 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Wed, 3 Jul 2024 18:33:39 -0700 Subject: [PATCH 01/32] less logging --- src/cnv/chatlog/npc_chatter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 7e61c5e..bd457d5 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -176,7 +176,7 @@ def run(self): cachefile = settings.get_cachefile(name, message, category) if os.path.exists(cachefile): - log.info(f"(tighttts) Cache HIT: {cachefile}") + log.debug(f"(tighttts) Cache HIT: {cachefile}") # requires pydub? with AudioFile(cachefile) as input: with AudioFile( @@ -199,7 +199,7 @@ def run(self): # the directory already exists. This is not a problem. pass - log.info(f"(tighttts) Cache MISS: {cachefile} not found") + log.debug(f"(tighttts) Cache MISS: {cachefile} not found") # building session out here instead of inside get_character # keeps character alive and properly tied to the database as we From ba9da2817ce9121c7ace9ce449238c10b8504059 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Wed, 3 Jul 2024 19:32:54 -0700 Subject: [PATCH 02/32] first pass, looks a little less like poop --- src/cnv/tabs/configuration.py | 234 ++++++++++++++++++++-------------- 1 file changed, 139 insertions(+), 95 deletions(-) diff --git a/src/cnv/tabs/configuration.py b/src/cnv/tabs/configuration.py index 9f12896..8ab84fd 100644 --- a/src/cnv/tabs/configuration.py +++ b/src/cnv/tabs/configuration.py @@ -10,31 +10,37 @@ log = logging.getLogger(__name__) -class ConfigurationTab(ttk.Frame): - tkdict = {} - - def language_selection(self) -> ttk.Frame: - frame = ttk.Frame( - self - ) +class MasterVolume(ttk.Frame): + """ + Frame to provide widgets and persistence logic for a global volume control. + This is for playback volume. + """ + +class SpokenLanguageSelection(ttk.Frame): + """ + The user gets to decide which language they want to hear. They may also + need to decide which translation provider to utilize w/config for that + provider. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) ttk.Label( - frame, + self, text="Spoken Language", anchor="e", ).pack(side="left", fill="x", expand=True) + current = settings.get_config_key('language', "English") self.language = tk.StringVar(value=current) - default_engine_combo = ttk.Combobox(frame, textvariable=self.language) + default_engine_combo = ttk.Combobox(self, textvariable=self.language) default_engine_combo["values"] = list(settings.LANGUAGES.keys()) default_engine_combo["state"] = "readonly" default_engine_combo.pack(side="left", fill="x") self.language.trace_add('write', self.change_language) - - return frame - + def change_language(self, a, b, c): newvalue = self.language.get() prior = settings.get_config_key('language', "English") @@ -42,7 +48,20 @@ def change_language(self, a, b, c): # tempting to just restart if prior and newvalue != prior: log.info(f'Changing language to {newvalue}') - # we should immediately translate and localize the UI + # we should immediately translate and localize the UI + +class EngineAuthentication(ttk.Notebook): + """ + Collects tabs for configuring authentication for each of the TTS engines. The + actual tab contents are provided by the engine(s). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + elevenlabs = self.elevenlabs_token_frame() + elevenlabs.pack(side="top", fill="both", expand=True) + self.add(elevenlabs, text="ElevenLabs") def elevenlabs_token_frame(self) -> ttk.Frame: """ @@ -69,108 +88,123 @@ def elevenlabs_token_frame(self) -> ttk.Frame: ).pack(side="left", fill="x", expand=True) return elevenlabs - def polymorph(self, a, b, c): - """ - Given a config change through the UI, persist it. Easy. - """ - for key in self.tkdict: - value = self.tkdict[key].get() - # settings is smart enough to only write to disk when there is - # a change so this is much better than worst case. - settings.set_config_key(key, value) - return + def change_elevenlabs_key(self, a, b, c): + with open("eleven_labs.key", 'w') as h: + h.write(self.elevenlabs_key.get()) - def get_tkvar(self, tkvarClass, category, system, tag): - """ - There is a tkvarClass instance located at category/system/tag. - Instantiate it, give it the right value, hand it back. - """ - key = f'{category}_{system}_{tag}' - tkvar = self.tkdict.get(key) - if tkvar is None: - tkvar = tkvarClass( - value=settings.get_config_key(key, False) # False is sus. - ) - tkvar.trace_add( - 'write', self.polymorph - ) - self.tkdict[key] = tkvar + def get_elevenlabs_key(self): + keyfile = 'eleven_labs.key' + value = None - return tkvar + if os.path.exists(keyfile): + with open(keyfile, 'r') as h: + value = h.read() + return value - def normalize_prompt_frame(self, parent, category): - """ - frame with ui for the normalize checkbox - """ - frame = ttk.Frame(parent) - ttk.Label( - frame, - text="Normalize all voices", - anchor="e", - ).grid(column=0, row=1) - tk.Checkbutton( - frame, - variable=self.get_tkvar(tk.BooleanVar, category, 'engine', 'normalize') - ).grid(column=1, row=1) - return frame +class ChannelToEngineMap(ttk.Frame): + """ + Allows the user to choose a primary and secondary for each channel. _Current_ channels are + npc, player and system. + """ + tkdict = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.engine_priorities_header().pack(side="top", fill="x") + for channel in ['npc', 'player', 'system']: + self.engine_priorities_frame(channel).pack(side="top", fill="x") + def engine_priorities_header(self): + frame = ttk.Frame(self) + frame.columnconfigure(0, minsize=125, uniform="enginemap") + frame.columnconfigure(1, weight=2, uniform="enginemap") + frame.columnconfigure(2, weight=2, uniform="enginemap") + frame.columnconfigure(3, weight=2, uniform="enginemap") + + for index, label in enumerate([ + '', + 'Primary Engine', + 'Secondary Engine', + 'Normalize Voices', + ]): + ttk.Label( + frame, + text=label, + anchor="n", + ).grid(column=index, row=0, sticky='n') + + return frame + def engine_priorities_frame(self, category): """ the whole config frame for a particular category of entity within the game. npc/player/system """ - frame = ttk.Frame( - self, - borderwidth=1, - relief="groove" - ) + frame = ttk.Frame(self) + + frame.columnconfigure(0, minsize=125, uniform="enginemap") + frame.columnconfigure(1, weight=2, uniform="enginemap") + frame.columnconfigure(2, weight=2, uniform="enginemap") + frame.columnconfigure(3, weight=2, uniform="enginemap") ttk.Label( frame, text=f"{category}", anchor="e", - ).pack(side="top") + ).grid(column=0, row=0, sticky='e') primary_engine = self.choose_engine( frame, - "Primary Engine", self.get_tkvar(tk.StringVar, category, 'engine', 'primary') ) - primary_engine.pack(side="top") + primary_engine.grid(column=1, row=0, sticky='n') secondary_engine = self.choose_engine( frame, - "Secondary Engine", self.get_tkvar(tk.StringVar, category, 'engine', 'secondary') ) - secondary_engine.pack(side="top") + secondary_engine.grid(column=2, row=0, sticky='n') # tkvar = self.get_tkvar(tk.BooleanVar, category, 'engine', 'normalize') - self.normalize_prompt_frame(frame, category).pack(side="top") + self.normalize_prompt_frame( + frame, category + ).grid(column=3, row=0, sticky='n') return frame - def __init__(self, parent, *args, **kwargs): - super().__init__(parent, *args, **kwargs) + def polymorph(self, a, b, c): + """ + Given a config change through the UI, persist it. Easy. + """ + for key in self.tkdict: + value = self.tkdict[key].get() + # settings is smart enough to only write to disk when there is + # a change so this is much better than worst case. + settings.set_config_key(key, value) + return - self.language_selection().pack(side="top", fill="x") + def get_tkvar(self, tkvarClass, category, system, tag): + """ + There is a tkvarClass instance located at category/system/tag. + Instantiate it, give it the right value, hand it back. + """ + key = f'{category}_{system}_{tag}' + tkvar = self.tkdict.get(key) + if tkvar is None: + tkvar = tkvarClass( + value=settings.get_config_key(key, False) # False is sus. + ) + tkvar.trace_add( + 'write', self.polymorph + ) + self.tkdict[key] = tkvar - self.elevenlabs_token_frame().pack(side="top", fill="x") - - self.engine_priorities_frame("npc").pack(side="top", fill="x") - self.engine_priorities_frame("player").pack(side="top", fill="x") - self.engine_priorities_frame("system").pack(side="top", fill="x") + return tkvar - def choose_engine(self, parent, prompt, engine_var): - frame = ttk.Frame( - parent, - ) - ttk.Label( - frame, - text=prompt, - anchor="e", - ).grid(column=0, row=0) + def choose_engine(self, parent, engine_var): + frame = ttk.Frame(parent) default_engine_combo = ttk.Combobox(frame, textvariable=engine_var) default_engine_combo["values"] = [e.cosmetic for e in engines.ENGINE_LIST] @@ -179,19 +213,6 @@ def choose_engine(self, parent, prompt, engine_var): return frame - def change_elevenlabs_key(self, a, b, c): - with open("eleven_labs.key", 'w') as h: - h.write(self.elevenlabs_key.get()) - - def get_elevenlabs_key(self): - keyfile = 'eleven_labs.key' - value = None - - if os.path.exists(keyfile): - with open(keyfile, 'r') as h: - value = h.read() - return value - def change_default_engine(self, a, b, c): settings.set_config_key( 'DEFAULT_ENGINE', @@ -215,3 +236,26 @@ def change_default_player_engine_normalize(self, a, b, c): 'DEFAULT_PLAYER_ENGINE_NORMALIZE', self.default_player_engine_normalize.get() ) + + def normalize_prompt_frame(self, parent, category): + """ + frame with ui for the normalize checkbox + """ + frame = ttk.Frame(parent) + + tk.Checkbutton( + frame, + variable=self.get_tkvar(tk.BooleanVar, category, 'engine', 'normalize') + ).grid(column=0, row=0, sticky='n') + return frame + + +class ConfigurationTab(ttk.Frame): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + MasterVolume(self).pack(side="top", fill="x") + SpokenLanguageSelection(self).pack(side="top", fill="x") + EngineAuthentication(self).pack(side="top", fill="x") + ChannelToEngineMap(self).pack(side="top", fill="x") \ No newline at end of file From bb0322324a2a358eef7cdb1bb0f4af62824a2ff6 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Thu, 4 Jul 2024 09:45:41 -0700 Subject: [PATCH 03/32] breaking up the engines --- src/cnv/engines/amazonpolly.py | 241 ++++++ src/cnv/engines/base.py | 334 ++++++++ src/cnv/engines/elevenlabs.py | 268 +++++++ src/cnv/engines/engines.py | 1360 +------------------------------- src/cnv/engines/googlecloud.py | 144 ++++ src/cnv/engines/windowstts.py | 439 +++++++++++ src/cnv/tabs/configuration.py | 55 +- 7 files changed, 1446 insertions(+), 1395 deletions(-) create mode 100644 src/cnv/engines/amazonpolly.py create mode 100644 src/cnv/engines/base.py create mode 100644 src/cnv/engines/elevenlabs.py create mode 100644 src/cnv/engines/googlecloud.py create mode 100644 src/cnv/engines/windowstts.py diff --git a/src/cnv/engines/amazonpolly.py b/src/cnv/engines/amazonpolly.py new file mode 100644 index 0000000..28a0e0d --- /dev/null +++ b/src/cnv/engines/amazonpolly.py @@ -0,0 +1,241 @@ + +import logging + +import boto3 +import cnv.database.models as models +import cnv.lib.settings as settings +from voicebox.tts.amazonpolly import AmazonPolly as AmazonPollyTTS + +from .base import TTSEngine + +log = logging.getLogger(__name__) + +class AmazonPolly(TTSEngine): + """ + Pricing: + https://aws.amazon.com/polly/pricing/?p=pm&c=ml&pd=polly&z=4 + I think this could be a really great fit for this + project with its free million characters of tts per + month, and (in 2024) each addition million at highest + quality for $16. If the API can make it clear when + you cross from free to paid and quality is anywhere + near elevenlabs.. lets see what we can get. + """ + cosmetic = "Amazon Polly" + key = "amazonpolly" + + config = ( + ('Engine', 'engine', "StringVar", 'standard', {}, "get_engine_names"), + ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), + ('Sample Rate', 'sample_rate', "StringVar", '16000', {}, "get_sample_rates") + ) + client = None + + def get_client(self): + if self.client: + return self.client + + self.session = boto3.Session() + self.client = self.session.client('polly') + return self.client + + def _language_code_filter(self, voice): + """ + True if this voice is able to speak this language_code. + """ + allowed_language_codes = settings.get_voice_language_codes() + + for allowed_code in allowed_language_codes: + if ( + f"{allowed_code}-" in voice["LanguageCode"] + ): + #or + #f"{allowed_code}-" in code for code in voice.get("AdditionalLanguageCodes", []) + #): + log.debug(f'{voice["LanguageCode"]=}/{voice.get('AdditionalLanguageCodes', [])} is allowed for {allowed_code=}') + return True + return False + + def get_language_codes(self): + all_language_codes = models.diskcache(f'{self.key}_language_code') + + if all_language_codes is None: + # log.info('Building AmazonPolly language_code cache') + all_voices = self.get_voices() + out = set() + + for voice_id in all_voices: + voice = all_voices[voice_id] + + if self._gender_filter(voice): + out.add(voice["LanguageCode"]) + for code in voice.get('AdditionalLanguageCodes', []): + out.add(code) + + all_language_codes = [ {'language_code': code} for code in out] + models.diskcache(f'{self.key}_language_code', all_language_codes) + + # any filtering needed for language codes? + codes = [code['language_code'] for code in all_language_codes] + + return codes + + def get_engine_names(self): + all_engines = models.diskcache(f'{self.key}_engine') + + if all_engines is None: + all_voices = self.get_voices() + + out = set() + secondary = set() + # is this going to be intuitive or just weird? + for voice in all_voices: + + if self._language_code_filter(voice): + if self._gender_filter(voice): + for code in voice.get('SupportedEngines', []): + out.add(code) + else: + for code in voice.get('SupportedEngines', []): + secondary.add(code) + + if not out: + log.warning('No engines exist that support this language/gender. Ignoring gender.') + out = secondary + + all_engines = [ {'engine': engine_name} for engine_name in out ] + models.diskcache(f'{self.key}_engine', all_engines) + + return [engine['engine'] for engine in all_engines] + + def get_voice_names(self, gender=None): + all_voices = self.get_voices() + + if gender and not hasattr(self, 'gender'): + self.gender = gender + + out = set() + secondary = set() + for voice in all_voices: + if self._language_code_filter(voice): + if self._gender_filter(voice): + log.debug(f'Including voice {voice["Name"]}') + out.add(voice["Name"]) + else: + secondary.add(voice["Name"]) + else: + log.debug(f'Excluding {voice["Name"]}') + + if not out: + log.warning('No voices exist that support this language/gender. Ignoring gender.') + out = secondary + + out = sorted(list(out)) + + if out: + if self.config_vars["voice_name"].get() not in out: + # our currently selected voice is invalid. Pick a new one. + self.config_vars["voice_name"].set(out[0]) + return out + else: + return [] + + def voice_name_to_voice_id(self, voice_name): + voice_name = voice_name.strip() + all_voices = self.get_voices() + for voice in all_voices: + if voice['Name'] == voice_name: + return voice['Id'] + + log.error(f'Could not convert {voice_name=} to a voice_id') + return None + + def get_sample_rates(self, filter_by=None): + # what does this depend on? + # and.. it depends only some internal details in voicebox + # If we change voicebox to use mp3 or ogg_vorbis we could + # use [8000, 16000, 22050, 24000] + # But since it is getting PCM from Polly the only + # valid options are 8000 and 16000. + + # why is this being cached? stupid? ridiculous? + # this lets us treat all stringvar fields the same way, so yes, but no. + all_sample_rates = models.diskcache(f'{self.key}_sample_rate') + + if all_sample_rates is None: + all_sample_rates = [ + {"sample_rate": "8000"}, + {"sample_rate": "16000"} + ] + models.diskcache(f'{self.key}_sample_rate', all_sample_rates) + + return [ rate['sample_rate'] for rate in all_sample_rates ] + + def get_tts(self): + """ + Returns a voicebox TTS object initialized for a specific + character + """ + # https://boto3.amazonaws.com/v1/documentation/api/latest/index.html + # + # ~/.aws/credentials + # C:\Users\\.aws\credentials + # + # [default] + # aws_access_key_id = YOUR_ACCESS_KEY + # aws_secret_access_key = YOUR_SECRET_KEY + # + # ~/.aws/config: + # [default] + # region=us-west-1 + # + # https://us-east-2.console.aws.amazon.com/polly/home/SynthesizeSpeech + + raw_voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) + voice_id = self.voice_name_to_voice_id(raw_voice_name) + + # Engine (string) – Specifies the engine ( standard, neural, long-form + # or generative) used by Amazon Polly when processing input text for + # speech synthesis. + engine = self.config_vars["engine"].get() + + # LanguageCode (string) – The language identification tag (ISO 639 code + # for the language name-ISO 3166 country code) for filtering the list of + # voices returned. If you don’t specify this optional parameter, all + # available voices are returned. + # language_code=self.override.get('language_code', self.config_vars["language_code"].get()) + lexicon_names=[] + sample_rate = self.override.get('sample_rate', self.config_vars["sample_rate"].get()) + + return AmazonPollyTTS( + client=self.get_client(), + voice_id=voice_id.strip(), + engine=engine, + # language_code=language_code, + lexicon_names=lexicon_names, + sample_rate=int(sample_rate) + ) + + def get_voices(self): + # Language code of the voice. + + # We aren't really interested in listing _every_ language code. We only + # want the ones that have at least one Amazon Polly voice. + all_voices = models.diskcache(f'{self.key}_voice_name') + + if all_voices is None: + session = boto3.Session() + client = session.client('polly') + + all_voices = [] + for voice in client.describe_voices()['Voices']: + log.debug(f'{voice=}') + voice['voice_name'] = voice["Name"] + voice['language_code'] = voice["LanguageCode"] + voice['gender'] = voice["Gender"] + all_voices.append(voice) + + models.diskcache(f'{self.key}_voice_name', all_voices) + + return all_voices + diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py new file mode 100644 index 0000000..88f124e --- /dev/null +++ b/src/cnv/engines/base.py @@ -0,0 +1,334 @@ +import logging +import tkinter as tk +from tkinter import ttk + +import cnv.database.models as models +import cnv.lib.settings as settings +import voicebox +from sqlalchemy import select + +log = logging.getLogger(__name__) + + +class USE_SECONDARY(Exception): + """ + signal to disable this engine for this session + """ + + +# Base Class for engines +class TTSEngine(ttk.Frame): + auth_ui_class = None + + def __init__(self, parent, rank, name, category, *args, **kwargs): + log.debug(f'Initializing TTSEngine {parent=} {rank=} {name=} {category=}') + super().__init__(parent, *args, **kwargs) + self.rank = rank + self.parent = parent + self.name = name + self.category = category + self.override = {} + self.parameters = set('voice_name') + self.config_vars = {} + self.widget = {} + + self.set_config_meta(self.config) + + self.draw_config_meta() + self.load_character(category=category, name=name) + self.repopulate_options() + + def get_config_meta(self): + with models.db() as session: + response = session.scalars( + select(models.EngineConfigMeta).where( + models.EngineConfigMeta.engine_key == self.key + ) + ).all() + return response + + def set_config_meta(self, *rows): + # wipe existing configuration metadata + with models.db() as session: + old_settings = session.scalars( + select(models.EngineConfigMeta).where( + models.EngineConfigMeta.engine_key==self.key + ) + ).all() + + for old_row in old_settings: + session.delete(old_row) + session.commit() + + with models.db() as session: + for row in rows[0]: + # log.info(f"{row=}") + cosmetic, key, varfunc, default, cfg, fn = row + field = models.EngineConfigMeta( + engine_key=self.key, + cosmetic=cosmetic, + key=key, + varfunc=varfunc, + default=default, + cfgdict=cfg, + gatherfunc=fn + ) + session.add(field) + session.commit() + + def say(self, message, effects, sink=None, *args, **kwargs): + tts = self.get_tts() + # log.info(f'{self}.say({message=}, {effects=}, {sink=}, {args=}, {kwargs=}') + # log.info(f'Invoking voicebox.SimpleVoicebox({tts=}, {effects=}, {sink=})') + vb = voicebox.SimpleVoicebox( + tts=tts, + effects=effects, + sink=sink + ) + + if message: + + try: + vb.say(message) + except Exception as err: + log.error('vb: %s', vb) + log.error("Error in TTSEngine.say(): %s", err) + if hasattr(err, "grpc_status_code"): + # google error + # Error in TTSEngine.say(): 503 failed to connect to all addresses; last error: UNAVAILABLE: ipv4:172.217.12.106:443: WSA Error + + # this is what happens when you try to use google TTS when + # networking is borked. + log.error(err) + if err.grpc_status_code == 14: + raise USE_SECONDARY + + elif err.status_code == 401: + log.error(err.body) + if err.body.get('detail', {}).get('status') == "quota_exceeded": + raise USE_SECONDARY + raise + + def get_tts(self): + return voicebox.tts.tts.TTS() + + def load_character(self, category, name): + # Retrieve configuration settings from the DB + # and use them to set values on widgets + # settings.how_did_i_get_here() + + self.loading = True + self.name = name + self.category = category + + with models.db() as session: + character = models.Character.get(name, category, session) + + self.gender = settings.get_npc_gender(character.name) + + engine_config = models.get_engine_config(character.id, self.rank) + + for key, value in engine_config.items(): + log.debug(f'Setting config {key} to {value}') + + # log.info(f"{dir(self)}") + if hasattr(self, 'config_vars'): + # the polly way + log.debug(f'PolyConfig[{key}] = {value}') + # log.info(f'{self.config_vars=}') + if key in self.config_vars: + self.config_vars[key].set(value) + else: + log.error(f'OBSOLETE config[{key}] = {value}') + # everything else + getattr(self, key).set(value) + setattr(self, key + "_base", value) + + # log.info("TTSEngine.load_character complete") + self.loading = False + return character + + def save_character(self, name, category): + # Retrieve configuration settings from widgets + # and persist them to the DB + # log.info(f"save_character({name}, {category})") + + character = models.Character.get(name, category) + + if character is None: + # new character? This is not typical. + # log.info(f'Creating new character {name}`') + + with models.db() as session: + character = models.Character( + name=name, + category=models.category_str2int(category), + engine=settings.get_config_key( + 'DEFAULT_ENGINE', settings.DEFAULT_ENGINE + ), + ) + + session.add(character) + session.commit() + session.refresh(character) + + # log.info("character: %s", character) + for key in self.parameters: + # log.info(f"Processing attribute {key}...") + # do we already have a value for this key? + value = str(getattr(self, key).get()) + + # do we already have a value for this key? + with models.db() as session: + config_setting = session.execute( + select(models.BaseTTSConfig).where( + models.BaseTTSConfig.character_id == character.id, + models.BaseTTSConfig.rank == self.rank, + models.BaseTTSConfig.key == key, + ) + ).scalar_one_or_none() + + if config_setting and config_setting.value != value: + log.debug('Updating existing setting') + config_setting.value = value + session.commit() + + elif not config_setting: + log.debug('Saving new BaseTTSConfig') + with models.db() as session: + new_config_setting = models.BaseTTSConfig( + character_id=character.id, + rank=self.rank, + key=key, + value=value + ) + session.add(new_config_setting) + session.commit() + + def draw_config_meta(self): + # now we build it. + for m in self.get_config_meta(): + frame = ttk.Frame(self) + frame.columnconfigure(0, minsize=125, uniform="ttsengine") + frame.columnconfigure(1, weight=2, uniform="ttsengine") + ttk.Label(frame, text=m.cosmetic, anchor="e").grid( + row=0, column=0, sticky="e", padx=10 + ) + + # create the tk.var for the value of this widget + varfunc = getattr(tk, m.varfunc) + self.config_vars[m.key] = varfunc(value=m.default) + + # create the widget itself + if m.varfunc == "StringVar": + self._tkStringVar(m.key, frame) + elif m.varfunc == "DoubleVar": + self._tkDoubleVar(m.key, frame, m.cfgdict) + elif m.varfunc == "BooleanVar": + self._tkBooleanVar(m.key, frame) + else: + # this will fail, but at least it will fail with a log message. + log.error(f'No widget defined for variables like {varfunc}') + + # changes to the value of this widget trip a generic 'reconfig' + # handler. + self.config_vars[m.key].trace_add("write", self.reconfig) + frame.pack(side="top", fill="x", expand=True) + + def _tkStringVar(self, key, frame): + # combo widget for strings + self.widget[key] = ttk.Combobox( + frame, + textvariable=self.config_vars[key], + ) + self.widget[key]["state"] = "readonly" + self.widget[key].grid(row=0, column=1, sticky="ew") + + def _tkDoubleVar(self, key, frame, cfg): + # doubles get a scale widget. I haven't been able to get the ttk.Scale + # widget to behave itself. I like the visual a bit better, but its hard + # to get equivilent results. + + self.widget[key] = tk.Scale( + frame, + variable=self.config_vars[key], + from_=cfg.get('min', 0), + to=cfg['max'], + orient='horizontal', + digits=cfg.get('digits', 2), + resolution=cfg.get('resolution', 1) + ) + self.widget[key].grid(row=0, column=1, sticky="ew") + + def _tkBooleanVar(self, key, frame): + """ + Still using a label then checkbutton because the 'text' field on + checkbutton puts the text after the button. Well, and it will make it + easier to maintain consistency with the other widgets. Oh, and text + doesn't belong on a checkbox. It's a wart, sorry. + """ + self.widget[key] = ttk.Checkbutton( + frame, + text="", + variable=self.config_vars[key], + onvalue=True, + offvalue=False + ) + self.widget[key].grid(row=0, column=1, sticky="ew") + + def reconfig(self, *args, **kwargs): + """ + Any engine value has been changed. In most cases this is a single + change, but it could also be multiple changes. The changes are between + the current values in all the UI configuration widgets and the values + stored in the database. + + We need to persist the changes, but in some cases changes can cascade. + For example changing the language can change the available voices. So + each time a change comes through we shake the knob to see if any of our + combo widgets need to repopulate. + """ + if self.loading: + return + + # log.info(f'reconfig({args=}, {kwargs=})') + with models.db() as session: + character = models.Character.get( + name=self.name, + category=self.category, + session=session + ) + + config = {} + for m in self.get_config_meta(): + config[m.key] = self.config_vars[m.key].get() + + models.set_engine_config(character.id, self.rank, config) + self.repopulate_options() + + def repopulate_options(self): + for m in self.get_config_meta(): + # for cosmetic, key, varfunc, default, cfg, fn in self.CONFIG_TUPLE: + # our change may filter the other widgets, possibly + # rendering the previous value invalid. + if m.varfunc == "StringVar": + # log.info(f"{m.cosmetic=} {m.key=} {m.default=} {m.gatherfunc=}") + all_options = getattr(self, m.gatherfunc)() + if not all_options: + log.error(f'{m.gatherfunc=} returned no options ({self.cosmetic})') + + self.widget[m.key]["values"] = all_options + + if self.config_vars[m.key].get() not in all_options: + # log.info(f'Expected to find {self.config_vars[m.key].get()!r} in list {all_options!r}') + self.config_vars[m.key].set(all_options[0]) + + def _gender_filter(self, voice): + if hasattr(self, 'gender') and self.gender: + log.debug(f'{self.gender.title()} ?= {voice["gender"].title()}') + try: + return self.gender.title() == voice["gender"].title() + except KeyError: + log.warning('Failed to find "gender" in:') + log.debug(f"{voice=}") + return True diff --git a/src/cnv/engines/elevenlabs.py b/src/cnv/engines/elevenlabs.py new file mode 100644 index 0000000..bb192ae --- /dev/null +++ b/src/cnv/engines/elevenlabs.py @@ -0,0 +1,268 @@ +import logging +import os +import tempfile +from dataclasses import dataclass, field +import tkinter as tk +from tkinter import ttk +from typing import Union + +import cnv.database.models as models +import cnv.lib.audio as audio +import elevenlabs +import voicebox +from elevenlabs.client import ElevenLabs as ELABS +from voicebox.audio import Audio +from voicebox.types import StrOrSSML + +from .base import TTSEngine + +log = logging.getLogger(__name__) + +class ElevenLabsAuthUI(ttk.Frame): + label = "ElevenLabs" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + description = ttk.Frame(self) + tk.Message( + description, + text="https://elevenlabs.io/ is a leading edge company focused on providing" + "top quality text to speech technology. A free account provides 10,000 characters" + "of text-to-speech. When it runs out we can automatically toggle over to your" + "secondary voice provider. Create an account, login to it. Then in the bottom " + "left, click yourself. Choose 'Profile + API key'. Put that API key here.", + anchor="nw", + justify="left" + ).pack(side="left", fill="both", expand=True) + description.grid(column=0, row=0, sticky='w') + + self.columnconfigure(0, minsize=125, uniform="baseconfig") + self.columnconfigure(1, weight=2, uniform="baseconfig") + self.columnconfigure(2, weight=2, uniform="baseconfig") + + ttk.Label( + self, + text="ElevenLabs API Key", + anchor="e", + ).grid(column=1, row=0, sticky='e') + + self.elevenlabs_key = tk.StringVar(value=self.get_elevenlabs_key()) + self.elevenlabs_key.trace_add('write', self.change_elevenlabs_key) + ttk.Entry( + self, + textvariable=self.elevenlabs_key, + show="*" + ).grid(column=2, row=0, sticky='w') + + def change_elevenlabs_key(self, a, b, c): + with open("eleven_labs.key", 'w') as h: + h.write(self.elevenlabs_key.get()) + + def get_elevenlabs_key(self): + keyfile = 'eleven_labs.key' + value = None + + if os.path.exists(keyfile): + with open(keyfile, 'r') as h: + value = h.read() + return value + + +def get_elevenlabs_client(): + if os.path.exists("./eleven_labs.key"): + with open("./eleven_labs.key") as h: + # umm, I can't do that, can I? + elvenlabs_api_key = h.read().strip() + + # https://github.com/elevenlabs/elevenlabs-python/blob/main/src/elevenlabs/client.py#L42 + client = ELABS(api_key=elvenlabs_api_key) + return client + else: + log.warning("Elevenlabs Requires valid eleven_labs.key file") + +class ElevenLabs(TTSEngine): + """ + Elevenlabs detects the incoming language; so in theory every voice works with every language. I have doubts. + """ + cosmetic = "Eleven Labs" + key = "elevenlabs" + api_key = None + auth_ui_class = ElevenLabsAuthUI + + config = ( + ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), + ('Stability', 'stability', "DoubleVar", 0.5, {'min': 0, 'max': 1, 'resolution': 0.025}, None), + ('Similarity Boost', 'similarity_boost', "DoubleVar", 0, {'min': 0, 'max': 1, 'resolution': 0.025}, None), + ('Style', 'style', "DoubleVar", 0.0, {'min': 0, 'max': 1, 'resolution': 0.025}, None), + ('Speaker Boost', 'use_speaker_boost', "BooleanVar", True, {}, None) + ) + + def get_voice_names(self, gender=None): + """ + PSA, I know there is a lot of gender in this code. The intention is to + better guess which voice from an assortment of voices aligns with the + expections of the person playing the game. Adding voices in games is a + great power and it comes with a great responsibility. + + Make lord recluse sound like a little girl? give statesman a heavy lisp? + all the 5th column a strong german accent? all the street punks an + enthic voice? + + This is a weapon. To avoid weilding it we set a simple rule. The goal + is the voice that best presents a realistic interpretation of what each + character ought to sound like based on the description, appearance and + dialog. + + TODO: we need to add a 'cache expire' button on the config for each of + primary/secondary. + """ + all_voices = self.get_voices() + + if gender and not hasattr(self, 'gender'): + self.gender = gender + + # log.info(f'ElevenLabs get_voice_name({gender=}) ({self.gender})') + out = set() + for voice in all_voices: + if self._gender_filter(voice): + out.add(voice['voice_name']) + + out = sorted(list(out)) + + if out: + if self.config_vars["voice_name"].get() not in out: + # our currently selected voice is invalid. Pick a new one. + log.error('Invalid voice selecton: %s. Overriding...', self.config_vars["voice_name"].get()) + self.config_vars["voice_name"].set(out[0]) + return out + else: + return [] + + def get_voices(self): + all_voices = models.diskcache(f'{self.key}_voice_name') + + if all_voices is None: + client = get_elevenlabs_client() + all_raw_voices = client.voices.get_all() + + all_voices = [] + for voice in all_raw_voices.voices: + # log.info(f"{voice=}") + all_voices.append({ + 'id': voice.voice_id, + 'voice_name': voice.name, + 'gender': voice.labels['gender'].title() + }) + + # log.info(all_voices) + models.diskcache(f'{self.key}_voice_name', all_voices) + + return all_voices + + def get_tts(self): + # voice is an elevenlabs.Voice instance, We need input from the user + # so we add a choice field the __init__ + # model : :class:`elevenlabs.Model` instance, or a string representing the model ID. + + # settings comments from https://elevenlabs.io/docs/speech-synthesis/voice-settings + voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) + + # The stability slider determines how stable the voice is and the + # randomness between each generation. Lowering this slider introduces a + # broader emotional range for the voice. As mentioned before, this is + # also influenced heavily by the original voice. Setting the slider too + # low may result in odd performances that are overly random and cause + # the character to speak too quickly. On the other hand, setting it too + # high can lead to a monotonous voice with limited emotion. + stability = self.override.get('stability', self.config_vars["stability"].get()) + + # "similarity_boost" corresponds to"Clarity + Similarity Enhancement" in the web app + similarity_boost = self.override.get('similarity_boost', self.config_vars["similarity_boost"].get()) + + # With the introduction of the newer models, we also added a style + # exaggeration setting. This setting attempts to amplify the style of + # the original speaker. It does consume additional computational + # resources and might increase latency if set to anything other than 0. + # It’s important to note that using this setting has shown to make the + # model slightly less stable, as it strives to emphasize and imitate the + # style of the original voice. In general, we recommend keeping this + # setting at 0 at all times. + style = self.override.get('style', self.config_vars["style"].get()) + + # This is another setting that was introduced in the new models. The + # setting itself is quite self-explanatory – it boosts the similarity to + # the original speaker. However, using this setting requires a slightly + # higher computational load, which in turn increases latency. The + # differences introduced by this setting are generally rather subtle. + use_speaker_boost = self.override.get('use_speaker_boost', self.config_vars["use_speaker_boost"].get()) + + # model = elevenlabs.Model() + model = None + + # log.info(f'Creating ttsElevenLab(, voice={voice_name}, model={model})') + return ttsElevenLabs( + api_key=self.api_key, + stability=stability, + similarity_boost=similarity_boost, + style=style, + use_speaker_boost=use_speaker_boost, + voice=voice_name, + model=model + ) + +@dataclass +class ttsElevenLabs(voicebox.tts.TTS): + """ + There was an API update in the elevenlabs client that broke the built in voicebox support. + """ + api_key: str = None + voice: Union[str, elevenlabs.Voice] = field(default_factory=lambda: elevenlabs.DEFAULT_VOICE) + model: Union[str, elevenlabs.Model] = 'eleven_monolingual_v1' + stability: float = 0.71 + similarity_boost: float = 0.5 + style: float = 0.0 + use_speaker_boost : bool = True + + def voice_name_to_id(self, voice_name): + voice_name = voice_name.strip() + for voice in models.diskcache('elevenlabs_voice_name'): + if voice['voice_name'] == voice_name: + return voice['id'] + + log.error('Unknown voice: %s', voice_name) + + def get_speech(self, text: StrOrSSML) -> Audio: + client = get_elevenlabs_client() + # https://github.com/elevenlabs/elevenlabs-python/blob/main/src/elevenlabs/client.py#L118 + # default response is an iterator providing an mp3_44100_128. + # + # I tried asking 11labs for a PCM response (wav),so we don't need to decompress an mp3 + # but the PCM wav format returned by 11labs isn't direcly compatible with the + # wav format that the python wave library known how to open. + log.debug(f"self.voice: {self.voice}") + + voice_id = self.voice_name_to_id(self.voice) + + # I'm not actually clear on what exactly 'model' does. + # voice_model = None + + audio_data = client.generate( + text=text, + voice=elevenlabs.Voice( + voice_id=voice_id, + settings=elevenlabs.VoiceSettings( + stability=self.stability, + similarity_boost=self.similarity_boost, + style=self.style, + use_speaker_boost=self.use_speaker_boost + ) + ) + ) + + with tempfile.NamedTemporaryFile() as tmp: + tmp.close() + # start with an mp3 file + mp3filename = tmp.name + ".mp3" + elevenlabs.save(audio_data, mp3filename) + + return audio.mp3file_to_Audio(mp3filename) diff --git a/src/cnv/engines/engines.py b/src/cnv/engines/engines.py index d695dac..7d7e59a 100644 --- a/src/cnv/engines/engines.py +++ b/src/cnv/engines/engines.py @@ -1,1358 +1,14 @@ -import json import logging -import os -import sys -import tempfile -import time -import tkinter as tk -from dataclasses import dataclass, field -from io import BytesIO -from tkinter import ttk -from typing import Union -import boto3 -import cnv.database.models as models -import cnv.lib.audio as audio -import cnv.lib.settings as settings -import elevenlabs -import numpy as np -import tts.sapi -import voicebox -from elevenlabs.client import ElevenLabs as ELABS -from google.cloud import texttospeech -from sqlalchemy import select -from voicebox.audio import Audio -from voicebox.tts.amazonpolly import AmazonPolly as AmazonPollyTTS -from voicebox.types import StrOrSSML - -logging.basicConfig( - level=settings.LOGLEVEL, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) +from .amazonpolly import AmazonPolly +from .elevenlabs import ElevenLabs +from .googlecloud import GoogleCloud +from .windowstts import WindowsTTS log = logging.getLogger(__name__) - -class USE_SECONDARY(Exception): - """ - signal to disable this engine for this session - """ - -@dataclass -class WindowsSapi(voicebox.tts.tts.TTS): - rate: int = 1 - voice: str = "Zira" - - def get_speech(self, text: StrOrSSML) -> Audio: - voice = tts.sapi.Sapi() - log.debug(f"Saying {text!r} as {self.voice} at rate {self.rate}") - voice.set_rate(self.rate) - voice.set_voice(self.voice) - - stream = tts.sapi.comtypes.client.CreateObject('SAPI.SpMemoryStream') - - # save the original output stream - temp_stream = voice.voice.AudioOutputStream - - # hijack it - voice.voice.AudioOutputStream = stream - - voice.say(text) - - # restore it - voice.voice.AudioOutputStream = temp_stream - - samples = np.frombuffer( - bytes(stream.GetData()), - dtype=np.int16 - ) - - audio = voicebox.tts.utils.get_audio_from_samples( - samples, - 22050 - ) - - return audio - - -# Base Class for engines -class TTSEngine(ttk.Frame): - def __init__(self, parent, rank, name, category, *args, **kwargs): - log.debug(f'Initializing TTSEngine {parent=} {rank=} {name=} {category=}') - super().__init__(parent, *args, **kwargs) - self.rank = rank - self.parent = parent - self.name = name - self.category = category - self.override = {} - self.parameters = set('voice_name') - self.config_vars = {} - self.widget = {} - - self.set_config_meta(self.config) - - self.draw_config_meta() - self.load_character(category=category, name=name) - self.repopulate_options() - - def get_config_meta(self): - with models.db() as session: - response = session.scalars( - select(models.EngineConfigMeta).where( - models.EngineConfigMeta.engine_key == self.key - ) - ).all() - return response - - def set_config_meta(self, *rows): - # wipe existing configuration metadata - with models.db() as session: - old_settings = session.scalars( - select(models.EngineConfigMeta).where( - models.EngineConfigMeta.engine_key==self.key - ) - ).all() - - for old_row in old_settings: - session.delete(old_row) - session.commit() - - with models.db() as session: - for row in rows[0]: - # log.info(f"{row=}") - cosmetic, key, varfunc, default, cfg, fn = row - field = models.EngineConfigMeta( - engine_key=self.key, - cosmetic=cosmetic, - key=key, - varfunc=varfunc, - default=default, - cfgdict=cfg, - gatherfunc=fn - ) - session.add(field) - session.commit() - - def say(self, message, effects, sink=None, *args, **kwargs): - tts = self.get_tts() - # log.info(f'{self}.say({message=}, {effects=}, {sink=}, {args=}, {kwargs=}') - # log.info(f'Invoking voicebox.SimpleVoicebox({tts=}, {effects=}, {sink=})') - vb = voicebox.SimpleVoicebox( - tts=tts, - effects=effects, - sink=sink - ) - - if message: - - try: - vb.say(message) - except Exception as err: - log.error('vb: %s', vb) - log.error("Error in TTSEngine.say(): %s", err) - if hasattr(err, "grpc_status_code"): - # google error - # Error in TTSEngine.say(): 503 failed to connect to all addresses; last error: UNAVAILABLE: ipv4:172.217.12.106:443: WSA Error - - # this is what happens when you try to use google TTS when - # networking is borked. - log.error(err) - if err.grpc_status_code == 14: - raise USE_SECONDARY - - elif err.status_code == 401: - log.error(err.body) - if err.body.get('detail', {}).get('status') == "quota_exceeded": - raise USE_SECONDARY - raise - - def get_tts(self): - return voicebox.tts.tts.TTS() - - def load_character(self, category, name): - # Retrieve configuration settings from the DB - # and use them to set values on widgets - # settings.how_did_i_get_here() - - self.loading = True - self.name = name - self.category = category - - with models.db() as session: - character = models.Character.get(name, category, session) - - self.gender = settings.get_npc_gender(character.name) - - engine_config = models.get_engine_config(character.id, self.rank) - - for key, value in engine_config.items(): - log.debug(f'Setting config {key} to {value}') - - # log.info(f"{dir(self)}") - if hasattr(self, 'config_vars'): - # the polly way - log.debug(f'PolyConfig[{key}] = {value}') - # log.info(f'{self.config_vars=}') - if key in self.config_vars: - self.config_vars[key].set(value) - else: - log.error(f'OBSOLETE config[{key}] = {value}') - # everything else - getattr(self, key).set(value) - setattr(self, key + "_base", value) - - # log.info("TTSEngine.load_character complete") - self.loading = False - return character - - def save_character(self, name, category): - # Retrieve configuration settings from widgets - # and persist them to the DB - # log.info(f"save_character({name}, {category})") - - character = models.Character.get(name, category) - - if character is None: - # new character? This is not typical. - # log.info(f'Creating new character {name}`') - - with models.db() as session: - character = models.Character( - name=name, - category=models.category_str2int(category), - engine=settings.get_config_key( - 'DEFAULT_ENGINE', settings.DEFAULT_ENGINE - ), - ) - - session.add(character) - session.commit() - session.refresh(character) - - # log.info("character: %s", character) - for key in self.parameters: - # log.info(f"Processing attribute {key}...") - # do we already have a value for this key? - value = str(getattr(self, key).get()) - - # do we already have a value for this key? - with models.db() as session: - config_setting = session.execute( - select(models.BaseTTSConfig).where( - models.BaseTTSConfig.character_id == character.id, - models.BaseTTSConfig.rank == self.rank, - models.BaseTTSConfig.key == key, - ) - ).scalar_one_or_none() - - if config_setting and config_setting.value != value: - log.debug('Updating existing setting') - config_setting.value = value - session.commit() - - elif not config_setting: - log.debug('Saving new BaseTTSConfig') - with models.db() as session: - new_config_setting = models.BaseTTSConfig( - character_id=character.id, - rank=self.rank, - key=key, - value=value - ) - session.add(new_config_setting) - session.commit() - - def draw_config_meta(self): - # now we build it. - for m in self.get_config_meta(): - frame = ttk.Frame(self) - frame.columnconfigure(0, minsize=125, uniform="ttsengine") - frame.columnconfigure(1, weight=2, uniform="ttsengine") - ttk.Label(frame, text=m.cosmetic, anchor="e").grid( - row=0, column=0, sticky="e", padx=10 - ) - - # create the tk.var for the value of this widget - varfunc = getattr(tk, m.varfunc) - self.config_vars[m.key] = varfunc(value=m.default) - - # create the widget itself - if m.varfunc == "StringVar": - self._tkStringVar(m.key, frame) - elif m.varfunc == "DoubleVar": - self._tkDoubleVar(m.key, frame, m.cfgdict) - elif m.varfunc == "BooleanVar": - self._tkBooleanVar(m.key, frame) - else: - # this will fail, but at least it will fail with a log message. - log.error(f'No widget defined for variables like {varfunc}') - - # changes to the value of this widget trip a generic 'reconfig' - # handler. - self.config_vars[m.key].trace_add("write", self.reconfig) - frame.pack(side="top", fill="x", expand=True) - - def _tkStringVar(self, key, frame): - # combo widget for strings - self.widget[key] = ttk.Combobox( - frame, - textvariable=self.config_vars[key], - ) - self.widget[key]["state"] = "readonly" - self.widget[key].grid(row=0, column=1, sticky="ew") - - def _tkDoubleVar(self, key, frame, cfg): - # doubles get a scale widget. I haven't been able to get the ttk.Scale - # widget to behave itself. I like the visual a bit better, but its hard - # to get equivilent results. - - self.widget[key] = tk.Scale( - frame, - variable=self.config_vars[key], - from_=cfg.get('min', 0), - to=cfg['max'], - orient='horizontal', - digits=cfg.get('digits', 2), - resolution=cfg.get('resolution', 1) - ) - self.widget[key].grid(row=0, column=1, sticky="ew") - - def _tkBooleanVar(self, key, frame): - """ - Still using a label then checkbutton because the 'text' field on - checkbutton puts the text after the button. Well, and it will make it - easier to maintain consistency with the other widgets. Oh, and text - doesn't belong on a checkbox. It's a wart, sorry. - """ - self.widget[key] = ttk.Checkbutton( - frame, - text="", - variable=self.config_vars[key], - onvalue=True, - offvalue=False - ) - self.widget[key].grid(row=0, column=1, sticky="ew") - - def reconfig(self, *args, **kwargs): - """ - Any engine value has been changed. In most cases this is a single - change, but it could also be multiple changes. The changes are between - the current values in all the UI configuration widgets and the values - stored in the database. - - We need to persist the changes, but in some cases changes can cascade. - For example changing the language can change the available voices. So - each time a change comes through we shake the knob to see if any of our - combo widgets need to repopulate. - """ - if self.loading: - return - - # log.info(f'reconfig({args=}, {kwargs=})') - with models.db() as session: - character = models.Character.get( - name=self.name, - category=self.category, - session=session - ) - - config = {} - for m in self.get_config_meta(): - config[m.key] = self.config_vars[m.key].get() - - models.set_engine_config(character.id, self.rank, config) - self.repopulate_options() - - def repopulate_options(self): - for m in self.get_config_meta(): - # for cosmetic, key, varfunc, default, cfg, fn in self.CONFIG_TUPLE: - # our change may filter the other widgets, possibly - # rendering the previous value invalid. - if m.varfunc == "StringVar": - # log.info(f"{m.cosmetic=} {m.key=} {m.default=} {m.gatherfunc=}") - all_options = getattr(self, m.gatherfunc)() - if not all_options: - log.error(f'{m.gatherfunc=} returned no options ({self.cosmetic})') - - self.widget[m.key]["values"] = all_options - - if self.config_vars[m.key].get() not in all_options: - # log.info(f'Expected to find {self.config_vars[m.key].get()!r} in list {all_options!r}') - self.config_vars[m.key].set(all_options[0]) - - def _gender_filter(self, voice): - if hasattr(self, 'gender') and self.gender: - log.debug(f'{self.gender.title()} ?= {voice["gender"].title()}') - try: - return self.gender.title() == voice["gender"].title() - except KeyError: - log.warning('Failed to find "gender" in:') - log.debug(f"{voice=}") - return True - - -class WindowsTTS(TTSEngine): - cosmetic = "Windows TTS" - key = "windowstts" - - VOICE_SUPERSET = { - 'Hoda': { - 'gender': 'Female', - 'language_code': 'arb' # arabic - }, - 'Naayf': { - 'gender': 'Male', - 'language_code': 'ar-SA' # arabic (saudi) - }, - 'Ivan': { - 'gender': 'Male', - 'language_code': 'bg-BG' # Bulgarian - }, - 'Herena': { - 'gender': 'Female', - 'language_code': 'ca-ES' # Catalan - }, - 'Kangkang': { - 'gender': 'Male', - 'language_code': 'cmn-CN' # Chinese (simplified) - }, - 'Huihui': { - 'gender': 'Female', - 'language_code': 'cmn-CN' # Chinese (simplified) - }, - "Yaoyao": { - 'gender': 'Female', - 'language_code': 'cmn-CN' # Chinese (simplified) - }, - 'Danny': { - 'gender': 'Male', - 'language_code': 'yue-CN' # Cantonese (Traditional, Hong Kong SAR) - }, - 'Tracy': { - 'gender': 'Female', - 'language_code': 'yue-CN' # Cantonese (Traditional, Hong Kong SAR) - }, - 'Zhiwei': { - 'gender': 'Male', - 'language_code': 'yue-CN' # Chinese (Traditional, Taiwan) - }, - 'Matej': { - 'gender': 'Male', - 'language_code': 'hr-HR' # Croatian - }, - 'Jakub': { - 'gender': 'Male', - 'language_code': 'cs-CZ' # Czech - }, - "Helle": { - 'gender': 'Female', - 'language_code': 'da-DK' # Danish - }, - "Frank": { - 'gender': 'Male', - 'language_code': 'nl-NL' # Dutch - }, - "James": { - 'gender': 'Male', - 'language_code': 'en-AU' # English (Australia) - }, - "Catherine": { - 'gender': 'Female', - 'language_code': 'en-AU' # English (Australia) - }, - "Richard": { - 'gender': 'Male', - 'language_code': 'en-CA' # English (Canada) - }, - "Linda": { - 'gender': 'Female', - 'language_code': 'en-CA' # English (Canada) - }, - "Nathalie": { - 'gender': 'Female', - 'language_code': 'en-CA' # English (Canada) (this might be french, idk) - }, - "George": { - 'gender': 'Male', - 'language_code': 'en-GB' # English (GB) - }, - "Hazel": { - 'gender': 'Female', - 'language_code': 'en-GB' # English (GB) - }, - "Susan": { - 'gender': 'Female', - 'language_code': 'en-GB' # English (GB) - }, - "Ravi": { - 'gender': 'Male', - 'language_code': 'en-IN' # English (India) - }, - "Heera": { - 'gender': 'Female', - 'language_code': 'en-IN' # English (India) - }, - "Sean": { - 'gender': 'Male', - 'language_code': 'en-IE' # English (Ireland) - }, - "David": { - 'gender': 'Male', - 'language_code': 'en-US' # English (US) - }, - "Mark": { - 'gender': 'Male', - 'language_code': 'en-US' # English (US) - }, - "Zira": { - 'gender': 'Female', - 'language_code': 'en-US' # English (US) - }, - "Heidi": { - 'gender': 'Female', - 'language_code': 'fi-FL' # Finnish - }, - "Bart": { - 'gender': 'Male', - 'language_code': 'nl-BE' # Flemish (Belgian Dutch) - }, - "Claude": { - 'gender': 'Male', - 'language_code': 'fr-CA' # French (Canadian) - }, - "Caroline": { - 'gender': 'Female', - 'language_code': 'fr-CA' # French (Canadian) - }, - "Paul": { - 'gender': 'Male', - 'language_code': 'fr-FR' # French - }, - "Hortense": { - 'gender': 'Female', - 'language_code': 'fr-FR' # French - }, - "Julie": { - 'gender': 'Female', - 'language_code': 'fr-FR' # French - }, - "Guillaume": { - 'gender': 'Male', - 'language_code': 'fr-CH' # French (Switzerland) - }, - "Michael": { - 'gender': 'Male', - 'language_code': 'de-AT' # German (Austria) - }, - "Stefan": { - 'gender': 'Male', - 'language_code': 'de-DE' # German - }, - "Hedda": { - 'gender': 'Female', - 'language_code': 'de-DE' # German - }, - "Katja": { - 'gender': 'Female', - 'language_code': 'de-DE' # German - }, - "Karsten": { - 'gender': 'Male', - 'language_code': 'de-CH' # German (Switzerland) - }, - "Stefanos": { - 'gender': 'Male', - 'language_code': 'el-GR' # Greek - }, - "Asaf": { - 'gender': 'Male', - 'language_code': 'he-IL' # Hebrew - }, - "Hemant": { - 'gender': 'Male', - 'language_code': 'hi-IN' # Hindi (India) - }, - "Kalpana": { - 'gender': 'Female', - 'language_code': 'hi-IN' # Hindi (India) - }, - "Szabolcs": { - 'gender': 'Male', - 'language_code': 'hu-HU' # Hungarian - }, - "Andika": { - 'gender': 'Male', - 'language_code': 'id-ID' # Indonesian - }, - "Cosimo": { - 'gender': 'Male', - 'language_code': 'it-IT' # Italian - }, - "Elsa": { - 'gender': 'Female', - 'language_code': 'it-IT' # Italian - }, - "Ichiro": { - 'gender': 'Male', - 'language_code': 'ja-JP' # Japanese - }, - "Sayaka": { - 'gender': 'Male', - 'language_code': 'ja-JP' # Japanese - }, - "Ayumi": { - 'gender': 'Female', - 'language_code': 'ja-JP' # Japanese - }, - "Haruka": { - 'gender': 'Female', - 'language_code': 'ja-JP' # Japanese - }, - "Rizwan": { - 'gender': 'Male', - 'language_code': 'ms-MY' # Malay - }, - "Jon": { - 'gender': 'Male', - 'language_code': 'nb-NO' # Norwegian - }, - "Adam": { - 'gender': 'Male', - 'language_code': 'pl-PL' # Polish - }, - "Paulina": { - 'gender': 'Female', - 'language_code': 'pl-PL' # Polish - }, - "Daniel": { - 'gender': 'Male', - 'language_code': 'pt-BR' # Portuguese (Brazil) - }, - "Maria": { - 'gender': 'Female', - 'language_code': 'pt-BR' # Portuguese (Brazil) - }, - "Helia": { - 'gender': 'Female', - 'language_code': 'pt-PT' # Portuguese - }, - "Andrei": { - 'gender': 'Male', - 'language_code': 'ro-RO' # Romanian - }, - "Pavel": { - 'gender': 'Male', - 'language_code': 'ru-RU' # Russian - }, - "Irina": { - 'gender': 'Female', - 'language_code': 'ru-RU' # Russian - }, - "Filip": { - 'gender': 'Male', - 'language_code': 'sk-SK' # Slovak - }, - "Lado": { - 'gender': 'Male', - 'language_code': 'hu-SL' # Slovenian - }, - "Heami": { - 'gender': 'Female', - 'language_code': 'ko-KR' # Korean - }, - "Pablo": { - 'gender': 'Male', - 'language_code': 'es-ES' # Spanish (Spain) - }, - "Helena": { - 'gender': 'Female', - 'language_code': 'es-ES' # Spanish (Spain) - }, - "Laura": { - 'gender': 'Female', - 'language_code': 'es-ES' # Spanish (Spain) - }, - "Raul": { - 'gender': 'Male', - 'language_code': 'es-MX' # Spanish (Mexico) - }, - "Sabina": { - 'gender': 'Female', - 'language_code': 'es-MX' # Spanish (Mexico) - }, - "Bengt": { - 'gender': 'Male', - 'language_code': 'sv-SE' # Swedish - }, - "Valluvar": { - 'gender': 'Male', - 'language_code': 'ta-IN' # Tamil - }, - "Pattara": { - 'gender': 'Male', - 'language_code': 'th-TH' # Thai - }, - "Tolga": { - 'gender': 'Male', - 'language_code': 'tr-TR' # Turkish - }, - "An": { - 'gender': 'Male', - 'language_code': 'vi-VN' # Vietnamese - }, - } - - config = ( - ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), - ('Speaking Rate', 'rate', "DoubleVar", 1, {'min': -3.5, 'max': 3.5, 'digits': 2, 'resolution': 0.5}, None) - ) - - def get_tts(self): - """ - Return a pre-configured tts class instance - """ - rate = int(self.override.get('rate', self.config_vars["rate"].get())) - voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) - return WindowsSapi(rate=rate, voice=voice_name) - - def name_to_gender(self, name): - if name in self.VOICE_SUPERSET: - return self.VOICE_SUPERSET[name]["gender"] - return 'Neutral' - - def get_voice_names(self, gender=None): - """ - return a sorted list of available voices - I don't know how much this list will vary - from windows version to version and from - machine to machine. - """ - log.debug(f'Retrieving TTS voice names filtered to only show gender {self.gender}') - # all_voices = models.diskcache(f"{self.key}_voice_name") - all_voices = None - - if all_voices is None: - all_voices = [] - wintts = tts.sapi.Sapi() - voices = wintts.get_voice_names() - for v in voices: - if "Desktop" in v: - continue - - name = " ".join(v.split("-")[0].split()[1:]) - if name in self.VOICE_SUPERSET: - all_voices.append({ - 'voice_name': name, - 'gender': self.name_to_gender(name), - 'language_code': self.VOICE_SUPERSET[name]['language_code'] - }) - else: - all_voices.append({ - 'voice_name': name, - 'gender': self.name_to_gender(name) - }) - - models.diskcache(f"{self.key}_voice_name", all_voices) - - allowed_language_codes = settings.get_voice_language_codes() - nice_names = [] - - for voice in all_voices: - if gender and voice['gender'] != gender: - continue - - # filter out voices that are not compatible with our language - if 'language_code' in voice: - found = False - for code in allowed_language_codes: - if f"{code}-" in voice['language_code']: - found = True - else: - log.debug(f"{code}- not found in {voice['language_code']}") - - if not found: - continue - - nice_names.append(voice["voice_name"]) - - return sorted(nice_names) - - -class GoogleCloud(TTSEngine): - cosmetic = "Google Text-to-Speech" - key = 'googletts' - - config = ( - ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), - ('Speakin Rate', 'speaking_rate', "DoubleVar", 1, {'min': 0.5, 'max': 1.75, 'digits': 3, 'resolution': 0.25}, None), - ('Voice Pitch', 'voice_pitch', "DoubleVar", 1, {'min': -10, 'max': 10, 'resolution': 0.5}, None) - ) - - def _language_code_filter(self, voice): - """ - True if this voice is able to speak this language_code. - """ - allowed_language_codes = settings.get_voice_language_codes() - - # two letter code ala: en, and matches against en-whatever - for allowed_code in allowed_language_codes: - if any(f"{allowed_code}-" in code for code in voice["language_codes"]): - log.debug(f'{allowed_code=} matches with {voice["language_codes"]=}') - return True - return False - - def get_voice_names(self, gender=None): - all_voices = self.get_voices() - - if gender and not hasattr(self, 'gender'): - self.gender = gender - - out = set() - for voice in all_voices: - if self._language_code_filter(voice): - if self._gender_filter(voice): - out.add(voice['voice_name']) - - if not out: - log.error(f'There are no voices available with language={self.language_code} and gender={self.gender}') - - out = sorted(list(out)) - - if out: - voice_name = self.config_vars["voice_name"].get() - if voice_name not in out: - # our currently selected voice is invalid. Pick a new one. - log.error(f'Voice {voice_name} is now invalid') - self.config_vars["voice_name"].set(out[0]) - return out - else: - return [] - - def get_voices(self): - all_voices = models.diskcache(f'{self.key}_voice_name') - - if all_voices is None: - client = texttospeech.TextToSpeechClient() - req = texttospeech.ListVoicesRequest() - resp = client.list_voices(req) - all_voices = [] - for voice in resp.voices: - # log.info(f'{voice.language_codes=}') - # log.info(dir(voice.language_codes)) - - language_codes = [] - for code in voice.language_codes: - # log.info(f'{code=}') - language_codes.append(code) - row = { - 'voice_name': voice.name, - 'natural_sample_rate_hertz': voice.natural_sample_rate_hertz, - 'gender': {1: 'Female', 2: 'Male'}[voice.ssml_gender.value], - 'language_codes': language_codes - } - # log.info(f'{row=}') - # for key in row: - # log.info(f'{key} == {json.dumps(row[key])}') - - all_voices.append(row) - - models.diskcache(f'{self.key}_voice_name', all_voices) - - return all_voices - - @staticmethod - def get_voice_gender(voice_name): - with models.db() as session: - ssml_gender = session.scalars( - select(models.GoogleVoices.ssml_gender).where( - models.GoogleVoices.name == voice_name - ) - ).first() - return ssml_gender - - def get_voice_language(self, voice_name): - """ - first compatible language code - """ - allowed_language_codes = settings.get_voice_language_codes() - - all_voices = self.get_voices() - for voice in all_voices: - if voice["voice_name"] == voice_name: - for code in voice['language_codes']: - for allowed in allowed_language_codes: - if f"{allowed}-" in code: - return code - return None - - def get_tts(self): - voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) - speaking_rate = self.override.get('speaking_rate', self.config_vars["speaking_rate"].get()) - voice_pitch = self.override.get('voice_pitch', self.config_vars["voice_pitch"].get()) - language_code = self.get_voice_language(voice_name) - - client = texttospeech.TextToSpeechClient() - - audio_config = texttospeech.AudioConfig( - speaking_rate=float(speaking_rate), - pitch=float(voice_pitch) - ) - - voice_params = texttospeech.VoiceSelectionParams( - language_code=language_code, - name=voice_name, - ) - - return voicebox.tts.GoogleCloudTTS( - client=client, - voice_params=voice_params, - audio_config=audio_config - ) - - -def get_elevenlabs_client(): - if os.path.exists("./eleven_labs.key"): - with open("./eleven_labs.key") as h: - # umm, I can't do that, can I? - elvenlabs_api_key = h.read().strip() - - # https://github.com/elevenlabs/elevenlabs-python/blob/main/src/elevenlabs/client.py#L42 - client = ELABS(api_key=elvenlabs_api_key) - return client - else: - log.warning("Elevenlabs Requires valid eleven_labs.key file") - - -def as_gender(in_gender): - if in_gender in ["female"]: - return "female" - if in_gender in ["male"]: - return "male" - else: - return "neutral" - - return in_gender - - -class ElevenLabs(TTSEngine): - """ - Elevenlabs detects the incoming language; so in theory every voice works with every language. I have doubts. - """ - cosmetic = "Eleven Labs" - key = "elevenlabs" - api_key = None - - config = ( - ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), - ('Stability', 'stability', "DoubleVar", 0.5, {'min': 0, 'max': 1, 'resolution': 0.025}, None), - ('Similarity Boost', 'similarity_boost', "DoubleVar", 0, {'min': 0, 'max': 1, 'resolution': 0.025}, None), - ('Style', 'style', "DoubleVar", 0.0, {'min': 0, 'max': 1, 'resolution': 0.025}, None), - ('Speaker Boost', 'use_speaker_boost', "BooleanVar", True, {}, None) - ) - - def get_voice_names(self, gender=None): - """ - PSA, I know there is a lot of gender in this code. The intention is to - better guess which voice from an assortment of voices aligns with the - expections of the person playing the game. Adding voices in games is a - great power and it comes with a great responsibility. - - Make lord recluse sound like a little girl? give statesman a heavy lisp? - all the 5th column a strong german accent? all the street punks an - enthic voice? - - This is a weapon. To avoid weilding it we set a simple rule. The goal - is the voice that best presents a realistic interpretation of what each - character ought to sound like based on the description, appearance and - dialog. - - TODO: we need to add a 'cache expire' button on the config for each of - primary/secondary. - """ - all_voices = self.get_voices() - - if gender and not hasattr(self, 'gender'): - self.gender = gender - - # log.info(f'ElevenLabs get_voice_name({gender=}) ({self.gender})') - out = set() - for voice in all_voices: - if self._gender_filter(voice): - out.add(voice['voice_name']) - - out = sorted(list(out)) - - if out: - if self.config_vars["voice_name"].get() not in out: - # our currently selected voice is invalid. Pick a new one. - log.error('Invalid voice selecton: %s. Overriding...', self.config_vars["voice_name"].get()) - self.config_vars["voice_name"].set(out[0]) - return out - else: - return [] - - def get_voices(self): - all_voices = models.diskcache(f'{self.key}_voice_name') - - if all_voices is None: - client = get_elevenlabs_client() - all_raw_voices = client.voices.get_all() - - all_voices = [] - for voice in all_raw_voices.voices: - # log.info(f"{voice=}") - all_voices.append({ - 'id': voice.voice_id, - 'voice_name': voice.name, - 'gender': voice.labels['gender'].title() - }) - - # log.info(all_voices) - models.diskcache(f'{self.key}_voice_name', all_voices) - - return all_voices - - def get_tts(self): - # voice is an elevenlabs.Voice instance, We need input from the user - # so we add a choice field the __init__ - # model : :class:`elevenlabs.Model` instance, or a string representing the model ID. - - # settings comments from https://elevenlabs.io/docs/speech-synthesis/voice-settings - voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) - - # The stability slider determines how stable the voice is and the - # randomness between each generation. Lowering this slider introduces a - # broader emotional range for the voice. As mentioned before, this is - # also influenced heavily by the original voice. Setting the slider too - # low may result in odd performances that are overly random and cause - # the character to speak too quickly. On the other hand, setting it too - # high can lead to a monotonous voice with limited emotion. - stability = self.override.get('stability', self.config_vars["stability"].get()) - - # "similarity_boost" corresponds to"Clarity + Similarity Enhancement" in the web app - similarity_boost = self.override.get('similarity_boost', self.config_vars["similarity_boost"].get()) - - # With the introduction of the newer models, we also added a style - # exaggeration setting. This setting attempts to amplify the style of - # the original speaker. It does consume additional computational - # resources and might increase latency if set to anything other than 0. - # It’s important to note that using this setting has shown to make the - # model slightly less stable, as it strives to emphasize and imitate the - # style of the original voice. In general, we recommend keeping this - # setting at 0 at all times. - style = self.override.get('style', self.config_vars["style"].get()) - - # This is another setting that was introduced in the new models. The - # setting itself is quite self-explanatory – it boosts the similarity to - # the original speaker. However, using this setting requires a slightly - # higher computational load, which in turn increases latency. The - # differences introduced by this setting are generally rather subtle. - use_speaker_boost = self.override.get('use_speaker_boost', self.config_vars["use_speaker_boost"].get()) - - # model = elevenlabs.Model() - model = None - - # log.info(f'Creating ttsElevenLab(, voice={voice_name}, model={model})') - return ttsElevenLabs( - api_key=self.api_key, - stability=stability, - similarity_boost=similarity_boost, - style=style, - use_speaker_boost=use_speaker_boost, - voice=voice_name, - model=model - ) - -@dataclass -class ttsElevenLabs(voicebox.tts.TTS): - """ - There was an API update in the elevenlabs client that broke the built in voicebox support. - """ - api_key: str = None - voice: Union[str, elevenlabs.Voice] = field(default_factory=lambda: elevenlabs.DEFAULT_VOICE) - model: Union[str, elevenlabs.Model] = 'eleven_monolingual_v1' - stability: float = 0.71 - similarity_boost: float = 0.5 - style: float = 0.0 - use_speaker_boost : bool = True - - def voice_name_to_id(self, voice_name): - voice_name = voice_name.strip() - for voice in models.diskcache(f'elevenlabs_voice_name'): - if voice['voice_name'] == voice_name: - return voice['id'] - - log.error('Unknown voice: %s', voice_name) - - def get_speech(self, text: StrOrSSML) -> Audio: - client = get_elevenlabs_client() - # https://github.com/elevenlabs/elevenlabs-python/blob/main/src/elevenlabs/client.py#L118 - # default response is an iterator providing an mp3_44100_128. - # - # I tried asking 11labs for a PCM response (wav),so we don't need to decompress an mp3 - # but the PCM wav format returned by 11labs isn't direcly compatible with the - # wav format that the python wave library known how to open. - log.debug(f"self.voice: {self.voice}") - - voice_id = self.voice_name_to_id(self.voice) - - # I'm not actually clear on what exactly 'model' does. - # voice_model = None - - audio_data = client.generate( - text=text, - voice=elevenlabs.Voice( - voice_id=voice_id, - settings=elevenlabs.VoiceSettings( - stability=self.stability, - similarity_boost=self.similarity_boost, - style=self.style, - use_speaker_boost=self.use_speaker_boost - ) - ) - ) - - with tempfile.NamedTemporaryFile() as tmp: - tmp.close() - # start with an mp3 file - mp3filename = tmp.name + ".mp3" - elevenlabs.save(audio_data, mp3filename) - - return audio.mp3file_to_Audio(mp3filename) - - -class AmazonPolly(TTSEngine): - """ - Pricing: - https://aws.amazon.com/polly/pricing/?p=pm&c=ml&pd=polly&z=4 - I think this could be a really great fit for this - project with its free million characters of tts per - month, and (in 2024) each addition million at highest - quality for $16. If the API can make it clear when - you cross from free to paid and quality is anywhere - near elevenlabs.. lets see what we can get. - """ - cosmetic = "Amazon Polly" - key = "amazonpolly" - - config = ( - ('Engine', 'engine', "StringVar", 'standard', {}, "get_engine_names"), - ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), - ('Sample Rate', 'sample_rate', "StringVar", '16000', {}, "get_sample_rates") - ) - client = None - - def get_client(self): - if self.client: - return self.client - - self.session = boto3.Session() - self.client = self.session.client('polly') - return self.client - - def _language_code_filter(self, voice): - """ - True if this voice is able to speak this language_code. - """ - allowed_language_codes = settings.get_voice_language_codes() - - for allowed_code in allowed_language_codes: - if ( - f"{allowed_code}-" in voice["LanguageCode"] - ): - #or - #f"{allowed_code}-" in code for code in voice.get("AdditionalLanguageCodes", []) - #): - log.debug(f'{voice["LanguageCode"]=}/{voice.get('AdditionalLanguageCodes', [])} is allowed for {allowed_code=}') - return True - return False - - def get_language_codes(self): - all_language_codes = models.diskcache(f'{self.key}_language_code') - - if all_language_codes is None: - # log.info('Building AmazonPolly language_code cache') - all_voices = self.get_voices() - out = set() - - for voice_id in all_voices: - voice = all_voices[voice_id] - - if self._gender_filter(voice): - out.add(voice["LanguageCode"]) - for code in voice.get('AdditionalLanguageCodes', []): - out.add(code) - - all_language_codes = [ {'language_code': code} for code in out] - models.diskcache(f'{self.key}_language_code', all_language_codes) - - # any filtering needed for language codes? - codes = [code['language_code'] for code in all_language_codes] - - return codes - - def get_engine_names(self): - all_engines = models.diskcache(f'{self.key}_engine') - - if all_engines is None: - all_voices = self.get_voices() - - out = set() - secondary = set() - # is this going to be intuitive or just weird? - for voice in all_voices: - - if self._language_code_filter(voice): - if self._gender_filter(voice): - for code in voice.get('SupportedEngines', []): - out.add(code) - else: - for code in voice.get('SupportedEngines', []): - secondary.add(code) - - if not out: - log.warning('No engines exist that support this language/gender. Ignoring gender.') - out = secondary - - all_engines = [ {'engine': engine_name} for engine_name in out ] - models.diskcache(f'{self.key}_engine', all_engines) - - return [engine['engine'] for engine in all_engines] - - def get_voice_names(self, gender=None): - all_voices = self.get_voices() - - if gender and not hasattr(self, 'gender'): - self.gender = gender - - out = set() - secondary = set() - for voice in all_voices: - if self._language_code_filter(voice): - if self._gender_filter(voice): - log.debug(f'Including voice {voice["Name"]}') - out.add(voice["Name"]) - else: - secondary.add(voice["Name"]) - else: - log.debug(f'Excluding {voice["Name"]}') - - if not out: - log.warning('No voices exist that support this language/gender. Ignoring gender.') - out = secondary - - out = sorted(list(out)) - - if out: - if self.config_vars["voice_name"].get() not in out: - # our currently selected voice is invalid. Pick a new one. - self.config_vars["voice_name"].set(out[0]) - return out - else: - return [] - - def voice_name_to_voice_id(self, voice_name): - voice_name = voice_name.strip() - all_voices = self.get_voices() - for voice in all_voices: - if voice['Name'] == voice_name: - return voice['Id'] - - log.error(f'Could not convert {voice_name=} to a voice_id') - return None - - def get_sample_rates(self, filter_by=None): - # what does this depend on? - # and.. it depends only some internal details in voicebox - # If we change voicebox to use mp3 or ogg_vorbis we could - # use [8000, 16000, 22050, 24000] - # But since it is getting PCM from Polly the only - # valid options are 8000 and 16000. - - # why is this being cached? stupid? ridiculous? - # this lets us treat all stringvar fields the same way, so yes, but no. - all_sample_rates = models.diskcache(f'{self.key}_sample_rate') - - if all_sample_rates is None: - all_sample_rates = [ - {"sample_rate": "8000"}, - {"sample_rate": "16000"} - ] - models.diskcache(f'{self.key}_sample_rate', all_sample_rates) - - return [ rate['sample_rate'] for rate in all_sample_rates ] - - def get_tts(self): - """ - Returns a voicebox TTS object initialized for a specific - character - """ - # https://boto3.amazonaws.com/v1/documentation/api/latest/index.html - # - # ~/.aws/credentials - # C:\Users\\.aws\credentials - # - # [default] - # aws_access_key_id = YOUR_ACCESS_KEY - # aws_secret_access_key = YOUR_SECRET_KEY - # - # ~/.aws/config: - # [default] - # region=us-west-1 - # - # https://us-east-2.console.aws.amazon.com/polly/home/SynthesizeSpeech - - raw_voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) - voice_id = self.voice_name_to_voice_id(raw_voice_name) - - # Engine (string) – Specifies the engine ( standard, neural, long-form - # or generative) used by Amazon Polly when processing input text for - # speech synthesis. - engine = self.config_vars["engine"].get() - - # LanguageCode (string) – The language identification tag (ISO 639 code - # for the language name-ISO 3166 country code) for filtering the list of - # voices returned. If you don’t specify this optional parameter, all - # available voices are returned. - # language_code=self.override.get('language_code', self.config_vars["language_code"].get()) - lexicon_names=[] - sample_rate = self.override.get('sample_rate', self.config_vars["sample_rate"].get()) - - return AmazonPollyTTS( - client=self.get_client(), - voice_id=voice_id.strip(), - engine=engine, - # language_code=language_code, - lexicon_names=lexicon_names, - sample_rate=int(sample_rate) - ) - - def get_voices(self): - # Language code of the voice. - - # We aren't really interested in listing _every_ language code. We only - # want the ones that have at least one Amazon Polly voice. - all_voices = models.diskcache(f'{self.key}_voice_name') - - if all_voices is None: - session = boto3.Session() - client = session.client('polly') - - all_voices = [] - for voice in client.describe_voices()['Voices']: - log.debug(f'{voice=}') - voice['voice_name'] = voice["Name"] - voice['language_code'] = voice["LanguageCode"] - voice['gender'] = voice["Gender"] - all_voices.append(voice) - - models.diskcache(f'{self.key}_voice_name', all_voices) - - return all_voices - - -# https://github.com/coqui-ai/tts +# https://github.com/coqui-ai/tts +# # I tried this. Doesn't work yet in Windown w/Py 3.12 due to the absense of # compiled pytorch binaries. I'm more than a little worried the resources # requirment and speed will make it impractical. @@ -1364,4 +20,6 @@ def get_engine(engine_name): return engine_cls -ENGINE_LIST = [ WindowsTTS, GoogleCloud, ElevenLabs, AmazonPolly ] +ENGINE_LIST = [ + WindowsTTS, GoogleCloud, ElevenLabs, AmazonPolly +] diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py new file mode 100644 index 0000000..22be5d3 --- /dev/null +++ b/src/cnv/engines/googlecloud.py @@ -0,0 +1,144 @@ +import logging + +import cnv.database.models as models +import cnv.lib.settings as settings +import voicebox +from google.cloud import texttospeech +from sqlalchemy import select + +from .base import TTSEngine + +log = logging.getLogger(__name__) + +class GoogleCloud(TTSEngine): + cosmetic = "Google Text-to-Speech" + key = 'googletts' + auth_ui_class = None + + config = ( + ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), + ('Speakin Rate', 'speaking_rate', "DoubleVar", 1, {'min': 0.5, 'max': 1.75, 'digits': 3, 'resolution': 0.25}, None), + ('Voice Pitch', 'voice_pitch', "DoubleVar", 1, {'min': -10, 'max': 10, 'resolution': 0.5}, None) + ) + + def _language_code_filter(self, voice): + """ + True if this voice is able to speak this language_code. + """ + allowed_language_codes = settings.get_voice_language_codes() + + # two letter code ala: en, and matches against en-whatever + for allowed_code in allowed_language_codes: + if any(f"{allowed_code}-" in code for code in voice["language_codes"]): + log.debug(f'{allowed_code=} matches with {voice["language_codes"]=}') + return True + return False + + def get_voice_names(self, gender=None): + all_voices = self.get_voices() + + if gender and not hasattr(self, 'gender'): + self.gender = gender + + out = set() + for voice in all_voices: + if self._language_code_filter(voice): + if self._gender_filter(voice): + out.add(voice['voice_name']) + + if not out: + log.error(f'There are no voices available with language={self.language_code} and gender={self.gender}') + + out = sorted(list(out)) + + if out: + voice_name = self.config_vars["voice_name"].get() + if voice_name not in out: + # our currently selected voice is invalid. Pick a new one. + log.error(f'Voice {voice_name} is now invalid') + self.config_vars["voice_name"].set(out[0]) + return out + else: + return [] + + def get_voices(self): + all_voices = models.diskcache(f'{self.key}_voice_name') + + if all_voices is None: + client = texttospeech.TextToSpeechClient() + req = texttospeech.ListVoicesRequest() + resp = client.list_voices(req) + all_voices = [] + for voice in resp.voices: + # log.info(f'{voice.language_codes=}') + # log.info(dir(voice.language_codes)) + + language_codes = [] + for code in voice.language_codes: + # log.info(f'{code=}') + language_codes.append(code) + row = { + 'voice_name': voice.name, + 'natural_sample_rate_hertz': voice.natural_sample_rate_hertz, + 'gender': {1: 'Female', 2: 'Male'}[voice.ssml_gender.value], + 'language_codes': language_codes + } + # log.info(f'{row=}') + # for key in row: + # log.info(f'{key} == {json.dumps(row[key])}') + + all_voices.append(row) + + models.diskcache(f'{self.key}_voice_name', all_voices) + + return all_voices + + @staticmethod + def get_voice_gender(voice_name): + with models.db() as session: + ssml_gender = session.scalars( + select(models.GoogleVoices.ssml_gender).where( + models.GoogleVoices.name == voice_name + ) + ).first() + return ssml_gender + + def get_voice_language(self, voice_name): + """ + first compatible language code + """ + allowed_language_codes = settings.get_voice_language_codes() + + all_voices = self.get_voices() + for voice in all_voices: + if voice["voice_name"] == voice_name: + for code in voice['language_codes']: + for allowed in allowed_language_codes: + if f"{allowed}-" in code: + return code + return None + + def get_tts(self): + voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) + speaking_rate = self.override.get('speaking_rate', self.config_vars["speaking_rate"].get()) + voice_pitch = self.override.get('voice_pitch', self.config_vars["voice_pitch"].get()) + language_code = self.get_voice_language(voice_name) + + client = texttospeech.TextToSpeechClient() + + audio_config = texttospeech.AudioConfig( + speaking_rate=float(speaking_rate), + pitch=float(voice_pitch) + ) + + voice_params = texttospeech.VoiceSelectionParams( + language_code=language_code, + name=voice_name, + ) + + return voicebox.tts.GoogleCloudTTS( + client=client, + voice_params=voice_params, + audio_config=audio_config + ) + diff --git a/src/cnv/engines/windowstts.py b/src/cnv/engines/windowstts.py new file mode 100644 index 0000000..f95e60d --- /dev/null +++ b/src/cnv/engines/windowstts.py @@ -0,0 +1,439 @@ +import logging +from dataclasses import dataclass + +import cnv.database.models as models +import cnv.lib.settings as settings +import numpy as np +import tts.sapi +import voicebox +from voicebox.audio import Audio +from voicebox.types import StrOrSSML + +from .base import TTSEngine + +log = logging.getLogger(__name__) + + +class WindowsTTS(TTSEngine): + cosmetic = "Windows TTS" + key = "windowstts" + auth_ui_class = None + + VOICE_SUPERSET = { + 'Hoda': { + 'gender': 'Female', + 'language_code': 'arb' # arabic + }, + 'Naayf': { + 'gender': 'Male', + 'language_code': 'ar-SA' # arabic (saudi) + }, + 'Ivan': { + 'gender': 'Male', + 'language_code': 'bg-BG' # Bulgarian + }, + 'Herena': { + 'gender': 'Female', + 'language_code': 'ca-ES' # Catalan + }, + 'Kangkang': { + 'gender': 'Male', + 'language_code': 'cmn-CN' # Chinese (simplified) + }, + 'Huihui': { + 'gender': 'Female', + 'language_code': 'cmn-CN' # Chinese (simplified) + }, + "Yaoyao": { + 'gender': 'Female', + 'language_code': 'cmn-CN' # Chinese (simplified) + }, + 'Danny': { + 'gender': 'Male', + 'language_code': 'yue-CN' # Cantonese (Traditional, Hong Kong SAR) + }, + 'Tracy': { + 'gender': 'Female', + 'language_code': 'yue-CN' # Cantonese (Traditional, Hong Kong SAR) + }, + 'Zhiwei': { + 'gender': 'Male', + 'language_code': 'yue-CN' # Chinese (Traditional, Taiwan) + }, + 'Matej': { + 'gender': 'Male', + 'language_code': 'hr-HR' # Croatian + }, + 'Jakub': { + 'gender': 'Male', + 'language_code': 'cs-CZ' # Czech + }, + "Helle": { + 'gender': 'Female', + 'language_code': 'da-DK' # Danish + }, + "Frank": { + 'gender': 'Male', + 'language_code': 'nl-NL' # Dutch + }, + "James": { + 'gender': 'Male', + 'language_code': 'en-AU' # English (Australia) + }, + "Catherine": { + 'gender': 'Female', + 'language_code': 'en-AU' # English (Australia) + }, + "Richard": { + 'gender': 'Male', + 'language_code': 'en-CA' # English (Canada) + }, + "Linda": { + 'gender': 'Female', + 'language_code': 'en-CA' # English (Canada) + }, + "Nathalie": { + 'gender': 'Female', + 'language_code': 'en-CA' # English (Canada) (this might be french, idk) + }, + "George": { + 'gender': 'Male', + 'language_code': 'en-GB' # English (GB) + }, + "Hazel": { + 'gender': 'Female', + 'language_code': 'en-GB' # English (GB) + }, + "Susan": { + 'gender': 'Female', + 'language_code': 'en-GB' # English (GB) + }, + "Ravi": { + 'gender': 'Male', + 'language_code': 'en-IN' # English (India) + }, + "Heera": { + 'gender': 'Female', + 'language_code': 'en-IN' # English (India) + }, + "Sean": { + 'gender': 'Male', + 'language_code': 'en-IE' # English (Ireland) + }, + "David": { + 'gender': 'Male', + 'language_code': 'en-US' # English (US) + }, + "Mark": { + 'gender': 'Male', + 'language_code': 'en-US' # English (US) + }, + "Zira": { + 'gender': 'Female', + 'language_code': 'en-US' # English (US) + }, + "Heidi": { + 'gender': 'Female', + 'language_code': 'fi-FL' # Finnish + }, + "Bart": { + 'gender': 'Male', + 'language_code': 'nl-BE' # Flemish (Belgian Dutch) + }, + "Claude": { + 'gender': 'Male', + 'language_code': 'fr-CA' # French (Canadian) + }, + "Caroline": { + 'gender': 'Female', + 'language_code': 'fr-CA' # French (Canadian) + }, + "Paul": { + 'gender': 'Male', + 'language_code': 'fr-FR' # French + }, + "Hortense": { + 'gender': 'Female', + 'language_code': 'fr-FR' # French + }, + "Julie": { + 'gender': 'Female', + 'language_code': 'fr-FR' # French + }, + "Guillaume": { + 'gender': 'Male', + 'language_code': 'fr-CH' # French (Switzerland) + }, + "Michael": { + 'gender': 'Male', + 'language_code': 'de-AT' # German (Austria) + }, + "Stefan": { + 'gender': 'Male', + 'language_code': 'de-DE' # German + }, + "Hedda": { + 'gender': 'Female', + 'language_code': 'de-DE' # German + }, + "Katja": { + 'gender': 'Female', + 'language_code': 'de-DE' # German + }, + "Karsten": { + 'gender': 'Male', + 'language_code': 'de-CH' # German (Switzerland) + }, + "Stefanos": { + 'gender': 'Male', + 'language_code': 'el-GR' # Greek + }, + "Asaf": { + 'gender': 'Male', + 'language_code': 'he-IL' # Hebrew + }, + "Hemant": { + 'gender': 'Male', + 'language_code': 'hi-IN' # Hindi (India) + }, + "Kalpana": { + 'gender': 'Female', + 'language_code': 'hi-IN' # Hindi (India) + }, + "Szabolcs": { + 'gender': 'Male', + 'language_code': 'hu-HU' # Hungarian + }, + "Andika": { + 'gender': 'Male', + 'language_code': 'id-ID' # Indonesian + }, + "Cosimo": { + 'gender': 'Male', + 'language_code': 'it-IT' # Italian + }, + "Elsa": { + 'gender': 'Female', + 'language_code': 'it-IT' # Italian + }, + "Ichiro": { + 'gender': 'Male', + 'language_code': 'ja-JP' # Japanese + }, + "Sayaka": { + 'gender': 'Male', + 'language_code': 'ja-JP' # Japanese + }, + "Ayumi": { + 'gender': 'Female', + 'language_code': 'ja-JP' # Japanese + }, + "Haruka": { + 'gender': 'Female', + 'language_code': 'ja-JP' # Japanese + }, + "Rizwan": { + 'gender': 'Male', + 'language_code': 'ms-MY' # Malay + }, + "Jon": { + 'gender': 'Male', + 'language_code': 'nb-NO' # Norwegian + }, + "Adam": { + 'gender': 'Male', + 'language_code': 'pl-PL' # Polish + }, + "Paulina": { + 'gender': 'Female', + 'language_code': 'pl-PL' # Polish + }, + "Daniel": { + 'gender': 'Male', + 'language_code': 'pt-BR' # Portuguese (Brazil) + }, + "Maria": { + 'gender': 'Female', + 'language_code': 'pt-BR' # Portuguese (Brazil) + }, + "Helia": { + 'gender': 'Female', + 'language_code': 'pt-PT' # Portuguese + }, + "Andrei": { + 'gender': 'Male', + 'language_code': 'ro-RO' # Romanian + }, + "Pavel": { + 'gender': 'Male', + 'language_code': 'ru-RU' # Russian + }, + "Irina": { + 'gender': 'Female', + 'language_code': 'ru-RU' # Russian + }, + "Filip": { + 'gender': 'Male', + 'language_code': 'sk-SK' # Slovak + }, + "Lado": { + 'gender': 'Male', + 'language_code': 'hu-SL' # Slovenian + }, + "Heami": { + 'gender': 'Female', + 'language_code': 'ko-KR' # Korean + }, + "Pablo": { + 'gender': 'Male', + 'language_code': 'es-ES' # Spanish (Spain) + }, + "Helena": { + 'gender': 'Female', + 'language_code': 'es-ES' # Spanish (Spain) + }, + "Laura": { + 'gender': 'Female', + 'language_code': 'es-ES' # Spanish (Spain) + }, + "Raul": { + 'gender': 'Male', + 'language_code': 'es-MX' # Spanish (Mexico) + }, + "Sabina": { + 'gender': 'Female', + 'language_code': 'es-MX' # Spanish (Mexico) + }, + "Bengt": { + 'gender': 'Male', + 'language_code': 'sv-SE' # Swedish + }, + "Valluvar": { + 'gender': 'Male', + 'language_code': 'ta-IN' # Tamil + }, + "Pattara": { + 'gender': 'Male', + 'language_code': 'th-TH' # Thai + }, + "Tolga": { + 'gender': 'Male', + 'language_code': 'tr-TR' # Turkish + }, + "An": { + 'gender': 'Male', + 'language_code': 'vi-VN' # Vietnamese + }, + } + + config = ( + ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), + ('Speaking Rate', 'rate', "DoubleVar", 1, {'min': -3.5, 'max': 3.5, 'digits': 2, 'resolution': 0.5}, None) + ) + + def get_tts(self): + """ + Return a pre-configured tts class instance + """ + rate = int(self.override.get('rate', self.config_vars["rate"].get())) + voice_name = self.override.get('voice_name', self.config_vars["voice_name"].get()) + return WindowsSapi(rate=rate, voice=voice_name) + + def name_to_gender(self, name): + if name in self.VOICE_SUPERSET: + return self.VOICE_SUPERSET[name]["gender"] + return 'Neutral' + + def get_voice_names(self, gender=None): + """ + return a sorted list of available voices + I don't know how much this list will vary + from windows version to version and from + machine to machine. + """ + log.debug(f'Retrieving TTS voice names filtered to only show gender {self.gender}') + # all_voices = models.diskcache(f"{self.key}_voice_name") + all_voices = None + + if all_voices is None: + all_voices = [] + wintts = tts.sapi.Sapi() + voices = wintts.get_voice_names() + for v in voices: + if "Desktop" in v: + continue + + name = " ".join(v.split("-")[0].split()[1:]) + if name in self.VOICE_SUPERSET: + all_voices.append({ + 'voice_name': name, + 'gender': self.name_to_gender(name), + 'language_code': self.VOICE_SUPERSET[name]['language_code'] + }) + else: + all_voices.append({ + 'voice_name': name, + 'gender': self.name_to_gender(name) + }) + + models.diskcache(f"{self.key}_voice_name", all_voices) + + allowed_language_codes = settings.get_voice_language_codes() + nice_names = [] + + for voice in all_voices: + if gender and voice['gender'] != gender: + continue + + # filter out voices that are not compatible with our language + if 'language_code' in voice: + found = False + for code in allowed_language_codes: + if f"{code}-" in voice['language_code']: + found = True + else: + log.debug(f"{code}- not found in {voice['language_code']}") + + if not found: + continue + + nice_names.append(voice["voice_name"]) + + return sorted(nice_names) + + +@dataclass +class WindowsSapi(voicebox.tts.tts.TTS): + rate: int = 1 + voice: str = "Zira" + + def get_speech(self, text: StrOrSSML) -> Audio: + voice = tts.sapi.Sapi() + log.debug(f"Saying {text!r} as {self.voice} at rate {self.rate}") + voice.set_rate(self.rate) + voice.set_voice(self.voice) + + stream = tts.sapi.comtypes.client.CreateObject('SAPI.SpMemoryStream') + + # save the original output stream + temp_stream = voice.voice.AudioOutputStream + + # hijack it + voice.voice.AudioOutputStream = stream + + voice.say(text) + + # restore it + voice.voice.AudioOutputStream = temp_stream + + samples = np.frombuffer( + bytes(stream.GetData()), + dtype=np.int16 + ) + + audio = voicebox.tts.utils.get_audio_from_samples( + samples, + 22050 + ) + + return audio \ No newline at end of file diff --git a/src/cnv/tabs/configuration.py b/src/cnv/tabs/configuration.py index 8ab84fd..ff02a79 100644 --- a/src/cnv/tabs/configuration.py +++ b/src/cnv/tabs/configuration.py @@ -1,6 +1,5 @@ import logging -import os import tkinter as tk from tkinter import ttk @@ -25,11 +24,14 @@ class SpokenLanguageSelection(ttk.Frame): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.columnconfigure(0, minsize=125, uniform="baseconfig") + self.columnconfigure(1, weight=2, uniform="baseconfig") + ttk.Label( self, text="Spoken Language", anchor="e", - ).pack(side="left", fill="x", expand=True) + ).grid(column=0, row=0, sticky='e') current = settings.get_config_key('language', "English") self.language = tk.StringVar(value=current) @@ -37,7 +39,7 @@ def __init__(self, *args, **kwargs): default_engine_combo = ttk.Combobox(self, textvariable=self.language) default_engine_combo["values"] = list(settings.LANGUAGES.keys()) default_engine_combo["state"] = "readonly" - default_engine_combo.pack(side="left", fill="x") + default_engine_combo.grid(column=1, row=0, sticky='w') self.language.trace_add('write', self.change_language) @@ -50,6 +52,7 @@ def change_language(self, a, b, c): log.info(f'Changing language to {newvalue}') # we should immediately translate and localize the UI + class EngineAuthentication(ttk.Notebook): """ Collects tabs for configuring authentication for each of the TTS engines. The @@ -59,47 +62,11 @@ class EngineAuthentication(ttk.Notebook): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - elevenlabs = self.elevenlabs_token_frame() - elevenlabs.pack(side="top", fill="both", expand=True) - self.add(elevenlabs, text="ElevenLabs") - - def elevenlabs_token_frame(self) -> ttk.Frame: - """ - Returns a frame holding any/all authentication configuration needed for - ElevenLabs - """ - elevenlabs = ttk.Frame( - self, - borderwidth=1, - relief="groove" - ) - ttk.Label( - elevenlabs, - text="ElevenLabs API Token", - anchor="e", - ).pack(side="left", fill="x", expand=True) - - self.elevenlabs_key = tk.StringVar(value=self.get_elevenlabs_key()) - self.elevenlabs_key.trace_add('write', self.change_elevenlabs_key) - ttk.Entry( - elevenlabs, - textvariable=self.elevenlabs_key, - show="*" - ).pack(side="left", fill="x", expand=True) - return elevenlabs - - def change_elevenlabs_key(self, a, b, c): - with open("eleven_labs.key", 'w') as h: - h.write(self.elevenlabs_key.get()) - - def get_elevenlabs_key(self): - keyfile = 'eleven_labs.key' - value = None - - if os.path.exists(keyfile): - with open(keyfile, 'r') as h: - value = h.read() - return value + for engine_ui in engines.ENGINE_LIST: + if engine_ui.auth_ui_class: + auth_ui = engine_ui.auth_ui_class() + auth_ui.pack(side="top", fill="x") + self.add(auth_ui, text=auth_ui.label) class ChannelToEngineMap(ttk.Frame): From c815e0c9e3dac77a0792221b01342dc865116733 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Fri, 5 Jul 2024 15:25:39 -0700 Subject: [PATCH 04/32] config menu isn't totally awful now, oauth based google tts --- google_credential.json | 13 +++ pyproject.toml | 2 + requirements.txt | 2 + src/cnv/engines/amazonpolly.py | 148 ++++++++++++++++++++++++++++++++- src/cnv/engines/base.py | 80 ++++++++++++++++++ src/cnv/engines/elevenlabs.py | 46 +++++----- src/cnv/engines/googlecloud.py | 115 ++++++++++++++++++++++++- src/cnv/tabs/configuration.py | 11 +-- 8 files changed, 386 insertions(+), 31 deletions(-) create mode 100644 google_credential.json diff --git a/google_credential.json b/google_credential.json new file mode 100644 index 0000000..3dbb455 --- /dev/null +++ b/google_credential.json @@ -0,0 +1,13 @@ +{ + "installed": { + "client_id": "790411949022-3rs6qetdkan6ekiun0eo9v77ocgga5c5.apps.googleusercontent.com", + "project_id": "argos-fleece", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "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", + "redirect_uris": [ + "http://localhost" + ] + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ef97a98..74268d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "colorama", "elevenlabs", "google-cloud-texttospeech", + "google-auth-oauthlib", "matplotlib", "pyautogui", "pyfeather", @@ -29,6 +30,7 @@ dependencies = [ "setuptools", "sqlalchemy-utils", "sqlalchemy", + "tkinterweb", "translate", "tts @ git+https://github.com/DeepHorizons/tts@master#11cabe8", "voicebox-tts", diff --git a/requirements.txt b/requirements.txt index a06334a..2d8df11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ elevenlabs==1.1.2 executing==2.0.1 fonttools==4.51.0 google-api-core==2.18.0 +google-auth-oauthlib==1.2.0 google-auth==2.29.0 google-cloud-texttospeech==2.16.3 googleapis-common-protos==1.63.0 @@ -95,6 +96,7 @@ SQLAlchemy-Utils==0.41.2 SQLAlchemy==2.0.29 stack-data==0.6.3 tkfeather==1.0.0 +tkinterweb==3.23.10 tqdm==4.66.2 traitlets==5.14.3 translate==3.6.1 diff --git a/src/cnv/engines/amazonpolly.py b/src/cnv/engines/amazonpolly.py index 28a0e0d..c6eb747 100644 --- a/src/cnv/engines/amazonpolly.py +++ b/src/cnv/engines/amazonpolly.py @@ -1,15 +1,158 @@ - +import os import logging +import tkinter as tk +from tkinter import ttk +import configparser + +import webbrowser + import boto3 import cnv.database.models as models import cnv.lib.settings as settings from voicebox.tts.amazonpolly import AmazonPolly as AmazonPollyTTS -from .base import TTSEngine +from .base import TTSEngine, MarkdownLabel log = logging.getLogger(__name__) +class LinkList(ttk.Frame): + def __init__(self, parent, links, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") + self.columnconfigure(1, weight=2, uniform="baseconfig") + + index = 0 + log.info(links) + for text, link, docs in links: + ttk.Button( + self, + text=text, + command=lambda: webbrowser.open(link) + ).grid(column=0, row=index) + + MarkdownLabel( + self, + text=docs, + ).grid(column=1, row=index) + index += 1 + + +class AmazonPollyAuthUI(ttk.Frame): + label = "Amazon Polly" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.credential_fn = os.path.expanduser('~/.aws/credentials') + + mdlabel = MarkdownLabel( + self, + text="""[Amazon Polly](https://aws.amazon.com/pm/polly/) is an + excellent text-to-speech service in AWS. A free tier account is good + for one year and provides 5 million characters of text-to-speech. + That is a lot. After the free year expires, or if you run out it + (currently) costs $4 per million characters.""".replace("\n", " ") + ) + mdlabel.on_link_click(self.link_click) + mdlabel.pack(side="top", fill="x", expand=False) + + # ok, so I'm amused by little things. + LinkList( + self, [ + [ + 'Create an IAM user', + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html#id_users_create_console", + """You want to create a user with only the permissions that + are absolutely necessary. We're applying the + "AmazonPollyReadOnlyAccess" policy. Nothing else. + """ + ], + [ + 'Create and retrieve the keys', + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey", + """Then we create an access key. This allows a program to make requests on behalf + of the minimal-access user we just created. + """ + ] + ] + ).pack(side="top", fill="both", expand=True) + + auth_settings = ttk.Frame(self) + auth_settings.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") + auth_settings.columnconfigure(1, weight=2, uniform="baseconfig") + + count = 0 + self.tkvars = {} + for key, text, getter, setter, is_hidden in [( + 'access_key_id', + 'Access Key ID', + self.get_access_key_id, + self.set_access_key_id, + False + ), ( + 'secret_access_key', + 'Secret Access Key', + self.get_secret_access_key, + self.set_secret_access_key, + True + )]: + ttk.Label( + auth_settings, + text=text, + anchor="e", + ).grid(column=0, row=count, sticky='e') + self.tkvars[key] = tk.StringVar(value=getter()) + self.tkvars[key].trace_add('write', setter) + + entry = ttk.Entry( + auth_settings, + textvariable=self.tkvars[key], + # show="*" + ) + if is_hidden: + entry.config({'show': '*'}) + # TODO: config show based on is_hidden + entry.grid(column=1, row=count, sticky='w') + + count += 1 + auth_settings.pack(side="top", fill="x", expand=True) + + def link_click(self, url): + log.info('link click') + # no funny business, just open the URL in a browser. + webbrowser.open(url, autoraise=True) + + def _read_credentials(self): + config = configparser.ConfigParser() + config.read(self.credential_fn) + return config + + def _set_credential(self, key, cred_key): + config = self._read_credentials() + value = self.tkvars[key].get() + config[cred_key] = value + with open(self.credential_fn, 'w') as configfile: + config.write(configfile) + + def get_access_key_id(self): + config = self._read_credentials() + return config['default']['aws_access_key_id'] + + def set_access_key_id(self, *args, **kwargs): + self._set_credential( + key="access_key_id", + cred_key="aws_access_key_id", + ) + + def get_secret_access_key(self): + config = self._read_credentials() + return config['default']['aws_secret_access_key'] + + def set_secret_access_key(self, *args, **kwargs): + self._set_credential( + key="secret_access_key_id", + cred_key="aws_secret_access_key", + ) + class AmazonPolly(TTSEngine): """ Pricing: @@ -23,6 +166,7 @@ class AmazonPolly(TTSEngine): """ cosmetic = "Amazon Polly" key = "amazonpolly" + auth_ui_class = AmazonPollyAuthUI config = ( ('Engine', 'engine', "StringVar", 'standard', {}, "get_engine_names"), diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 88f124e..9ce0842 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -5,7 +5,9 @@ import cnv.database.models as models import cnv.lib.settings as settings import voicebox +from markdown_it import MarkdownIt from sqlalchemy import select +from tkinterweb import HtmlLabel log = logging.getLogger(__name__) @@ -15,6 +17,84 @@ class USE_SECONDARY(Exception): signal to disable this engine for this session """ +class MarkdownLabel(HtmlLabel): # Label + def __init__(self, *args, **kwargs): + md = MarkdownIt( + 'commonmark', + { + 'breaks': True, + 'html': True + } + ) + kwargs['text'] = md.render(kwargs['text']) + log.info(kwargs['text']) + super().__init__(*args, **kwargs) + + +class Notebook(ttk.Frame): + def __init__(self, parent, takefocus=True, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.notebook = ttk.Notebook(self, takefocus=takefocus) + self.blankframe = lambda: tk.Frame(self.notebook, height=0, bd=0, highlightthickness=0) + + self.notebook.pack(side="top", fill="x", expand=True) + self.notebook.bind("<>", self.on_tab_change) + + self.pages = [] + self.previous_page = None + + def on_tab_change(self, event): + self.event_generate("<>") + tabId = self.notebook.index(self.notebook.select()) + newpage = self.pages[tabId] + if self.previous_page: + self.previous_page.pack_forget() + newpage.pack(fill="both", expand=True) + self.previous_page = newpage + + def add(self, child, **kwargs): + if child in self.pages: + raise ValueError("{} is already managed by {}.".format(child, self)) + self.notebook.add(self.blankframe(), **kwargs) + self.pages.append(child) + + def insert(self, where, child, **kwargs): + if child in self.pages: + raise ValueError("{} is already managed by {}.".format(child, self)) + self.notebook.insert(where, self.blankframe(), **kwargs) + self.pages.insert(where, child) + + def enable_traversal(self): + self.notebook.enable_traversal() + + def select(self, tabId): + if not isinstance(tabId, int) and tabId in self.pages: + tabId = self.pages.index(tabId) + self.notebook.select(tabId) + + def tab(self, tabId, option=None, **kwargs): + if not isinstance(tabId, int) and tabId in self.pages: + tabId = self.pages.index(tabId) + self.notebook.tab(tabId, option, **kwargs) + + def forget(self, tabId): + if not isinstance(tabId, int): + tabId = self.pages.index(tabId) + self.pages.remove(tabId) + else: + del self.pages[tabId] + self.notebook.forget(self.pages.index(tabId)) + + def index(self, child): + try: + return self.pages.index(child) + except IndexError: + return self.notebook.index(child) + + def tabs(self): + return self.pages + # Base Class for engines class TTSEngine(ttk.Frame): diff --git a/src/cnv/engines/elevenlabs.py b/src/cnv/engines/elevenlabs.py index bb192ae..7156a75 100644 --- a/src/cnv/engines/elevenlabs.py +++ b/src/cnv/engines/elevenlabs.py @@ -14,7 +14,7 @@ from voicebox.audio import Audio from voicebox.types import StrOrSSML -from .base import TTSEngine +from .base import TTSEngine, MarkdownLabel log = logging.getLogger(__name__) @@ -23,36 +23,40 @@ class ElevenLabsAuthUI(ttk.Frame): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - description = ttk.Frame(self) - tk.Message( - description, - text="https://elevenlabs.io/ is a leading edge company focused on providing" - "top quality text to speech technology. A free account provides 10,000 characters" - "of text-to-speech. When it runs out we can automatically toggle over to your" - "secondary voice provider. Create an account, login to it. Then in the bottom " - "left, click yourself. Choose 'Profile + API key'. Put that API key here.", - anchor="nw", - justify="left" - ).pack(side="left", fill="both", expand=True) - description.grid(column=0, row=0, sticky='w') - - self.columnconfigure(0, minsize=125, uniform="baseconfig") - self.columnconfigure(1, weight=2, uniform="baseconfig") - self.columnconfigure(2, weight=2, uniform="baseconfig") + mdlabel = MarkdownLabel( + self, + text="[ElevenLabs](https://elevenlabs.io/) is a leading edge company focused on providing " + "top quality text to speech technology. A free account provides 10,000 characters " + "of text-to-speech. When it runs out we can automatically toggle over to your " + "secondary voice provider. Don't like the drop in quality? Elevenlabs " + "[pricing](https://elevenlabs.io/pricing) is premium but not unreasonable.\n" + "Supporting using your voice clone as your own playback voice " + "is surpisingly close to easy.\n" + "* Create an account\n" + "* login to it\n" + "* In the bottom left corner, click yourself\n" + "* Choose *'Profile + API key'*", + ) + mdlabel.pack(side="top", fill="x", expand=False) + + auth_settings = ttk.Frame(self) + auth_settings.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") + auth_settings.columnconfigure(1, weight=2, uniform="baseconfig") ttk.Label( - self, + auth_settings, text="ElevenLabs API Key", anchor="e", - ).grid(column=1, row=0, sticky='e') + ).grid(column=0, row=0, sticky='e') self.elevenlabs_key = tk.StringVar(value=self.get_elevenlabs_key()) self.elevenlabs_key.trace_add('write', self.change_elevenlabs_key) ttk.Entry( - self, + auth_settings, textvariable=self.elevenlabs_key, show="*" - ).grid(column=2, row=0, sticky='w') + ).grid(column=1, row=0, sticky='w') + auth_settings.pack(side="top", fill="x", expand=True) def change_elevenlabs_key(self, a, b, c): with open("eleven_labs.key", 'w') as h: diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py index 22be5d3..ad0e992 100644 --- a/src/cnv/engines/googlecloud.py +++ b/src/cnv/engines/googlecloud.py @@ -1,19 +1,126 @@ import logging +import os +import tkinter as tk +from tkinter import ttk import cnv.database.models as models import cnv.lib.settings as settings import voicebox +import webbrowser +from google.auth.transport.requests import Request from google.cloud import texttospeech +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow from sqlalchemy import select -from .base import TTSEngine +from .base import MarkdownLabel, TTSEngine log = logging.getLogger(__name__) +# https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize +SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] + +# the credentials are a secret in a typical oauth workflow +# but we are a desktop applicaton, there are no secrets. +# +# google auth uses this "secret" as a unique to identify our +# application. It pops open a browser, asks about auth to +# give the application permission to utilize the users google +# account for text-to-speech. +# +# The response is a 'code', which is sent to this application. +# the code is then sent back to google to create a token. +# that token can be used on every text-to-speech request until +# it expires, then we refresh it to get the a token. +# +# To completely remove access, delete the token. If you delete +# the credential you will have to re-install. +credential_file = "google_credential.json" +token_file = "google_token.json" + +# it looks like I have some hoops to jump through before google will let this be +# a "published" app for oauth purposes. nothing huge. I need a domain with a +# few pages, a youtube explaining what I'm doing, a written explanation of what +# I'm doing and verified domains. That seems overwhelming, but it isn't really +# that bad but it will take some time. In the meantime this is in "test" mode; +# the 100 user limit is no big deal but it's unclear to me if they need to be +# pre-approved. If they do this will not work and I'm sorry, that kind of +# sucks. If you send me your google account email address I can add you to the +# test user list. + +def get_credentials(): + """ + Returns credentials or None. Does not make the user do anything. + This is what anything that needs google access calls to retrieve + the credentials. + """ + creds = None + if os.path.exists(token_file): + creds = Credentials.from_authorized_user_file( + token_file, SCOPES + ) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + # try and refresh the credential + creds.refresh(Request()) + + # persist the refreshed token to disk + with open(token_file, "w") as token: + token.write(creds.to_json()) + else: + creds = None + + return creds + + +class GoogleCloudAuthUI(ttk.Frame): + label = "Google Cloud" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + mdlabel = MarkdownLabel( + self, + text="""[Google Text-to-Speech](https://cloud.google.com/text-to-speech?hl=en) + is an solid txt-to-speech service from google. A free tier account + (currently 7/24) provides 1 million characters per month. if you run + out it (currently) costs $4-$16 per million characters, billed + per-character. + """.replace("\n", " ") + ) + mdlabel.on_link_click(self.link_click) + mdlabel.pack(side="top", fill="x", expand=False) + + ttk.Button( + self, + text="Browser oauth2 authentication", + command=self.authenticate + ).pack(side="top") + + + def link_click(self, url): + log.info('link click') + # no funny business, just open the URL in a browser. + webbrowser.open(url, autoraise=True) + + def authenticate(self): + if not os.path.exists(credential_file): + log.error(f'Installation error: Required file {credential_file} not found.') + return + + flow = InstalledAppFlow.from_client_secrets_file( + credential_file, SCOPES + ) + creds = flow.run_local_server(port=0) + # Save the credentials for the next run + with open(token_file, "w") as token: + token.write(creds.to_json()) + + class GoogleCloud(TTSEngine): cosmetic = "Google Text-to-Speech" key = 'googletts' - auth_ui_class = None + auth_ui_class = GoogleCloudAuthUI config = ( ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), @@ -124,7 +231,9 @@ def get_tts(self): voice_pitch = self.override.get('voice_pitch', self.config_vars["voice_pitch"].get()) language_code = self.get_voice_language(voice_name) - client = texttospeech.TextToSpeechClient() + client = texttospeech.TextToSpeechClient( + credentials=get_credentials() + ) audio_config = texttospeech.AudioConfig( speaking_rate=float(speaking_rate), diff --git a/src/cnv/tabs/configuration.py b/src/cnv/tabs/configuration.py index ff02a79..fdbcd03 100644 --- a/src/cnv/tabs/configuration.py +++ b/src/cnv/tabs/configuration.py @@ -5,6 +5,7 @@ import cnv.lib.settings as settings from cnv.engines import engines +from cnv.engines.base import Notebook log = logging.getLogger(__name__) @@ -53,19 +54,19 @@ def change_language(self, a, b, c): # we should immediately translate and localize the UI -class EngineAuthentication(ttk.Notebook): +class EngineAuthentication(Notebook): """ Collects tabs for configuring authentication for each of the TTS engines. The actual tab contents are provided by the engine(s). """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) for engine_ui in engines.ENGINE_LIST: if engine_ui.auth_ui_class: - auth_ui = engine_ui.auth_ui_class() - auth_ui.pack(side="top", fill="x") + auth_ui = engine_ui.auth_ui_class(self) + #auth_ui.pack(side="top", fill="both", expand=True) #column=0, row=0) self.add(auth_ui, text=auth_ui.label) From 79439101ccda9f34c74a128a98bf8c2b7effb61a Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Fri, 5 Jul 2024 19:44:02 -0700 Subject: [PATCH 05/32] hopeful fix for randomization of secondary engine voices --- src/cnv/chatlog/npc_chatter.py | 4 +- src/cnv/database/models.py | 206 ++++++++++++++++++--------------- src/cnv/engines/engines.py | 2 + src/cnv/logger.py | 5 + 4 files changed, 120 insertions(+), 97 deletions(-) diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index bd457d5..1ea7fc3 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -35,7 +35,9 @@ CAPTION_SPEAKER_INDICATORS = ( ('Positron here', 'Positron'), ('Matthew, is it', 'Dana'), # Cinderburn mission - ("Dana... you're alive?!", 'Matthew'), + ("Dana you're alive?!", 'Matthew'), + ("This is Penelope Yin!", 'Penelope Yin'), + ('This is Robert Alderman', 'Robert Alderman') ) # class ParallelTTS(threading.Thread): diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 267fa92..c8cf51b 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -136,6 +136,8 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel # based on the preset, and some random choices # where the preset does not specify, create a voice # for this NPC. + if group_name in ["Random Any"]: + group_name = None # first we set the engine based on global defaults pkey = f'{str_category}_engine_primary' @@ -151,15 +153,12 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel engine=primary_engine_name, engine_secondary=secondary_engine_name, category=category, + group_name=group_name ) session.add(character) session.commit() session.refresh(character) - - # now for the preset and/or random choices - engine_key = ENGINE_COSMETIC_TO_ID[primary_engine_name] - rank = "primary" - + # if all_npc provided a gender, we will use that. if gender is None: # otherwise, use the gender value in preset. IE: the preset gender @@ -173,104 +172,119 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel else: gender = random.choice(['Male', 'Female']) - # all of the available _engine_ configuration values - engine_config_meta = session.scalars( - select(EngineConfigMeta).where( - EngineConfigMeta.engine_key==engine_key - ) - ).all() + # now for the preset and/or random choices + # engine_key = ENGINE_COSMETIC_TO_ID[primary_engine_name] + # rank = "primary" - log.debug(f'|- The configuration fields relevant to the {engine_key} TTS Engine are:') - # loop through the availabe configuration settings - for config_meta in engine_config_meta: - log.debug(f"|- {config_meta}") - # we want sensible defaults with some jitter - # for each voice engine config setting. - value = None - - # does this configuration setting take a string value from a list of - # possible choices? - if config_meta.varfunc == "StringVar": - # we don't know what the possible values are since we can't run - # the function without instantiating the engine, which will drag - # in TK baggage. - - # but.. we can accesss the cache?. does that introduce a - # sequence dependency? - all_values = diskcache(f"{engine_key}_{config_meta.key}") - - if all_values is None: - log.warning(f'Cache {engine_key}_{config_meta.key} is empty') - value = "" - else: - # it's a dict, keyey on voice_name - # language_code_regex = "en-.*" - if language_code_regex and 'language_code' in all_values[0].keys(): - # pass through languages that satisfy the regex - out = [] - for v in all_values: - code = v.get('language_code', '') - if re.match(language_code_regex, code): - out.append(v) - all_values = out + # all of the available _engine_ configuration values + # engine_config_meta = session.scalars( + # select(EngineConfigMeta).where( + # EngineConfigMeta.engine_key==engine_key + # ) + # ).all() + + for rank, engine_key in [ + ["primary", ENGINE_COSMETIC_TO_ID[primary_engine_name]], + ["secondary", ENGINE_COSMETIC_TO_ID[secondary_engine_name]] + ]: + # all of the available _engine_ configuration values + engine_config_meta = session.scalars( + select(EngineConfigMeta).where( + EngineConfigMeta.engine_key==engine_key + ) + ).all() + + log.debug(f'|- The configuration fields relevant to the {engine_key} TTS Engine are:') + # loop through the availabe configuration settings + for config_meta in engine_config_meta: + log.debug(f"|- {config_meta}") + # we want sensible defaults with some jitter + # for each voice engine config setting. + value = None + + # does this configuration setting take a string value from a list of + # possible choices? + if config_meta.varfunc == "StringVar": + # we don't know what the possible values are since we can't run + # the function without instantiating the engine, which will drag + # in TK baggage. + + # but.. we can accesss the cache?. does that introduce a + # sequence dependency? + all_values = diskcache(f"{engine_key}_{config_meta.key}") - # if we have a gender, filter out the voices that don't - # have the same gender. - if gender and 'gender' in all_values[0].keys(): - def gender_filter(voice): - return voice['gender'] == gender - all_values = filter(gender_filter, all_values) - - # does the preset have any more guidance? - # use the preset if there is one. Otherwise - # choose randomly from the available options. - log.debug(f"{all_values=}") - - if config_meta.key in preset: - value = preset[config_meta.key] + if all_values is None: + log.warning(f'Cache {engine_key}_{config_meta.key} is empty') + value = "" else: - chosen_row = random.choice(list(all_values)) - log.debug(f'Random selection: {chosen_row}') - value = chosen_row[config_meta.key] - - # do we have a numeric value, with a min/max and some - # hints about useful granularity? - elif config_meta.varfunc == "DoubleVar": - # no cache, use the preset or a random choice in the range. - # this shouldn't be .uniform, it should be more likely - # for the values that are more common. - value = preset.get( - config_meta.key, - random.uniform( - config_meta.cfgdict['min'], - config_meta.cfgdict['max'] + # it's a dict, keyey on voice_name + # language_code_regex = "en-.*" + if language_code_regex and 'language_code' in all_values[0].keys(): + # pass through languages that satisfy the regex + out = [] + for v in all_values: + code = v.get('language_code', '') + if re.match(language_code_regex, code): + out.append(v) + all_values = out + + # if we have a gender, filter out the voices that don't + # have the same gender. + if gender and 'gender' in all_values[0].keys(): + def gender_filter(voice): + return voice['gender'] == gender + all_values = filter(gender_filter, all_values) + + # does the preset have any more guidance? + # use the preset if there is one. Otherwise + # choose randomly from the available options. + log.debug(f"{all_values=}") + + if config_meta.key in preset: + value = preset[config_meta.key] + else: + chosen_row = random.choice(list(all_values)) + log.debug(f'Random selection: {chosen_row}') + value = chosen_row[config_meta.key] + + # do we have a numeric value, with a min/max and some + # hints about useful granularity? + elif config_meta.varfunc == "DoubleVar": + # no cache, use the preset or a random choice in the range. + # this shouldn't be .uniform, it should be more likely + # for the values that are more common. + value = preset.get( + config_meta.key, + random.uniform( + config_meta.cfgdict['min'], + config_meta.cfgdict['max'] + ) ) - ) - # round to nearest multiple of 'resolution' - resolution = config_meta.cfgdict.get('resolution', 1.0) - value = ( - resolution * round(value / resolution) - ) + # round to nearest multiple of 'resolution' + resolution = config_meta.cfgdict.get('resolution', 1.0) + value = ( + resolution * round(value / resolution) + ) - # do we have a true/false, enable/disable sort thing? - elif config_meta.varfunc == "BooleanVar": - # to be or not to be, that is the question. - value = preset.get( - config_meta.key, - random.choice([True, False]) - ) + # do we have a true/false, enable/disable sort thing? + elif config_meta.varfunc == "BooleanVar": + # to be or not to be, that is the question. + value = preset.get( + config_meta.key, + random.choice([True, False]) + ) - # write our value for this configuration setting to the database - log.debug(f'Configuring {rank} engine {engine_key}: Setting {config_meta.key} to {value}') - new_config_entry = BaseTTSConfig( - character_id=character.id, - rank=rank, - key=config_meta.key, - value=value - ) - session.add(new_config_entry) - session.commit() + # write our value for this configuration setting to the database + log.debug(f'Configuring {rank} engine {engine_key}: Setting {config_meta.key} to {value}') + new_config_entry = BaseTTSConfig( + character_id=character.id, + rank=rank, + key=config_meta.key, + value=value + ) + session.add(new_config_entry) + session.commit() # add effects but only if there is a preset, no random effects. for effect_dict in preset.get('Effects', []): diff --git a/src/cnv/engines/engines.py b/src/cnv/engines/engines.py index 7d7e59a..42791b3 100644 --- a/src/cnv/engines/engines.py +++ b/src/cnv/engines/engines.py @@ -7,6 +7,8 @@ log = logging.getLogger(__name__) +from .base import USE_SECONDARY + # https://github.com/coqui-ai/tts # # I tried this. Doesn't work yet in Windown w/Py 3.12 due to the absense of diff --git a/src/cnv/logger.py b/src/cnv/logger.py index b19c072..305e016 100644 --- a/src/cnv/logger.py +++ b/src/cnv/logger.py @@ -32,6 +32,11 @@ 'level': 'DEBUG', 'propagate': True }, + "botocore.credentials": { + 'handlers': ['default', 'error_file'], + 'level': 'WARNING', + 'propagate': True + } # 'coh_npc_voices': { # 'handlers': ['default', 'error_file'], # 'level': 'DEBUG', From 3baeac91b6420530e88bc7e4ab0c660c7f3e0146 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Fri, 5 Jul 2024 19:46:52 -0700 Subject: [PATCH 06/32] less logging --- src/cnv/engines/amazonpolly.py | 2 -- src/cnv/engines/base.py | 2 +- src/cnv/voices/voice_editor.py | 8 ++++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/cnv/engines/amazonpolly.py b/src/cnv/engines/amazonpolly.py index c6eb747..885f355 100644 --- a/src/cnv/engines/amazonpolly.py +++ b/src/cnv/engines/amazonpolly.py @@ -23,7 +23,6 @@ def __init__(self, parent, links, *args, **kwargs): self.columnconfigure(1, weight=2, uniform="baseconfig") index = 0 - log.info(links) for text, link, docs in links: ttk.Button( self, @@ -117,7 +116,6 @@ def __init__(self, *args, **kwargs): auth_settings.pack(side="top", fill="x", expand=True) def link_click(self, url): - log.info('link click') # no funny business, just open the URL in a browser. webbrowser.open(url, autoraise=True) diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 9ce0842..4732cec 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -27,7 +27,7 @@ def __init__(self, *args, **kwargs): } ) kwargs['text'] = md.render(kwargs['text']) - log.info(kwargs['text']) + # log.info(kwargs['text']) super().__init__(*args, **kwargs) diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 977ce53..dce0650 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -89,7 +89,7 @@ def choose_phrase(self, *args, **kwargs): """ a phrase was chosen. """ - # make sure this characters is the one selected in the charcter list + # make sure this characters is the one selected in the character list character = self.detailside.parent.get_selected_character() # retrieve the selected phrase @@ -97,7 +97,11 @@ def choose_phrase(self, *args, **kwargs): if selected_index >= 0: log.debug(f'Retrieving phrase at index {selected_index}') - phrase_id = self.phrase_id[selected_index] + try: + phrase_id = self.phrase_id[selected_index] + except IndexError: + # likely "Rebuild all phrases" + return # we want to work with the translated string message, is_translated = models.get_translated(phrase_id) From 5ed466f2d54b5646f2c9686a4fa4431a7a6096e1 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Fri, 5 Jul 2024 20:06:47 -0700 Subject: [PATCH 07/32] should fix the groupings of npcs when autoadded --- src/cnv/database/models.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index c8cf51b..1623fff 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -129,15 +129,13 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel group_name = None if group_name: - group_name = settings.get_alias(group_name) + alias_name = settings.get_alias(group_name) preset = settings.get_preset(group_name) - # based on the preset, and some random choices - # where the preset does not specify, create a voice - # for this NPC. - if group_name in ["Random Any"]: - group_name = None + # we want to use the alias instead of the group name. + if alias_name not in ["Random Any"]: + group_name = alias_name # first we set the engine based on global defaults pkey = f'{str_category}_engine_primary' From 0b60efd0d16450723e1153c1ea0ea272412ed056 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 6 Jul 2024 09:25:22 -0700 Subject: [PATCH 08/32] better engine configuration cosmetics --- src/cnv/engines/amazonpolly.py | 18 ++++++--- src/cnv/engines/base.py | 11 +++++- src/cnv/engines/elevenlabs.py | 9 ++++- src/cnv/engines/googlecloud.py | 70 +++++++++++++++++++++++++++++++--- src/cnv/tabs/configuration.py | 6 ++- 5 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/cnv/engines/amazonpolly.py b/src/cnv/engines/amazonpolly.py index 885f355..07b93a4 100644 --- a/src/cnv/engines/amazonpolly.py +++ b/src/cnv/engines/amazonpolly.py @@ -53,7 +53,11 @@ def __init__(self, *args, **kwargs): ) mdlabel.on_link_click(self.link_click) mdlabel.pack(side="top", fill="x", expand=False) - + + s = ttk.Style() + s.configure('EngineAuth.TFrame', background='white') + s.configure('EngineAuth.TLabel', background='white') + # ok, so I'm amused by little things. LinkList( self, [ @@ -72,10 +76,11 @@ def __init__(self, *args, **kwargs): of the minimal-access user we just created. """ ] - ] + ], + style="EngineAuth.TFrame" ).pack(side="top", fill="both", expand=True) - auth_settings = ttk.Frame(self) + auth_settings = ttk.Frame(self, style='EngineAuth.TFrame') auth_settings.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") auth_settings.columnconfigure(1, weight=2, uniform="baseconfig") @@ -83,13 +88,13 @@ def __init__(self, *args, **kwargs): self.tkvars = {} for key, text, getter, setter, is_hidden in [( 'access_key_id', - 'Access Key ID', + 'Access Key ID ', self.get_access_key_id, self.set_access_key_id, False ), ( 'secret_access_key', - 'Secret Access Key', + 'Secret Access Key ', self.get_secret_access_key, self.set_secret_access_key, True @@ -98,6 +103,7 @@ def __init__(self, *args, **kwargs): auth_settings, text=text, anchor="e", + style="EngineAuth.TLabel" ).grid(column=0, row=count, sticky='e') self.tkvars[key] = tk.StringVar(value=getter()) self.tkvars[key].trace_add('write', setter) @@ -110,7 +116,7 @@ def __init__(self, *args, **kwargs): if is_hidden: entry.config({'show': '*'}) # TODO: config show based on is_hidden - entry.grid(column=1, row=count, sticky='w') + entry.grid(column=1, row=count, sticky='ew') count += 1 auth_settings.pack(side="top", fill="x", expand=True) diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 4732cec..2031a41 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -26,12 +26,21 @@ def __init__(self, *args, **kwargs): 'html': True } ) - kwargs['text'] = md.render(kwargs['text']) + kwargs['text'] = f""" + {md.render(kwargs['text'])} + """ # log.info(kwargs['text']) super().__init__(*args, **kwargs) class Notebook(ttk.Frame): + """ + Workaround an error in tkinterweb.HtmlLabel + We make a Notebook but the pages are blankframe. + + When a notebook tab is selected we forget the previous page + and pack the new page (fill=both, expand=true). + """ def __init__(self, parent, takefocus=True, *args, **kwargs): super().__init__(parent, *args, **kwargs) diff --git a/src/cnv/engines/elevenlabs.py b/src/cnv/engines/elevenlabs.py index 7156a75..9054f03 100644 --- a/src/cnv/engines/elevenlabs.py +++ b/src/cnv/engines/elevenlabs.py @@ -39,7 +39,11 @@ def __init__(self, *args, **kwargs): ) mdlabel.pack(side="top", fill="x", expand=False) - auth_settings = ttk.Frame(self) + s = ttk.Style() + s.configure('EngineAuth.TFrame', background='white') + s.configure('EngineAuth.TLabel', background='white') + + auth_settings = ttk.Frame(self, style='EngineAuth.TFrame') auth_settings.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") auth_settings.columnconfigure(1, weight=2, uniform="baseconfig") @@ -47,6 +51,7 @@ def __init__(self, *args, **kwargs): auth_settings, text="ElevenLabs API Key", anchor="e", + style='EngineAuth.TLabel' ).grid(column=0, row=0, sticky='e') self.elevenlabs_key = tk.StringVar(value=self.get_elevenlabs_key()) @@ -55,7 +60,7 @@ def __init__(self, *args, **kwargs): auth_settings, textvariable=self.elevenlabs_key, show="*" - ).grid(column=1, row=0, sticky='w') + ).grid(column=1, row=0, sticky='ew') auth_settings.pack(side="top", fill="x", expand=True) def change_elevenlabs_key(self, a, b, c): diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py index ad0e992..8de7072 100644 --- a/src/cnv/engines/googlecloud.py +++ b/src/cnv/engines/googlecloud.py @@ -1,7 +1,7 @@ import logging import os import tkinter as tk -from tkinter import ttk +from tkinter import ttk, font import cnv.database.models as models import cnv.lib.settings as settings @@ -47,6 +47,9 @@ # pre-approved. If they do this will not work and I'm sorry, that kind of # sucks. If you send me your google account email address I can add you to the # test user list. +# +# Since I'm not really sure oauth will work smoothly; I'll have ADC as an +# alternative. def get_credentials(): """ @@ -70,7 +73,14 @@ def get_credentials(): token.write(creds.to_json()) else: creds = None - + + if creds is None and 'GOOGLE_APPLICATION_CREDENTIALS' in os.environ: + # https://cloud.google.com/docs/authentication/provide-credentials-adc#local-key + log.debug('Using Application Default Credential: %s', os.environ['GOOGLE_APPLICATION_CREDENTIALS']) + return None + else: + log.warning('No valid Google authentication method provided. Google voices will not work.') + return creds @@ -78,6 +88,8 @@ class GoogleCloudAuthUI(ttk.Frame): label = "Google Cloud" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + self.columnconfigure(0, weight=1) mdlabel = MarkdownLabel( self, @@ -89,13 +101,54 @@ def __init__(self, *args, **kwargs): """.replace("\n", " ") ) mdlabel.on_link_click(self.link_click) - mdlabel.pack(side="top", fill="x", expand=False) + mdlabel.grid(column=0, row=0, sticky="nsew") + #pack(side="top", fill="x", expand=False) + s = ttk.Style() + s.configure('EngineAuth.TFrame', background='white') + s.configure('EngineAuth.TLabel', background='white') + + frame = ttk.Frame(self, style='EngineAuth.TFrame') + + frame.columnconfigure(0, weight=2) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(2, weight=2) ttk.Button( - self, + frame, text="Browser oauth2 authentication", command=self.authenticate - ).pack(side="top") + ).grid(column=0, row=0) + #pack(side="left") + + ttk.Label( + frame, + font=font.Font( + size=12, + weight="bold" + ), + text=" OR ", + anchor="center", + style="EngineAuth.TLabel" + ).grid(column=1, row=0, sticky="nsew") + #.pack(side="left") + + adc = ttk.Frame(frame) + ttk.Button( + adc, + text="Create a service account key", + command=lambda: self.link_click('https://cloud.google.com/iam/docs/keys-create-delete#creating') + ).pack(side="top", fill='x') + + ttk.Button( + adc, + text="Set GOOGLE_APPLICATION_CREDENTIALS", + command=lambda: self.link_click('https://cloud.google.com/docs/authentication/provide-credentials-adc#local-key') + ).pack(side="top", fill='x') + adc.grid(column=2, row=0, sticky="n") + #.pack(side="left", fill="x") + + frame.grid(column=0, row=1, sticky="nsew") + #.pack(side="top", fill="x") def link_click(self, url): @@ -231,8 +284,13 @@ def get_tts(self): voice_pitch = self.override.get('voice_pitch', self.config_vars["voice_pitch"].get()) language_code = self.get_voice_language(voice_name) + kwargs = {} + credentials = get_credentials() + if credentials: + kwargs['credentials'] = credentials + client = texttospeech.TextToSpeechClient( - credentials=get_credentials() + **kwargs ) audio_config = texttospeech.AudioConfig( diff --git a/src/cnv/tabs/configuration.py b/src/cnv/tabs/configuration.py index fdbcd03..23a44d0 100644 --- a/src/cnv/tabs/configuration.py +++ b/src/cnv/tabs/configuration.py @@ -66,7 +66,6 @@ def __init__(self, parent, *args, **kwargs): for engine_ui in engines.ENGINE_LIST: if engine_ui.auth_ui_class: auth_ui = engine_ui.auth_ui_class(self) - #auth_ui.pack(side="top", fill="both", expand=True) #column=0, row=0) self.add(auth_ui, text=auth_ui.label) @@ -225,5 +224,8 @@ def __init__(self, *args, **kwargs): MasterVolume(self).pack(side="top", fill="x") SpokenLanguageSelection(self).pack(side="top", fill="x") - EngineAuthentication(self).pack(side="top", fill="x") + EngineAuthentication( + self, + padding=5 + ).pack(side="top", fill="x") ChannelToEngineMap(self).pack(side="top", fill="x") \ No newline at end of file From 6c0f4ab5b96a755da5b4ac9cbb4433574db3887a Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 6 Jul 2024 15:22:18 -0700 Subject: [PATCH 09/32] added openai voices --- src/cnv/database/models.py | 2 + src/cnv/engines/base.py | 8 +- src/cnv/engines/elevenlabs.py | 2 + src/cnv/engines/engines.py | 3 +- src/cnv/engines/openai.py | 165 ++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/cnv/engines/openai.py diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 1623fff..31a6004 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -167,6 +167,8 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel if gender is None: if name in ["Celestine", "Alessandra", ]: gender = 'Female' + elif name in ["Matthew", ]: + gender = "Male" else: gender = random.choice(['Male', 'Female']) diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 2031a41..2ebe1c2 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -26,11 +26,13 @@ def __init__(self, *args, **kwargs): 'html': True } ) - kwargs['text'] = f""" - {md.render(kwargs['text'])} + text = f""" + {md.render(kwargs.pop('text'))} """ - # log.info(kwargs['text']) + + kwargs['text'] = text super().__init__(*args, **kwargs) + # self.load_html(text) class Notebook(ttk.Frame): diff --git a/src/cnv/engines/elevenlabs.py b/src/cnv/engines/elevenlabs.py index 9054f03..3206c0c 100644 --- a/src/cnv/engines/elevenlabs.py +++ b/src/cnv/engines/elevenlabs.py @@ -89,6 +89,7 @@ def get_elevenlabs_client(): else: log.warning("Elevenlabs Requires valid eleven_labs.key file") + class ElevenLabs(TTSEngine): """ Elevenlabs detects the incoming language; so in theory every voice works with every language. I have doubts. @@ -219,6 +220,7 @@ def get_tts(self): model=model ) + @dataclass class ttsElevenLabs(voicebox.tts.TTS): """ diff --git a/src/cnv/engines/engines.py b/src/cnv/engines/engines.py index 42791b3..8f0fc07 100644 --- a/src/cnv/engines/engines.py +++ b/src/cnv/engines/engines.py @@ -4,6 +4,7 @@ from .elevenlabs import ElevenLabs from .googlecloud import GoogleCloud from .windowstts import WindowsTTS +from .openai import OpenAI log = logging.getLogger(__name__) @@ -23,5 +24,5 @@ def get_engine(engine_name): ENGINE_LIST = [ - WindowsTTS, GoogleCloud, ElevenLabs, AmazonPolly + WindowsTTS, GoogleCloud, ElevenLabs, AmazonPolly, OpenAI ] diff --git a/src/cnv/engines/openai.py b/src/cnv/engines/openai.py new file mode 100644 index 0000000..86f0f32 --- /dev/null +++ b/src/cnv/engines/openai.py @@ -0,0 +1,165 @@ +import logging +import os +import tkinter as tk +from dataclasses import dataclass +from tkinter import ttk +from typing import Union + +import numpy as np +import voicebox +from openai import OpenAI as OAI +from voicebox.audio import Audio +from voicebox.types import StrOrSSML + +from .base import MarkdownLabel, TTSEngine + +log = logging.getLogger(__name__) + +OPENAI_KEY_FILE = "openai.key" + +class OpenAIAuthUI(ttk.Frame): + label = "OpenAI" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + mdlabel = MarkdownLabel( + self, + text="[OpenAI](https://openai.com/) The quality of the OpenAI voices " + "is really excellent. Costs are on the high end at $0.015 per 1,000 " + "input characters. ie: $15 per million characters\n" + "It is easy to put a hard limit on how much you want to spend. " + "The variety of voices is extremely limited (there are 6). I think " + "this is a great choice for the most imporant snippets of NPC dialog " + "where having a few high quality unique voices in reserve pays off." + ) + mdlabel.pack(side="top", fill="x", expand=False) + + s = ttk.Style() + s.configure('EngineAuth.TFrame', background='white') + s.configure('EngineAuth.TLabel', background='white') + + auth_settings = ttk.Frame(self, style='EngineAuth.TFrame') + auth_settings.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") + auth_settings.columnconfigure(1, weight=2, uniform="baseconfig") + + ttk.Label( + auth_settings, + text="OpenAI API Key", + anchor="e", + style='EngineAuth.TLabel' + ).grid(column=0, row=0, sticky='e') + + self.openai_key = tk.StringVar(value=self.get_openai_key()) + self.openai_key.trace_add('write', self.change_openai_key) + + ttk.Entry( + auth_settings, + textvariable=self.openai_key, + show="*" + ).grid(column=1, row=0, sticky='ew') + auth_settings.pack(side="top", fill="x", expand=True) + + def change_openai_key(self, a, b, c): + with open(OPENAI_KEY_FILE, 'w') as h: + h.write(self.openai_key.get()) + + def get_openai_key(self): + value = None + + if os.path.exists(OPENAI_KEY_FILE): + with open(OPENAI_KEY_FILE, 'r') as h: + value = h.read() + return value + + +def get_openai_client(): + if os.path.exists(OPENAI_KEY_FILE): + with open(OPENAI_KEY_FILE) as h: + openai_api_key = h.read().strip() + + client = OAI(api_key=openai_api_key) + return client + else: + log.warning(f"OpenAI Requires valid {OPENAI_KEY_FILE} file") + +# female = ['alloy, 'nova', 'shimmer'] +# male = ['echo', 'onyx'] +# neutral = ['fable'] +class OpenAI(TTSEngine): + """ + OpenAI detects the incoming language; so in theory every voice works with every language. I have doubts. + """ + cosmetic = "OpenAI" + key = "openai" + auth_ui_class = OpenAIAuthUI + + config = ( + ('Voice Name', 'voice', "StringVar", "", {}, "get_voice_names"), + ('Voice Model', 'model', "StringVar", "", {}, "get_models"), + ('Speed', 'speed', "DoubleVar", 1.0, {'min': 0.25, 'max': 4.0, 'resolution': 0.25}, None), + ) + + def get_models(self): + return [ + 'tts-1', + 'tts-1-hd' + ] + + def get_voice_names(self, gender=None): + if gender.upper() == "FEMALE": + return ['alloy', 'nova', 'shimmer', 'fable'] + elif gender.upper() == "MALE": + return ['echo', 'onyx', 'fable'] + else: + return [ + 'alloy', + 'echo', + 'fable', + 'onyx', + 'nova', + 'shimmer' + ] + + def get_tts(self): + voice = self.override.get('voice', self.config_vars["voice"].get()) + model = self.override.get('model', self.config_vars["model"].get()) + + return ttsOpenAI( + # api_key=self.api_key, + voice=voice, + model=model, + ) + + +@dataclass +class ttsOpenAI(voicebox.tts.TTS): + """ + """ + voice: Union[str] = "alloy" + model: Union[str] = "tts-1" + speed: float = 1.0 + + def get_speech(self, text: StrOrSSML) -> Audio: + client = get_openai_client() + + log.debug(f"self.voice: {self.voice}") + + # PCM: Similar to WAV but containing the raw samples in 24kHz (16-bit + # signed, low-endian), without the header. + response = client.audio.speech.create( + model=self.model, + voice=self.voice, + response_format="pcm", + input=text + ) + + samples = np.frombuffer( + bytes(response.read()), + dtype=np.int16 + ) + + return voicebox.tts.utils.get_audio_from_samples( + samples, + 24000 + ) From 11a8d545e262d622d32f44ccbcf2979b5a85bf43 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 6 Jul 2024 20:49:15 -0700 Subject: [PATCH 10/32] re-arrange voices detail to make primary/secondary dominant --- .gitignore | 2 + src/cnv/database/models.py | 60 +++++++++++ src/cnv/engines/base.py | 5 +- src/cnv/engines/openai.py | 6 +- src/cnv/lib/settings.py | 11 +- src/cnv/voices/voice_editor.py | 182 ++++++++++++++------------------- 6 files changed, 153 insertions(+), 113 deletions(-) diff --git a/.gitignore b/.gitignore index 3105c10..fa24e42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ raw_data/ venv_backup/ cache/ +openai.key eleven_labs.key config.json +google_token.json # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 31a6004..87a139e 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -3,6 +3,7 @@ import random import re import sys +import tkinter as tk from contextlib import contextmanager from datetime import datetime from typing import Optional, Self @@ -348,6 +349,65 @@ def get(cls, name: str, category: int, session: Connectable) -> Self: return character +#selected_character = None +TKVAR = {} +#selected_category = None + +def set_selected_character(name, category): + if TKVAR.get('character') is None: + TKVAR['character'] = tk.StringVar() + + if TKVAR.get('category') is None: + TKVAR['category'] = tk.StringVar() + + TKVAR['character'].set(name) + TKVAR['category'].set(category) + +def get_selected_character(): + if 'character' not in TKVAR: + return None + + with db() as session: + character = Character.get( + name=TKVAR['character'].get(), + category=TKVAR['category'].get(), + session=session + ) + return character + +def get_engine(rank): + if ('engine', rank) not in TKVAR: + return None + else: + return TKVAR[('engine', rank)].get() + +def set_engine(rank, value): + if TKVAR.get(('engine', rank)) is None: + TKVAR[('engine', rank)] = tk.StringVar() + + TKVAR[('engine', rank)].set(value) + +# list of instantiated effect classes +ACTIVE_EFFECTS = [] +def get_effects(): + return ACTIVE_EFFECTS + +def pop_effect(): + return ACTIVE_EFFECTS.pop() + +def add_effect(new_effect): + ACTIVE_EFFECTS.append(new_effect) + +def remove_effect(effect): + ACTIVE_EFFECTS.remove(effect) + +def wipe_all_effects(): + while ACTIVE_EFFECTS: + effect = pop_effect() + effect.clear_traces() + effect.pack_forget() + + def get_character_from_rawname(raw_name, session): log.error(f'OBSOLETE get_character_from_rawname({raw_name=}, {session=})') # use Character.get() diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 2ebe1c2..812cf29 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -298,7 +298,7 @@ def save_character(self, name, category): def draw_config_meta(self): # now we build it. - for m in self.get_config_meta(): + for index, m in enumerate(self.get_config_meta()): frame = ttk.Frame(self) frame.columnconfigure(0, minsize=125, uniform="ttsengine") frame.columnconfigure(1, weight=2, uniform="ttsengine") @@ -324,7 +324,8 @@ def draw_config_meta(self): # changes to the value of this widget trip a generic 'reconfig' # handler. self.config_vars[m.key].trace_add("write", self.reconfig) - frame.pack(side="top", fill="x", expand=True) + frame.grid(column=0, row=index) + #.pack(side="top", fill="x", expand=True) def _tkStringVar(self, key, frame): # combo widget for strings diff --git a/src/cnv/engines/openai.py b/src/cnv/engines/openai.py index 86f0f32..24c0b6c 100644 --- a/src/cnv/engines/openai.py +++ b/src/cnv/engines/openai.py @@ -30,7 +30,7 @@ def __init__(self, *args, **kwargs): "input characters. ie: $15 per million characters\n" "It is easy to put a hard limit on how much you want to spend. " "The variety of voices is extremely limited (there are 6). I think " - "this is a great choice for the most imporant snippets of NPC dialog " + "this is a great choice for the most important snippets of NPC dialog " "where having a few high quality unique voices in reserve pays off." ) mdlabel.pack(side="top", fill="x", expand=False) @@ -107,9 +107,9 @@ def get_models(self): ] def get_voice_names(self, gender=None): - if gender.upper() == "FEMALE": + if gender and gender.upper() == "FEMALE": return ['alloy', 'nova', 'shimmer', 'fable'] - elif gender.upper() == "MALE": + elif gender and gender.upper() == "MALE": return ['echo', 'onyx', 'fable'] else: return [ diff --git a/src/cnv/lib/settings.py b/src/cnv/lib/settings.py index 829317e..759d03b 100644 --- a/src/cnv/lib/settings.py +++ b/src/cnv/lib/settings.py @@ -41,7 +41,7 @@ # to cache hit anyway. PERSIST_PLAYER_CHAT = True -REPLAY = False +REPLAY = True logging.basicConfig( level=LOGLEVEL, @@ -138,20 +138,21 @@ def clean_customer_name(in_name): return in_name, clean_name -def cache_filename(name, message): +def cache_filename(name, message, rank): clean_message = re.sub(r'[^\w]', '', message) clean_message = hashlib.sha256(message.encode()).hexdigest()[:5] + f"_{clean_message[:10]}" + clean_message += rank[0] return clean_message + ".mp3" -def get_cachefile(name, message, category): +def get_cachefile(name, message, category, rank): name, clean_name = clean_customer_name(name) - log.debug(f"{name=} {clean_name=} {message=}") + log.debug(f"{name=} {clean_name=} {message=} {rank=}") # ie: abcde_timetodan.mp3 # this should be unique to this messags, it's only # a 5 character hash, collisions are possible. - filename = cache_filename(name, message) + filename = cache_filename(name, message, rank) # do we already have this NPC/Message rendered to an audio file? # first we need the path the file ought to have diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index dce0650..b141795 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -35,15 +35,14 @@ class WavfileMajorFrame(ttk.LabelFrame): ALL_PHRASES = "⪡ Rebuild all phrases ⪢" - def __init__(self, parent, detailside, *args, **kwargs): + def __init__(self, rank, *args, **kwargs): kwargs['text'] = 'Wavefile(s)' - super().__init__(parent, *args, **kwargs) + super().__init__(*args, **kwargs) self.phrase_id = [] + self.rank = rank frame = ttk.Frame(self) - self.detailside = detailside - self.translated = tk.StringVar(value="") self.chosen_phrase = tk.StringVar( @@ -90,7 +89,7 @@ def choose_phrase(self, *args, **kwargs): a phrase was chosen. """ # make sure this characters is the one selected in the character list - character = self.detailside.parent.get_selected_character() + character = models.get_selected_character() # retrieve the selected phrase selected_index = self.options.current() @@ -112,7 +111,9 @@ def choose_phrase(self, *args, **kwargs): self.translated.set("") # find the file associated with this phrase - cachefile = self.get_cachefile(character, message) + cachefile = self.get_cachefile( + character, message, self.rank + ) if os.path.exists(cachefile): wavfilename = audio.mp3file_to_wavfile( @@ -132,7 +133,7 @@ def choose_phrase(self, *args, **kwargs): def populate_phrases(self): log.debug('** populate_phrases() called **') - character = self.detailside.parent.get_selected_character() + character = models.get_selected_character() if character is None: # no character selected return @@ -222,9 +223,9 @@ def show_wave(self, cachefile): # self.plt.set_xlim(0, duration) self.visualize_wav.pack(side='top', fill=tk.BOTH, expand=1) - def get_cachefile(self, character, msg): + def get_cachefile(self, character, msg, rank): _, clean_name = settings.clean_customer_name(character.name) - filename = settings.cache_filename(character.name, msg) + filename = settings.cache_filename(character.name, msg, rank) return os.path.abspath( os.path.join( @@ -242,7 +243,7 @@ def play_cache(self): global ENGINE_OVERRIDE message = self.chosen_phrase.get() - character = self.detailside.parent.get_selected_character() + character = models.get_selected_character() if message == self.ALL_PHRASES: with models.db() as session: @@ -264,7 +265,7 @@ def play_cache(self): for phrase in all_phrases: msg, is_translated = models.get_translated(phrase.id) - cachefile = self.get_cachefile(character, msg) + cachefile = self.get_cachefile(character, msg, self.rank) wavfilename = audio.mp3file_to_wavfile( mp3filename=cachefile @@ -283,27 +284,24 @@ def say_it(self, use_secondary=False): """ Speak aloud whatever is in the chosen_phrase tk.Variable, using whatever TTS engine is selected. + + doing this with the selected value instead of the db value for the current + character was a bad idea. sorry. """ global ENGINE_OVERRIDE message = self.chosen_phrase.get() log.debug(f"Speak: {message}") - # parent is the frame inside DetailSide - if use_secondary: - rank = 'secondary' - engine_name = self.detailside.secondary_tab.selected_engine.get() - else: - rank = 'primary' - engine_name = self.detailside.primary_tab.selected_engine.get() - + + engine_name = models.get_engine(self.rank) ttsengine = engines.get_engine(engine_name) log.debug(f"Engine: {ttsengine}") effect_list = [ - e.get_effect() for e in self.detailside.effect_list.effects + e.get_effect() for e in models.get_effects() ] - character = self.detailside.parent.get_selected_character() + character = models.get_selected_character() if message == self.ALL_PHRASES: with models.db() as session: @@ -333,7 +331,8 @@ def say_it(self, use_secondary=False): cachefile = settings.get_cachefile( character.name, msg, - character.cat_str() + character.cat_str(), + rank=self.rank ) sink = Distributor([ @@ -345,13 +344,10 @@ def say_it(self, use_secondary=False): log.debug(f"Creating ttsengine for {character.name}") # None because we aren't attaching any widgets - try: - rank = 'primary' - if use_secondary or ENGINE_OVERRIDE.get(character.engine, False): - rank = 'secondary' - - ttsengine(None, rank, name=character.name, category=character.category).say(msg, effect_list, sink=sink) + try: + ttsengine(None, self.rank, name=character.name, category=character.category).say(msg, effect_list, sink=sink) except engines.USE_SECONDARY: + return ENGINE_OVERRIDE[character.engine] = True return self.say_it(use_secondary=True) @@ -383,7 +379,7 @@ def say_it(self, use_secondary=False): tk.messagebox.showerror(title="Error", message=f"Engine {engine_name} did not provide audio") -class EngineSelection(ttk.Frame): +class EngineSelection(ttk.LabelFrame): """ Frame for just the Text to speech labal and a combobox to choose a different engine. We are @@ -391,6 +387,7 @@ class EngineSelection(ttk.Frame): """ def __init__(self, parent, selected_engine, *args, **kwargs): + kwargs['text'] = 'Engine' super().__init__(parent, *args, **kwargs) self.selected_engine = selected_engine @@ -409,14 +406,13 @@ def __init__(self, parent, selected_engine, *args, **kwargs): ) -class EngineSelectAndConfigure(ttk.LabelFrame): +class EngineSelectAndConfigure(ttk.Frame): """ two element stack, the first has the engine selection, the second has all the parameters supported by the seleted engine """ def __init__(self, rank, parent, *args, **kwargs): - kwargs['text'] = 'Engine' super().__init__(parent, *args, **kwargs) self.rank = rank self.parent = parent @@ -428,13 +424,27 @@ def __init__(self, rank, parent, *args, **kwargs): "write", self.change_selected_engine ) + + self.phrase_selector = WavfileMajorFrame( + rank, self + ) + self.phrase_selector.pack(side="top", fill="x", expand=True) + + # self.presetSelect = PresetSelector( + # self.frame, self, self.selected_character + # ) + # self.presetSelect.pack(side="top", fill="x", expand=True) + with models.Session(models.engine) as session: self.load_character(session) - es = EngineSelection(self, self.selected_engine) - es.pack(side="top", fill="x", expand=True) + self.es = EngineSelection(self, self.selected_engine) + self.es.pack(side="top", fill="x", expand=True) + + def set_engine(self, engine_name): + # update the phrase selector + self.phrase_selector.populate_phrases() - def set_engine(self, engine_name): # this set() will trip change_selected_engine # which will in turn set a value for engine_parameters self.selected_engine.set(engine_name) @@ -447,21 +457,23 @@ def change_selected_engine(self, a, b, c): # clear the old engine configuration # show the selected engine configuration log.debug('EngineSelectAndConfigure.change_selected_engine()') - - character = self.parent.get_selected_character() + + character = models.get_selected_character() + engine_name = self.selected_engine.get() + clear = False if character is None: return if self.rank == "primary": - if character.engine != self.selected_engine.get(): + if character.engine != engine_name: clear = True - log.debug(f'{self.rank} engine changing from {character.engine!r} to {self.selected_engine.get()!r}') + log.debug(f'{self.rank} engine changing from {character.engine!r} to {engine_name.get()!r}') elif self.rank == "secondary": - if character.engine_secondary != self.selected_engine.get(): + if character.engine_secondary != engine_name: clear = True - log.debug(f'{self.rank} engine changing from {character.engine_secondary!r} to {self.selected_engine.get()!r}') + log.debug(f'{self.rank} engine changing from {character.engine_secondary!r} to {engine_name!r}') if self.engine_parameters: self.engine_parameters.pack_forget() @@ -478,30 +490,24 @@ def change_selected_engine(self, a, b, c): for row in rows: log.debug(f'Deleting {row}...') - # if row.key in self.config_vars: - # # remove traces - # info = self.config_vars[row.key].trace_info() - # for i in info: - # self.config_vars[row.key].trace_remove(*i) - # del self.config_vars[row.key] session.delete(row) session.commit() - engine_cls = engines.get_engine(self.selected_engine.get()) + models.set_engine(self.rank, engine_name) + engine_cls = engines.get_engine(engine_name) - if engine_cls: - self.engine_parameters = engine_cls( - self, - rank=self.rank, - category=character.category, - name=character.name - ) - self.engine_parameters.pack(side="top", fill="x", expand=True) - else: + if not engine_cls: + # that didn't work.. try the default engine engine_cls = engines.get_engine(settings.DEFAULT_ENGINE) - self.engine_parameters = engine_cls(self, self.rank, [character.category, character.name]) - self.engine_parameters.pack(side="top", fill="x", expand=True) + self.engine_parameters = engine_cls( + self.es, + rank=self.rank, + category=character.category, + name=character.name + ) + self.engine_parameters.grid(column=0, row=1, columnspan=2) + # side="top", fill="x", expand=True) self.save_character() def save_character(self): @@ -509,13 +515,8 @@ def save_character(self): save this engine selection to the database """ log.debug('EngineSelectAndConfig.save_character()') - character = self.parent.get_selected_character() - - #raw_name = self.selected_character - #if not raw_name: - # log.warning('Name is required to save a character') - # return - + character = models.get_selected_character() + category_str = models.category_int2str(character.category) name = character.name @@ -551,15 +552,10 @@ def load_character(self, session): We've set the character name, we want the rest of the metadata to populate. Setting the engine name will domino the rest. """ - selected_character = self.parent.get_selected_character() - - character = models.get_character_from_rawname( - selected_character, - session=session - ) + character = models.get_selected_character() if character is None: - log.error('Character %s does not exist.' % selected_character) + log.error('Character %s does not exist.' % character) return None if character.engine in ["", None]: @@ -586,7 +582,6 @@ def __init__(self, parent, *args, **kwargs): kwargs['text'] = "Effects", super().__init__(parent, *args, **kwargs) - self.effects = [] self.buffer = False self.name = None @@ -603,10 +598,7 @@ def load_effects(self, name, category): self.category = category # teardown any effects already in place - while self.effects: - effect = self.effects.pop() - effect.clear_traces() - effect.pack_forget() + models.wipe_all_effects() with models.db() as session: character = models.Character.get(name, category, session) @@ -643,7 +635,7 @@ def load_effects(self, name, category): ) effect_config_frame.pack(side="top", fill="x", expand=True) effect_config_frame.effect_id.set(effect.id) - self.effects.append(effect_config_frame) + models.add_effect(effect_config_frame) effect_config_frame.load() @@ -699,7 +691,7 @@ def add_effect(self, effect_name): borderwidth=1 ) effect_config_frame.pack(side="top", fill="x", expand=True) - self.effects.append(effect_config_frame) + settings.add_effect(effect_config_frame) with models.Session(models.engine) as session: # retrieve this character @@ -733,7 +725,7 @@ def remove_effect(self, effect_obj): log.debug(f'Removing effect {effect_obj}') # remove it from the effects list - self.effects.remove(effect_obj) + models.remove_effect(effect_obj) effect_id = effect_obj.effect_id.get() # remove it from the database with models.Session(models.engine) as session: @@ -836,15 +828,9 @@ def __init__(self, parent, *args, **kwargs): (0, 0), window=self.frame, anchor="nw", tags="self.frame" ) - # if you pack it, it won't scroll. - # self.frame.pack(side='top') - - # TODO: get rid of this shite - self.frame.get_selected_character = parent.get_selected_character self.frame.bind("", self.onFrameConfigure) self.canvas.bind("", self.onCanvasConfigure) - ### name_frame = ttk.Frame(self.frame) @@ -854,7 +840,7 @@ def __init__(self, parent, *args, **kwargs): textvariable=self.character_name, anchor="center", font=font.Font( - size=24, + size=22, weight="bold" ) ).pack(side="left", fill="x", expand=True) @@ -895,16 +881,6 @@ def __init__(self, parent, *args, **kwargs): justify="left" ).pack(side="top", fill="x") - self.phrase_selector = WavfileMajorFrame( - self.frame, self, - ) - self.phrase_selector.pack(side="top", fill="x", expand=True) - - # self.presetSelect = PresetSelector( - # self.frame, self, self.selected_character - # ) - # self.presetSelect.pack(side="top", fill="x", expand=True) - engine_notebook = ttk.Notebook(self.frame) self.primary_tab = EngineSelectAndConfigure( 'primary', self.frame, @@ -977,9 +953,12 @@ def load_character(self, category, name): log.debug(f'DetailSide.load_character({name})') # TODO: "choose" and highlight this character on the listside - group_name = "" + models.set_selected_character( + name, category + ) + self.character_name.set(name) if category == "npc": @@ -994,7 +973,7 @@ def load_character(self, category, name): return else: self.character_description.set("") - self.group_name.set("") + self.group_name.set("Unaffiliated") # load the character with models.db() as session: @@ -1004,16 +983,12 @@ def load_character(self, category, name): character.group_name = group_name session.commit() - # update the phrase selector - self.phrase_selector.populate_phrases() - # set the engines itself # log.debug('b character: %s | %s | %s', character, character.engine, character.engine_secondary) self.primary_tab.set_engine(character.engine) self.secondary_tab.set_engine(character.engine_secondary) # set engine and parameters - # log.debug(f'{dir(self.primary_tab)=}') if self.primary_tab.engine_parameters: self.primary_tab.engine_parameters.load_character(category, name) @@ -1200,6 +1175,7 @@ def selected_category_and_name(self): def character_selected(self, event=None): category, name, item = self.selected_category_and_name() + if name is None: return From efaa4c4b58a70f00db6dee93cb40f4813569aff1 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 6 Jul 2024 21:45:37 -0700 Subject: [PATCH 11/32] bug squish --- aliases.json | 3 +- pyproject.toml | 1 + src/cnv/chatlog/npc_chatter.py | 50 +++++++++++++++------------------ src/cnv/database/models.py | 7 +++-- src/cnv/engines/engines.py | 11 ++++++-- src/cnv/engines/googlecloud.py | 1 - src/cnv/tabs/character.py | 2 ++ src/cnv/voices/voice_builder.py | 48 ++++++++++++++++++++++++------- src/cnv/voices/voice_editor.py | 2 +- 9 files changed, 79 insertions(+), 46 deletions(-) diff --git a/aliases.json b/aliases.json index f0b2828..86afec1 100644 --- a/aliases.json +++ b/aliases.json @@ -1,6 +1,7 @@ { "5thColumn": "Random Any", "5thColumnEndgame": "Random Any", + "Arachnos_60s": "Arachnos", "ArachnosEndgame": "Arachnos", "BanishedPantheon": "Random Any", "Behavioral Adjustees": "Random Any", @@ -25,6 +26,6 @@ "TheDestroyers": "Random Any", "TheFamily": "Random Any", "Tsoo": "Random Any", - "TsooEndgame": "Random Any", + "TsooEndgame": "Tsoo", "Warriors": "Random Any" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 74268d6..d0425b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "colorama", "elevenlabs", "google-cloud-texttospeech", + "openai", "google-auth-oauthlib", "matplotlib", "pyautogui", diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 1ea7fc3..2380080 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -175,34 +175,29 @@ def run(self): log.debug(f"Speaking thread received {category} {name}:{message}") - cachefile = settings.get_cachefile(name, message, category) + found = False + for rank in ['primary', 'secondary']: + cachefile = settings.get_cachefile(name, message, category, rank) - if os.path.exists(cachefile): - log.debug(f"(tighttts) Cache HIT: {cachefile}") - # requires pydub? - with AudioFile(cachefile) as input: - with AudioFile( - filename=cachefile + ".wav", - samplerate=input.samplerate, - num_channels=input.num_channels, - ) as output: - while input.tell() < input.frames: - output.write(input.read(1024)) - - audio = get_audio_from_wav_file(cachefile + ".wav") - os.unlink(cachefile + ".wav") - - voicebox.sinks.SoundDevice().play(audio) - else: - # make sure the directory exists - try: - os.mkdir(os.path.dirname(cachefile)) - except OSError: - # the directory already exists. This is not a problem. - pass - - log.debug(f"(tighttts) Cache MISS: {cachefile} not found") - + if os.path.exists(cachefile): + found = True + log.debug(f"(tighttts) Cache HIT: {cachefile}") + # requires pydub? + with AudioFile(cachefile) as input: + with AudioFile( + filename=cachefile + ".wav", + samplerate=input.samplerate, + num_channels=input.num_channels, + ) as output: + while input.tell() < input.frames: + output.write(input.read(1024)) + + audio = get_audio_from_wav_file(cachefile + ".wav") + os.unlink(cachefile + ".wav") + + voicebox.sinks.SoundDevice().play(audio) + + if not found: # building session out here instead of inside get_character # keeps character alive and properly tied to the database as we # pass it into update_character_last_spoke and voice_builder. @@ -212,6 +207,7 @@ def run(self): models.update_character_last_spoke(character.id, session) # it isn't very well named, but this will speak "message" as # character and cache a copy into cachefile. + voice_builder.create(character, message, session) # we've said our piece. diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 87a139e..4e7af27 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -118,7 +118,8 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel gender = None group_name = None preset = {} - + alias_name = "" + # look up this character by name if str_category == "npc": npc_spec = settings.get_npc_data(name) @@ -135,7 +136,7 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel preset = settings.get_preset(group_name) # we want to use the alias instead of the group name. - if alias_name not in ["Random Any"]: + if alias_name not in ["", "Random Any"]: group_name = alias_name # first we set the engine based on global defaults @@ -168,7 +169,7 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel if gender is None: if name in ["Celestine", "Alessandra", ]: gender = 'Female' - elif name in ["Matthew", ]: + elif name in ["Matthew", "Toothbreaker Jones"]: gender = "Male" else: gender = random.choice(['Male', 'Female']) diff --git a/src/cnv/engines/engines.py b/src/cnv/engines/engines.py index 8f0fc07..dbfc101 100644 --- a/src/cnv/engines/engines.py +++ b/src/cnv/engines/engines.py @@ -1,14 +1,15 @@ import logging from .amazonpolly import AmazonPolly +from .base import USE_SECONDARY from .elevenlabs import ElevenLabs from .googlecloud import GoogleCloud -from .windowstts import WindowsTTS from .openai import OpenAI +from .windowstts import WindowsTTS log = logging.getLogger(__name__) -from .base import USE_SECONDARY + # https://github.com/coqui-ai/tts # @@ -24,5 +25,9 @@ def get_engine(engine_name): ENGINE_LIST = [ - WindowsTTS, GoogleCloud, ElevenLabs, AmazonPolly, OpenAI + WindowsTTS, + GoogleCloud, + ElevenLabs, + AmazonPolly, + OpenAI, ] diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py index 8de7072..c38d018 100644 --- a/src/cnv/engines/googlecloud.py +++ b/src/cnv/engines/googlecloud.py @@ -1,6 +1,5 @@ import logging import os -import tkinter as tk from tkinter import ttk, font import cnv.database.models as models diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index 322fd4e..75fc1b0 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -125,8 +125,10 @@ def __init__(self, parent, hero, *args, **kwargs): if xp_gain: sum_xp += xp_gain + if inf_gain: sum_inf += inf_gain + event_time = datetime.strptime(datestring, "%Y-%m-%d %H:%M:%S") while previous_event and (event_time - previous_event) > timedelta(minutes=1, seconds=30): diff --git a/src/cnv/voices/voice_builder.py b/src/cnv/voices/voice_builder.py index 8730b1d..f551f57 100644 --- a/src/cnv/voices/voice_builder.py +++ b/src/cnv/voices/voice_builder.py @@ -54,6 +54,10 @@ def create(character, message, session): effect_list.append(effect_instance.get_effect()) + rank = 'primary' + if ENGINE_OVERRIDE.get(character.engine, False): + rank = 'secondary' + # have we seen this particular phrase before? if character.category != PLAYER_CATEGORY or settings.PERSIST_PLAYER_CHAT: # phrase_id = models.get_or_create_phrase_id( @@ -63,9 +67,11 @@ def create(character, message, session): # ) # message = models.get_translated(phrase_id) - cachefile = settings.get_cachefile( - character.name, message, character.cat_str() + character.name, + message, + character.cat_str(), + rank ) try: @@ -75,10 +81,6 @@ def create(character, message, session): # the directory already exists. This is not a problem. pass - sink = Distributor([ - SoundDevice(), - WaveFile(cachefile + '.wav') - ]) save = True else: sink = Distributor([ @@ -89,10 +91,6 @@ def create(character, message, session): name = character.name category = character.category - rank = 'primary' - if ENGINE_OVERRIDE.get(character.engine, False): - rank = 'secondary' - # character.engine may already have a value. It probably does. We're over-writing it # with anything we have in the dict ENGINE_OVERRIDE. But if we don't have anything, you can keep # your previous value and carry on. @@ -101,9 +99,20 @@ def create(character, message, session): if rank == 'secondary': raise engines.USE_SECONDARY + if save: + sink = Distributor([ + SoundDevice(), + WaveFile(cachefile + '.wav') + ]) + else: + sink = Distributor([ + SoundDevice() + ]) + log.debug(f'Using engine: {character.engine}') engines.get_engine(character.engine)(None, 'primary', name, category).say(message, effect_list, sink=sink) except engines.USE_SECONDARY: + rank = 'secondary' # our chosen engine for this character isn't working. So we're going to switch # to the secondary and use that for the rest of this session. ENGINE_OVERRIDE[character.engine] = True @@ -113,6 +122,25 @@ def create(character, message, session): ) ) + # new rank, new cachefile + cachefile = settings.get_cachefile( + character.name, + message, + character.cat_str(), + rank + ) + + # new cachefile, new sink. + if save: + sink = Distributor([ + SoundDevice(), + WaveFile(cachefile + '.wav') + ]) + else: + sink = Distributor([ + SoundDevice() + ]) + if character.engine_secondary: # use the secondary engine config defined for this character engine_instance = engines.get_engine(character.engine_secondary) diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index b141795..e438919 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -371,7 +371,7 @@ def say_it(self, use_secondary=False): msg = translator.translate(msg) try: - ttsengine(None, rank, character.name, character.category).say(msg, effect_list) + ttsengine(None, self.rank, character.name, character.category).say(msg, effect_list) except engines.DISABLE_ENGINES: # I'm not even sure what we want to do. The user clicked 'play' but # we don't have any quota left for the selected engine. From 9659240e31a192ad46efb66f8ee609b3a768b399 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 7 Jul 2024 09:01:37 -0700 Subject: [PATCH 12/32] initial azure tts support --- .gitignore | 1 + pyproject.toml | 1 + src/cnv/engines/azure.py | 222 +++++++++++++++++++++++++++++++++ src/cnv/engines/engines.py | 2 + src/cnv/voices/voice_editor.py | 2 +- 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/cnv/engines/azure.py diff --git a/.gitignore b/.gitignore index fa24e42..da5e452 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ openai.key eleven_labs.key config.json google_token.json +azure.json # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/pyproject.toml b/pyproject.toml index d0425b6..6d8dbeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ ] dependencies = [ "alembic", + "azure-cognitiveservices-speech", "colorama", "elevenlabs", "google-cloud-texttospeech", diff --git a/src/cnv/engines/azure.py b/src/cnv/engines/azure.py new file mode 100644 index 0000000..45e62b6 --- /dev/null +++ b/src/cnv/engines/azure.py @@ -0,0 +1,222 @@ +import json +import logging +import os +import tkinter as tk +from dataclasses import dataclass +from tkinter import ttk +from typing import Union + +import azure.cognitiveservices.speech as speechsdk +import cnv.database.models as models +import numpy as np +import voicebox +from voicebox.audio import Audio +from voicebox.types import StrOrSSML + +from .base import MarkdownLabel, TTSEngine + +log = logging.getLogger(__name__) + +AZURE_AUTH_FILE = "azure.json" + +class AzureAuthUI(ttk.Frame): + label = "Azure" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + mdlabel = MarkdownLabel( + self, + text="[Azure](https://azure.microsoft.com/en-us/products/ai-services/text-to-speech) " + "In the azure console you're going to need to create a speech service. Defaults for " + "everything not required. You'll end up in the overview page with two keys and a " + "region. We only need one of the keys (and the region)." + ) + mdlabel.pack(side="top", fill="x", expand=False) + + s = ttk.Style() + s.configure('EngineAuth.TFrame', background='white') + s.configure('EngineAuth.TLabel', background='white') + + auth_settings = ttk.Frame(self, style='EngineAuth.TFrame') + auth_settings.columnconfigure(0, minsize=125, weight=0, uniform="baseconfig") + auth_settings.columnconfigure(1, weight=2, uniform="baseconfig") + + ttk.Label( + auth_settings, + text="Azure Subscription Key", + anchor="e", + style='EngineAuth.TLabel' + ).grid(column=0, row=0, sticky='e') + + self.azure_subscription_key = tk.StringVar(value=self.get_azure_subscription_key()) + self.azure_subscription_key.trace_add('write', self.change_azure_authentication) + + ttk.Entry( + auth_settings, + textvariable=self.azure_subscription_key, + show="*" + ).grid(column=1, row=0, sticky='ew') + auth_settings.pack(side="top", fill="x", expand=True) + + ttk.Label( + auth_settings, + text="Azure Region", + anchor="e", + style='EngineAuth.TLabel' + ).grid(column=0, row=1, sticky='e') + + self.region = tk.StringVar(value=self.get_region()) + self.region.trace_add('write', self.change_azure_authentication) + + ttk.Entry( + auth_settings, + textvariable=self.region, + ).grid(column=1, row=1, sticky='ew') + auth_settings.pack(side="top", fill="x", expand=True) + + def change_azure_authentication(self, a, b, c): + with open(AZURE_AUTH_FILE, 'w') as h: + h.write( + json.dumps({ + 'subscription_key': self.azure_subscription_key.get(), + 'service_region': self.region.get() + }) + ) + + def get_azure_authentication(self, key): + if os.path.exists(AZURE_AUTH_FILE): + with open(AZURE_AUTH_FILE, 'r') as h: + value = json.loads(h.read()) + + return value[key] + else: + return "" + + def get_azure_subscription_key(self): + return self.get_azure_authentication('subscription_key') + + def get_region(self): + return self.get_azure_authentication('service_region') + + +def get_azure_config(): + if os.path.exists(AZURE_AUTH_FILE): + with open(AZURE_AUTH_FILE) as h: + config = json.loads(h.read()) + + speech_key = config['subscription_key'] + service_region = config['service_region'] + + client = speechsdk.SpeechConfig( + subscription=speech_key, + region=service_region + ) + return client + else: + log.warning(f"Azure Requires valid {AZURE_AUTH_FILE} file") + + +class Azure(TTSEngine): + """ + """ + cosmetic = "Azure" + key = "azure" + auth_ui_class = AzureAuthUI + + config = ( + ('Voice Name', 'voice', "StringVar", "", {}, "get_voice_names"), + # ('Voice Model', 'model', "StringVar", "", {}, "get_models"), + # ('Speed', 'speed', "DoubleVar", 1.0, {'min': 0.25, 'max': 4.0, 'resolution': 0.25}, None), + ) + + def get_models(self): + return [ + 'tts-1', + 'tts-1-hd' + ] + + def get_voice_names(self, gender=None): + voices = self.get_voices() + # TODO: filter by language code + if gender: + return [v['voice_name'] for v in voices if v['gender'] == gender] + + return [v['voice_name'] for v in voices] + + def get_voices(self): + all_voices = models.diskcache(f'{self.key}_voice_name') + + if all_voices is None: + speech_config = get_azure_config() + + speech_synthesizer = speechsdk.SpeechSynthesizer( + speech_config=speech_config, + audio_config=None + ) + + # Request the list of available voices + result = speech_synthesizer.get_voices_async().get() + + all_voices = [] + for entry in result.voices: + # log.info(f"{entry.gender.name=} {dir(entry.gender)}") + + all_voices.append({ + 'voice_name': entry.short_name, + 'gender': entry.gender.name, + 'locale': entry.locale + }) + + models.diskcache(f'{self.key}_voice_name', all_voices) + + return all_voices + + def get_tts(self): + voice = self.override.get('voice', self.config_vars["voice"].get()) + + return ttsOpenAI( + voice=voice, + ) + + +@dataclass +class ttsOpenAI(voicebox.tts.TTS): + """ + """ + voice: Union[str] = "en-US-AvaMultilingualNeural" + + def get_speech(self, text: StrOrSSML) -> Audio: + speech_config = get_azure_config() + + log.debug(f"self.voice: {self.voice}") + + speech_config.speech_synthesis_voice_name = self.voice + speech_config.set_speech_synthesis_output_format( + speechsdk.SpeechSynthesisOutputFormat['Riff24Khz16BitMonoPcm'] + ) + + speech_synthesizer = speechsdk.SpeechSynthesizer( + speech_config=speech_config, + audio_config=None + ) + + result = speech_synthesizer.speak_text(text) + log.info('speak_text complete') + + if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted: + stream = speechsdk.AudioDataStream(result) + + audio_buffer = bytes(10000000) + size = stream.read_data(audio_buffer) + + log.info('Creating numpy buffer') + samples = np.frombuffer( + audio_buffer[:size], + dtype=np.int16 + ) + + return voicebox.tts.utils.get_audio_from_samples( + samples, + 24000 + ) diff --git a/src/cnv/engines/engines.py b/src/cnv/engines/engines.py index dbfc101..46cb962 100644 --- a/src/cnv/engines/engines.py +++ b/src/cnv/engines/engines.py @@ -6,6 +6,7 @@ from .googlecloud import GoogleCloud from .openai import OpenAI from .windowstts import WindowsTTS +from .azure import Azure log = logging.getLogger(__name__) @@ -30,4 +31,5 @@ def get_engine(engine_name): ElevenLabs, AmazonPolly, OpenAI, + Azure ] diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index e438919..8f99caa 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -469,7 +469,7 @@ def change_selected_engine(self, a, b, c): if self.rank == "primary": if character.engine != engine_name: clear = True - log.debug(f'{self.rank} engine changing from {character.engine!r} to {engine_name.get()!r}') + log.debug(f'{self.rank} engine changing from {character.engine!r} to {engine_name!r}') elif self.rank == "secondary": if character.engine_secondary != engine_name: clear = True From 5ff396d282944b344e99ef80babf06fc7c6d7e28 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 7 Jul 2024 20:48:36 -0700 Subject: [PATCH 13/32] language code filtering for azure --- src/cnv/engines/azure.py | 27 ++++-- src/cnv/engines/base.py | 37 ++++---- src/cnv/engines/googlecloud.py | 4 +- src/cnv/voices/voice_editor.py | 151 ++++++++++++++++++--------------- 4 files changed, 125 insertions(+), 94 deletions(-) diff --git a/src/cnv/engines/azure.py b/src/cnv/engines/azure.py index 45e62b6..c7d4249 100644 --- a/src/cnv/engines/azure.py +++ b/src/cnv/engines/azure.py @@ -8,6 +8,7 @@ import azure.cognitiveservices.speech as speechsdk import cnv.database.models as models +from cnv.lib import settings import numpy as np import voicebox from voicebox.audio import Audio @@ -126,8 +127,6 @@ class Azure(TTSEngine): config = ( ('Voice Name', 'voice', "StringVar", "", {}, "get_voice_names"), - # ('Voice Model', 'model', "StringVar", "", {}, "get_models"), - # ('Speed', 'speed', "DoubleVar", 1.0, {'min': 0.25, 'max': 4.0, 'resolution': 0.25}, None), ) def get_models(self): @@ -138,11 +137,27 @@ def get_models(self): def get_voice_names(self, gender=None): voices = self.get_voices() - # TODO: filter by language code + allow_language_codes = settings.get_voice_language_codes() + + language_filtered = [] + for v in voices: + if v['locale'].split("-")[0] not in allow_language_codes: + continue + language_filtered.append(v) + + # getting the wrong gender (because the right one isn't available) + # isn't as bad as a voice that can't pronounce the language. + gender_filtered = [] if gender: - return [v['voice_name'] for v in voices if v['gender'] == gender] - - return [v['voice_name'] for v in voices] + for v in language_filtered: + if gender is None or v['gender'] != gender: + continue + gender_filtered.add(v) + + if gender_filtered: + return [v['voice_name'] for v in gender_filtered] + else: + return [v['voice_name'] for v in language_filtered] def get_voices(self): all_voices = models.diskcache(f'{self.key}_voice_name') diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 812cf29..e4855a2 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -125,7 +125,7 @@ def __init__(self, parent, rank, name, category, *args, **kwargs): self.set_config_meta(self.config) - self.draw_config_meta() + self.draw_config_meta(self) self.load_character(category=category, name=name) self.repopulate_options() @@ -296,14 +296,15 @@ def save_character(self, name, category): session.add(new_config_setting) session.commit() - def draw_config_meta(self): - # now we build it. + def draw_config_meta(self, parent): + # now we build it. Row 0 is taken by the engine selector, the rest is ours. + # column sizing is handled upstream, we need to stay clean with + parent.columnconfigure(0, minsize=125, uniform="ttsengine") + parent.columnconfigure(1, weight=2, uniform="ttsengine") + for index, m in enumerate(self.get_config_meta()): - frame = ttk.Frame(self) - frame.columnconfigure(0, minsize=125, uniform="ttsengine") - frame.columnconfigure(1, weight=2, uniform="ttsengine") - ttk.Label(frame, text=m.cosmetic, anchor="e").grid( - row=0, column=0, sticky="e", padx=10 + ttk.Label(parent, text=m.cosmetic, anchor="e").grid( + row=index + 1, column=0, sticky="e", padx=10 ) # create the tk.var for the value of this widget @@ -312,11 +313,11 @@ def draw_config_meta(self): # create the widget itself if m.varfunc == "StringVar": - self._tkStringVar(m.key, frame) + self._tkStringVar(index + 1, m.key, parent) elif m.varfunc == "DoubleVar": - self._tkDoubleVar(m.key, frame, m.cfgdict) + self._tkDoubleVar(index + 1, m.key, parent, m.cfgdict) elif m.varfunc == "BooleanVar": - self._tkBooleanVar(m.key, frame) + self._tkBooleanVar(index + 1, m.key, parent) else: # this will fail, but at least it will fail with a log message. log.error(f'No widget defined for variables like {varfunc}') @@ -324,19 +325,17 @@ def draw_config_meta(self): # changes to the value of this widget trip a generic 'reconfig' # handler. self.config_vars[m.key].trace_add("write", self.reconfig) - frame.grid(column=0, row=index) - #.pack(side="top", fill="x", expand=True) - def _tkStringVar(self, key, frame): + def _tkStringVar(self, index, key, frame): # combo widget for strings self.widget[key] = ttk.Combobox( frame, textvariable=self.config_vars[key], ) self.widget[key]["state"] = "readonly" - self.widget[key].grid(row=0, column=1, sticky="ew") + self.widget[key].grid(row=index, column=1, sticky="new") - def _tkDoubleVar(self, key, frame, cfg): + def _tkDoubleVar(self, index, key, frame, cfg): # doubles get a scale widget. I haven't been able to get the ttk.Scale # widget to behave itself. I like the visual a bit better, but its hard # to get equivilent results. @@ -350,9 +349,9 @@ def _tkDoubleVar(self, key, frame, cfg): digits=cfg.get('digits', 2), resolution=cfg.get('resolution', 1) ) - self.widget[key].grid(row=0, column=1, sticky="ew") + self.widget[key].grid(row=index, column=1, sticky="new") - def _tkBooleanVar(self, key, frame): + def _tkBooleanVar(self, index, key, frame): """ Still using a label then checkbutton because the 'text' field on checkbutton puts the text after the button. Well, and it will make it @@ -366,7 +365,7 @@ def _tkBooleanVar(self, key, frame): onvalue=True, offvalue=False ) - self.widget[key].grid(row=0, column=1, sticky="ew") + self.widget[key].grid(row=index, column=1, sticky="new") def reconfig(self, *args, **kwargs): """ diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py index c38d018..74e6a49 100644 --- a/src/cnv/engines/googlecloud.py +++ b/src/cnv/engines/googlecloud.py @@ -75,10 +75,12 @@ def get_credentials(): if creds is None and 'GOOGLE_APPLICATION_CREDENTIALS' in os.environ: # https://cloud.google.com/docs/authentication/provide-credentials-adc#local-key + # https://cloud.google.com/docs/authentication/external/set-up-adc log.debug('Using Application Default Credential: %s', os.environ['GOOGLE_APPLICATION_CREDENTIALS']) return None else: - log.warning('No valid Google authentication method provided. Google voices will not work.') + log.debug('Using: gcloud auth application-default') + log.debug('If it does not work, try "gcloud auth application-default login"') return creds diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 8f99caa..e2ef0fc 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -40,6 +40,7 @@ def __init__(self, rank, *args, **kwargs): super().__init__(*args, **kwargs) self.phrase_id = [] self.rank = rank + self.visualize_wav = None frame = ttk.Frame(self) @@ -54,8 +55,7 @@ def __init__(self, rank, *args, **kwargs): textvariable=self.chosen_phrase ) self.options["values"] = [] - - self.populate_phrases() + self.options.pack(side="left", fill="x", expand=True) self.play_btn = ttk.Button(frame, text="Play", command=self.play_cache) @@ -64,6 +64,9 @@ def __init__(self, rank, *args, **kwargs): regen_btn.pack(side="left") self.play_btn.pack(side="left") + # must be called after self.play_btn exists + self.populate_phrases() + frame.pack(side="top", expand=True, fill="x") # NOT inside the frame @@ -75,7 +78,7 @@ def __init__(self, rank, *args, **kwargs): justify="left" ).pack(side="top", fill="x") - self.visualize_wav = None + def set_translated(self, *args, **kwargs): """ @@ -129,7 +132,6 @@ def choose_phrase(self, *args, **kwargs): self.clear_wave() self.play_btn["state"] = "disabled" - def populate_phrases(self): log.debug('** populate_phrases() called **') @@ -379,84 +381,71 @@ def say_it(self, use_secondary=False): tk.messagebox.showerror(title="Error", message=f"Engine {engine_name} did not provide audio") -class EngineSelection(ttk.LabelFrame): +class EngineSelectAndConfigure(ttk.LabelFrame): """ - Frame for just the Text to speech labal and - a combobox to choose a different engine. We are - tracing on the variable, not binding the widget. + Responsible for everything inside the "Engine" section + of the detailside. There is one instance of this object per + layer of engine (primary, secondary, etc..) """ - - def __init__(self, parent, selected_engine, *args, **kwargs): + def __init__(self, rank, *args, **kwargs): kwargs['text'] = 'Engine' - super().__init__(parent, *args, **kwargs) - self.selected_engine = selected_engine + super().__init__(*args, **kwargs) + log.debug(f'EngineSelectAndConfigure.__init__({rank=}') + self.rank = rank + self.engine_parameters = None self.columnconfigure(0, minsize=125, uniform="ttsengine") self.columnconfigure(1, weight=2, uniform="ttsengine") + + #-- Row 0 -------------------------------------- ttk.Label(self, text="Text to Speech Engine", anchor="e").grid( - column=0, row=0, sticky="e" + row=0, column=0, sticky="e", padx=10 ) - base_tts = ttk.Combobox(self, textvariable=self.selected_engine) - base_tts["values"] = [e.cosmetic for e in engines.ENGINE_LIST] - base_tts["state"] = "readonly" - - base_tts.grid( - column=1, row=0, sticky="ew" - ) - - -class EngineSelectAndConfigure(ttk.Frame): - """ - two element stack, the first has the engine selection, - the second has all the parameters supported by the seleted engine - """ - - def __init__(self, rank, parent, *args, **kwargs): - super().__init__(parent, *args, **kwargs) - self.rank = rank - self.parent = parent - self.selected_engine = tk.StringVar() - self.engine_parameters = None - self.selected_engine.trace_add( "write", self.change_selected_engine ) - - self.phrase_selector = WavfileMajorFrame( - rank, self + base_tts = ttk.Combobox(self, textvariable=self.selected_engine) + base_tts["values"] = [e.cosmetic for e in engines.ENGINE_LIST] + base_tts["state"] = "readonly" + base_tts.grid( + column=1, row=0, sticky="new" ) - self.phrase_selector.pack(side="top", fill="x", expand=True) - - # self.presetSelect = PresetSelector( - # self.frame, self, self.selected_character - # ) - # self.presetSelect.pack(side="top", fill="x", expand=True) - + #-- Row 1 -------------------------------------- with models.Session(models.engine) as session: - self.load_character(session) + self.load_character_engines(session) - self.es = EngineSelection(self, self.selected_engine) - self.es.pack(side="top", fill="x", expand=True) - - def set_engine(self, engine_name): - # update the phrase selector - self.phrase_selector.populate_phrases() + def set_engine(self, engine_name): + """ + When a character is loading (detailside.load_character()) this is + called. We can expect models.get_selected_character() to provide + a character object for whomever we are working on. + """ + log.debug(f'[{self.rank}] ESC.set_engine({engine_name})') # this set() will trip change_selected_engine # which will in turn set a value for engine_parameters self.selected_engine.set(engine_name) + def change_selected_engine(self, a, b, c): """ - the user changed the engine. + 1. the user changed the engine for this character. + + or + + 2. we swapped the character out from under this, then did an engine.set + which is triggered here to configure a totally different character. + + Having this one function handle both those states is a poor design, + since only state #1 should write anything to the database. """ # No problem. # clear the old engine configuration # show the selected engine configuration - log.debug('EngineSelectAndConfigure.change_selected_engine()') + log.info('EngineSelectAndConfigure.change_selected_engine()') character = models.get_selected_character() engine_name = self.selected_engine.get() @@ -476,10 +465,14 @@ def change_selected_engine(self, a, b, c): log.debug(f'{self.rank} engine changing from {character.engine_secondary!r} to {engine_name!r}') if self.engine_parameters: - self.engine_parameters.pack_forget() + log.debug('Clearing prior engine_parameters') + for w in self.engine_parameters.winfo_children: + w.destroy() + self.engine_parameters.destroy() # remove any existing engine level configuration if clear: + log.debug(f'Deleting BaseTTS for {character.id=} {self.rank=}') with models.db() as session: rows = session.scalars( select(models.BaseTTSConfig).where( @@ -492,22 +485,37 @@ def change_selected_engine(self, a, b, c): log.debug(f'Deleting {row}...') session.delete(row) session.commit() + else: + log.debug(f'Not changing the {self.rank} character engines ({engine_name})') models.set_engine(self.rank, engine_name) engine_cls = engines.get_engine(engine_name) if not engine_cls: # that didn't work.. try the default engine + log.warning(f'Invalid Engine: {engine_name}. Using default {settings.DEFAULT_ENGINE} engine.') engine_cls = engines.get_engine(settings.DEFAULT_ENGINE) self.engine_parameters = engine_cls( - self.es, + self, rank=self.rank, category=character.category, name=character.name ) - self.engine_parameters.grid(column=0, row=1, columnspan=2) - # side="top", fill="x", expand=True) + self.engine_parameters.grid(column=0, row=1, columnspan=2, sticky='new') + + #-- Row 2 -------------------------------------- + self.engine_parameters = None + self.phrase_selector = WavfileMajorFrame( + self.rank, self + ) + self.phrase_selector.grid( + columnspan=2, column=0, row=2, sticky="new" + ) + + # update the phrase selector + self.phrase_selector.populate_phrases() + self.save_character() def save_character(self): @@ -534,20 +542,27 @@ def save_character(self): with models.Session(models.engine) as session: character = models.Character.get(name, category_str, session) - log.debug( - f'''Saving {self.rank} changed engine_string {character.name} - {character.engine} {character.engine_secondary} {engine_string} - ''', - ) - + change = False if self.rank == "primary" and character.engine != engine_string: character.engine = engine_string + change = True elif self.rank == "secondary" and character.engine_secondary != engine_string: character.engine_secondary = engine_string + change = True + + if change: + log.debug( + f'''Saving {self.rank} changed engine_string {character.name} + {character.engine=} + {character.engine_secondary=} + {self.rank=} + {engine_string=} + ''', + ) - session.commit() + session.commit() - def load_character(self, session): + def load_character_engines(self, session): """ We've set the character name, we want the rest of the metadata to populate. Setting the engine name will domino the rest. @@ -883,10 +898,10 @@ def __init__(self, parent, *args, **kwargs): engine_notebook = ttk.Notebook(self.frame) self.primary_tab = EngineSelectAndConfigure( - 'primary', self.frame, + 'primary', self, ) self.secondary_tab = EngineSelectAndConfigure( - 'secondary', self.frame, + 'secondary', self, ) engine_notebook.add(self.primary_tab, text='Primary') engine_notebook.add(self.secondary_tab, text='Secondary') From 3e295482527970e7639b79103bb69ad527d809e0 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 7 Jul 2024 22:44:24 -0700 Subject: [PATCH 14/32] less magic in the effects tab --- src/cnv/voices/voice_editor.py | 166 ++++++++++++++++++++++----------- 1 file changed, 110 insertions(+), 56 deletions(-) diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index e2ef0fc..b3c3eec 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -36,11 +36,17 @@ class WavfileMajorFrame(ttk.LabelFrame): ALL_PHRASES = "⪡ Rebuild all phrases ⪢" def __init__(self, rank, *args, **kwargs): + log.debug(f"Initializing WavfileMajorFrame({rank=})") kwargs['text'] = 'Wavefile(s)' super().__init__(*args, **kwargs) self.phrase_id = [] self.rank = rank self.visualize_wav = None + + self.fig = None + self.plt = None + self.spec = None + self.canvas = None frame = ttk.Frame(self) @@ -64,9 +70,6 @@ def __init__(self, rank, *args, **kwargs): regen_btn.pack(side="left") self.play_btn.pack(side="left") - # must be called after self.play_btn exists - self.populate_phrases() - frame.pack(side="top", expand=True, fill="x") # NOT inside the frame @@ -78,6 +81,11 @@ def __init__(self, rank, *args, **kwargs): justify="left" ).pack(side="top", fill="x") + # Wavfile visualizations + self.visualize_wav = ttk.Frame(self, padding = 0) + + # must be called after self.play_btn exists + self.populate_phrases() def set_translated(self, *args, **kwargs): @@ -126,6 +134,8 @@ def choose_phrase(self, *args, **kwargs): # and display the wav self.show_wave(wavfilename) return + else: + self.clear_wave() log.debug(f'Cached mp3 {cachefile} does not exist.') @@ -183,35 +193,52 @@ def populate_phrases(self): def clear_wave(self): if hasattr(self, 'canvas'): log.debug('*** clear_wave() called ***') - self.plt.clear() - self.spec.clear() - self.canvas.draw_idle() + if self.plt: + self.plt.clear() + + if self.spec: + self.spec.clear() + + if self.canvas: + #for item in self.canvas.get_tk_widget().find_all(): + # self.canvas.get_tk_widget().delete(item) + + self.canvas.draw_idle() def show_wave(self, cachefile): """ Visualize a wav file - # I know, not very efficient to just jam this in here - # https://learnpython.com/blog/plot-waveform-in-python/ - # https://matplotlib.org/3.1.0/gallery/user_interfaces/embedding_in_tk_sgskip.html + I know, not very efficient to just jam this in here + * https://learnpython.com/blog/plot-waveform-in-python/ + * https://matplotlib.org/3.1.0/gallery/user_interfaces/embedding_in_tk_sgskip.html """ - sampling_rate, data = wavfile.read(cachefile) - if self.visualize_wav is None: - # our widget hasn't been rendered. Do be a sweetie and take - # care of that for me. - self.visualize_wav = ttk.Frame(self, padding = 0) + if self.fig is None: self.fig = Figure( - figsize=(3, 2), # (width, height) figsize in inches (not kidding) + figsize=(3, 4), # (width, height) figsize in inches (not kidding) dpi=100, # but we get dpi too so... sane? layout='constrained' ) + if self.plt is None: + self.plt = self.fig.add_subplot(211, facecolor=('xkcd:light grey')) + else: + self.plt.remove() self.plt = self.fig.add_subplot(211, facecolor=('xkcd:light grey')) + + if self.spec is None: self.spec = self.fig.add_subplot(212) + else: + self.spec.clear() + + # 211 = rows=2 columns=1 index=1 + if self.canvas is None: self.canvas = FigureCanvasTkAgg(self.fig, self.visualize_wav) self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=1) - else: - self.clear_wave() + + sampling_rate, data = wavfile.read(cachefile) + + log.debug(f'*** show_wave({cachefile=}) called ***') # if channels == 1: self.plt.plot(data) @@ -414,6 +441,16 @@ def __init__(self, rank, *args, **kwargs): column=1, row=0, sticky="new" ) #-- Row 1 -------------------------------------- + + #-- Row 2 -------------------------------------- + self.engine_parameters = None + self.phrase_selector = WavfileMajorFrame( + self.rank, self + ) + self.phrase_selector.grid( + columnspan=2, column=0, row=2, sticky="new" + ) + with models.Session(models.engine) as session: self.load_character_engines(session) @@ -445,7 +482,7 @@ def change_selected_engine(self, a, b, c): # No problem. # clear the old engine configuration # show the selected engine configuration - log.info('EngineSelectAndConfigure.change_selected_engine()') + log.debug('EngineSelectAndConfigure.change_selected_engine()') character = models.get_selected_character() engine_name = self.selected_engine.get() @@ -466,7 +503,7 @@ def change_selected_engine(self, a, b, c): if self.engine_parameters: log.debug('Clearing prior engine_parameters') - for w in self.engine_parameters.winfo_children: + for w in self.engine_parameters.winfo_children(): w.destroy() self.engine_parameters.destroy() @@ -504,15 +541,6 @@ def change_selected_engine(self, a, b, c): ) self.engine_parameters.grid(column=0, row=1, columnspan=2, sticky='new') - #-- Row 2 -------------------------------------- - self.engine_parameters = None - self.phrase_selector = WavfileMajorFrame( - self.rank, self - ) - self.phrase_selector.grid( - columnspan=2, column=0, row=2, sticky="new" - ) - # update the phrase selector self.phrase_selector.populate_phrases() @@ -592,22 +620,24 @@ def load_character_engines(self, session): return character -class EffectList(ttk.LabelFrame): +class EffectList(ttk.Frame): def __init__(self, parent, *args, **kwargs): - kwargs['text'] = "Effects", super().__init__(parent, *args, **kwargs) - self.buffer = False + # self.buffer = False self.name = None self.category = None + self.next_effect_row = 0 self.add_effect_combo = AddEffect(self, self) - self.add_effect_combo.pack(side="top", fill="x", expand=True) + self.add_effect_combo.grid( + column=0, row=0, sticky="new" + ) + #pack(side="top", fill="both", expand=True) def load_effects(self, name, category): log.debug('EffectList.load_effects()') - has_effects = False self.name = name self.category = category @@ -631,8 +661,8 @@ def load_effects(self, name, category): ) ).all() - for effect in voice_effects: - has_effects = True + index = -1 + for index, effect in enumerate(voice_effects): log.debug(f'Adding effect {effect} found in the database') effect_class = effects.EFFECTS[effect.effect_name] @@ -648,22 +678,30 @@ def load_effects(self, name, category): relief="groove", style="Effect.TFrame" ) - effect_config_frame.pack(side="top", fill="x", expand=True) + effect_config_frame.grid(row=index, column=0, sticky="new") + #pack(side="top", fill="x", expand=True) effect_config_frame.effect_id.set(effect.id) models.add_effect(effect_config_frame) effect_config_frame.load() - - if not has_effects: - self.buffer = ttk.Frame(self, width=1, height=1).pack(side="top") - else: - if self.buffer: - self.buffer.pack_forget() + + self.next_effect_row = index + 1 + + # if not has_effects: + # self.buffer = ttk.Frame(self, width=1, height=1).pack(side="top") + # else: + # if self.buffer: + # self.buffer.pack_forget() log.debug("Rebuilding add_effect") - self.add_effect_combo.pack_forget() + self.add_effect_combo.grid_forget() self.add_effect_combo = AddEffect(self, self) - self.add_effect_combo.pack(side="top", fill='x', expand=True) + self.add_effect_combo.grid( + row=self.next_effect_row, + column=0, + sticky="new" + ) + #(side="top", fill='x', expand=True) def add_effect(self, effect_name): """ @@ -705,8 +743,22 @@ def add_effect(self, effect_name): style="EffectConfig.TFrame", borderwidth=1 ) - effect_config_frame.pack(side="top", fill="x", expand=True) - settings.add_effect(effect_config_frame) + self.add_effect_combo.grid_forget() + effect_config_frame.grid( + column=0, + row=self.next_effect_row, + sticky="new" + ) + + self.next_effect_row += 1 + log.debug("Rebuilding add_effect") + self.add_effect_combo.grid( + row=self.next_effect_row, + column=0, + sticky="new" + ) + + models.add_effect(effect_config_frame) with models.Session(models.engine) as session: # retrieve this character @@ -760,8 +812,8 @@ def remove_effect(self, effect_obj): session.commit() # forget the widgets for this object - effect_obj.pack_forget() - self.pack() + effect_obj.grid_forget() + # self.grid() class AddEffect(ttk.Frame): @@ -769,7 +821,7 @@ class AddEffect(ttk.Frame): # hell buddy. This is the shit that causing errors when people # use an app at different resolutions. This will center at one spot # and all the other entries will be different. - add_an_effect = "⪡ Add an Effect ⪢" + ADD_AN_EFFECT = "⪡ Add an Effect ⪢" def __init__(self, parent, effect_list, *args, **kwargs): super().__init__(parent, *args, **kwargs) @@ -779,7 +831,7 @@ def __init__(self, parent, effect_list, *args, **kwargs): # this combobox. That only gives us the scope of what can see this # selected_effect to either read the current value or trace_add to # trigger whenever selected_effect is written to. - self.selected_effect = tk.StringVar(value=self.add_an_effect) + self.selected_effect = tk.StringVar(value=self.ADD_AN_EFFECT) self.selected_effect.trace_add("write", self.add_effect) effect_combobox = ttk.Combobox( @@ -791,7 +843,7 @@ def __init__(self, parent, effect_list, *args, **kwargs): effect_combobox["state"] = "readonly" # <-[XXX ] - effect_combobox.pack(side="left", fill="x", expand=True) + effect_combobox.pack(side="top", fill="x", expand=True) # [XXX]-> # ttk.Button(self, text="Add Effect", command=self.add_effect).pack(side="right") @@ -806,7 +858,7 @@ def add_effect(self, varname, lindex, operation): self.effect_list.add_effect(effect_name) # reset the UI back to the "add an effect" prompt - self.selected_effect.set(self.add_an_effect) + self.selected_effect.set(self.ADD_AN_EFFECT) class DetailSide(ttk.Frame): @@ -903,15 +955,17 @@ def __init__(self, parent, *args, **kwargs): self.secondary_tab = EngineSelectAndConfigure( 'secondary', self, ) + + # list of effects already configured, but .. we don't + # actually _have_ a character yet, so this is kind of stupid. + self.effect_list = EffectList(self.frame, name=None, category=None) + engine_notebook.add(self.primary_tab, text='Primary') engine_notebook.add(self.secondary_tab, text='Secondary') + engine_notebook.add(self.effect_list, text='Effects') engine_notebook.pack(side="top", fill="x", expand=True) - # list of effects already configured, but .. we don't - # actually _have_ a character yet, so this is kind of stupid. - self.effect_list = EffectList(self.frame, name=None, category=None) - self.effect_list.pack(side="top", fill="x", expand=True) self.bind('', self._bound_to_mousewheel) self.bind('', self._unbound_to_mousewheel) From ecfb46800c0b3a12e4f7c6b433fd541fd3d93908 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 7 Jul 2024 22:53:01 -0700 Subject: [PATCH 15/32] fix for clearing effects when changing characters --- src/cnv/database/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 4e7af27..5a55d4f 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -406,7 +406,7 @@ def wipe_all_effects(): while ACTIVE_EFFECTS: effect = pop_effect() effect.clear_traces() - effect.pack_forget() + effect.grid_forget() def get_character_from_rawname(raw_name, session): From 0a9b4b650a209ff638835f0761a271f265f1c7f0 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 7 Jul 2024 22:56:52 -0700 Subject: [PATCH 16/32] enable play after regenerating --- src/cnv/voices/voice_editor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index b3c3eec..d0b1dc8 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -68,7 +68,7 @@ def __init__(self, rank, *args, **kwargs): regen_btn = ttk.Button(frame, text="Regen", command=self.say_it) regen_btn.pack(side="left") - self.play_btn.pack(side="left") + self.play_btn.pack(side="left") frame.pack(side="top", expand=True, fill="x") @@ -386,6 +386,8 @@ def say_it(self, use_secondary=False): cachefile + ".wav", mp3filename=cachefile ) + + self.play_btn["state"] = "normal" # unlink the wav file? if not all_phrases: @@ -624,8 +626,6 @@ class EffectList(ttk.Frame): def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) - # self.buffer = False - self.name = None self.category = None self.next_effect_row = 0 @@ -634,7 +634,6 @@ def __init__(self, parent, *args, **kwargs): self.add_effect_combo.grid( column=0, row=0, sticky="new" ) - #pack(side="top", fill="both", expand=True) def load_effects(self, name, category): log.debug('EffectList.load_effects()') @@ -643,6 +642,8 @@ def load_effects(self, name, category): self.category = category # teardown any effects already in place + #for effect in models.get_effects(): + models.wipe_all_effects() with models.db() as session: From 4a9a85cbed7f809b0e8693cd12a213f35c85021f Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Fri, 12 Jul 2024 19:03:09 -0700 Subject: [PATCH 17/32] working on stability now, fix for google auth --- requirements.txt | 2 ++ src/cnv/engines/googlecloud.py | 18 +++++++++++++----- src/cnv/lib/settings.py | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2d8df11..f2ea47e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ altgraph==0.17.4 annotated-types==0.6.0 anyio==4.3.0 asttokens==2.4.1 +azure-cognitiveservices-speech==1.38.0 boto3==1.34.103 build==1.2.1 cachetools==5.3.3 @@ -52,6 +53,7 @@ mypy-boto3-polly==1.34.101 nh3==0.2.17 nltk==3.8.1 numpy==1.26.4 +openai==1.35.10 packaging==24.0 parso==0.8.4 pedalboard==0.9.3 diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py index 74e6a49..a06593c 100644 --- a/src/cnv/engines/googlecloud.py +++ b/src/cnv/engines/googlecloud.py @@ -7,6 +7,7 @@ import voicebox import webbrowser from google.auth.transport.requests import Request +from google.auth.exceptions import RefreshError from google.cloud import texttospeech from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow @@ -65,11 +66,18 @@ def get_credentials(): if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: # try and refresh the credential - creds.refresh(Request()) - - # persist the refreshed token to disk - with open(token_file, "w") as token: - token.write(creds.to_json()) + try: + creds.refresh(Request()) + + # persist the refreshed token to disk + with open(token_file, "w") as token: + token.write(creds.to_json()) + + except RefreshError as err: + # our token can't be refreshed. + log.error(err) + os.unlink(token_file) + creds = None else: creds = None diff --git a/src/cnv/lib/settings.py b/src/cnv/lib/settings.py index 759d03b..030a07d 100644 --- a/src/cnv/lib/settings.py +++ b/src/cnv/lib/settings.py @@ -41,7 +41,7 @@ # to cache hit anyway. PERSIST_PLAYER_CHAT = True -REPLAY = True +REPLAY = False logging.basicConfig( level=LOGLEVEL, From fe160d277d953039eaf067764bb56fc7cae7332d Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Fri, 12 Jul 2024 23:19:55 -0700 Subject: [PATCH 18/32] switch to customtkinter widgets --- requirements.txt | 1 + src/cnv/database/models.py | 9 +- src/cnv/engines/base.py | 38 ++- src/cnv/sidekick.py | 70 +++-- src/cnv/tabs/automation.py | 2 +- src/cnv/tabs/character.py | 116 +++++++- src/cnv/tabs/configuration.py | 9 +- src/cnv/tabs/voices.py | 19 +- src/cnv/voices/voice_editor.py | 487 +++++++++++++++------------------ 9 files changed, 421 insertions(+), 330 deletions(-) diff --git a/requirements.txt b/requirements.txt index f2ea47e..2aefa23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ click==8.1.7 colorama==0.4.6 comtypes==1.3.1 contourpy +customtkinter==5.2.2 cycler==0.12.1 decorator==5.1.1 docutils==0.20.1 diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 5a55d4f..623ed11 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -576,9 +576,7 @@ class Translation(Base): text: Mapped[str] = mapped_column(String(256)) -def get_or_create_phrase_id(name, category, message): - """ - """ +def get_or_create_phrase(name, category, message): with db() as session: character = Character().get( name=name, category=category, session=session) @@ -597,7 +595,10 @@ def get_or_create_phrase_id(name, category, message): session.add(phrase) session.commit() - return phrase.id + return phrase + +def get_or_create_phrase_id(name, category, message): + return get_or_create_phrase(name, category, message).id def get_translated(phrase_id): diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index e4855a2..b0cbf5f 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -1,7 +1,7 @@ import logging import tkinter as tk from tkinter import ttk - +import customtkinter as ctk import cnv.database.models as models import cnv.lib.settings as settings import voicebox @@ -35,7 +35,7 @@ def __init__(self, *args, **kwargs): # self.load_html(text) -class Notebook(ttk.Frame): +class Notebook(ctk.CTkFrame): """ Workaround an error in tkinterweb.HtmlLabel We make a Notebook but the pages are blankframe. @@ -108,7 +108,7 @@ def tabs(self): # Base Class for engines -class TTSEngine(ttk.Frame): +class TTSEngine(ctk.CTkFrame): auth_ui_class = None def __init__(self, parent, rank, name, category, *args, **kwargs): @@ -303,7 +303,7 @@ def draw_config_meta(self, parent): parent.columnconfigure(1, weight=2, uniform="ttsengine") for index, m in enumerate(self.get_config_meta()): - ttk.Label(parent, text=m.cosmetic, anchor="e").grid( + ctk.CTkLabel(parent, text=m.cosmetic, anchor="e").grid( row=index + 1, column=0, sticky="e", padx=10 ) @@ -328,11 +328,12 @@ def draw_config_meta(self, parent): def _tkStringVar(self, index, key, frame): # combo widget for strings - self.widget[key] = ttk.Combobox( + self.widget[key] = ctk.CTkComboBox( frame, - textvariable=self.config_vars[key], + variable=self.config_vars[key], + state="readonly" ) - self.widget[key]["state"] = "readonly" + # self.widget[key]["state"] = "readonly" self.widget[key].grid(row=index, column=1, sticky="new") def _tkDoubleVar(self, index, key, frame, cfg): @@ -340,15 +341,24 @@ def _tkDoubleVar(self, index, key, frame, cfg): # widget to behave itself. I like the visual a bit better, but its hard # to get equivilent results. - self.widget[key] = tk.Scale( + self.widget[key] = ctk.CTkSlider( frame, variable=self.config_vars[key], from_=cfg.get('min', 0), to=cfg['max'], - orient='horizontal', - digits=cfg.get('digits', 2), - resolution=cfg.get('resolution', 1) - ) + orientation='horizontal', + number_of_steps=20 + #digits=cfg.get('digits', 2), + #resolution=cfg.get('resolution', 1) + ) + # frame, + # variable=self.config_vars[key], + # from_=cfg.get('min', 0), + # to=cfg['max'], + # orient='horizontal', + # digits=cfg.get('digits', 2), + # resolution=cfg.get('resolution', 1) + # ) self.widget[key].grid(row=index, column=1, sticky="new") def _tkBooleanVar(self, index, key, frame): @@ -358,7 +368,7 @@ def _tkBooleanVar(self, index, key, frame): easier to maintain consistency with the other widgets. Oh, and text doesn't belong on a checkbox. It's a wart, sorry. """ - self.widget[key] = ttk.Checkbutton( + self.widget[key] = ctk.CTkSwitch( frame, text="", variable=self.config_vars[key], @@ -408,7 +418,7 @@ def repopulate_options(self): if not all_options: log.error(f'{m.gatherfunc=} returned no options ({self.cosmetic})') - self.widget[m.key]["values"] = all_options + self.widget[m.key].configure(values=all_options) if self.config_vars[m.key].get() not in all_options: # log.info(f'Expected to find {self.config_vars[m.key].get()!r} in list {all_options!r}') diff --git a/src/cnv/sidekick.py b/src/cnv/sidekick.py index 3af86fa..7fb7273 100644 --- a/src/cnv/sidekick.py +++ b/src/cnv/sidekick.py @@ -1,16 +1,6 @@ """ There is more awesome to be had. """ -# sys.path.append( -# os.path.abspath( -# os.path.join( -# os.path.dirname( -# os.path.realpath(__file__) -# ), -# '..', '..') -# ) -# ) - import ctypes import logging import multiprocessing @@ -23,6 +13,7 @@ import cnv.chatlog.npc_chatter as npc_chatter import cnv.logger import colorama +import customtkinter as ctk import lib.settings as settings import pyautogui as p import win32api @@ -46,9 +37,36 @@ log = logging.getLogger(__name__) EXIT = False +class MainTabView(ctk.CTkTabview): + def __init__(self, master, event_queue, **kwargs): + kwargs["height"] = 1024 # this is really more like maxheight + kwargs['border_color'] = "darkgrey" + kwargs['border_width'] = 2 + kwargs['anchor'] = 'nw' + super().__init__(master, **kwargs) + + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + for tablabel, tabobj in ( + ('Character', character.CharacterTab), + ('Voices', voices.VoicesTab), + ('Configuration', configuration.ConfigurationTab), + ('Automation', automation.AutomationTab), + ): + ctkframe = self.add(tablabel) + + tab_contents = tabobj(ctkframe, event_queue) + + ctkframe.columnconfigure(0, weight=1) + ctkframe.rowconfigure(0, weight=1) + tab_contents.grid(column=0, row=0, sticky='nsew') + + def main(): colorama.init() - root = tk.Tk() + #root = tk.Tk() + root = ctk.CTk() def on_closing(): global EXIT @@ -63,26 +81,20 @@ def on_closing(): root.resizable(True, True) root.title("City of Heroes Sidekick") - notebook = ttk.Notebook(root) event_queue = multiprocessing.Queue() - char = character.CharacterTab(notebook, event_queue) - char.pack(side="top", fill="both", expand=True) - notebook.add(char, text='Character') - - voice = voices.VoicesTab(notebook) - voice.pack(side="top", fill="both", expand=True) - notebook.add(voice, text='Voices') - - config = configuration.ConfigurationTab(notebook) - config.pack(side="top", fill="both", expand=True) - notebook.add(config, text="Configuration") - - automate = automation.AutomationTab(notebook) - automate.pack(side="top", fill="both", expand=True) - notebook.add(automate, text="Automation") - - notebook.pack(fill="both", expand=True) + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + + mtv = MainTabView( + root, event_queue=event_queue + ) + mtv.grid( + column=0, row=0, sticky="nsew" + ) + + # put the tabs on the left side + # mtv._segmented_button.grid(sticky="w") # in the mainloop we want to know if event_queue gets a new # entry. diff --git a/src/cnv/tabs/automation.py b/src/cnv/tabs/automation.py index e1f9255..473df3d 100644 --- a/src/cnv/tabs/automation.py +++ b/src/cnv/tabs/automation.py @@ -33,7 +33,7 @@ class AutomationTab(ttk.Frame): Lets make this awesome. """ - def __init__(self, parent, *args, **kwargs): + def __init__(self, parent, event_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) if parent: diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index 75fc1b0..7fc90d9 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -1,20 +1,25 @@ import logging +import multiprocessing +import queue import tkinter as tk from datetime import datetime, timedelta from tkinter import ttk import cnv.database.models as models import cnv.voices.voice_editor as voice_editor +import customtkinter as ctk import matplotlib.dates as mdates import numpy as np +from cnv.chatlog import npc_chatter +from cnv.lib import settings from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure from sqlalchemy import func, select log = logging.getLogger(__name__) -class ChartFrame(ttk.Frame): +class ChartFrame(ctk.CTkFrame): def __init__(self, parent, hero, *args, **kwargs): super().__init__(parent, *args, **kwargs) log.debug('Initializing ChartFrame()') @@ -220,19 +225,110 @@ def __init__(self, parent, hero, *args, **kwargs): log.debug('graph constructed') -class CharacterTab(ttk.Frame): +class ChatterService: + def start(self, event_queue): + self.speaking_queue = queue.Queue() + + npc_chatter.TightTTS(self.speaking_queue, event_queue) + self.speaking_queue.put((None, "Attaching to most recent log...", 'system')) + + logdir = "G:/CoH/homecoming/accounts/VVonder/Logs" + #logdir = "g:/CoH/homecoming/accounts/VVonder/Logs" + badges = True + team = True + npc = True + + ls = npc_chatter.LogStream( + logdir, self.speaking_queue, event_queue, badges, npc, team + ) + while True: + ls.tail() + + +class Chatter(ctk.CTkFrame): + attach_label = 'Attach to Log' + detach_label = "Detach from Log" + def __init__(self, parent, event_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) + self.event_queue = event_queue + self.button_text = tk.StringVar(value=self.attach_label) + self.attached = False + self.hero = None + + self.logdir = tk.StringVar( + value=settings.get_config_key('logdir', default='') + ) + self.logdir.trace_add('write', self.save_logdir) + + # expand the entry box + self.columnconfigure(0, weight=0) + self.columnconfigure(1, weight=1) + self.columnconfigure(2, weight=0) + + ctk.CTkButton( + self, + textvariable=self.button_text, + command=self.attach_chatter + ).grid(column=0, row=0) + + ctk.CTkEntry( + self, + textvariable=self.logdir + ).grid(column=1, row=0, sticky="ew") + + ctk.CTkButton( + self, + text="Set Log Dir", + command=self.ask_directory + ).grid(column=2, row=0) + self.cs = ChatterService() + + def save_logdir(self, *args): + logdir = self.logdir.get() + log.debug(f'Persisting setting logdir={logdir}') + settings.set_config_key('logdir', logdir) + + def ask_directory(self): + dirname = tk.filedialog.askdirectory() + self.logdir.set(dirname) + + def attach_chatter(self): + """ + Not sure exactly how I want to do this. I think the best long term + option is to just launch a process and be done with it. + """ + if self.attached: + # we are already attached, I guess we want to stop. + self.p.terminate() + self.button_text.set(self.attach_label) + self.attached = False + log.debug('Detached') + else: + # we are not attached, lets do that. + self.attached = True + self.button_text.set(self.detach_label) + self.p = multiprocessing.Process(target=self.cs.start, args=(self.event_queue, )) + self.p.start() + log.debug('Attached') + + +class CharacterTab(ctk.CTkFrame): + def __init__(self, parent, event_queue, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.columnconfigure(0, weight=1) + self.name = tk.StringVar() - self.chatter = voice_editor.Chatter(self, event_queue) - self.chatter.pack(side="top", fill=tk.X) + self.chatter = Chatter(self, event_queue) + self.chatter.grid(column=0, row=0, sticky="nsew") self.total_exp = tk.IntVar(value=0) self.total_inf = tk.IntVar(value=0) totals_frame = self.totals_frame() - totals_frame.pack(side="top", fill=tk.X, expand=False) + totals_frame.grid(column=0, row=1, sticky="ew") self.start_time = datetime.now() self.set_hero() @@ -241,11 +337,11 @@ def totals_frame(self): """ Frame for displaying xp and influence totals """ - frame = ttk.Frame(self) - ttk.Label(frame, text="Experience").pack(side="left") - ttk.Label(frame, textvariable=self.total_exp).pack(side="left") - ttk.Label(frame, text="Influence").pack(side="left") - ttk.Label(frame, textvariable=self.total_inf).pack(side="left") + frame = ctk.CTkFrame(self) + ctk.CTkLabel(frame, text="Experience").grid(column=0, row=0) + ctk.CTkLabel(frame, textvariable=self.total_exp).grid(column=1, row=0) + ctk.CTkLabel(frame, text="Influence").grid(column=0, row=1) + ctk.CTkLabel(frame, textvariable=self.total_inf).grid(column=1, row=1) return frame def update_xpinf(self): diff --git a/src/cnv/tabs/configuration.py b/src/cnv/tabs/configuration.py index 23a44d0..5c785c7 100644 --- a/src/cnv/tabs/configuration.py +++ b/src/cnv/tabs/configuration.py @@ -2,7 +2,7 @@ import logging import tkinter as tk from tkinter import ttk - +import customtkinter as ctk import cnv.lib.settings as settings from cnv.engines import engines from cnv.engines.base import Notebook @@ -217,15 +217,14 @@ def normalize_prompt_frame(self, parent, category): return frame -class ConfigurationTab(ttk.Frame): +class ConfigurationTab(ctk.CTkFrame): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, parent, event_queue, *args, **kwargs): + super().__init__(parent, *args, **kwargs) MasterVolume(self).pack(side="top", fill="x") SpokenLanguageSelection(self).pack(side="top", fill="x") EngineAuthentication( self, - padding=5 ).pack(side="top", fill="x") ChannelToEngineMap(self).pack(side="top", fill="x") \ No newline at end of file diff --git a/src/cnv/tabs/voices.py b/src/cnv/tabs/voices.py index 05ec302..b94d52e 100644 --- a/src/cnv/tabs/voices.py +++ b/src/cnv/tabs/voices.py @@ -1,24 +1,33 @@ import logging import tkinter as tk from tkinter import ttk - +import customtkinter as ctk import cnv.database.models as models import cnv.voices.voice_editor as voice_editor log = logging.getLogger(__name__) -class VoicesTab(ttk.Frame): - def __init__(self, parent, *args, **kwargs): +class VoicesTab(ctk.CTkFrame): + def __init__(self, parent, event_queue, *args, **kwargs): + kwargs['border_color'] = "red" + kwargs['border_width'] = 2 super().__init__(parent, *args, **kwargs) self.detailside=None self.listside=None + self.rowconfigure(0, weight=1) + + self.columnconfigure(0, weight=0) + self.columnconfigure(1, weight=1) + detailside = voice_editor.DetailSide(self) listside = voice_editor.ListSide(self, detailside) - listside.pack(side="left", fill=tk.Y, expand=False) - detailside.pack(side="left", fill="both", expand=True) + listside.grid(column=0, row=0, sticky="nsew") + #.pack(side="left", fill=tk.Y, expand=False) + detailside.grid(column=1, row=0, sticky="nsew") + #.pack(side="left", fill="both", expand=True) def get_selected_character(self): if (self.detailside is None or self.listside is None): diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index d0b1dc8..75e9772 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -1,24 +1,22 @@ """Voice Editor component""" import logging -import multiprocessing import os -import queue import sys import tkinter as tk from tkinter import font, ttk -from translate import Translator +import customtkinter as ctk import voicebox -from cnv.chatlog import npc_chatter from cnv.database import db, models from cnv.effects import effects from cnv.engines import engines -from cnv.lib import settings, audio +from cnv.lib import audio, settings from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure from scipy.io import wavfile from sqlalchemy import delete, desc, select from tkfeather import Feather +from translate import Translator from voicebox.sinks import Distributor, SoundDevice, WaveFile from voicebox.tts.utils import get_audio_from_wav_file @@ -32,12 +30,12 @@ ENGINE_OVERRIDE = {} #class ChoosePhrase(ttk.Frame): -class WavfileMajorFrame(ttk.LabelFrame): +class WavfileMajorFrame(ctk.CTkFrame): ALL_PHRASES = "⪡ Rebuild all phrases ⪢" def __init__(self, rank, *args, **kwargs): log.debug(f"Initializing WavfileMajorFrame({rank=})") - kwargs['text'] = 'Wavefile(s)' + # kwargs['text'] = 'Wavefile(s)' super().__init__(*args, **kwargs) self.phrase_id = [] self.rank = rank @@ -48,7 +46,7 @@ def __init__(self, rank, *args, **kwargs): self.spec = None self.canvas = None - frame = ttk.Frame(self) + frame = ctk.CTkFrame(self) self.translated = tk.StringVar(value="") @@ -56,24 +54,27 @@ def __init__(self, rank, *args, **kwargs): value="" ) self.chosen_phrase.trace_add('write', self.choose_phrase) - self.options = ttk.Combobox( + self.options = ctk.CTkComboBox( frame, - textvariable=self.chosen_phrase + values=[], + variable=self.chosen_phrase ) - self.options["values"] = [] - self.options.pack(side="left", fill="x", expand=True) - self.play_btn = ttk.Button(frame, text="Play", command=self.play_cache) + self.play_btn = ctk.CTkButton( + frame, text="Play", width=80, command=self.play_cache + ) - regen_btn = ttk.Button(frame, text="Regen", command=self.say_it) + regen_btn = ctk.CTkButton( + frame, text="Regen", width=80, command=self.say_it + ) regen_btn.pack(side="left") self.play_btn.pack(side="left") frame.pack(side="top", expand=True, fill="x") # NOT inside the frame - ttk.Label( + ctk.CTkLabel( self, textvariable=self.translated, wraplength=350, @@ -82,7 +83,7 @@ def __init__(self, rank, *args, **kwargs): ).pack(side="top", fill="x") # Wavfile visualizations - self.visualize_wav = ttk.Frame(self, padding = 0) + self.visualize_wav = ctk.CTkFrame(self) # must be called after self.play_btn exists self.populate_phrases() @@ -103,42 +104,41 @@ def choose_phrase(self, *args, **kwargs): character = models.get_selected_character() # retrieve the selected phrase - selected_index = self.options.current() + message = self.options.get() + if message in [self.ALL_PHRASES, ]: + return + + # determine the id of this phrase + phrase = models.get_or_create_phrase( + character.name, + character.category, + message + ) - if selected_index >= 0: - log.debug(f'Retrieving phrase at index {selected_index}') - try: - phrase_id = self.phrase_id[selected_index] - except IndexError: - # likely "Rebuild all phrases" - return + # we want to work with the translated string + message, is_translated = models.get_translated(phrase.id) - # we want to work with the translated string - message, is_translated = models.get_translated(phrase_id) + if is_translated: + self.translated.set(message) + else: + self.translated.set("") - if is_translated: - self.translated.set(message) - else: - self.translated.set("") + # find the file associated with this phrase + cachefile = self.get_cachefile( + character, message, self.rank + ) - # find the file associated with this phrase - cachefile = self.get_cachefile( - character, message, self.rank + if os.path.exists(cachefile): + # activate the play button and display the waveform + wavfilename = audio.mp3file_to_wavfile( + mp3filename=cachefile ) - - if os.path.exists(cachefile): - wavfilename = audio.mp3file_to_wavfile( - mp3filename=cachefile - ) - self.play_btn["state"] = "normal" - # and display the wav - self.show_wave(wavfilename) - return - else: - self.clear_wave() - - log.debug(f'Cached mp3 {cachefile} does not exist.') - + self.play_btn["state"] = "normal" + # and display the wav + self.show_wave(wavfilename) + return + + log.debug(f'Cached mp3 {cachefile} does not exist.') self.clear_wave() self.play_btn["state"] = "disabled" @@ -171,7 +171,7 @@ def populate_phrases(self): values.append(phrase.text) values.append(self.ALL_PHRASES) - self.options["values"] = values + self.options.configure(values=values) if character_phrases: # default to the first phrase @@ -342,13 +342,19 @@ def say_it(self, use_secondary=False): ) ).all() else: - phrase_id = self.phrase_id[self.options.current()] - with models.db() as session: - all_phrases = session.scalars( - select(models.Phrases).where( - models.Phrases.id == phrase_id - ) - ).all() + all_phrases = [ models.get_or_create_phrase( + name=character.name, + category=character.category, + message=message + ), ] + + # phrase_id = self.phrase_id[self.options.current()] + # with models.db() as session: + # all_phrases = session.scalars( + # select(models.Phrases).where( + # models.Phrases.id == phrase_id + # ) + # ).all() for phrase in all_phrases: log.debug(f'{phrase=}') @@ -427,7 +433,7 @@ def __init__(self, rank, *args, **kwargs): self.columnconfigure(1, weight=2, uniform="ttsengine") #-- Row 0 -------------------------------------- - ttk.Label(self, text="Text to Speech Engine", anchor="e").grid( + ctk.CTkLabel(self, text="Text to Speech Engine", anchor="e").grid( row=0, column=0, sticky="e", padx=10 ) @@ -436,9 +442,14 @@ def __init__(self, rank, *args, **kwargs): "write", self.change_selected_engine ) - base_tts = ttk.Combobox(self, textvariable=self.selected_engine) - base_tts["values"] = [e.cosmetic for e in engines.ENGINE_LIST] - base_tts["state"] = "readonly" + base_tts = ctk.CTkComboBox( + self, + variable=self.selected_engine, + state='readonly', + values=[e.cosmetic for e in engines.ENGINE_LIST] + ) + # base_tts["values"] = [e.cosmetic for e in engines.ENGINE_LIST] + # base_tts["state"] = "readonly" base_tts.grid( column=1, row=0, sticky="new" ) @@ -862,7 +873,54 @@ def add_effect(self, varname, lindex, operation): self.selected_effect.set(self.ADD_AN_EFFECT) -class DetailSide(ttk.Frame): +class BiographyFrame(ctk.CTkFrame): + def __init__( + self, + parent, + character_name, + group_name, + character_description, + *args, + **kwargs + ): + super().__init__(parent, *args, **kwargs) + + self.columnconfigure(0, weight=1) + self.rowconfigure((0, 1), weight=0) + self.rowconfigure(2, weight=1) + + ctk.CTkLabel( + self, + textvariable=character_name, + anchor="center", + font=ctk.CTkFont( + size=22, + weight="bold" + ) + ).grid(column=0, row=0, sticky="ew") + + # which group is this npc a member of. this will + # frequently not have a value + ctk.CTkLabel( + self, + textvariable=group_name, + wraplength=220, + anchor="n", + justify="center" + ).grid(column=0, row=1, sticky="ew") + + # description of the character (if there is one) + ctk.CTkLabel( + self, + textvariable=character_description, + wraplength=350, + anchor="nw", + justify="left" + ).grid(column=0, row=2, sticky="nsew") + + + +class DetailSide(ctk.CTkScrollableFrame): """ Primary frame for the "detail" side of the application. """ @@ -873,83 +931,71 @@ def __init__(self, parent, *args, **kwargs): self.listside = None self.trashcan = Feather("trash-2", size=24) - self.vsb = tk.Scrollbar(self, orient="vertical") - self.vsb.pack(side="right", fill="y", expand=False) + #self.scrollable_frame = ctk.CTkScrollableFrame(self) - self.canvas = tk.Canvas( - self, - borderwidth=0, - background="#ffffff", - yscrollcommand=self.vsb.set - ) - self.canvas.pack(side="left", fill="both", expand=True) + #self.vsb = ctk.CTkScrollbar(self) + #self.vsb.pack(side="right", fill="y", expand=False) + + #self.canvas = tk.Canvas( + # self, + # borderwidth=0, + # background="#ffffff", + # yscrollcommand=self.vsb.set + # ) + # self.canvas.pack(side="left", fill="both", expand=True) # drag the scrollbar, see the canvas slide - self.vsb.config(command=self.canvas.yview) + #self.vsb.configure(command=self.canvas.yview) - self.canvas.xview_moveto(0) - self.canvas.yview_moveto(0) + #self.canvas.xview_moveto(0) + #self.canvas.yview_moveto(0) # this is the scrollable thing - self.frame = ttk.Frame(self.canvas) - self.frame_id = self.canvas.create_window( - (0, 0), window=self.frame, anchor="nw", - tags="self.frame" - ) - - self.frame.bind("", self.onFrameConfigure) - self.canvas.bind("", self.onCanvasConfigure) - - name_frame = ttk.Frame(self.frame) + # self.frame = ttk.Frame(self.canvas) + # self.frame_id = self.canvas.create_window( + # (0, 0), window=self.frame, anchor="nw", + # tags="self.frame" + # ) + + # self.frame.bind("", self.onFrameConfigure) + # self.canvas.bind("", self.onCanvasConfigure) + + # style = ttk.Style() + # style.configure( + # "RemoveCharacter.TButton", + # width=1 + # ) + + # biography + self.rowconfigure(0, weight=0) + + # enginenotebook + self.rowconfigure(1, weight=1) self.character_name = tk.StringVar() - ttk.Label( - name_frame, - textvariable=self.character_name, - anchor="center", - font=font.Font( - size=22, - weight="bold" - ) - ).pack(side="left", fill="x", expand=True) + self.group_name = tk.StringVar() + self.character_description = tk.StringVar() - style = ttk.Style() - style.configure( - "RemoveCharacter.TButton", - width=1 + biography = BiographyFrame( + self, + character_name=self.character_name, + group_name=self.group_name, + character_description=self.character_description ) - ttk.Button( - name_frame, + ctk.CTkButton( + biography, image=self.trashcan.icon, - style="RemoveCharacter.TButton", + text="", + width=45, + #style="RemoveCharacter.TButton", command=self.remove_character ).place(relx=1, rely=0, anchor='ne') - name_frame.pack(side="top", fill="x", expand=True) + biography.grid(column=0, row=0, sticky='nsew') + #.pack(side="top", fill="both", expand=True) - self.group_name = tk.StringVar() - # which group is this npc a member of. this will - # frequently not have a value - ttk.Label( - self.frame, - textvariable=self.group_name, - wraplength=220, - anchor="n", - justify="center" - ).pack(side="top", fill="x") - - self.character_description = tk.StringVar() - # description of the character (if there is one) - ttk.Label( - self.frame, - textvariable=self.character_description, - wraplength=350, - anchor="nw", - justify="left" - ).pack(side="top", fill="x") - - engine_notebook = ttk.Notebook(self.frame) + engine_notebook = ttk.Notebook(self) self.primary_tab = EngineSelectAndConfigure( 'primary', self, ) @@ -959,17 +1005,17 @@ def __init__(self, parent, *args, **kwargs): # list of effects already configured, but .. we don't # actually _have_ a character yet, so this is kind of stupid. - self.effect_list = EffectList(self.frame, name=None, category=None) + self.effect_list = EffectList(self, name=None, category=None) engine_notebook.add(self.primary_tab, text='Primary') engine_notebook.add(self.secondary_tab, text='Secondary') engine_notebook.add(self.effect_list, text='Effects') - engine_notebook.pack(side="top", fill="x", expand=True) - + engine_notebook.grid(column=0, row=1, sticky="nsew") + #.pack(side="top", fill="x", expand=True) - self.bind('', self._bound_to_mousewheel) - self.bind('', self._unbound_to_mousewheel) + #self.bind('', self._bound_to_mousewheel) + #self.bind('', self._unbound_to_mousewheel) def selected_category_and_name(self): """ @@ -977,39 +1023,39 @@ def selected_category_and_name(self): """ return self.listside.selected_category_and_name() - def _bound_to_mousewheel(self, event): - self.canvas.bind_all("", self._on_mousewheel) + # def _bound_to_mousewheel(self, event): + # self.canvas.bind_all("", self._on_mousewheel) - def _unbound_to_mousewheel(self, event): - self.canvas.unbind_all("") + # def _unbound_to_mousewheel(self, event): + # self.canvas.unbind_all("") - def _on_mousewheel(self, event): - top, bottom = self.vsb.get() - if top > 0.0 or bottom < 1.0: - self.canvas.yview_scroll(int(-1*(event.delta/120)), "units") + # def _on_mousewheel(self, event): + # top, bottom = self.vsb.get() + # if top > 0.0 or bottom < 1.0: + # self.canvas.yview_scroll(int(-1*(event.delta/120)), "units") def remove_character(self): if self.listside: self.listside.delete_selected_character() - def onFrameConfigure(self, event): - """Reset the scroll region to encompass the inner frame""" - # Update the scrollbars to match the size of the inner frame. - size = (self.frame.winfo_reqwidth(), self.frame.winfo_reqheight()) - self.canvas.config(scrollregion="0 0 %s %s" % size) - if self.frame.winfo_reqwidth() != self.canvas.winfo_width(): - # Update the canvas's width to fit the inner frame. - self.canvas.config(width=self.frame.winfo_reqwidth()) - - # self.canvas.configure(scrollregion=self.canvas.bbox("all")) - - def onCanvasConfigure(self, event): - if self.frame.winfo_reqwidth() != self.canvas.winfo_width(): - # Update the frame width to fill the canvas. - self.canvas.itemconfigure( - self.frame_id, - width=self.canvas.winfo_width() - ) + # def onFrameConfigure(self, event): + # """Reset the scroll region to encompass the inner frame""" + # # Update the scrollbars to match the size of the inner frame. + # size = (self.frame.winfo_reqwidth(), self.frame.winfo_reqheight()) + # self.canvas.config(scrollregion="0 0 %s %s" % size) + # if self.frame.winfo_reqwidth() != self.canvas.winfo_width(): + # # Update the canvas's width to fit the inner frame. + # self.canvas.config(width=self.frame.winfo_reqwidth()) + + # # self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + # def onCanvasConfigure(self, event): + # if self.frame.winfo_reqwidth() != self.canvas.winfo_width(): + # # Update the frame width to fill the canvas. + # self.canvas.itemconfigure( + # self.frame_id, + # width=self.canvas.winfo_width() + # ) def load_character(self, category, name): """ @@ -1075,8 +1121,8 @@ def load_character(self, category, name): # self.presetSelect.reset() # scroll to the top - self.vsb.set(0, 1) - self.canvas.yview_moveto(0) + # self.vsb.set(0, 1) + # self.canvas.yview_moveto(0) # class PresetSelector(ttk.Frame): @@ -1167,31 +1213,42 @@ def get_phrases(self): cursor.close() return phrases -class ListSide(ttk.Frame): +class ListSide(ctk.CTkFrame): def __init__(self, parent, detailside, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.detailside = detailside #wait, what? self.detailside.listside = self + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=0) + self.rowconfigure(1, weight=1) + self.rowconfigure(2, weight=0) + self.list_filter = tk.StringVar(value="") - listfilter = ttk.Entry(self, width=40, textvariable=self.list_filter) - listfilter.pack(side="top", fill=tk.X) + listfilter = ctk.CTkEntry( + self, + width=40, + textvariable=self.list_filter + ) + listfilter.grid(column=0, row=0, columnspan=2, sticky="ew") + self.list_filter.trace_add('write', self.apply_list_filter) - listarea = ttk.Frame(self) + #listarea = ctk.CTkFrame(self) columns = ('name', ) self.character_tree = ttk.Treeview( - listarea, selectmode="browse", columns=columns, show='' + self, # listarea, + selectmode="browse", + columns=columns, + show='' ) - self.character_tree.column('name', width=200, stretch=tk.YES) self.refresh_character_list() - self.character_tree.pack(side="left", expand=True, fill=tk.BOTH) + self.character_tree.grid(column=0, row=1, sticky='nsew') - vsb = tk.Scrollbar( - listarea, - orient='vertical', + vsb = ctk.CTkScrollbar( + self, command=self.character_tree.yview ) self.character_tree.configure(yscrollcommand=vsb.set) @@ -1199,19 +1256,14 @@ def __init__(self, parent, detailside, *args, **kwargs): self.bind('', self._bound_to_mousewheel) self.bind('', self._unbound_to_mousewheel) - vsb.pack(side='right', fill=tk.Y) - listarea.pack(side="top", expand=True, fill=tk.BOTH) + vsb.grid(column=2, row=1, sticky='ns') - action_frame = ttk.Frame(self) - ttk.Button( - action_frame, + ctk.CTkButton( + self, text="Refresh", command=self.refresh_character_list - ).pack( - side="right" - ) + ).grid(column=0, columnspan=3, row=2, sticky='e') - action_frame.pack(side="top", expand=False, fill=tk.X) self.character_tree.bind("<>", self.character_selected) def _bound_to_mousewheel(self, event): @@ -1457,95 +1509,6 @@ def get_selected_character_item(self): return item -class ChatterService: - def start(self, event_queue): - self.speaking_queue = queue.Queue() - - npc_chatter.TightTTS(self.speaking_queue, event_queue) - self.speaking_queue.put((None, "Attaching to most recent log...", 'system')) - - logdir = "G:/CoH/homecoming/accounts/VVonder/Logs" - #logdir = "g:/CoH/homecoming/accounts/VVonder/Logs" - badges = True - team = True - npc = True - - ls = npc_chatter.LogStream( - logdir, self.speaking_queue, event_queue, badges, npc, team - ) - while True: - ls.tail() - - -class Chatter(ttk.Frame): - attach_label = 'Attach to Log' - detach_label = "Detach from Log" - - def __init__(self, parent, event_queue, *args, **kwargs): - super().__init__(parent, *args, **kwargs) - self.event_queue = event_queue - self.button_text = tk.StringVar(value=self.attach_label) - self.attached = False - self.hero = None - - self.logdir = tk.StringVar( - value=settings.get_config_key('logdir', default='') - ) - self.logdir.trace_add('write', self.save_logdir) - - ttk.Button( - self, - textvariable=self.button_text, - command=self.attach_chatter - ).pack( - side="left" - ) - tk.Entry( - self, - textvariable=self.logdir - ).pack( - side="left", - fill='x', - expand=True - ) - - ttk.Button( - self, - text="Set Log Dir", - command=self.ask_directory - ).pack(side="left") - - self.cs = ChatterService() - - def save_logdir(self, *args): - logdir = self.logdir.get() - log.debug(f'Persisting setting logdir={logdir}') - settings.set_config_key('logdir', logdir) - - def ask_directory(self): - dirname = tk.filedialog.askdirectory() - self.logdir.set(dirname) - - def attach_chatter(self): - """ - Not sure exactly how I want to do this. I think the best long term - option is to just launch a process and be done with it. - """ - if self.attached: - # we are already attached, I guess we want to stop. - self.p.terminate() - self.button_text.set(self.attach_label) - self.attached = False - log.debug('Detached') - else: - # we are not attached, lets do that. - self.attached = True - self.button_text.set(self.detach_label) - self.p = multiprocessing.Process(target=self.cs.start, args=(self.event_queue, )) - self.p.start() - log.debug('Attached') - - # def main(): # root = tk.Tk() # # root.iconbitmap("myIcon.ico") From d49a4365631320a34f24c40b01fd9e62e0a1091b Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 13 Jul 2024 14:20:28 -0700 Subject: [PATCH 19/32] important cosmetic fixes --- src/cnv/engines/base.py | 6 ++++++ src/cnv/sidekick.py | 31 ++++++++++++++++--------------- src/cnv/tabs/voices.py | 13 ++++--------- src/cnv/voices/voice_editor.py | 12 +++++++++++- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index b0cbf5f..e5f37b1 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -341,6 +341,12 @@ def _tkDoubleVar(self, index, key, frame, cfg): # widget to behave itself. I like the visual a bit better, but its hard # to get equivilent results. + + # TODO: + # display the current value + # mark ticks/steps? + # use digits/resolution to determine steps? + # self.widget[key] = ctk.CTkSlider( frame, variable=self.config_vars[key], diff --git a/src/cnv/sidekick.py b/src/cnv/sidekick.py index 7fb7273..ad39a33 100644 --- a/src/cnv/sidekick.py +++ b/src/cnv/sidekick.py @@ -40,13 +40,13 @@ class MainTabView(ctk.CTkTabview): def __init__(self, master, event_queue, **kwargs): kwargs["height"] = 1024 # this is really more like maxheight - kwargs['border_color'] = "darkgrey" - kwargs['border_width'] = 2 + #kwargs['border_color'] = "darkgrey" + #kwargs['border_width'] = 2 kwargs['anchor'] = 'nw' super().__init__(master, **kwargs) - self.columnconfigure(0, weight=1) - self.rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) for tablabel, tabobj in ( ('Character', character.CharacterTab), @@ -55,11 +55,10 @@ def __init__(self, master, event_queue, **kwargs): ('Automation', automation.AutomationTab), ): ctkframe = self.add(tablabel) + ctkframe.grid_columnconfigure(0, weight=1) + ctkframe.grid_rowconfigure(0, weight=1) tab_contents = tabobj(ctkframe, event_queue) - - ctkframe.columnconfigure(0, weight=1) - ctkframe.rowconfigure(0, weight=1) tab_contents.grid(column=0, row=0, sticky='nsew') @@ -77,25 +76,27 @@ def on_closing(): root.protocol("WM_DELETE_WINDOW", on_closing) root.iconbitmap("sidekick.ico") - root.geometry("680x640+200+200") + root.geometry("720x640+200+200") root.resizable(True, True) root.title("City of Heroes Sidekick") event_queue = multiprocessing.Queue() - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) + root.grid_columnconfigure(0, weight=1) + root.grid_rowconfigure(0, weight=1) + buffer = ctk.CTkFrame(root) + buffer.grid_columnconfigure(0, weight=1) + buffer.grid_rowconfigure(0, weight=1) + mtv = MainTabView( - root, event_queue=event_queue + buffer, event_queue=event_queue ) mtv.grid( - column=0, row=0, sticky="nsew" + column=0, row=0, sticky="new" ) + buffer.grid(column=0, row=0, sticky="nsew") - # put the tabs on the left side - # mtv._segmented_button.grid(sticky="w") - # in the mainloop we want to know if event_queue gets a new # entry. last_character_update = None diff --git a/src/cnv/tabs/voices.py b/src/cnv/tabs/voices.py index b94d52e..9acb838 100644 --- a/src/cnv/tabs/voices.py +++ b/src/cnv/tabs/voices.py @@ -10,24 +10,19 @@ class VoicesTab(ctk.CTkFrame): def __init__(self, parent, event_queue, *args, **kwargs): - kwargs['border_color'] = "red" - kwargs['border_width'] = 2 super().__init__(parent, *args, **kwargs) self.detailside=None self.listside=None - - self.rowconfigure(0, weight=1) - - self.columnconfigure(0, weight=0) - self.columnconfigure(1, weight=1) + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=1) detailside = voice_editor.DetailSide(self) listside = voice_editor.ListSide(self, detailside) listside.grid(column=0, row=0, sticky="nsew") - #.pack(side="left", fill=tk.Y, expand=False) detailside.grid(column=1, row=0, sticky="nsew") - #.pack(side="left", fill="both", expand=True) def get_selected_character(self): if (self.detailside is None or self.listside is None): diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 75e9772..245b574 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -971,6 +971,7 @@ def __init__(self, parent, *args, **kwargs): # enginenotebook self.rowconfigure(1, weight=1) + self.columnconfigure(0, weight=1) self.character_name = tk.StringVar() self.group_name = tk.StringVar() @@ -996,6 +997,10 @@ def __init__(self, parent, *args, **kwargs): #.pack(side="top", fill="both", expand=True) engine_notebook = ttk.Notebook(self) + engine_notebook.grid_rowconfigure(0, weight=1) + engine_notebook.grid_rowconfigure(1, weight=1) + engine_notebook.grid_columnconfigure(0, weight=1) + self.primary_tab = EngineSelectAndConfigure( 'primary', self, ) @@ -1231,7 +1236,12 @@ def __init__(self, parent, detailside, *args, **kwargs): width=40, textvariable=self.list_filter ) - listfilter.grid(column=0, row=0, columnspan=2, sticky="ew") + listfilter.grid( + column=0, + row=0, + columnspan=2, + sticky="ew" + ) self.list_filter.trace_add('write', self.apply_list_filter) From 2755e4c94499b38f925b54419dbd093bd08b5285 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 13 Jul 2024 16:03:03 -0700 Subject: [PATCH 20/32] more cosmetic fixes --- src/cnv/database/models.py | 3 + src/cnv/effects/effects.py | 119 ++++++++++++++++++++++----------- src/cnv/engines/base.py | 2 +- src/cnv/voices/voice_editor.py | 58 ++++++++++------ 4 files changed, 122 insertions(+), 60 deletions(-) diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index 623ed11..fcdc030 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -666,6 +666,9 @@ class EffectSetting(Base): key: Mapped[str] = mapped_column(String(256)) value: Mapped[str] = mapped_column(String(256)) + def __str__(self): + return f"" + class Hero(Base): __tablename__ = "hero" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) diff --git a/src/cnv/effects/effects.py b/src/cnv/effects/effects.py index b26eeb1..6cdaf8f 100644 --- a/src/cnv/effects/effects.py +++ b/src/cnv/effects/effects.py @@ -1,6 +1,7 @@ import logging import tkinter as tk from tkinter import font, ttk +import customtkinter as ctk import cnv.database.models as models import numpy as np @@ -13,7 +14,7 @@ WRAPLENGTH=350 -class LScale(ttk.Frame): +class LScale(ctk.CTkFrame): """ Labeled choose-a-number """ @@ -33,7 +34,7 @@ def __init__( self.columnconfigure(0, minsize=125, uniform="effect") self.columnconfigure(1, weight=2, uniform="effect") - if _type == int: + if isinstance(_type, int): variable = tk.IntVar( name=f"{parent.label.lower()}_{pname}", value=default @@ -45,31 +46,61 @@ def __init__( ) # label for the setting - ttk.Label( + ctk.CTkLabel( self, text=label, anchor="e", justify='right' ).grid(row=0, column=0, sticky='e') + # TODO: + # display the current value + # mark ticks/steps? + # use digits/resolution to determine steps? + # + log.info(f'{resolution=}') + if resolution: + steps = int((to - from_) / resolution) + else: + steps = 20 + + if steps > 50: + log.warning(f'Resolution for {label} is too detailed') + # widget for viewing/changing the value - tk.Scale( + ctk.CTkSlider( self, + variable=variable, from_=from_, to=to, - orient='horizontal', - variable=variable, - *args, - digits=digits, - resolution=resolution, - **kwargs + orientation='horizontal', + number_of_steps=steps ).grid(row=0, column=1, sticky='ew') + ctk.CTkLabel( + self, + textvariable=variable + ).grid(row=0, column=2, sticky='e') + + # tk.Scale( + # self, + # from_=from_, + # to=to, + # orient='horizontal', + # variable=variable, + # *args, + # digits=digits, + # resolution=resolution, + # **kwargs + # ).grid(row=0, column=1, sticky='ew') + setattr(parent, pname, variable) parent.parameters.append(pname) + if digits: + parent.digits[pname] = digits -class LCombo(ttk.Frame): +class LCombo(ctk.CTkFrame): """ Combo widget to select a string from a set of possible values """ @@ -87,7 +118,7 @@ def __init__(self, variable = tk.StringVar(value=default) # label for the setting - ttk.Label( + ctk.CTkLabel( self, text=label, anchor="e", @@ -95,12 +126,14 @@ def __init__(self, ).grid(row=0, column=0, sticky='e') # widget for viewing/changing the value - options = ttk.Combobox( + options = ctk.CTkComboBox( self, - textvariable=variable + values=list(choices), + variable=variable, + state='readonly' ) - options['values'] = list(choices) - options['state'] = 'readonly' + # options['values'] = list(choices) + # options['state'] = 'readonly' options.grid(row=0, column=1, sticky='ew') @@ -108,7 +141,7 @@ def __init__(self, parent.parameters.append(pname) -class LBoolean(ttk.Frame): +class LBoolean(ctk.CTkFrame): def __init__( self, parent, @@ -126,7 +159,7 @@ def __init__( ) # label for the setting - ttk.Label( + ctk.CTkLabel( self, text=label, anchor="e", @@ -134,7 +167,7 @@ def __init__( ).grid(row=0, column=0, sticky='e') # widget for viewing/changing the value - ttk.Checkbutton( + ctk.CTkSwitch( self, text="", variable=variable, @@ -146,7 +179,7 @@ def __init__( parent.parameters.append(pname) -class EffectParameterEditor(ttk.Frame): +class EffectParameterEditor(ctk.CTkFrame): label = "Label" desc = "Description of effect" @@ -156,39 +189,42 @@ def __init__(self, parent, *args, **kwargs): self.effect_id = tk.IntVar() self.parameters = [] self.traces = {} + self.digits = {} self.trashcan = Feather("trash-2", size=24) - topbar = ttk.Frame(self) + topbar = ctk.CTkFrame(self) # the name of this effect - ttk.Label( + ctk.CTkLabel( topbar, text=self.label.title(), anchor="n", - font=font.Font( + font=ctk.CTkFont( size=18, weight="bold" ) ).pack(side='left', fill='x', expand=True) - ttk.Style().configure( - "CloseFrame.TButton", - anchor="center", - width=1, - height=1 - ) + # ttk.Style().configure( + # "CloseFrame.TButton", + # anchor="center", + # width=1, + # height=1 + # ) # delete button - ttk.Button( + ctk.CTkButton( topbar, image=self.trashcan.icon, - style="CloseFrame.TButton", + #style="CloseFrame.TButton", + text="", + width=40, command=self.remove_effect ).place(relx=1, rely=0, anchor='ne') topbar.pack(side="top", fill='x', expand=True) # the descriptive text for this effect - ttk.Label( + ctk.CTkLabel( self, text=self.desc, anchor="n", @@ -233,12 +269,19 @@ def reconfig(self, varname, lindex, operation): ) ).all() - log.debug(f'Sync to db {effect_settings}') found = set() for effect_setting in effect_settings: + log.info(f'Sync to db {effect_setting}') found.add(effect_setting.key) try: new_value = str(getattr(self, effect_setting.key).get()) + digits = self.digits.get(effect_setting.key, None) + if digits is not None: + formatstr = "{:.%sf}" % digits + new_value = formatstr.format(float(new_value)) + getattr(self, effect_setting.key).set(new_value) + else: + log.info(f'{effect_setting.key} not in digits {self.digits}') except AttributeError: log.error(f'Invalid configuration. Cannot set {effect_setting.key} on a {self} effect.') continue @@ -329,7 +372,7 @@ def __init__(self, parent, *args, **kwargs): LScale( self, pname='low_frequency', - label='Low Frequency', + label='Low Frequency (Hz)', desc="Filter frequency in Hz", default=100, from_=100, @@ -340,7 +383,7 @@ def __init__(self, parent, *args, **kwargs): LScale( self, pname="high_frequency", - label='High Frequency', + label='High Frequency (Hz)', desc="Filter frequency in Hz", default=0, from_=0, @@ -361,7 +404,7 @@ def __init__(self, parent, *args, **kwargs): LCombo( self, pname="type_", - label='Type', + label='IIR Filter Type', desc='type of IIR filter to design', default="butter", choices=IIR_FILTERS, @@ -827,7 +870,7 @@ def __init__(self, parent, *args, **kwargs): from_=0, to=100, digits=0, - resolution=1 + resolution=5 ).pack(side='top', fill='x', expand=True) LScale( @@ -851,7 +894,7 @@ def __init__(self, parent, *args, **kwargs): from_=0, to=50, digits=1, - resolution=0.5 + resolution=1 ).pack(side='top', fill='x', expand=True) LScale( diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index e5f37b1..a93b0b3 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -47,7 +47,7 @@ def __init__(self, parent, takefocus=True, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.notebook = ttk.Notebook(self, takefocus=takefocus) - self.blankframe = lambda: tk.Frame(self.notebook, height=0, bd=0, highlightthickness=0) + self.blankframe = lambda: ctk.CTkFrame(self.notebook, height=0) self.notebook.pack(side="top", fill="x", expand=True) self.notebook.bind("<>", self.on_tab_change) diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 245b574..0eec1c6 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -633,13 +633,15 @@ def load_character_engines(self, session): return character -class EffectList(ttk.Frame): +class EffectList(ctk.CTkFrame): def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.name = None self.category = None self.next_effect_row = 0 + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) self.add_effect_combo = AddEffect(self, self) self.add_effect_combo.grid( @@ -686,9 +688,9 @@ def load_effects(self, name, category): # not very DRY effect_config_frame = effect_class( self, - borderwidth=1, - relief="groove", - style="Effect.TFrame" + # borderwidth=1, + # relief="groove", + # style="Effect.TFrame" ) effect_config_frame.grid(row=index, column=0, sticky="new") #pack(side="top", fill="x", expand=True) @@ -745,15 +747,15 @@ def add_effect(self, effect_name): # with an apply(Audio) that returns an Audio; An "Audio" is a pretty # simple object wrapping a np.ndarray of [-1 to 1] samples. # - ttk.Style().configure( - "EffectConfig.TFrame", - highlightbackground="black", - relief="groove" - ) + # ttk.Style().configure( + # "EffectConfig.TFrame", + # highlightbackground="black", + # relief="groove" + # ) effect_config_frame = effect( self, - style="EffectConfig.TFrame", - borderwidth=1 + # style="EffectConfig.TFrame", + # borderwidth=1 ) self.add_effect_combo.grid_forget() effect_config_frame.grid( @@ -996,25 +998,39 @@ def __init__(self, parent, *args, **kwargs): biography.grid(column=0, row=0, sticky='nsew') #.pack(side="top", fill="both", expand=True) - engine_notebook = ttk.Notebook(self) + engine_notebook = ctk.CTkTabview( + self, + anchor="nw" + ) engine_notebook.grid_rowconfigure(0, weight=1) engine_notebook.grid_rowconfigure(1, weight=1) engine_notebook.grid_columnconfigure(0, weight=1) + primary = engine_notebook.add('Primary') + primary.grid_rowconfigure(0, weight=1) + primary.grid_columnconfigure(0, weight=1) + + secondary = engine_notebook.add('Secondary') + secondary.grid_rowconfigure(0, weight=1) + secondary.grid_columnconfigure(0, weight=1) + + effects = engine_notebook.add('Effects') + effects.grid_rowconfigure(0, weight=1) + effects.grid_columnconfigure(0, weight=1) + self.primary_tab = EngineSelectAndConfigure( - 'primary', self, + 'primary', primary, ) + self.primary_tab.grid(column=0, row=0, sticky="nsew") + self.secondary_tab = EngineSelectAndConfigure( - 'secondary', self, + 'secondary', secondary, ) + self.secondary_tab.grid(column=0, row=0, sticky="nsew") - # list of effects already configured, but .. we don't - # actually _have_ a character yet, so this is kind of stupid. - self.effect_list = EffectList(self, name=None, category=None) - - engine_notebook.add(self.primary_tab, text='Primary') - engine_notebook.add(self.secondary_tab, text='Secondary') - engine_notebook.add(self.effect_list, text='Effects') + # list of effects already configured + self.effect_list = EffectList(effects) + self.effect_list.grid(column=0, row=0, sticky="nsew") engine_notebook.grid(column=0, row=1, sticky="nsew") #.pack(side="top", fill="x", expand=True) From fdda918775b4149716ed86f1ac62382151b722e1 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 13 Jul 2024 20:53:07 -0700 Subject: [PATCH 21/32] closing in a new release --- pyproject.toml | 1 - requirements.txt | 1 - src/cnv/chatlog/npc_chatter.py | 14 ++-- src/cnv/database/models.py | 33 ++++++++ src/cnv/effects/effects.py | 38 ++++----- src/cnv/engines/base.py | 29 ++++--- src/cnv/engines/engines.py | 1 - src/cnv/lib/gui.py | 140 ++++++++++++++++++++++++++++++++ src/cnv/lib/icons/trash-2.png | Bin 0 -> 15517 bytes src/cnv/lib/settings.py | 13 +-- src/cnv/logger.py | 25 +++--- src/cnv/sidekick.py | 50 ++++++------ src/cnv/tabs/automation.py | 2 +- src/cnv/tabs/character.py | 48 ++++++----- src/cnv/tabs/configuration.py | 3 +- src/cnv/tabs/voices.py | 4 +- src/cnv/voices/voice_builder.py | 13 +-- src/cnv/voices/voice_editor.py | 74 ++++------------- 18 files changed, 318 insertions(+), 171 deletions(-) create mode 100644 src/cnv/lib/gui.py create mode 100644 src/cnv/lib/icons/trash-2.png diff --git a/pyproject.toml b/pyproject.toml index 6d8dbeb..e31eb4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "google-auth-oauthlib", "matplotlib", "pyautogui", - "pyfeather", "pyfiglet", "pypiwin32", "rich", diff --git a/requirements.txt b/requirements.txt index 2aefa23..074128e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -98,7 +98,6 @@ sounddevice==0.4.6 SQLAlchemy-Utils==0.41.2 SQLAlchemy==2.0.29 stack-data==0.6.3 -tkfeather==1.0.0 tkinterweb==3.23.10 tqdm==4.66.2 traitlets==5.14.3 diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 2380080..192f95e 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -1,12 +1,10 @@ import argparse -import concurrent.futures import glob import io import logging import os import queue import re -import sys import threading import time from datetime import datetime @@ -178,8 +176,9 @@ def run(self): found = False for rank in ['primary', 'secondary']: cachefile = settings.get_cachefile(name, message, category, rank) - - if os.path.exists(cachefile): + + # if primary exists, play that. else secondary. + if not found and os.path.exists(cachefile): found = True log.debug(f"(tighttts) Cache HIT: {cachefile}") # requires pydub? @@ -197,7 +196,8 @@ def run(self): voicebox.sinks.SoundDevice().play(audio) - if not found: + # neither primary nor secondary exist. + if not found: # building session out here instead of inside get_character # keeps character alive and properly tied to the database as we # pass it into update_character_last_spoke and voice_builder. @@ -205,13 +205,13 @@ def run(self): character = models.Character.get(name, category, session) models.update_character_last_spoke(character.id, session) + # it isn't very well named, but this will speak "message" as # character and cache a copy into cachefile. - voice_builder.create(character, message, session) # we've said our piece. - self.speaking_queue.task_done() + # self.speaking_queue.task_done() def plainstring(dialog): diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index fcdc030..c501bb5 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -72,6 +72,7 @@ def category_int2str(inint): except ValueError: return '' +# obsolete? ENGINE_COSMETIC_TO_ID = { 'Google Text-to-Speech': 'googletts', 'Windows TTS': 'windowstts', @@ -674,6 +675,38 @@ class Hero(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(256)) + +def get_hero(): + hero_id = settings.get_config_key('hero_id', cf='state.json') + if hero_id: + with db() as session: + hero = session.scalar( + select(Hero).where( + Hero.id==hero_id + ) + ) + + return hero + +def set_hero(hero_id=None, name=None): + if hero_id: + with db() as session: + hero = session.scalar( + select(Hero).where( + Hero.id==hero_id + ) + ) + elif name: + with db() as session: + hero = session.scalar( + select(Hero).where( + Hero.name==name + ) + ) + + settings.set_config_key('hero_id', hero.id, cf='state.json') + + class HeroStatEvent(Base): __tablename__ = "hero_stat_events" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/src/cnv/effects/effects.py b/src/cnv/effects/effects.py index 6cdaf8f..bb5b38c 100644 --- a/src/cnv/effects/effects.py +++ b/src/cnv/effects/effects.py @@ -1,6 +1,5 @@ import logging import tkinter as tk -from tkinter import font, ttk import customtkinter as ctk import cnv.database.models as models @@ -8,7 +7,7 @@ import pedalboard import voicebox from sqlalchemy import select -from tkfeather import Feather +from cnv.lib.gui import Feather log = logging.getLogger(__name__) @@ -51,14 +50,10 @@ def __init__( text=label, anchor="e", justify='right' - ).grid(row=0, column=0, sticky='e') + ).grid(row=0, column=0, sticky='e', padx=5) # TODO: - # display the current value # mark ticks/steps? - # use digits/resolution to determine steps? - # - log.info(f'{resolution=}') if resolution: steps = int((to - from_) / resolution) else: @@ -116,6 +111,8 @@ def __init__(self, super().__init__(parent, *args, **kwargs) variable = tk.StringVar(value=default) + self.columnconfigure(0, minsize=125, uniform="effect") + self.columnconfigure(1, weight=2, uniform="effect") # label for the setting ctk.CTkLabel( @@ -153,6 +150,9 @@ def __init__( ): super().__init__(parent, *args, **kwargs) + self.columnconfigure(0, minsize=125, uniform="effect") + self.columnconfigure(1, weight=2, uniform="effect") + variable = tk.BooleanVar( name=f"{pname}", value=default @@ -173,7 +173,7 @@ def __init__( variable=variable, onvalue=True, offvalue=False - ).grid(row=0, column=0, sticky='ew') + ).grid(row=0, column=1, sticky='ew') setattr(parent, pname, variable) parent.parameters.append(pname) @@ -190,32 +190,28 @@ def __init__(self, parent, *args, **kwargs): self.parameters = [] self.traces = {} self.digits = {} - self.trashcan = Feather("trash-2", size=24) + self.trashcan = Feather( + 'trash-2', + size=22 + ) topbar = ctk.CTkFrame(self) # the name of this effect ctk.CTkLabel( topbar, - text=self.label.title(), + text=self.label.title() + " ", anchor="n", font=ctk.CTkFont( - size=18, - weight="bold" + size=24, + # weight="bold", + slant='italic' ) ).pack(side='left', fill='x', expand=True) - # ttk.Style().configure( - # "CloseFrame.TButton", - # anchor="center", - # width=1, - # height=1 - # ) - # delete button ctk.CTkButton( topbar, - image=self.trashcan.icon, - #style="CloseFrame.TButton", + image=self.trashcan.CTkImage, text="", width=40, command=self.remove_effect diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index a93b0b3..673ca0d 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -192,11 +192,13 @@ def say(self, message, effects, sink=None, *args, **kwargs): # networking is borked. log.error(err) if err.grpc_status_code == 14: + log.error(f'Google Error code {err.grpc_status_code}. Switching to secondary.') raise USE_SECONDARY elif err.status_code == 401: log.error(err.body) if err.body.get('detail', {}).get('status') == "quota_exceeded": + log.error('ElevelLabs quota exceeded. Switching to secondary.') raise USE_SECONDARY raise @@ -334,7 +336,7 @@ def _tkStringVar(self, index, key, frame): state="readonly" ) # self.widget[key]["state"] = "readonly" - self.widget[key].grid(row=index, column=1, sticky="new") + self.widget[key].grid(row=index, column=1, columnspan=2, sticky="new") def _tkDoubleVar(self, index, key, frame, cfg): # doubles get a scale widget. I haven't been able to get the ttk.Scale @@ -347,26 +349,29 @@ def _tkDoubleVar(self, index, key, frame, cfg): # mark ticks/steps? # use digits/resolution to determine steps? # + if cfg.get('resolution'): + steps = int((cfg['max'] - cfg.get('min', 0)) / cfg.get('resolution')) + else: + steps = 20 + + if steps > 50: + log.warning(f'Resolution for {key} is too detailed') + self.widget[key] = ctk.CTkSlider( frame, variable=self.config_vars[key], from_=cfg.get('min', 0), to=cfg['max'], orientation='horizontal', - number_of_steps=20 - #digits=cfg.get('digits', 2), - #resolution=cfg.get('resolution', 1) + number_of_steps=steps ) - # frame, - # variable=self.config_vars[key], - # from_=cfg.get('min', 0), - # to=cfg['max'], - # orient='horizontal', - # digits=cfg.get('digits', 2), - # resolution=cfg.get('resolution', 1) - # ) self.widget[key].grid(row=index, column=1, sticky="new") + ctk.CTkLabel( + frame, + textvariable=self.config_vars[key] + ).grid(row=index, column=2, sticky='e') + def _tkBooleanVar(self, index, key, frame): """ Still using a label then checkbutton because the 'text' field on diff --git a/src/cnv/engines/engines.py b/src/cnv/engines/engines.py index 46cb962..ae36c61 100644 --- a/src/cnv/engines/engines.py +++ b/src/cnv/engines/engines.py @@ -1,7 +1,6 @@ import logging from .amazonpolly import AmazonPolly -from .base import USE_SECONDARY from .elevenlabs import ElevenLabs from .googlecloud import GoogleCloud from .openai import OpenAI diff --git a/src/cnv/lib/gui.py b/src/cnv/lib/gui.py new file mode 100644 index 0000000..1a1857f --- /dev/null +++ b/src/cnv/lib/gui.py @@ -0,0 +1,140 @@ +""" +based on: + https://github.com/JRiggles/tkfeather/blob/main/tkfeather/tkfeather.py + +CTkInter doesn't play very nicely when it comes to images. This gives us +easy access to all the feather icons for buttons, with the option of choosing +different icons for light/dark mode. + +tkfeather +A tkinter wrapper for Feather Icons by Cole Bemis - https://feathericons.com + +MIT License + +Original Copyright (c) 2024 John Riggles [sudo_whoami] +Changes in this variant are Copyright (c) 2024 Jason Kane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from pathlib import Path +from PIL import Image, ImageTk +import customtkinter as ctk + + +class Feather: + """ + Import: + >>> from tkfeather import Feather + + Initialization: + >>> Feather(name: str, size: int [optional]) + + ### Args + - `name: str` - The name of the Feather icon + - `size: int [optional]` - The size of the icon image in pixels, square + (default: 24) + + Note: Sizes smaller than 24px are allowed but aren't recommended as the + icon will become blurred + + The minimum allowed `size` is 1, and the maximum allowed `size` is 1024 + + Passing a `size` value outside the range of 1 to 1024 will raise a + `ValueError` + + ### Properties + - `Feather.icon` - an `ImageTk.PhotoImage` object for the given Feather + icon + + ### Class Methods + - `Feather.icons_available()` - returns a list with the names of all + available Feather Icons + + Trying to use an icon that doesn't exist will raise a `FileNotFoundError` + + ### Usage: + You must maintain a reference to the Feather instance in a variable in + order to keep the image from being garbage-collected: + >>> # this works + >>> feather = Feather('home') + >>> label = tk.Label(parent, image=feather.icon) + >>> label.pack() + + If you don't maintain a reference to the image, it won't appear! + >>> # this doesn't work - the label will have no image + >>> label = tk.Label(parent, image=Feather('home').icon) + >>> label.pack() + """ + + _ICONS_DIR = Path(__file__).parent / 'icons' + + def __init__(self, light_name: str, dark_name: str = "", size: int = 24) -> None: + if not dark_name: + dark_name = light_name + + self._icon_name = { + 'light': light_name, + 'dark': dark_name + } + self._size = size + self._img = {} + + icon_path = {} + for shade in ['light', 'dark']: + icon_path[shade] = self._ICONS_DIR.joinpath(self._icon_name[shade]).with_suffix('.png') + + if icon_path[shade].exists(): + # cast to int to allow for other numeric arg types + self._size = int(self._size) + if self._size < 1 or self._size > 1024: + raise ValueError('"size" must be between 1 and 1024') + else: + with Image.open(icon_path[shade]) as img: + self._img[shade] = img.resize( # feather icons are square + size=(self._size, self._size), + resample=Image.Resampling.LANCZOS, + ) + else: + raise FileNotFoundError( + 'The image file for the icon ' + f'"{icon_path[shade]}" was not found' + ) + + @property + def CTkImage(self): + return ctk.CTkImage( + light_image=self._img['light'], + dark_image=self._img['dark'], + size=(self._size, self._size) + ) + + @property + def icon(self, shade='light') -> ImageTk.PhotoImage | None: + """An `ImageTk.PhotoImage` object for the given Feather icon""" + return ImageTk.PhotoImage(self._img[shade]) + + @classmethod + def icons_available(cls) -> list[str]: + """Return a list containing the names of all available Feather Icons""" + # NOTE: iterdir keeps including a nonexisting .DS_Store file, so glob + # it is... + return sorted([icon.stem for icon in cls._ICONS_DIR.glob('*.png')]) + + + diff --git a/src/cnv/lib/icons/trash-2.png b/src/cnv/lib/icons/trash-2.png new file mode 100644 index 0000000000000000000000000000000000000000..6a467e52a02df023eb3ad9e492a9e8769d88670d GIT binary patch literal 15517 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 update_frequency: - char.set_hero() + mtv.tabdict['Character'].set_hero() last_character_update = datetime.now() root.update_idletasks() diff --git a/src/cnv/tabs/automation.py b/src/cnv/tabs/automation.py index 473df3d..0f9ebe4 100644 --- a/src/cnv/tabs/automation.py +++ b/src/cnv/tabs/automation.py @@ -33,7 +33,7 @@ class AutomationTab(ttk.Frame): Lets make this awesome. """ - def __init__(self, parent, event_queue, *args, **kwargs): + def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) if parent: diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index 7fc90d9..af439c9 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -1,13 +1,10 @@ import logging import multiprocessing -import queue import tkinter as tk from datetime import datetime, timedelta -from tkinter import ttk import cnv.database.models as models -import cnv.voices.voice_editor as voice_editor import customtkinter as ctk import matplotlib.dates as mdates import numpy as np @@ -220,17 +217,18 @@ def __init__(self, parent, hero, *args, **kwargs): fig, master = self ) + # this is the only widget in ChartFrame canvas.draw() canvas.get_tk_widget().pack(fill="both", expand=True) log.debug('graph constructed') class ChatterService: - def start(self, event_queue): - self.speaking_queue = queue.Queue() - - npc_chatter.TightTTS(self.speaking_queue, event_queue) - self.speaking_queue.put((None, "Attaching to most recent log...", 'system')) + def start(self, event_queue, speaking_queue): + log.info('ChatterService.start()') + + npc_chatter.TightTTS(speaking_queue, event_queue) + speaking_queue.put((None, "Attaching to most recent log...", 'system')) logdir = "G:/CoH/homecoming/accounts/VVonder/Logs" #logdir = "g:/CoH/homecoming/accounts/VVonder/Logs" @@ -239,7 +237,7 @@ def start(self, event_queue): npc = True ls = npc_chatter.LogStream( - logdir, self.speaking_queue, event_queue, badges, npc, team + logdir, speaking_queue, event_queue, badges, npc, team ) while True: ls.tail() @@ -249,9 +247,10 @@ class Chatter(ctk.CTkFrame): attach_label = 'Attach to Log' detach_label = "Detach from Log" - def __init__(self, parent, event_queue, *args, **kwargs): + def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.event_queue = event_queue + self.speaking_queue = speaking_queue self.button_text = tk.StringVar(value=self.attach_label) self.attached = False self.hero = None @@ -309,19 +308,28 @@ def attach_chatter(self): # we are not attached, lets do that. self.attached = True self.button_text.set(self.detach_label) - self.p = multiprocessing.Process(target=self.cs.start, args=(self.event_queue, )) + self.p = multiprocessing.Process( + target=self.cs.start, + args=( + self.event_queue, + self.speaking_queue + ) + ) self.p.start() log.debug('Attached') class CharacterTab(ctk.CTkFrame): - def __init__(self, parent, event_queue, *args, **kwargs): + def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=0) + self.rowconfigure(1, weight=0) + self.rowconfigure(2, weight=1) self.name = tk.StringVar() - self.chatter = Chatter(self, event_queue) + self.chatter = Chatter(self, event_queue, speaking_queue) self.chatter.grid(column=0, row=0, sticky="nsew") self.total_exp = tk.IntVar(value=0) @@ -345,12 +353,14 @@ def totals_frame(self): return frame def update_xpinf(self): + hero_id = settings.get_config_key('hero_id', cf='state.json') + with models.db() as session: total_exp, total_inf = session.query( func.sum(models.HeroStatEvent.xp_gain), func.sum(models.HeroStatEvent.inf_gain) ).where( - models.HeroStatEvent.hero_id == self.chatter.hero.id, + models.HeroStatEvent.hero_id == hero_id, models.HeroStatEvent.event_time >= self.start_time ).all()[0] # first (only) row @@ -363,16 +373,16 @@ def set_hero(self, *args, **kwargs): Invoked at init(), but also whenever the character changes (logout to character select) and more critically, every N seconds to refresh the graph. """ - log.debug(f'{self.chatter=}') - log.debug(f'set_hero({self.chatter.hero})') + hero = models.get_hero() if hasattr(self, "progress_chart"): - self.progress_chart.pack_forget() + self.progress_chart.grid_forget() self.total_exp.set(0) self.total_inf.set(0) try: self.update_xpinf() - self.progress_chart = ChartFrame(self, self.chatter.hero) - self.progress_chart.pack(side="top", fill="both", expand=True) + self.progress_chart = ChartFrame(self, hero) + self.progress_chart.grid(column=0, row=2, sticky="nsew") + # side="top", fill="both", expand=True) except Exception as err: log.error(err) diff --git a/src/cnv/tabs/configuration.py b/src/cnv/tabs/configuration.py index 5c785c7..e676fc8 100644 --- a/src/cnv/tabs/configuration.py +++ b/src/cnv/tabs/configuration.py @@ -16,6 +16,7 @@ class MasterVolume(ttk.Frame): This is for playback volume. """ + class SpokenLanguageSelection(ttk.Frame): """ The user gets to decide which language they want to hear. They may also @@ -219,7 +220,7 @@ def normalize_prompt_frame(self, parent, category): class ConfigurationTab(ctk.CTkFrame): - def __init__(self, parent, event_queue, *args, **kwargs): + def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) MasterVolume(self).pack(side="top", fill="x") diff --git a/src/cnv/tabs/voices.py b/src/cnv/tabs/voices.py index 9acb838..226649c 100644 --- a/src/cnv/tabs/voices.py +++ b/src/cnv/tabs/voices.py @@ -1,6 +1,4 @@ import logging -import tkinter as tk -from tkinter import ttk import customtkinter as ctk import cnv.database.models as models import cnv.voices.voice_editor as voice_editor @@ -9,7 +7,7 @@ class VoicesTab(ctk.CTkFrame): - def __init__(self, parent, event_queue, *args, **kwargs): + def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.detailside=None self.listside=None diff --git a/src/cnv/voices/voice_builder.py b/src/cnv/voices/voice_builder.py index f551f57..29fbeef 100644 --- a/src/cnv/voices/voice_builder.py +++ b/src/cnv/voices/voice_builder.py @@ -4,7 +4,8 @@ import cnv.database.models as models import cnv.effects.effects as effects -import cnv.engines.engines as engines +from cnv.engines.engines import get_engine +from cnv.engines.base import USE_SECONDARY import cnv.lib.settings as settings import cnv.lib.audio as audio import pyfiglet @@ -97,7 +98,7 @@ def create(character, message, session): try: if rank == 'secondary': - raise engines.USE_SECONDARY + raise USE_SECONDARY if save: sink = Distributor([ @@ -110,8 +111,8 @@ def create(character, message, session): ]) log.debug(f'Using engine: {character.engine}') - engines.get_engine(character.engine)(None, 'primary', name, category).say(message, effect_list, sink=sink) - except engines.USE_SECONDARY: + get_engine(character.engine)(None, 'primary', name, category).say(message, effect_list, sink=sink) + except USE_SECONDARY: rank = 'secondary' # our chosen engine for this character isn't working. So we're going to switch # to the secondary and use that for the rest of this session. @@ -143,12 +144,12 @@ def create(character, message, session): if character.engine_secondary: # use the secondary engine config defined for this character - engine_instance = engines.get_engine(character.engine_secondary) + engine_instance = get_engine(character.engine_secondary) engine_instance(None, 'secondary', name, category).say(message, effect_list, sink=sink) else: # use the global default secondary engine engine_name = settings.get_config_key(f"{character.category}_engine_secondary") - engine_instance = engines.get_engine(engine_name) + engine_instance = get_engine(engine_name) engine_instance(None, 'secondary', name, category).say(message, effect_list, sink=sink) if save: diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 0eec1c6..4cec4ac 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -3,19 +3,20 @@ import os import sys import tkinter as tk -from tkinter import font, ttk +from tkinter import ttk import customtkinter as ctk import voicebox from cnv.database import db, models from cnv.effects import effects from cnv.engines import engines +from cnv.engines.base import USE_SECONDARY from cnv.lib import audio, settings from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure from scipy.io import wavfile from sqlalchemy import delete, desc, select -from tkfeather import Feather +from cnv.lib.gui import Feather from translate import Translator from voicebox.sinks import Distributor, SoundDevice, WaveFile from voicebox.tts.utils import get_audio_from_wav_file @@ -284,13 +285,12 @@ def play_cache(self): ) ).all() else: - phrase_id = self.phrase_id[self.options.current()] - with models.db() as session: - all_phrases = session.scalars( - select(models.Phrases).where( - models.Phrases.id == phrase_id - ) - ).all() + phrase = models.get_or_create_phrase( + name=character.name, + category=character.category, + message=message + ) + all_phrases = [ phrase, ] for phrase in all_phrases: msg, is_translated = models.get_translated(phrase.id) @@ -348,14 +348,6 @@ def say_it(self, use_secondary=False): message=message ), ] - # phrase_id = self.phrase_id[self.options.current()] - # with models.db() as session: - # all_phrases = session.scalars( - # select(models.Phrases).where( - # models.Phrases.id == phrase_id - # ) - # ).all() - for phrase in all_phrases: log.debug(f'{phrase=}') @@ -381,10 +373,8 @@ def say_it(self, use_secondary=False): # None because we aren't attaching any widgets try: ttsengine(None, self.rank, name=character.name, category=character.category).say(msg, effect_list, sink=sink) - except engines.USE_SECONDARY: + except USE_SECONDARY: return - ENGINE_OVERRIDE[character.engine] = True - return self.say_it(use_secondary=True) self.show_wave(cachefile + ".wav") @@ -931,42 +921,10 @@ def __init__(self, parent, *args, **kwargs): self.parent = parent self.listside = None - self.trashcan = Feather("trash-2", size=24) - - #self.scrollable_frame = ctk.CTkScrollableFrame(self) - - #self.vsb = ctk.CTkScrollbar(self) - #self.vsb.pack(side="right", fill="y", expand=False) - - #self.canvas = tk.Canvas( - # self, - # borderwidth=0, - # background="#ffffff", - # yscrollcommand=self.vsb.set - # ) - # self.canvas.pack(side="left", fill="both", expand=True) - - # drag the scrollbar, see the canvas slide - #self.vsb.configure(command=self.canvas.yview) - - #self.canvas.xview_moveto(0) - #self.canvas.yview_moveto(0) - - # this is the scrollable thing - # self.frame = ttk.Frame(self.canvas) - # self.frame_id = self.canvas.create_window( - # (0, 0), window=self.frame, anchor="nw", - # tags="self.frame" - # ) - - # self.frame.bind("", self.onFrameConfigure) - # self.canvas.bind("", self.onCanvasConfigure) - - # style = ttk.Style() - # style.configure( - # "RemoveCharacter.TButton", - # width=1 - # ) + self.trashcan = Feather( + 'trash-2', + size=22 + ) # biography self.rowconfigure(0, weight=0) @@ -990,13 +948,11 @@ def __init__(self, parent, *args, **kwargs): biography, image=self.trashcan.icon, text="", - width=45, - #style="RemoveCharacter.TButton", + width=40, command=self.remove_character ).place(relx=1, rely=0, anchor='ne') biography.grid(column=0, row=0, sticky='nsew') - #.pack(side="top", fill="both", expand=True) engine_notebook = ctk.CTkTabview( self, From 7f6c8172ac90e888837412d16bee7dedb68f0f99 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 14 Jul 2024 11:40:39 -0700 Subject: [PATCH 22/32] image fix --- sidekick.ico | Bin 106675 -> 107185 bytes sidekick.xcf | Bin 0 -> 129178 bytes src/cnv/database/models.py | 2 +- src/cnv/lib/settings.py | 2 +- src/cnv/sidekick.py | 2 +- src/cnv/voices/voice_editor.py | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 sidekick.xcf diff --git a/sidekick.ico b/sidekick.ico index a5aabdc1ea5f5f81c3dc7349cc5bdc88c38e7772..ec824bdb00627426499080936e5ceeab82e91582 100644 GIT binary patch delta 106880 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 dc z^3u0n=Eax4{FXP$n7M<2e(3opINWZCk?U-@MoxN;fi`BDH1{g*fR0o2cKZa+`k zqJ<=XC{5keOy*O9fU?)ais(syWF!YJW70GPEiqD2JBuy~3<8m6v@^?m-k|b=qBmq| z9pk!T?mS7SOObV$k0zL4k$FNY1#U89Ey?MYDbsdAB|==rU<5s(nb->B4Ngg%lyUQp zcZk+8NOGhM7~8A>#f&9^HUtu-X}yd4?^MHo*;9QkUp&w4cMs7)v$Z=w<~6$~PqV)_ zFTX=+2vFjHy(r`+FR0JX6Zu=03 zn`x}0s%i+5*0=~#79FapS;>AA+^UaXE|;-_sVefKU^bsK=&doE%qYt)^VuA09Qc@s zv<7PorimlXgg~e4&^8SqF~*T58BNoFkR%z4#gaUWdX7XV%;yUdt!W!Wnno+MAj7@E zrbI=&K?Fe>KzTu9J>zAIOf$B3PY}8te*E$+=A)X67j`&zamaXan|E&PaeD;!o!sV; zhb}Watohd0UZ8fKk34>X3uhGN_QQPeLyw80is#Eje(5Kz;`@(r)ZhHz!_Nzia{Nv;ElTYy=_($RYsQZaRbx@qb4x;O%@^#rMy<~d^pi>9S+EoISx zBw=PPlVwdVG{d69!ZaLJQ~JFgy) zn+Ig&AWUyG-V2n{*wy-` zh>&ZvPVhe7)XS<~mG#6PJw$13RPqIs(ijtO(8vJz)y*qWTCrR%QA%Ndn%G)+A7UaD z;vS_YgT&gX5lE8g2ArlD@8JQ05aMkvMH~R~KH#Oq`G6NhBwojoS!hi_pmj>3(|hfU zQkpC)fGAyV+7{=cl(??zxPeGOmZn&12rH~YD}@jWts)hg=LJ3ldfgtz#6hOIt|^Kl zRyHg8FiBI4ag

UDae7X@;|oUZ+RBtkFUvh>0-I3Yxkh)hP(5eNB_Fr4)Yz+PY;h z95Oh$%R9&Wyz=Uc^gB5hPM)Ki^fjN!;MCZ{8 zyMT)qPO-MW#+@72ST7Rp8>Dplk}8$F@%A2z!-{o1<(Izj7^g1p^1FY3_%e59JIrjC zrLR!AVL4xtr8(32obF)2vaZR?B9{C_Q;xQ6$;+GoBxypmT!IkH<}-whW4c)uZ!ZMQ zW^?39vRl_RNs>@kRYbRyV!2$97X|a#j65%Bnuczti!~ltC5C%O5s~gwkwn9^211~& zDx{D!b%hH7h}eU54#a<|0ubPY#S2eh<^Km*@9=;zvA1vAhTt96vSh_G>x+f zF2qvX1=2iYG8vt;5>N00t%D4rw@O0JR7IiP?1o|W9|8q8z;Zi&*WeF z;0I7Y|Gxit=_6i!J@h`nf<26j$%*x1=(IbBdH!9$m} z$c`iP32nutwFSTa%g+(K*67>`HjL^Gey$nu<~wWMi!PYC0EKx&PW5@#%Vo>MRDxNRrRXq%QK z(Rk;`(u}rg&{`9mU+L!|O_JD83xTs1YkjoPYU(%{XbfqRU`D*uf+S6`*3vIKSZm1hjLCGuU^ryi z)O5QYM#o1aiAG4U)~x0dTx>rU(XKt*!!!ax_(&mIj~9OquRTIV)I*>UF5sP|)9eNqTnXN|eZZ}$Vnzg*k&E*u)%~`Y!22WO$w6lh8l2Iy& zX&Yv?M)f;b6_H_YJ>JE!REfe^4@x1VB1p&m_ir*BCM=JyvE47(TpyydggYYe($zhR zndOO-1Yb-)2+B$#UyvIjldfgt2`GVj*TIkrXuF(EC zmg`BAG*W&m6rUhi%oi~UG%+yiszxGN%omhpywTbfU^tyj81(w!eFVFt!aGk{cBq$2 z5~VS1O|_VjWhv3a+mj?3ok)ZNohguDT#LtHy~PE2Z=eVfgV{Sn6h)di-W&D3cpFG{ zjud|qr6pqZWr4Ue(BrA93h<~*W2_;_Kx?Ds!TG3lXq$#2 }5G)Y*@XJlDU+cac( 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%0D5!0iERX48CG?}eBN+2odw88rWr&j{jAT~a>?oW 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 IM6N<$f=`D)Gynhq delta 106318 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�ianKn9dfFm#T&FlY>$>+4Jgb801Ma9HmUamaWv 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�q_mqmm6 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�(YXiy-3$D?M}Ph5`TL)5s*bj9 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

IBi1D~e5QGfHRi-A%8)h$GzV^*`-u+s^OvlB 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 literal 0 HcmV?d00001 diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index c501bb5..d5ebb9e 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -596,7 +596,7 @@ def get_or_create_phrase(name, category, message): session.add(phrase) session.commit() - return phrase + return phrase def get_or_create_phrase_id(name, category, message): return get_or_create_phrase(name, category, message).id diff --git a/src/cnv/lib/settings.py b/src/cnv/lib/settings.py index c66c591..2e603d7 100644 --- a/src/cnv/lib/settings.py +++ b/src/cnv/lib/settings.py @@ -41,7 +41,7 @@ # to cache hit anyway. PERSIST_PLAYER_CHAT = True -REPLAY = True +REPLAY = False logging.basicConfig( level=LOGLEVEL, diff --git a/src/cnv/sidekick.py b/src/cnv/sidekick.py index 8bbb85a..1638650 100644 --- a/src/cnv/sidekick.py +++ b/src/cnv/sidekick.py @@ -138,7 +138,7 @@ def on_closing(): elif key == "SPOKE": # name, category = value log.debug('Refreshing character list...') - # voice.listside.refresh_character_list() + mtv.tabdict['Voices'].listside.refresh_character_list() elif key == "RECHARGED": log.debug(f'Power {value} has recharged.') if value in ["Hasten", "Domination"]: diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 4cec4ac..20f4da7 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -946,7 +946,7 @@ def __init__(self, parent, *args, **kwargs): ctk.CTkButton( biography, - image=self.trashcan.icon, + image=self.trashcan.CTkImage, text="", width=40, command=self.remove_character From f8f18ff80289aa754bc351c54c83b96ddb6958e8 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 27 Jul 2024 15:31:39 -0700 Subject: [PATCH 23/32] Damage panel to see your damage output per-power for this session --- all_npcs.json | 5219 +---------------- .../versions/937d6b648884_add_damage_table.py | 40 + src/cnv/chatlog/npc_chatter.py | 506 +- src/cnv/database/db.py | 16 +- src/cnv/database/models.py | 62 +- src/cnv/effects/effects.py | 230 +- src/cnv/engines/amazonpolly.py | 3 +- src/cnv/engines/azure.py | 2 +- src/cnv/engines/base.py | 37 +- src/cnv/engines/googlecloud.py | 2 +- src/cnv/engines/openai.py | 79 +- src/cnv/lib/proc.py | 67 + src/cnv/lib/settings.py | 15 +- src/cnv/logger.py | 12 +- src/cnv/sidekick.py | 56 +- src/cnv/tabs/character.py | 491 +- src/cnv/voices/voice_editor.py | 66 +- 17 files changed, 1171 insertions(+), 5732 deletions(-) create mode 100644 migrations/versions/937d6b648884_add_damage_table.py create mode 100644 src/cnv/lib/proc.py diff --git a/all_npcs.json b/all_npcs.json index 1e93f6b..0aa4997 100644 --- a/all_npcs.json +++ b/all_npcs.json @@ -1,12 +1,4 @@ { - "": { - "costumes": [ - "InvisibleActor" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "'Zoombie' Bomb Cadaver": { "costumes": [ "Lokling_Zoombie" @@ -15,14 +7,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "000TestMob": { - "costumes": [ - "Croatoa_Cabal_Beth_Jonsson_Summon" - ], - "description": "Ignore this entity.", - "gender": "GENDER_FEMALE", - "group_name": "Pets" - }, "3K Kelvin": { "costumes": [ "Model_3K_Kelvin" @@ -39,14 +23,6 @@ "gender": "GENDER_MALE", "group_name": "5thColumnEndgame" }, - "5th Costumes": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "7th Generation Paragon Protector": { "costumes": [ "CMR" @@ -88,14 +64,6 @@ "gender": "GENDER_MALE", "group_name": "???" }, - "A bomb": { - "costumes": [ - "Rikti_UXB" - ], - "description": "This is a Rikti bomb that has yet to go off.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "ACU": { "costumes": [ "Clockwork_Loyalist_BCU_01", @@ -198,30 +166,6 @@ "gender": "GENDER_MALE", "group_name": "Longbow" }, - "Acid Lake": { - "costumes": [ - "InvisibleActor" - ], - "description": "Protect the reactor core!", - "gender": "GENDER_MALE", - "group_name": "Devouring Earth" - }, - "Acid Mortar": { - "costumes": [ - "Mortar_Launcher" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Traps" - }, - "Acidic Decomposition": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Experimentation" - }, "Acolyte of War": { "costumes": [ "CoW_Acolyte_Of_War_01", @@ -310,30 +254,6 @@ "gender": "GENDER_MALE", "group_name": "Miscellaneous" }, - "Advanced Corner Terminal": { - "costumes": [ - "v_base_object" - ], - "description": "This advanced terminal can be used to monitor all the functions of base security. It increases the control generated by the base computer system.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Advanced Database": { - "costumes": [ - "v_base_object" - ], - "description": "This database contains detailed knowledge about the known powers of super-powered beings, allowing you to better adjust and control your base security system.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Advanced Difficulty": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Advanced Drone": { "costumes": [ "RiktiDrone_1", @@ -344,22 +264,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Advanced Forge": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, - "Advanced Holodisplay": { - "costumes": [ - "v_base_object" - ], - "description": "The Edge Holodisplay is a state-of-the-art 3D display for managing base security. It increases the control generated for the base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Advanced Oscillator": { "costumes": [ "Oscillator_00" @@ -368,22 +272,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Advanced Terminal": { - "costumes": [ - "v_base_object" - ], - "description": "This advanced terminal can be used to monitor all the functions of base security. It increases the control generated by the base computer system.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Advanced Worktable": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, "Aelia Domitia Paulina": { "costumes": [ "Roman_Peasant_Female_01", @@ -435,14 +323,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Aeon Capacitor": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Aeon Protector Drone": { "costumes": [ "Arachnos_Police_Drone_LT" @@ -483,14 +363,6 @@ "gender": "GENDER_FEMALE", "group_name": "5thColumn" }, - "After Effects": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Agamemnon": { "costumes": [ "Thug_Warrior_Boss_02" @@ -598,14 +470,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Air Bomb": { - "costumes": [ - "Puddle" - ], - "description": "This is a Fuel Air Bomb deployed by the Freedom Corps Cataphract", - "gender": "GENDER_MALE", - "group_name": "FreedomCorps" - }, "Air Defense Coordinator": { "costumes": [ "Sky_Raiders_01" @@ -624,14 +488,6 @@ "gender": "GENDER_MALE", "group_name": "CircleOfThorns" }, - "Airstrike": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Shivan_Tutorial" - }, "Ajax": { "costumes": [ "Model_Ajax" @@ -900,14 +756,6 @@ "gender": "GENDER_FEMALE", "group_name": "Cabal" }, - "Analyzer": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Anathema": { "costumes": [ "Lost_40", @@ -1001,30 +849,6 @@ "gender": "GENDER_MALE", "group_name": "CarnivalOfWar" }, - "Ancient Lockbox": { - "costumes": [ - "V_Dest_Artifact" - ], - "description": "This powerful artifact guards the girdle of Aphrodite. Its twisted magic keeps anyone pure of heart from opening it to retrieve the goddess's possession.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, - "Ancient Obelisk": { - "costumes": [ - "OO_DarkObelisk" - ], - "description": "This ancient obelisk gives off a dark, disturbing feeling, brimming with an untold amount of power.", - "gender": "GENDER_NEUTER", - "group_name": "Security" - }, - "Ancient Tablet": { - "costumes": [ - "OO_Ancient_Tablet_01" - ], - "description": "This mystical tablet pulses with power..", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Ancient Thorn Spectre": { "costumes": [ "Nerva_Spectral_Demon" @@ -1268,14 +1092,6 @@ "gender": "GENDER_MALE", "group_name": "Dr. Kane's Horrors" }, - "Animated Staff": { - "costumes": [ - "Animated_Staff" - ], - "description": "The Animus Arcana are magical spells or items brought to life by the very nature of Night Ward's tenuous dimensional connections. This magic staff was once wielded by a powerful magic-user - their aptitude has bled into the weapon itself, granting it a life of its own.", - "gender": "GENDER_MALE", - "group_name": "AnimusArcana" - }, "Animated Stone": { "costumes": [ "Stone" @@ -1597,38 +1413,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Anti-Matter Particles": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Clockwork" - }, - "Anti-Matter Reactor Core": { - "costumes": [ - "Reactor_Core" - ], - "description": "The Rikti have been known to utilize anti-matter reactors to power their greatest vehicles and constructs, but this is the first time you've seen one up close.

At this range, it would destroy the entire space station if it were to suddenly go critical...", - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, - "Antimatter Burst": { - "costumes": [ - "Puddle" - ], - "description": "Gotterdammerung strikes you with an Antimatter Burst!", - "gender": "GENDER_MALE", - "group_name": "SummerEvent" - }, - "Antimatter Pulse Generator": { - "costumes": [ - "Puddle" - ], - "description": "This entity generates the Antimatter Pulse found at Keyes Island.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Antistia Lucilla": { "costumes": [ "Roman_Peasant_Female_01", @@ -1695,14 +1479,6 @@ "gender": "GENDER_MALE", "group_name": "Time Gladiator" }, - "Apocalypse Beam Generator": { - "costumes": [ - "Puddle" - ], - "description": "This entity generates the Apocalypse Beam at the finale of Sig Arc 1 Episode 7", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Apparition": { "costumes": [ "Croatoa_Ghost_Male_Lieutenant_01", @@ -1924,14 +1700,6 @@ "gender": "GENDER_MALE", "group_name": "Miscellaneous" }, - "Arachnos Transport Transponder": { - "costumes": [ - "Puddle" - ], - "description": "Arachnos no doubt stole the technology for these drones from the Rikti. Or perhaps they bargained for it. Lord Recluse will deal with anyone who gives him what he wants.", - "gender": "GENDER_MALE", - "group_name": "ArachnosEndgame" - }, "Arakhn": { "costumes": [ "Council_Arakhn" @@ -2077,14 +1845,6 @@ "gender": "GENDER_MALE", "group_name": "Zombies" }, - "Armageddon": { - "costumes": [ - "Puddle" - ], - "description": "This object will use a power that sets all of its enemies healths to 10% of their current values.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Armiger": { "costumes": [ "BK_Armiger_01", @@ -2139,14 +1899,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Arrest Mode": { - "costumes": [ - "Puddle" - ], - "description": "The dreadnaughts tend to lead invasion forces due to their incredible resilience and destructive prowess. Wherever the Goliath War Walker goes, a trail of destruction is left in its wake.", - "gender": "GENDER_MALE", - "group_name": "PraetorianWarworksEndGame" - }, "Arria Fadilla": { "costumes": [ "Roman_Peasant_Female_01", @@ -2248,14 +2000,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Artillery Barrage": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Marauder" - }, "Ascendant": { "costumes": [ "Council_Ascendant" @@ -2280,14 +2024,6 @@ "gender": "GENDER_MALE", "group_name": "Council" }, - "Ash Fall": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "TalonsOfVengeanceEndgame" - }, "Ashling Corlett": { "costumes": [ "Model_Ashling_Corlett_NoRing" @@ -2439,14 +2175,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Astral Haruspex": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Astral Projection": { "costumes": [ "Awakened_Conduit_Female_01", @@ -2544,22 +2272,6 @@ "gender": "GENDER_FEMALE", "group_name": "Seers Essence" }, - "Augury Table": { - "costumes": [ - "v_base_object" - ], - "description": "From here orders can be sent and plans can be made. This table increases the amount of control available for your base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Aura of Decay": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BanishedPantheonEndgame" - }, "Aurelia Fadilla": { "costumes": [ "Roman_Peasant_Female_01", @@ -2593,22 +2305,6 @@ "gender": "GENDER_FEMALE", "group_name": "NewPraetorians" }, - "Aurora Portal Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity watches for the death of a portal in the script and then revokes phasing if a portal is closed.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Aurora's Mind": { - "costumes": [ - "Puddle" - ], - "description": "This entity plays an effect for players who are within Aurora's phase.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Australis Essence": { "costumes": [ "Incarnate_Pet_Wisp_01" @@ -2625,22 +2321,6 @@ "gender": "GENDER_MALE", "group_name": "MaltaEndgame" }, - "Auto-Doc": { - "costumes": [ - "v_base_object" - ], - "description": "P1441251038", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, - "Autonomous Expert System": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "This computer uses the latest experimental technology and software programming to create a true synthetic intelligence. While its function is limited to security systems, the processing ability of this AI is such that it can handle all but the most demanding base needs.", - "gender": "GENDER_MALE", - "group_name": "Control" - }, "Avalanche Shaman": { "costumes": [ "Pantheon_Witchdoctor_01", @@ -2796,14 +2476,6 @@ "gender": "GENDER_NEUTER", "group_name": "Positron" }, - "BAF Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "BCU": { "costumes": [ "Clockwork_Loyalist_BCU_01", @@ -2844,30 +2516,6 @@ "description": "", "gender": "GENDER_MALE", "group_name": "Skulls" - }, - "Backdraft": { - "costumes": [ - "Puddle" - ], - "description": "This building is going to explode!", - "gender": "GENDER_MALE", - "group_name": "Hellions" - }, - "Backup Capacitor": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Backup Copy": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "PraetorianIDF" }, "Bad Penny": { "costumes": [ @@ -3138,46 +2786,6 @@ "gender": "GENDER_MALE", "group_name": "ArachnosEndgame" }, - "Barrel": { - "costumes": [ - "OO_Swr_Drum_Barrel_01" - ], - "description": "The contents of this barrel must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Barrel - Sewer": { - "costumes": [ - "OO_Swr_Drum_Barrel_01" - ], - "description": "The contents of this barrel must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Barrel - Warehouse": { - "costumes": [ - "OO_Wrhs_Drum_Barrel_01" - ], - "description": "The contents of this barrel must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Barrels (Group)": { - "costumes": [ - "V_Mayhem_Barrel_Group_01" - ], - "description": "These barrels are used to carry volatile liquids.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Barrier Invocation": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "IncarnatePets" - }, "Baryon": { "costumes": [ "Antimatter_Minions" @@ -3186,54 +2794,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Base Portal": { - "costumes": [ - "Base Portal" - ], - "description": "This portal will transport you to the Ouroboros Headquarters.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, - "Basic Forge": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, - "Basic Generator": { - "costumes": [ - "v_base_object" - ], - "description": "The Haddon 1185 is a proven answer to base power needs. Economical to run, load-bank tested, and government-approved for Super Group use, its German engineered motor is smooth running and compact.", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, - "Basic Reclaimator": { - "costumes": [ - "v_base_object" - ], - "description": "The reclamator works like those at the hospitals, teleporting defeated members to the base and automatically healing them. Its healing is less efficient than a hospital's, but can be improved with auxiliary items.", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, - "Basic Telepad": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Teleport" - }, - "Basic Worktable": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, "Basilisk": { "costumes": [ "Chimera_Minions_01" @@ -3308,398 +2868,6 @@ "gender": "GENDER_MALE", "group_name": "NewPraetorians" }, - "Beacon Aleph Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Aleph Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Atlas Park": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Atlas Park.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Beth Point": { - "costumes": [ - "v_base_object" - ], - "description": "Arcane Teleport", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Boomtown": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Boomtown.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Brickstown": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Brickstown.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Cap au Diable": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Cap au Diable.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Cascade Archipelago": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Cascade Archipelago", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Crey's Folly": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Crey's Folly.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Croatoa": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Croatoa.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Daleth Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Daleth Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Dark Astoria": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Dark Astoria.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Echo: Dark Astoria": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Echo: Dark Astoria.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Eden": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Eden.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Faultline": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Faultline.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Firebase Zulu": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Firebase Zulu", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon First Ward": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to First Ward.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Founders Falls": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Founders Falls.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Gimel Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Gimel Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Grandville": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Grandville.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Heth Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Heth Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Imperial City": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Imperial City", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Independence Port": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Independence Port.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Kallisti Wharf": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Kallisti Wharf.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Kaph Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Kaph Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Kings Row": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Kings Row.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Lamedh Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Lamedh Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Mercy Island": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Mercy Island.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Nerva": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to the Nerva Archipelago.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Neutropolis": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Neutropolis", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Night Ward": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Night Ward.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Nova Praetoria": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Nova Praetoria", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Peregrine Island": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Peregrine Island.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Perez Park": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Perez Park.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Pocket D": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Pocket D.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Port Oakes": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Port Oakes.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Rikti War Zone": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to the Rikti War Zone.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Samekh Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Samekh Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Sharkhead": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Sharkhead.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Skyway City": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Skyway City.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon St. Martial": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to St. Martial.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Steel Canyon": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Steel Canyon.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Striga": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Striga.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Talos Island": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Talos Island.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Terra Volta": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Terra Volta.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon The Chantry": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to The Chantry", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon The Hollows": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to The Hollows.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon The Storm Palace": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to The Storm Palace", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Yodh Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Yodh Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "Beacon Zayin Point": { - "costumes": [ - "v_base_object" - ], - "description": "Teleport Beacon to Zayin Point.", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, "Beastly Keetsoth": { "costumes": [ "Thug_Prisoner_01", @@ -4053,14 +3221,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Biological Warfare": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, "Biopulse": { "costumes": [ "Model_Biopulse" @@ -4091,14 +3251,6 @@ "gender": "GENDER_MALE", "group_name": "BlackKnightsEndgame" }, - "Black Hole Bomb": { - "costumes": [ - "Romans_Romulus_BlackholeBomb" - ], - "description": "How are you reading this? Well, now that you're here, this is a Black Hole Bomb, it'll explode; Oh yes.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Black Queen": { "costumes": [ "Model_Black_Queen" @@ -4244,14 +3396,6 @@ "gender": "GENDER_MALE", "group_name": "TheDestroyers" }, - "Blast Radius": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Blaze of Flame": { "costumes": [ "Legacy_of_Flame_Lieutenant_01", @@ -4285,14 +3429,6 @@ "gender": "GENDER_MALE", "group_name": "Infected" }, - "Blind": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Blind Makwa": { "costumes": [ "Model_Blind_Makwa_NoRing" @@ -4317,14 +3453,6 @@ "gender": "GENDER_MALE", "group_name": "RogueArachnos" }, - "Blizzard": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malaise" - }, "Blizzard Thorn Caster": { "costumes": [ "CoT_StormCaster_01", @@ -4541,14 +3669,6 @@ "gender": "GENDER_MALE", "group_name": "Container" }, - "Bomb Power Entity": { - "costumes": [ - "Puddle" - ], - "description": "This entity creates the bomb power", - "gender": "GENDER_MALE", - "group_name": "Hellions" - }, "Bombardier": { "costumes": [ "Goldbrickers_Lt" @@ -4597,30 +3717,6 @@ "gender": "GENDER_MALE", "group_name": "Skulls" }, - "Bonfire": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Fire Control" - }, - "Bookcase": { - "costumes": [ - "OO_Wrhs_Bookcase_01" - ], - "description": "These books need to be destroyed.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Bookshelf": { - "costumes": [ - "v_base_object" - ], - "description": "Information is key to defeating your enemies. This bookshelf generates additional control for your defenses.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Boomer": { "costumes": [ "Goldbrickers_Boss" @@ -4629,14 +3725,6 @@ "gender": "GENDER_MALE", "group_name": "GoldBrickers" }, - "Boon of Death": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "TalonsOfVengeanceEndgame" - }, "Booster Rocket": { "costumes": [ "Giant_Robot_Jets" @@ -4850,14 +3938,6 @@ "gender": "GENDER_MALE", "group_name": "Outcasts" }, - "Brief Capacitor": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Briefcase": { "costumes": [ "briefcase_explode" @@ -4892,14 +3972,6 @@ "gender": "GENDER_MALE", "group_name": "Freedom Corps" }, - "Brilliance": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "CarnivalOfLight" - }, "Bronze Bird": { "costumes": [ "HC_Bronze_Bird" @@ -5013,14 +4085,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Buffer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Bugged Out Freak Psycho": { "costumes": [ "FRKLK_LostC" @@ -5129,22 +4193,6 @@ "gender": "GENDER_MALE", "group_name": "Pets" }, - "Bunker Door": { - "costumes": [ - "P_Tnl_A_Door" - ], - "description": "These reinforced doors seal off Antimatter's bunkers and must be breached if there is any hope of getting at the reserve Power Cells located within.", - "gender": "GENDER_NEUTER", - "group_name": "Equipment" - }, - "Burn": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Praetorians" - }, "Burn Protector": { "costumes": [ "Model_Crey_Fire_Protector_01" @@ -5191,22 +4239,6 @@ "gender": "GENDER_FEMALE", "group_name": "Awakened" }, - "Burst of Speed": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Martial Manipulation" - }, - "Bus Stop": { - "costumes": [ - "bustop" - ], - "description": "This bus stop is covered so passengers can get in out of the weather.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Business - Female": { "costumes": [ "FemaleNPC_01", @@ -5723,14 +4755,6 @@ "gender": "GENDER_MALE", "group_name": "Trolls" }, - "Caltrops": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Devices" - }, "Calvin Scott": { "costumes": [ "Calvin_Scott_01_NoRing" @@ -5953,29 +4977,6 @@ "gender": "GENDER_FEMALE", "group_name": "Longbow" }, - "Caption Manager": { - "costumes": [ - "Puddle" - ], - "description": "This entity can be used in conjunction with a HealthObjective script to allow up to 7 captions to be played in a zone event.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Car": { - "costumes": [ - "V_Mayhem_Vehicle_Car01", - "V_Mayhem_Vehicle_Car01b", - "V_Mayhem_Vehicle_Car02", - "V_Mayhem_Vehicle_Car02b", - "V_Mayhem_Vehicle_Car03", - "V_Mayhem_Vehicle_Car03b", - "V_Mayhem_Vehicle_Car04", - "V_Mayhem_Vehicle_Car04b" - ], - "description": "This car looks like it's of sturdy construction.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Carabineir": { "costumes": [ "Nemesis_Soldier_01" @@ -5984,14 +4985,6 @@ "gender": "GENDER_MALE", "group_name": "Nemesis" }, - "Cardboard Box": { - "costumes": [ - "CardBoardBox_LG" - ], - "description": "A flimsy cardboard box.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Carl": { "costumes": [ "Citizen_Biz_Male_01", @@ -6190,14 +5183,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civilian" }, - "Carrion Creeper Patch": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Plant Control" - }, "Carrion Creeper Vine": { "costumes": [ "Pet_CreeperVine" @@ -6446,30 +5431,6 @@ "gender": "GENDER_MALE", "group_name": "CarnivalOfWar" }, - "Chain Confuse Jump 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, - "Chain Confuse Jump 2": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, - "Chain Confuse Jump 3": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, "Chain Gun": { "costumes": [ "Base_ChainGun" @@ -6486,70 +5447,6 @@ "gender": "GENDER_MALE", "group_name": "Turrets" }, - "Chain Induction Jump 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Chain Induction Jump 2": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Chain Induction Jump 3": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Chain Induction Jump 4": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Chain Jolt Jump 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, - "Chain Jolt Jump 2": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, - "Chain Jolt Jump 3": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, - "Chain Stun Jump 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, "Chain-Gun Turret": { "costumes": [ "Pet_Turret" @@ -6685,22 +5582,6 @@ "gender": "GENDER_FEMALE", "group_name": "Awakened" }, - "Charge Cone": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "You charge from your current location to your foe." - }, - "Charge Impact": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "You charge from your current location to your foe." - }, "Charles": { "costumes": [ "Citizen_Biz_Male_01", @@ -6774,14 +5655,6 @@ "gender": "GENDER_MALE", "group_name": "Independent" }, - "Chaser": { - "costumes": [ - "V_Dest_Chaser" - ], - "description": "This Longbow Chaser air-skiff is down for repair, rearming, and refueling. If someone were to attack it now, it could explode.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Chasseur": { "costumes": [ "Nemesis_Soldier_01" @@ -6798,22 +5671,6 @@ "gender": "GENDER_MALE", "group_name": "Nemesis" }, - "Chemical Burn": { - "costumes": [ - "Puddle" - ], - "description": "The most advanced of the Warworks shock troopers. These towering androids carry a heavy PGMP Rifle that is capable of delivering a deadly neurotoxin payload.", - "gender": "GENDER_MALE", - "group_name": "PraetorianDUSTIDFEndgame" - }, - "Chemical Grenade": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "NewPraetorians" - }, "Chemical Set": { "costumes": [ "OO_All_Chemical_set_01" @@ -6822,14 +5679,6 @@ "gender": "GENDER_MALE", "group_name": "Skulls" }, - "Chemical Warfare": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, "Chemical set": { "costumes": [ "OO_All_Chemical_set_01" @@ -7382,14 +6231,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Circuit Breaker": { - "costumes": [ - "v_base_object" - ], - "description": "By providing improved failsafes, the Rook Industries Circuit Breaker increases power output from all generator models.", - "gender": "GENDER_MALE", - "group_name": "EnergyAux" - }, "Citadel": { "costumes": [ "Model_Bastion_NoRing" @@ -7741,14 +6582,6 @@ "gender": "GENDER_MALE", "group_name": "Promethean Flame" }, - "Clear Pets Marker": { - "costumes": [ - "Puddle" - ], - "description": "This entity stays around until told to die.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Cleopatra": { "costumes": [ "Model_Cleopatra_NoRing" @@ -7757,14 +6590,6 @@ "gender": "GENDER_FEMALE", "group_name": "Powers Division" }, - "Clerk Desk": { - "costumes": [ - "v_base_object" - ], - "description": "From here orders can be sent and plans can be made. This desk increases the amount of control available for your base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Clockwork Construction": { "costumes": [ "Build_Diesel" @@ -8014,14 +6839,6 @@ "gender": "GENDER_MALE", "group_name": "Rogue Isles Villains" }, - "Combat Logs": { - "costumes": [ - "v_base_object" - ], - "description": "P4000216247", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, "Commander": { "costumes": [ "IDF_Commander_01" @@ -8056,30 +6873,6 @@ "gender": "GENDER_MALE", "group_name": "Arachnos" }, - "Computer": { - "costumes": [ - "OO_Tek1_Computer_01" - ], - "description": "This computer controls the base's security system, and must be destroyed.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Computer - Grey": { - "costumes": [ - "OO_Council_Computer_01" - ], - "description": "This computer must be destroyed.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Computer - White": { - "costumes": [ - "OO_Tek1_Computer_01" - ], - "description": "This computer must be destroyed.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Comrade": { "costumes": [ "RogueIslesVillains_Comrade" @@ -8128,14 +6921,6 @@ "gender": "GENDER_MALE", "group_name": "Shadow Shard Reflections" }, - "Conservator": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Consigliere": { "costumes": [ "Family_Boss_01", @@ -8157,46 +6942,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Consuming Burst": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique strikes you with a Consuming Burst!", - "gender": "GENDER_MALE", - "group_name": "Diabolique" - }, - "Contagion": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malignant_Infection" - }, - "Containment Chamber": { - "costumes": [ - "P_BioCapsule" - ], - "description": "These high-tech Containment Chambers provide a high-tech experimental platform for growing the IDF super soldiers.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Containment System Terminal": { - "costumes": [ - "KI_Reactor_Interface_01" - ], - "description": "These terminals are the main control system for the magnetic field which contains the antimatter reaction within the Keyes Island reactors. In order to maintain the magnetic field, these terminals need to have their field generation cells refreshed periodically.", - "gender": "GENDER_NEUTER", - "group_name": "Keyes Island Reactor" - }, - "Containment Unit": { - "costumes": [ - "FX_AbandonedTechContainment" - ], - "description": "Temp object for FX Texting", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Contaminated Brawler": { "costumes": [ "Thug_Contaminated_01", @@ -8303,14 +7048,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarthSeed" }, - "Contemplation Charts": { - "costumes": [ - "v_base_object" - ], - "description": "These contemplation charts will help prepare you for the worst kinds of combat situations. Here you can get Break Free Inspirations. Rent: 100 Prestige", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, "Contemptress": { "costumes": [ "FemaleNPC_Villain_01", @@ -8348,14 +7085,6 @@ "gender": "GENDER_MALE", "group_name": "Hired Civilians" }, - "Control Rune": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Controller Hero": { "costumes": [ "Model_Dr_Advance" @@ -8380,14 +7109,6 @@ "gender": "GENDER_MALE", "group_name": "TsooEndgame" }, - "Coral Empowerment Granter": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Coral Sentinel": { "costumes": [ "Coralax_Guardian_Boss" @@ -8475,14 +7196,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Corner Terminal": { - "costumes": [ - "v_base_object" - ], - "description": "This terminal monitors many of the functions of base security and increases the control generated by the base computer system.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Cornila Clara": { "costumes": [ "Roman_Peasant_Female_01", @@ -8508,14 +7221,6 @@ "gender": "GENDER_MALE", "group_name": "Time Gladiator" }, - "Corporeal Shard": { - "costumes": [ - "v_destsoulcryst_soul_crystal" - ], - "description": "Light reflects off the surface of these mysterious ice crystals in an unnatural way, it is almost as if they are sucking the heat out of the air itself. Lord Winter draws his tremendous power from these Ice Shards making him nearly indestructible. This particular crystal appear susceptible to lethal and smashing attacks.", - "gender": "GENDER_MALE", - "group_name": "Winter Horde" - }, "Correctional Officer": { "costumes": [ "Villain_Security_Guard_01", @@ -8529,14 +7234,6 @@ "gender": "GENDER_MALE", "group_name": "Prison Guard" }, - "Corrosive Puddle": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Experimentation" - }, "Corruptor": { "costumes": [ "Longbow_Male_Boss_01" @@ -8717,14 +7414,6 @@ "gender": "GENDER_MALE", "group_name": "Council" }, - "Counter": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Counter Spell": { "costumes": [ "Counter_Spell" @@ -8837,39 +7526,6 @@ "gender": "GENDER_MALE", "group_name": "D.O.O.M." }, - "Crate": { - "costumes": [ - "OO_Tek1_Crate_01" - ], - "description": "The contents of this crate must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Crate - Metal": { - "costumes": [ - "OO_Wrhs_Crate_01" - ], - "description": "The contents of this crate must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Crate - Tech": { - "costumes": [ - "OO_Tek1_Crate_01" - ], - "description": "The contents of this crate must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Crate - Wood": { - "costumes": [ - "V_Destructible_Props_Crate_01", - "V_Destructible_Props_Crate_02" - ], - "description": "This is a large cargo crate you used to sneak onto the Sky Raider ship. Blast your way out of here!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Crazed Bruiser": { "costumes": [ "MaleNPC_Villain_01", @@ -9362,14 +8018,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianResistance" }, - "Crowd Dispersal": { - "costumes": [ - "Puddle" - ], - "description": "The dreadnaughts tend to lead invasion forces due to their incredible resilience and destructive prowess. Wherever the Goliath War Walker goes, a trail of destruction is left in its wake.", - "gender": "GENDER_MALE", - "group_name": "PraetorianWarworksEndGame" - }, "Crusher": { "costumes": [ "Destroyer_Minion_Male_01", @@ -9418,14 +8066,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Crystal Focus": { - "costumes": [ - "v_base_object" - ], - "description": "The binding spells of this Crystal Focus increase the power output of energy devices.", - "gender": "GENDER_MALE", - "group_name": "EnergyAux" - }, "Crystal Shard": { "costumes": [ "Crystal_Titan_Mini" @@ -9442,46 +8082,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Crystal Ward": { - "costumes": [ - "v_base_object" - ], - "description": "The circle of protection is a standard part of any mystic's repertoire. This one is meant to contain and protect the power within.", - "gender": "GENDER_MALE", - "group_name": "EnergyAux" - }, - "Crystal of Corruption": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Crystal of Corruption" - }, - "Crystal of Health": { - "costumes": [ - "AE_CoT_Crystal_Health" - ], - "description": "These Green Crystals seem to resonate with a strange power.", - "gender": "GENDER_MALE", - "group_name": "CircleOfThornsEndgame" - }, - "Crystal of Pain": { - "costumes": [ - "AE_CoT_Crystal_Damage" - ], - "description": "These Red Crystals seem to resonate with a strange power.", - "gender": "GENDER_MALE", - "group_name": "CircleOfThornsEndgame" - }, - "Crystal of Vitality": { - "costumes": [ - "AE_CoT_Crystal_Endurance" - ], - "description": "These Blue Crystals seem to resonate with a strange power.", - "gender": "GENDER_MALE", - "group_name": "CircleOfThornsEndgame" - }, "Cuirasseur": { "costumes": [ "Nemesis_Soldier_16", @@ -9516,14 +8116,6 @@ "gender": "GENDER_FEMALE", "group_name": "Paragon Heroes" }, - "Cutscene Power Target": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Cyclone Elemental": { "costumes": [ "Rularuu_Storm_03" @@ -9605,14 +8197,6 @@ "gender": "GENDER_MALE", "group_name": "GenericHeroes" }, - "Cytoplasm": { - "costumes": [ - "Tentacle_Death" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DevouringEarth" - }, "D.U.S.T. Leader": { "costumes": [ "Praetorian_DUST_Leader_01", @@ -9643,14 +8227,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianDUSTIDFEndgame" }, - "DD Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "DPS Dummy": { "costumes": [ "Male_TargetDummy150" @@ -9675,14 +8251,6 @@ "gender": "GENDER_MALE", "group_name": "NPC_Pets" }, - "Damage Booster": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Damned": { "costumes": [ "Thug_Hellion_Boss_01", @@ -9703,22 +8271,6 @@ "gender": "GENDER_MALE", "group_name": "Hellions" }, - "Dampening Field": { - "costumes": [ - "Puddle" - ], - "description": "This reinforced door stands between you and freedom! The door seems to be vibrate and hum, as though it is coursing with energy.", - "gender": "GENDER_MALE", - "group_name": "5thColumnEndgame" - }, - "Dampening Ray": { - "costumes": [ - "v_base_object" - ], - "description": "The beam from this crystal weakens enemies, reducing the damage they can cause for a short duration.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Dana Habashy": { "costumes": [ "Model_Dana_Habashy" @@ -9875,14 +8427,6 @@ "gender": "GENDER_MALE", "group_name": "Dark Fiends" }, - "Dark Fog": { - "costumes": [ - "Croatoa_Dark_Fog" - ], - "description": "This strange fog seems to roam Croatoa with little purpose or predictability. Heroes who find themselves trapped within it have reported a variety of harmful effects.", - "gender": "GENDER_MALE", - "group_name": "CroatoaGhosts" - }, "Dark Nova": { "costumes": [ "Kheldian_NPC_Nova_Warshade" @@ -9925,14 +8469,6 @@ "gender": "GENDER_MALE", "group_name": "Rogue Isles Villains" }, - "Dark Servant": { - "costumes": [ - "Dark_Servant" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Dark Miasma" - }, "Dark Spectre": { "costumes": [ "MaleNPC_Villain_Ghost_01", @@ -10030,14 +8566,6 @@ "gender": "GENDER_MALE", "group_name": "Ex-Midnighter" }, - "Database": { - "costumes": [ - "v_base_object" - ], - "description": "This database contains detailed knowledge about the known powers of super-powered beings, allowing you to better adjust and control your base security system.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "David": { "costumes": [ "Citizen_Biz_Male_01", @@ -10166,14 +8694,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Death Cry": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "CircleOfThornsEndgame" - }, "Death Doll": { "costumes": [ "Thug_Skull_Female_Death_01", @@ -10191,14 +8711,6 @@ "gender": "GENDER_FEMALE", "group_name": "Skulls" }, - "Death Field": { - "costumes": [ - "Puddle" - ], - "description": "This reinforced door stands between you and freedom! The door seems to be vibrate and hum, as though it is coursing with energy.", - "gender": "GENDER_MALE", - "group_name": "5thColumn" - }, "Death Head Buckshot": { "costumes": [ "Thug_Skull_Male_Lieutenant_01", @@ -10320,14 +8832,6 @@ "gender": "GENDER_MALE", "group_name": "Skulls" }, - "Death's Embrace": { - "costumes": [ - "Puddle" - ], - "description": "Embrace your death!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Death's Head": { "costumes": [ "Tsoo_11" @@ -10482,22 +8986,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civilian" }, - "Debt Protector": { - "costumes": [ - "Puddle" - ], - "description": "Aaaaaaaaaaaaah!!!!!!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Debuffer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Decaying Luminous Eidolon": { "costumes": [ "Eidola_Decaying_Male_01", @@ -10536,14 +9024,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civic Squad" }, - "Decomposing Field": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "DevouringEarthSeed" - }, "Decomposing Luminous Eidolon": { "costumes": [ "Eidola_Decaying_Female_01", @@ -10716,14 +9196,6 @@ "gender": "GENDER_MALE", "group_name": "Longbow" }, - "Defenders Bastion": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Defender's Bastion" - }, "Defense Turret": { "costumes": [ "V_Antimatter_TurretB" @@ -10797,14 +9269,6 @@ "gender": "GENDER_MALE", "group_name": "Forlorn" }, - "Demolition Charge": { - "costumes": [ - "P_Tnl_IDF_Explosive" - ], - "description": "This demolition charge packs a whallop. It has a self-arming mechanism which can be disabled if destroyed before it complete the arming cycle.", - "gender": "GENDER_MALE", - "group_name": "Explosive" - }, "Demolition Companion": { "costumes": [ "Clockwork_Neuron_Boss_01", @@ -10944,14 +9408,6 @@ "gender": "GENDER_FEMALE", "group_name": "RoguesGallery" }, - "Desdemona's Blessing": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Desiccated Chamber": { "costumes": [ "Pantheon_Hull_01", @@ -10981,14 +9437,6 @@ "gender": "GENDER_MALE", "group_name": "The Dominion" }, - "Desk": { - "costumes": [ - "OO_Wrhs_Desk_01" - ], - "description": "The contents of this desk are valuable.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Destroyer": { "costumes": [ "Thug_12", @@ -11033,54 +9481,6 @@ "gender": "GENDER_MALE", "group_name": "PPD" }, - "Detonating Seedling": { - "costumes": [ - "Burr_Hamidon_NoShell_01" - ], - "description": "The continual barrage by IDF explosive devices within the tunnels has led to a rapid adaptation by the Devouring Earth, resulting in these little \"bomblings\". They will detonate very quickly after they are generated from the biomass within the Avatar's chamber.", - "gender": "GENDER_MALE", - "group_name": "Explosive" - }, - "Devastation Beam Attractor": { - "costumes": [ - "Puddle" - ], - "description": "This entity activates the Devastation Beam's hold of players in the field of effect.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Devastation Beam Generator": { - "costumes": [ - "Puddle" - ], - "description": "This entity generates the Devastation Beam found at Keyes Island.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Devastation Beam Graphic": { - "costumes": [ - "Puddle" - ], - "description": "This entity creates the Devastation Beam's visual effect.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Devastation Beam Mass Driver": { - "costumes": [ - "Puddle" - ], - "description": "This entity activates the Devastation Beam's damage to players in the field of effect.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Devastation Beam Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Developing Clone": { "costumes": [ "Model_Cole_Clone_Float" @@ -11111,14 +9511,6 @@ "gender": "GENDER_FEMALE", "group_name": "TalonsOfVengeance" }, - "Devoured Crystal Energy Trap": { - "costumes": [ - "Destructible_container_WilloftheEarth" - ], - "description": "This crystal channels a powerful energy that is tuned to the psychic link between Desdemona and Vanessa DeVore. As long as the crystals remain active, Desdemona is in peril.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Devoured Pyriss": { "costumes": [ "Devoured_Pyriss" @@ -11127,30 +9519,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Devouring Earth Hive Spire": { - "costumes": [ - "V_Dest_spire01_100" - ], - "description": "These strange living spires are somehow connected with Hamidon's ability to control the creatures of the Devouring Earth.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Devouring Earth Spore Cloud": { - "costumes": [ - "Puddle" - ], - "description": "This entity affects players with the spore clouds that infest the circular tunnel sections of the Underground", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Devouring Earth Trapper": { - "costumes": [ - "Puddle" - ], - "description": "This entity serves to pulse the \"trap\" which Desdemona becomes ensnared in during the Underground event.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Devouring Swarm": { "costumes": [ "Devouring_Swarm" @@ -11273,14 +9641,6 @@ "gender": "GENDER_MALE", "group_name": "DJ Zero" }, - "Dimensional Vortex": { - "costumes": [ - "v_base_object" - ], - "description": "Impressive bases require immense power. The dimensional vortex opens a gateway to a realm of pure energy which can be carefully tapped to accommodate your base needs.", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, "Diminuendo of the Soul": { "costumes": [ "DarknessControl_Shade" @@ -11305,14 +9665,6 @@ "gender": "GENDER_MALE", "group_name": "Malta" }, - "Disable Ouroboros Portals": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Disciple": { "costumes": [ "Talons_Devotee_01", @@ -11324,14 +9676,6 @@ "gender": "GENDER_FEMALE", "group_name": "TalonsOfVengeanceEndgame" }, - "Discordant Spores Generator": { - "costumes": [ - "Puddle" - ], - "description": "This entity generates the Discordant Spores found in the Avatar's chamber.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Diseased Abomination": { "costumes": [ "Slab_01", @@ -11343,14 +9687,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "Disintegration Spread": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Beam Rifle" - }, "Dismantler": { "costumes": [ "Clockwork_Loyalist_Boss_01", @@ -11401,14 +9737,6 @@ "gender": "GENDER_MALE", "group_name": "Container" }, - "Disruptor Pylon": { - "costumes": [ - "Raid_Pylon" - ], - "description": "These devices create an instability in the interdimensional field. When several are operating at once you can redirect an Item of Power to your base.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Dissident": { "costumes": [ "Citizen_Biz_Male_01", @@ -11482,14 +9810,6 @@ "gender": "GENDER_MALE", "group_name": "CapauDiableDemons" }, - "Distortion Field": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Time Manipulation" - }, "Diviner": { "costumes": [ "Seer_Diviner_01" @@ -11594,14 +9914,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Doctor Disguise": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Doctor Geist": { "costumes": [ "Model_Dr_Geist" @@ -11618,14 +9930,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Does Nothing": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Dog of War": { "costumes": [ "Ghoul_Rehab_Soldier_01", @@ -11788,25 +10092,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Donated Food": { - "costumes": [ - "OO_Wrhs_Crate_01", - "V_Destructible_Props_Crate_01", - "V_Destructible_Props_Crate_02" - ], - "description": "This crate is full of food that the concerned citizens of Paragon City have donated to the poor of the Rogue Isles.", - "gender": "GENDER_MALE", - "group_name": "Donations" - }, - "Donated School Books": { - "costumes": [ - "OO_Wrhs_Bookcase_01", - "OO_Wrhs_Crate_01" - ], - "description": "These school books were donated to the poor of the Rogue Isles by the concerned citizens of Paragon City.", - "gender": "GENDER_MALE", - "group_name": "Donations" - }, "Donna": { "costumes": [ "Citizen_Biz_Fem_01", @@ -11877,14 +10162,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianShepherds" }, - "Door": { - "costumes": [ - "P_Tnl_A_Door" - ], - "description": "This door is currently locked.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Dorjan, Counsel to Romulus": { "costumes": [ "Roman_Dorjan_Nictus" @@ -12318,22 +10595,6 @@ "gender": "GENDER_MALE", "group_name": "Nemesis" }, - "Drawdown Terminal": { - "costumes": [ - "KI_Reactor_Interface_01" - ], - "description": "These terminals are used to control Antimatter's drawdown of the reactor's energy. Destroying them will shutdown the connection and render him vulnerable again.", - "gender": "GENDER_NEUTER", - "group_name": "Equipment" - }, - "Dread Servant": { - "costumes": [ - "Dark_Servant" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, "Dread Templar": { "costumes": [ "BK_DreadTemplar_01", @@ -12386,14 +10647,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Drop Ship": { - "costumes": [ - "rikti_shuttle" - ], - "description": "A Rikti Transport ship. It also serves effectively as a Bomber.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Drusilla Longina": { "costumes": [ "Roman_Peasant_Female_01", @@ -12502,23 +10755,6 @@ "gender": "GENDER_MALE", "group_name": "Hellions" }, - "Dumpster": { - "costumes": [ - "Dumpster_Full", - "Dumpster_Empty" - ], - "description": "This is a typical garbage dumpster.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Dust Devil": { - "costumes": [ - "Pet_NoCollision" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Scirocco" - }, "EJ": { "costumes": [ "Thug_Prisoner_01", @@ -12546,14 +10782,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "EM Pulse": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electrical Armor" - }, "Eagle Blue Ink Man": { "costumes": [ "Inkman_Blue_01", @@ -12575,14 +10803,6 @@ "gender": "GENDER_MALE", "group_name": "Tsoo" }, - "Eagle Eye": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Eagle Red Ink Man": { "costumes": [ "Inkman_05", @@ -12611,22 +10831,6 @@ "gender": "GENDER_MALE", "group_name": "CircleOfThorns" }, - "Earthquake": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Earth Control" - }, - "Earthshatter": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique causes an Earthshatter at your location!", - "gender": "GENDER_MALE", - "group_name": "Diabolique" - }, "Echidna": { "costumes": [ "Model_Echidna" @@ -13141,14 +11345,6 @@ "gender": "GENDER_FEMALE", "group_name": "BanishedPantheonEndgame" }, - "Electrical Explosion": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "5thColumn" - }, "Electrixie": { "costumes": [ "Model_Electrixie" @@ -13186,14 +11382,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Elemental Shard": { - "costumes": [ - "v_destsoulcryst_soul_crystal" - ], - "description": "Light reflects off the surface of these mysterious ice crystals in an unnatural way, it is almost as if they are sucking the heat out of the air itself. Lord Winter draws his tremendous power from these Ice Shards making him nearly indestructible. This particular crystal appear susceptible to fire and cold attacks.", - "gender": "GENDER_MALE", - "group_name": "Winter Horde" - }, "Elementalist": { "costumes": [ "Talons_Spiritualist_Fire_01", @@ -13204,14 +11392,6 @@ "gender": "GENDER_FEMALE", "group_name": "TalonsOfVengeanceEndgame" }, - "Elite Analyzer": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Elite Automaton": { "costumes": [ "MaleAutomaton_01", @@ -13259,30 +11439,6 @@ "gender": "GENDER_MALE", "group_name": "Defense" }, - "Elite Dampening Ray": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Elite Energy Beam": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Elite Igniter": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Elite Quantum Agent": { "costumes": [ "Crey_Agent_M_02", @@ -13361,14 +11517,6 @@ "gender": "GENDER_FEMALE", "group_name": "PraetorianResistance" }, - "Elite Sapper": { - "costumes": [ - "v_base_object" - ], - "description": "The Eye of Despair saps the will of enemies, sucking ambition and drive from their very souls. Each hit drains Endurance from the target.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Elite Scanner": { "costumes": [ "Base_EliteScanner" @@ -13487,14 +11635,6 @@ "gender": "GENDER_FEMALE", "group_name": "Nictus" }, - "Embalmed": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Embalmed Abomination": { "costumes": [ "Cylok_Slab_01", @@ -13545,30 +11685,6 @@ "gender": "GENDER_MALE", "group_name": "Nictus" }, - "Emergence": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Apparitions" - }, - "Emergency Capacitor": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Emergency Level Pacification Device": { - "costumes": [ - "Puddle" - ], - "description": "These high energy projection devices were designed by Anti-Matter to aid in the defense of Praetoria. They have been repurposed here to help keep order in the Behavioral Adjustment Facility.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Emil Marcone": { "costumes": [ "Family_Boss_01" @@ -13609,14 +11725,6 @@ "gender": "GENDER_MALE", "group_name": "Civic Squad" }, - "Energy Beam": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Energy Font": { "costumes": [ "Ball_of_Light" @@ -13635,22 +11743,6 @@ "gender": "GENDER_MALE", "group_name": "CircleOfThorns" }, - "Energy Probe": { - "costumes": [ - "OO_tek1_machine_03" - ], - "description": "This strange machine looks highly advanced.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Energy Shield": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Energy Vortex": { "costumes": [ "Witch_Rift" @@ -13659,14 +11751,6 @@ "gender": "GENDER_MALE", "group_name": "Coralax" }, - "Enflame": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Sorcery" - }, "Enforcer": { "costumes": [ "KoV_DualPistols_LT_01", @@ -14008,14 +12092,6 @@ "gender": "GENDER_FEMALE", "group_name": "Longbow" }, - "Ethereal Shard": { - "costumes": [ - "v_destsoulcryst_soul_crystal" - ], - "description": "Light reflects off the surface of these mysterious ice crystals in an unnatural way, it is almost as if they are sucking the heat out of the air itself. Lord Winter draws his tremendous power from these Ice Shards making him nearly indestructible. This particular crystal appear susceptible to energy, negative energy and psionic attacks.", - "gender": "GENDER_MALE", - "group_name": "Winter Horde" - }, "Euphemia Betitia Proba": { "costumes": [ "Roman_Peasant_Female_01", @@ -14058,14 +12134,6 @@ "gender": "GENDER_MALE", "group_name": "5thColumn" }, - "Excalibur": { - "costumes": [ - "Puddle" - ], - "description": "This object drops sword bombs of doom.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Executioner": { "costumes": [ "KoV_TitanWeapon_LT_01", @@ -14093,14 +12161,6 @@ "gender": "GENDER_MALE", "group_name": "Krylov's Creations" }, - "Experiment Chamber": { - "costumes": [ - "V_RiktiVahz_Stasis_01" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, "Experiment Number Nine": { "costumes": [ "Giant_Pantheon" @@ -14109,46 +12169,6 @@ "gender": "GENDER_MALE", "group_name": "Dr. Kane's Horrors" }, - "Experimental Subdermal Vahz-Vapor": { - "costumes": [ - "Destructible_Barrel_Poison" - ], - "description": "Experimental Subdermal Vahz-Vapor", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Expert Forge": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, - "Expert Worktable": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, - "Explosion": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Explosion" - }, - "Explosive Rune": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Explosives": { "costumes": [ "briefcase_explode" @@ -14189,14 +12209,6 @@ "gender": "GENDER_MALE", "group_name": "Coralax" }, - "Eye of the Storm": { - "costumes": [ - "Puddle" - ], - "description": "The Eye of the Storm suppresses the effects of the psionic typhoon raging in Penelope's lair. It also creates a calm area from which Metronome can speak to Penelope and calm her, allowing her defenses to fall.", - "gender": "GENDER_MALE", - "group_name": "Psionic Typhoon" - }, "Faathim the Kind": { "costumes": [ "Rularuu_Faathim" @@ -14256,14 +12268,6 @@ "gender": "GENDER_FEMALE", "group_name": "Tailor" }, - "Fail Safe": { - "costumes": [ - "Puddle" - ], - "description": "The Fail Safe device will destroy any follower of the Reichsman. This is a last resort device.", - "gender": "GENDER_MALE", - "group_name": "5thColumn" - }, "Fake Nemesis": { "costumes": [ "Nemesis" @@ -14310,14 +12314,6 @@ "gender": "GENDER_MALE", "group_name": "Hellions" }, - "Fallout": { - "costumes": [ - "Pet_NoCollision" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Faltonia Betitia Proba": { "costumes": [ "Roman_Peasant_Female_01", @@ -14462,14 +12458,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Fear the Bomb": { - "costumes": [ - "Puddle" - ], - "description": "Forces enemies to retreat.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Fearsome Reflection": { "costumes": [ "P_Apparitions_Fearsome" @@ -14575,14 +12563,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "Fifteen Second Timer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "File Cabinet": { "costumes": [ "OO_Ofc1_File_Cabinet_Lrg_01" @@ -14626,14 +12606,6 @@ "gender": "GENDER_MALE", "group_name": "Citizen" }, - "Fire": { - "costumes": [ - "Hellion_Roof_Fire" - ], - "description": "The Hellions have started this fire and lives are at stake. Put it out immediately!", - "gender": "GENDER_MALE", - "group_name": "Hellions" - }, "Fire Dagger": { "costumes": [ "Tsoo_09" @@ -14642,14 +12614,6 @@ "gender": "GENDER_MALE", "group_name": "Tsoo" }, - "Fire Hydrant": { - "costumes": [ - "V_Mayhem_Firehydrant_01" - ], - "description": "This is a hydrant used in case of a fire emergency.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Fire Imp": { "costumes": [ "Fire_Elemental_01" @@ -14732,22 +12696,6 @@ "gender": "GENDER_FEMALE", "group_name": "Shining Stars" }, - "Flames": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Fire Armor" - }, - "Flames of Hephaestus": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Flashfreeze": { "costumes": [ "BWI_Ultimatum_Flashfreeze" @@ -14799,14 +12747,6 @@ "gender": "GENDER_MALE", "group_name": "Freaklok" }, - "Flow Lightning": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "TalonsOfVengeanceEndgame" - }, "Flower Knight": { "costumes": [ "Model_Kit_Sung" @@ -14866,30 +12806,6 @@ "gender": "GENDER_MALE", "group_name": "AnimusArcana" }, - "Force Field": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Force Field Door": { - "costumes": [ - "AE_5th_Celldoor_01" - ], - "description": "This reinforced door is made of heavy-duty material with a complex locking mechanism. It'll be tough to destroy!", - "gender": "GENDER_NEUTER", - "group_name": "Security" - }, - "Force Field Generator": { - "costumes": [ - "Force_Field_Drone" - ], - "description": "The Drone generates a Dispersion Bubble that gives the Mastermind's nearby allies increased Defense.", - "gender": "GENDER_MALE", - "group_name": "Traps" - }, "Force Field Generator Pod": { "costumes": [ "Hydra_Pod" @@ -14937,14 +12853,6 @@ "gender": "GENDER_MALE", "group_name": "WISDOM" }, - "Forklift": { - "costumes": [ - "V_Destructible_Props_Forklift" - ], - "description": "This is a small heavy-duty forklift used to move supplies around construction, storage and dock sites.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Forlorn Commando": { "costumes": [ "ForlornBoss1", @@ -15061,14 +12969,6 @@ "gender": "GENDER_FEMALE", "group_name": "Arachnos" }, - "Forty Five Second Timer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Found Freak": { "costumes": [ "FRKLK_LostA", @@ -15481,22 +13381,6 @@ "gender": "GENDER_MALE", "group_name": "Outcasts" }, - "Freezing Rain": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malaise" - }, - "Frigid Storm": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BanishedPantheonEndgame" - }, "Frost Shaman": { "costumes": [ "BanishedPantheon_Shaman_Male_Frost_01", @@ -15638,14 +13522,6 @@ "gender": "GENDER_MALE", "group_name": "Rularuu" }, - "Fury of the Swarm": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "DevouringEarthSeed" - }, "Fusilier": { "costumes": [ "Nemesis_Soldier_02" @@ -15662,30 +13538,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianIDF" }, - "Fusion Burn": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "PraetorianIDF" - }, - "Fusion Burst": { - "costumes": [ - "Puddle" - ], - "description": "Apocalypse strikes you with a Fusion Burst!", - "gender": "GENDER_MALE", - "group_name": "SummerEvent" - }, - "Fusion Generator": { - "costumes": [ - "v_base_object" - ], - "description": "Developed from prototypes found in Dr. Anguish's secret lab, the X3 Fusion is the best power solution money can by. Only the most noteworthy groups have one of these in their base!", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, "Fusionette": { "costumes": [ "Model_Fusionette" @@ -15938,30 +13790,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Gas Centrifuge": { - "costumes": [ - "Puddle" - ], - "description": "This extremely advanced device is built into the room and collects Rikti Monkey gas and then spins it down to an extremely potent and very lethal condensed extract that the Rikti use for weapons development.", - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, - "Gas Tank": { - "costumes": [ - "OO_Wrhs_Gascanister_01" - ], - "description": "This gas must not be released into the air!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Gas Trap": { - "costumes": [ - "v_base_object" - ], - "description": "This trap emits poisonous smoke from an arcane incense. The fumes from this incense blur the victim's senses, reducing his accuracy for a time.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Gate": { "costumes": [ "Gate" @@ -15986,14 +13814,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Geiser Graphic Entity": { - "costumes": [ - "Puddle" - ], - "description": "This entity creates the Geiser Effect", - "gender": "GENDER_MALE", - "group_name": "Hydra" - }, "General Aarons": { "costumes": [ "Model_GeneralAaron" @@ -16018,38 +13838,6 @@ "gender": "GENDER_MALE", "group_name": "Soldier" }, - "Generator": { - "costumes": [ - "Puddle" - ], - "description": "These strange devices thrum with power, harvesting the meta-human energies gathered by the Web and channeling them into Lord Recluse.", - "gender": "GENDER_MALE", - "group_name": "Generator" - }, - "Generator - Large": { - "costumes": [ - "OO_Strg_gen_lrg01_100" - ], - "description": "This generator must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Generator - Medium": { - "costumes": [ - "OO_Strg_gen_med01_100" - ], - "description": "This generator must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Generator Energy Curtain": { - "costumes": [ - "v_base_object" - ], - "description": "The Rook Power Shielding provides extra protection for base generator components. When activated it cloaks the unit in a protective force field.", - "gender": "GENDER_MALE", - "group_name": "EnergyAux" - }, "Geneticist": { "costumes": [ "CSC_07", @@ -16182,14 +13970,6 @@ "gender": "GENDER_MALE", "group_name": "SpectralPirates" }, - "Ghost Trap": { - "costumes": [ - "Puddle" - ], - "description": "Need Description", - "gender": "GENDER_MALE", - "group_name": "SpectralPirates" - }, "Ghost Widow": { "costumes": [ "Signature_Ghost_Widow" @@ -16290,14 +14070,6 @@ "gender": "GENDER_FEMALE", "group_name": "Omega Team" }, - "Glacier": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ice Armor" - }, "Glitched Builder": { "costumes": [ "Clockwork_Loyalist_Lieu_01", @@ -16414,14 +14186,6 @@ "gender": "GENDER_MALE", "group_name": "Paragon Heroes" }, - "Glue Grenade": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Paragon Police" - }, "Gnaea Placidia": { "costumes": [ "Roman_Peasant_Female_01", @@ -16553,14 +14317,6 @@ "gender": "GENDER_MALE", "group_name": "NewPraetorians" }, - "Grate": { - "costumes": [ - "Rikti_Ship_Grate" - ], - "description": "This grate covers important ship parts.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Gratidia Valeria": { "costumes": [ "Roman_Peasant_Female_01", @@ -16880,22 +14636,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "Ground Smash": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malaise" - }, - "Group of Barrels": { - "costumes": [ - "V_Mayhem_Barrel_Group_01" - ], - "description": "These barrels are used to carry volatile liquids.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Grym": { "costumes": [ "Model_Grym_NoRing" @@ -17126,22 +14866,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianClockworkResistance" }, - "Hacked Telepad": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Teleport" - }, - "Hail of Debris": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Wind Control" - }, "Halox von Horn": { "costumes": [ "Model_Halox_Von_Horn" @@ -17150,14 +14874,6 @@ "gender": "GENDER_MALE", "group_name": "Freedom Corps" }, - "Hamidon": { - "costumes": [ - "Tentacle_Death" - ], - "description": "Hamidon is the nucleus of a giant single celled organism, spawned from some twisted primordial soup. It has but one instinct, to devour the Earth and all that infests it.", - "gender": "GENDER_MALE", - "group_name": "DevouringEarth" - }, "Hamidon Bud": { "costumes": [ "Hamidon_Mini" @@ -17198,14 +14914,6 @@ "gender": "GENDER_MALE", "group_name": "Batzul" }, - "HardMode Objective Trigger": { - "costumes": [ - "Puddle" - ], - "description": "Oh no, you found the trampoline! Naughty naughty.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Hardcase": { "costumes": [ "Model_Hardcase_No_Ring" @@ -17469,22 +15177,6 @@ "gender": "GENDER_MALE", "group_name": "The Lost" }, - "Healing Wave": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Coralax" - }, - "Health Kit": { - "costumes": [ - "v_base_object" - ], - "description": "Use this health kit to buy and sell inspirations.", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, "Heart of a Storm": { "costumes": [ "OO_Heart_of_A_Storm" @@ -17493,14 +15185,6 @@ "gender": "GENDER_MALE", "group_name": "Rularuu" }, - "Heat Loss": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Cold Domination" - }, "Heavy Assault Suit": { "costumes": [ "Rikti_HeavyAssaultSuit" @@ -17952,14 +15636,6 @@ "gender": "GENDER_MALE", "group_name": "Warriors" }, - "High Explosives": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "High Roller": { "costumes": [ "Model_HighRoller" @@ -18017,14 +15693,6 @@ "gender": "GENDER_MALE", "group_name": "Paragon Heroes" }, - "Holodisplay": { - "costumes": [ - "v_base_object" - ], - "description": "The Holodisplay provides improved monitoring for base security, increasing the control generated for the base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Hombre": { "costumes": [ "Destroyer_Lt_Male_01", @@ -18223,14 +15891,6 @@ "gender": "GENDER_NEUTER", "group_name": "Beast Mastery" }, - "Howling Twilight": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Hro'Dtohz": { "costumes": [ "Rikti_Hro_Dtohz_01" @@ -18239,14 +15899,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Huge Cable": { - "costumes": [ - "CablePiece_xxl_01" - ], - "description": "These strange living spires are somehow connected with Hamidon's ability to control the creatures of the Devouring Earth.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Hunter": { "costumes": [ "Ghoul_Dirty_Minion_01", @@ -18347,30 +15999,6 @@ "gender": "GENDER_MALE", "group_name": "ArachnosEndgame" }, - "Ice Patch": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ice Armor" - }, - "Ice Slick": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BanishedPantheonEndgame" - }, - "Ice Storm": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malaise" - }, "Ice Thorn Caster": { "costumes": [ "CoT_IceCaster_01", @@ -18448,14 +16076,6 @@ "gender": "GENDER_FEMALE", "group_name": "Tailor" }, - "Igniter": { - "costumes": [ - "v_base_object" - ], - "description": "This crystal taps into the fires of other realms to cause minor damage over time to targets in range.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Illusion of Cortex": { "costumes": [ "Eidola_Cortex" @@ -18551,22 +16171,6 @@ "gender": "GENDER_MALE", "group_name": "Colleague's Pets" }, - "Impale": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BanishedPantheonEndgame" - }, - "Impending Death": { - "costumes": [ - "Puddle" - ], - "description": "The inside of Mot is a place of unrelenting despair, with the chill of death encroaching with every moment.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Imperious": { "costumes": [ "Roman_Imperious_01_NoRing" @@ -18587,14 +16191,6 @@ "gender": "GENDER_MALE", "group_name": "CarnivalOfVengeance" }, - "Improved Analyzer": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Improved Chain Gun": { "costumes": [ "Base_ImprovedChainGun" @@ -18611,14 +16207,6 @@ "gender": "GENDER_MALE", "group_name": "Defense" }, - "Improved Dampening Ray": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Improved Drone": { "costumes": [ "RiktiDrone_1", @@ -18629,30 +16217,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Improved Energy Beam": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Improved Igniter": { - "costumes": [ - "v_base_object" - ], - "description": "This crystal taps into the fires of other realms to cause moderate damage over time to targets in range.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Improved Sapper": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Improved Scanner": { "costumes": [ "Base_ImprovedScanner" @@ -18710,14 +16274,6 @@ "gender": "GENDER_MALE", "group_name": "GhoulsClean" }, - "Incandescence": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Destiny Judgement" - }, "Incandescent": { "costumes": [ "Vanguard_Incandescent_01_No_Ring" @@ -18726,22 +16282,6 @@ "gender": "GENDER_FEMALE", "group_name": "Vanguard Herald" }, - "Incarnate Debuff Granter": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Incarnate StoryArc Granter": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Incarnate Warhulk": { "costumes": [ "Nemesis_Heavy" @@ -18994,14 +16534,6 @@ "gender": "GENDER_FEMALE", "group_name": "Cabal" }, - "Insect Pod": { - "costumes": [ - "V_Dest_cacoon01_100" - ], - "description": "This is a webbed cocoon used to hold human-sized subject.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Instigator": { "costumes": [ "Citizen_Biz_Male_01", @@ -19067,86 +16599,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianPolice" }, - "Invention Worktable": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "The Invention Worktable is an required to make Enhancements in your base. Selecting it allows you to create Enhancements and other items from salvage you have collected.", - "gender": "GENDER_MALE", - "group_name": "Workshop" - }, - "Ion Core Final Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Core Final Judgement" - }, - "Ion Core Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Core Judgement" - }, - "Ion Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Judgement" - }, - "Ion Partial Core Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Partial Core Judgement" - }, - "Ion Partial Radial Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Partial Radial Judgement" - }, - "Ion Radial Final Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Radial Final Judgement" - }, - "Ion Radial Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Radial Judgement" - }, - "Ion Total Core Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Total Core Judgement" - }, - "Ion Total Radial Judgement": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Ion Total Radial Judgement" - }, "Iron Crane": { "costumes": [ "Tsoo_14" @@ -19189,14 +16641,6 @@ "gender": "GENDER_FEMALE", "group_name": "Scrapyarders" }, - "Irradiated Ground": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Radiation Melee" - }, "Isa Rune": { "costumes": [ "V_Dest_LC_Trap_Debuff_01" @@ -19205,14 +16649,6 @@ "gender": "GENDER_MALE", "group_name": "Relic" }, - "Itself": { - "costumes": [ - "Oil_Slick_Target" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "J.P. Mason": { "costumes": [ "Thug_Prisoner_01", @@ -19240,40 +16676,32 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "Jack": { - "costumes": [ - "Thug_Prisoner_01", - "Thug_Prisoner_02", - "Thug_Prisoner_03", - "Thug_Prisoner_04", - "Thug_Prisoner_05", - "Thug_Prisoner_06", - "Thug_Prisoner_07", - "Thug_Prisoner_08", - "Thug_Prisoner_09", - "Thug_Prisoner_10", - "Thug_Prisoner_11", - "Thug_Prisoner_12", - "Thug_Prisoner_13", - "Thug_Prisoner_14", - "Thug_Prisoner_15", - "Thug_Prisoner_16", - "Thug_Prisoner_17", - "Thug_Prisoner_18", - "Thug_Prisoner_19", - "Thug_Prisoner_20" - ], - "description": "In some jailbreaks, the toughest of the prisoners manage to overpower the Zig's security guards and grab their weapons.", - "gender": "GENDER_MALE", - "group_name": "Prisoners" - }, - "Jack Frost": { + "Jack": { "costumes": [ - "Jack" + "Thug_Prisoner_01", + "Thug_Prisoner_02", + "Thug_Prisoner_03", + "Thug_Prisoner_04", + "Thug_Prisoner_05", + "Thug_Prisoner_06", + "Thug_Prisoner_07", + "Thug_Prisoner_08", + "Thug_Prisoner_09", + "Thug_Prisoner_10", + "Thug_Prisoner_11", + "Thug_Prisoner_12", + "Thug_Prisoner_13", + "Thug_Prisoner_14", + "Thug_Prisoner_15", + "Thug_Prisoner_16", + "Thug_Prisoner_17", + "Thug_Prisoner_18", + "Thug_Prisoner_19", + "Thug_Prisoner_20" ], - "description": null, + "description": "In some jailbreaks, the toughest of the prisoners manage to overpower the Zig's security guards and grab their weapons.", "gender": "GENDER_MALE", - "group_name": "Ice Control" + "group_name": "Prisoners" }, "Jack Hammer": { "costumes": [ @@ -20352,14 +17780,6 @@ "gender": "GENDER_MALE", "group_name": "Freakshow" }, - "Jukebox 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Julia Antonia Severa": { "costumes": [ "Roman_Peasant_Female_01", @@ -21023,54 +18443,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Keyes Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Keyes Island Escape Watch": { - "costumes": [ - "Puddle" - ], - "description": "This entity is spawned by an escaping WarWalker during the Keyes Island event and will immediately kill itself. This will spur a recount of all active Patrol teams.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Keyes Island Escape Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Keyes Island Surrogate": { - "costumes": [ - "Puddle" - ], - "description": "This entity serves as a Surrogate Antimatter during the span of time in the Keyes event during which he is not present.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Keyes Island Systemic Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity serves as to suppress Systemic Decay when Antimatter is hidden but present on the map.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Keyes Island Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Kid Cryonix": { "costumes": [ "Model_Kid_Cryonix" @@ -21168,14 +18540,6 @@ "gender": "GENDER_FEMALE", "group_name": "Hapless Citizen" }, - "Kinetic Transfer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "NPC_Pets" - }, "King Midas": { "costumes": [ "Goldbrickers_King_Midas_Battle" @@ -21210,30 +18574,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Knock Diabolique": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Knockback": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Shivan" - }, - "Knockback Object": { - "costumes": [ - "Puddle" - ], - "description": "This entity is used to perform knockback effects on NPCs during cutscenes.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Koago": { "costumes": [ "Magmite_Pumicite_Giant" @@ -21266,22 +18606,6 @@ "gender": "GENDER_MALE", "group_name": "Rularuu" }, - "Lab Equipment": { - "costumes": [ - "AE_Tech_Lab_Pod_01" - ], - "description": "This Lab Equipment looks like something from a B-rated sci-fi movie. You can only imagine its diabolical purpose.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Laboratory Control Panel": { - "costumes": [ - "SSA2_Control_Panel" - ], - "description": "This panel contains critical information regarding Nemesis's Antikythera Amplifier.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Lady": { "costumes": [ "Dominatrix_Female_Minion_01", @@ -21326,14 +18650,6 @@ "gender": "GENDER_FEMALE", "group_name": "TalonsOfVengeance" }, - "Lambda Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Lambda Sector Turret": { "costumes": [ "Lambda_Sector_Turret" @@ -21342,14 +18658,6 @@ "gender": "GENDER_MALE", "group_name": "Turrets" }, - "Lambda Sector Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity kills itself when detecting Marauder in a volume it is spawned in. This triggers failure of his encounter and the event.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Lambent of Light": { "costumes": [ "Legacy_of_Light_Minion_01", @@ -21492,32 +18800,6 @@ "gender": "GENDER_MALE", "group_name": "GoldBrickers" }, - "Large Cable": { - "costumes": [ - "CablePiece_lrg_01" - ], - "description": "These strange living spires are somehow connected with Hamidon's ability to control the creatures of the Devouring Earth.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Large Cargo Crate": { - "costumes": [ - "V_Destructible_Props_Crate_01", - "V_Destructible_Props_Crate_02" - ], - "description": "This is a large cargo crate you used to sneak onto the Sky Raider ship. Blast your way out of here!", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Large Metal Crate": { - "costumes": [ - "V_Mayhem_Crate_Metal_Lrg_01", - "V_Mayhem_Crate_Metal_Lrg_02" - ], - "description": "This large crate is used to transport various types of goods.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Larry": { "costumes": [ "Citizen_Biz_Male_01", @@ -21955,14 +19237,6 @@ "gender": "GENDER_MALE", "group_name": "Nictus" }, - "Lethal Force Authorized": { - "costumes": [ - "Puddle" - ], - "description": "The dreadnaughts tend to lead invasion forces due to their incredible resilience and destructive prowess. Wherever the Goliath War Walker goes, a trail of destruction is left in its wake.", - "gender": "GENDER_MALE", - "group_name": "PraetorianWarworksEndGame" - }, "Levantera": { "costumes": [ "Vanguard_Levantera_01_No_Ring" @@ -21971,14 +19245,6 @@ "gender": "GENDER_FEMALE", "group_name": "Vanguard Shield" }, - "Ley Tap": { - "costumes": [ - "v_base_object" - ], - "description": "Through spells and constructs, mystic power is drawn directly from the earth to provide for base needs. A single tap is usually sufficient for a medium base.", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, "Liberated Brawler": { "costumes": [ "ForlornMin1", @@ -22159,14 +19425,6 @@ "gender": "GENDER_MALE", "group_name": "Essence" }, - "Lifegiving Spores": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Nature Affinity" - }, "Lifter": { "costumes": [ "Clockwork_Loyalist_Boss_01", @@ -22213,14 +19471,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Light the Path": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Light the Path" - }, "Lightning Blade": { "costumes": [ "Tsoo_08" @@ -22237,14 +19487,6 @@ "gender": "GENDER_MALE", "group_name": "Tsoo" }, - "Lightning Rod": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electrical Melee" - }, "Lightning Storm": { "costumes": [ "Lightning_Bolt" @@ -22350,22 +19592,6 @@ "gender": "GENDER_FEMALE", "group_name": "Pet" }, - "Liquefy": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Sonic Blast" - }, - "Liquid Nitrogen": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arsenal Control" - }, "Lisa": { "costumes": [ "Citizen_Biz_Fem_01", @@ -22746,14 +19972,6 @@ "gender": "GENDER_MALE", "group_name": "Freedom Corps" }, - "Longbow Disguise": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Longbow Eagle": { "costumes": [ "Longbow_Male_Group_02_Minion_01", @@ -22880,14 +20098,6 @@ "gender": "GENDER_MALE", "group_name": "Longbow" }, - "Longbow Slayer": { - "costumes": [ - "Puddle" - ], - "description": "Kills Longbow dead.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Longbow Spec-Ops": { "costumes": [ "Longbow_Male_Group_02_Minion_03", @@ -22973,14 +20183,6 @@ "gender": "GENDER_MALE", "group_name": "TheFamily" }, - "Lost Cure": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Fire Armor" - }, "Lost Son": { "costumes": [ "Tsoo_10" @@ -23309,14 +20511,6 @@ "gender": "GENDER_FEMALE", "group_name": "Freaklok" }, - "Machine": { - "costumes": [ - "OO_tek1_machine_01" - ], - "description": "This strange machine looks highly advanced.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Macht Jaeger": { "costumes": [ "Nemesis_Drone" @@ -23493,22 +20687,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Magic Axis": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "Protecting a base requires a way to know what is happening. This magic axis generates the control needed to bind all your defenses.", - "gender": "GENDER_MALE", - "group_name": "Control" - }, - "Magical Barrier": { - "costumes": [ - "AE_CoT_Celldoor_01" - ], - "description": "This magical barrier has been set up by a member of the Carnival of Light.", - "gender": "GENDER_MALE", - "group_name": "CarnivalOfLightEndgame" - }, "Magician Essence": { "costumes": [ "Vanguard_Sword_Male_Lt_03", @@ -23518,14 +20696,6 @@ "gender": "GENDER_MALE", "group_name": "Vanguard Essence" }, - "Magicked Telepad": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Teleport" - }, "Magma Thorn Caster": { "costumes": [ "CoT_SeismicCaster_01", @@ -23577,22 +20747,6 @@ "gender": "GENDER_MALE", "group_name": "Arachnos" }, - "Mailbox": { - "costumes": [ - "V_Mayhem_Mailbox_01" - ], - "description": "This is a typical mailbox used for the delivery of letters and parcels.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Mainframe": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "Managing all the systems in your base takes processing power. This mainframe computer has been programmed as a central control point for base security systems. It generates the control needed for base systems to operate.", - "gender": "GENDER_MALE", - "group_name": "Control" - }, "Maintenance Drone": { "costumes": [ "Mastermind_Repair_Bot" @@ -23693,38 +20847,6 @@ "gender": "GENDER_MALE", "group_name": "Hydra" }, - "Mana Crystal": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Mana Lens": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Mana Resonator": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Mana Seal": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Mangle": { "costumes": [ "Model_Mangle" @@ -24319,14 +21441,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Marker": { - "costumes": [ - "Puddle" - ], - "description": "This entity stays around until told to die.", - "gender": "GENDER_MALE", - "group_name": "FreedomPhalanx" - }, "Marshal Blitz": { "costumes": [ "Arachnos_MartialBlitz_01" @@ -24769,14 +21883,6 @@ "gender": "GENDER_FEMALE", "group_name": "Syndicate" }, - "Mayhem Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Meat Doctor": { "costumes": [ "Vahzilok_Meat_Doctor_01", @@ -24840,14 +21946,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "Medium Cable": { - "costumes": [ - "CablePiece_med_01" - ], - "description": "These strange living spires are somehow connected with Hamidon's ability to control the creatures of the Devouring Earth.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Mega Cog": { "costumes": [ "Sprocket_00", @@ -24858,14 +21956,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Mega Monitor": { - "costumes": [ - "v_base_object" - ], - "description": "This massive monitor displays all base security information in a single glance. It increases control generated for the base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Mek Man": { "costumes": [ "5th_Mek_Man" @@ -24947,14 +22037,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civilian" }, - "Meltdown": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "PraetorianIDF" - }, "Mender": { "costumes": [ "Clockwork_Loyalist_Lieu_01", @@ -25114,16 +22196,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Metal Crate": { - "costumes": [ - "V_Mayhem_Crates_Metal_Sml01", - "V_Mayhem_Crates_Metal_Sml02", - "V_Mayhem_Crates_Metal_Sml03" - ], - "description": "This large crate is used to transport various types of goods.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Metal Shift": { "costumes": [ "Model_Metal_Shift" @@ -25209,14 +22281,6 @@ "gender": "GENDER_MALE", "group_name": "Meteors" }, - "Meteoric": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Metronome MK I": { "costumes": [ "Clockwork_Loyalist_Boss_01" @@ -25355,14 +22419,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civilian" }, - "Microbial Suspension Cell": { - "costumes": [ - "Puddle" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, "Midnight": { "costumes": [ "Tsoo_06" @@ -25442,22 +22498,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "Mind Annihilation": { - "costumes": [ - "Puddle" - ], - "description": "Mother Mayhem will eventually take over and destroy your mind. Do not remain in the Seer Network for too long!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Mind Suppression Field": { - "costumes": [ - "Puddle" - ], - "description": "This entity creates the suppression field which allows Shalice Tilman to be controlled.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Mindcrasher": { "costumes": [ "Awakened_Mindcrasher_Female_01", @@ -25546,14 +22586,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianResistance" }, - "Minelayer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Traps" - }, "Minervina Flacilla": { "costumes": [ "Roman_Peasant_Female_01", @@ -25839,14 +22871,6 @@ "gender": "GENDER_FEMALE", "group_name": "Rogue Isles Villains" }, - "Mission Computer": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Mistress of Fury": { "costumes": [ "Egrimoir_05" @@ -26014,22 +23038,6 @@ "gender": "GENDER_MALE", "group_name": "ApparitionsPossessed" }, - "Money Bag": { - "costumes": [ - "Door_safe" - ], - "description": "Keep Out!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Monitor Bank": { - "costumes": [ - "v_base_object" - ], - "description": "Multiple monitors make it easier to observe activity throughout a base. The monitor bank increases control generated for the base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Monitor Sphere": { "costumes": [ "PPD_Monitor_Sphere" @@ -26038,14 +23046,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianPolice" }, - "Monkey Gas": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, "Monsoon Elemental Essence": { "costumes": [ "IncarnatePet_Storm_02" @@ -26054,14 +23054,6 @@ "gender": "GENDER_NEUTER", "group_name": "Elemental Essence" }, - "Monster Slayer": { - "costumes": [ - "Puddle" - ], - "description": "Kills Monsters dead.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Monstrosity": { "costumes": [ "V_Shivan_Boss" @@ -26153,14 +23145,6 @@ "gender": "GENDER_FEMALE", "group_name": "Peacebringers" }, - "Morale Improvement": { - "costumes": [ - "Puddle" - ], - "description": "Morale has improved, so the beatings may now stop!", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Morana": { "costumes": [ "Thug_Skull_AV_Petrovic_Morana" @@ -26191,22 +23175,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "Mot Presence": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Mot's Annihilation": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique will use the latent death energy emanating from Mot to eventually take over your body and consume it from with in, killing you. Do not remain in her presence for long!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Mother Malaise": { "costumes": [ "MindsOfMayhem_Malaise_Controlled" @@ -26320,14 +23288,6 @@ "gender": "GENDER_MALE", "group_name": "ArachnosEndgame" }, - "Mu Vault Door": { - "costumes": [ - "V_Destmdr_mudoor_100" - ], - "description": "This massive stone door seals off the Vaults of the Mu and the portal to the world beyond. It's covered in spells and wards placed on it long ago by the peoples of Mu.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Mucia Domitia Paulina": { "costumes": [ "Roman_Peasant_Female_01", @@ -26555,14 +23515,6 @@ "gender": "GENDER_FEMALE", "group_name": "The Vindicators" }, - "Mystic Advisor": { - "costumes": [ - "v_base_object" - ], - "description": "The advice of spirits from other realms is useful. Their guidance gives you more control for your base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Mystic Anchorite": { "costumes": [ "CoL_Mystic_Anchorite_Female_Suit_01", @@ -26583,38 +23535,6 @@ "gender": "GENDER_MALE", "group_name": "Pets" }, - "Mystic Orrery": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "This device monitors the motions of the planets and stars, allowing it to choose the most auspicious moments for weaving powerful control spells. These spells are necessary to bind the energies of all security devices in your base and keep them functioning properly.", - "gender": "GENDER_MALE", - "group_name": "Control" - }, - "Mystic Overseer": { - "costumes": [ - "v_base_object" - ], - "description": "Through the Overseer, you can call upon many spirits. Their guidance gives you more control for your base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Mystical Bookshelf": { - "costumes": [ - "v_base_object" - ], - "description": "Information is key to defeating your enemies. These magical texts generate additional control for your defenses.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Mystical Cauldron": { - "costumes": [ - "v_destcldrn_cauldron" - ], - "description": "An ancient cauldron that Arachnos stole from the Circle of Thorns. Inside, a bubbling mass of the tainted water from the Well of the Furies.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Nacht Elite Fire": { "costumes": [ "5thNight_10" @@ -27286,22 +24206,6 @@ "gender": "GENDER_MALE", "group_name": "Nemesis" }, - "Nemesis Gas": { - "costumes": [ - "Puddle" - ], - "description": "This poisonous gas was once a part of Nemesis' plot to conquer America. Though his plan to poison several major cities was thwarted, he still keeps large quantities of the gas around to help tip battles in his favor.", - "gender": "GENDER_MALE", - "group_name": "Nemesis" - }, - "Nemesis Mole Machine": { - "costumes": [ - "Nemesis_Drill_Up_100" - ], - "description": "These tunneling tanks bring Nemesis troops anywhere in the world, bypassing even the strongest of defenses and serving as mobile rally points for the Nemesis Army.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Nemesis Monstrosity": { "costumes": [ "Nemesis_Monstrosity" @@ -27372,22 +24276,6 @@ "gender": "GENDER_NEUTER", "group_name": "Demonic Essence" }, - "Nether Energy": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, - "Netherworld": { - "costumes": [ - "Puddle" - ], - "description": "This entity plays an effect for players who are within the Netherworld phase.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Network": { "costumes": [ "Model_Network" @@ -27396,14 +24284,6 @@ "gender": "GENDER_MALE", "group_name": "The Hammers of Justice" }, - "Neural Nullifier": { - "costumes": [ - "Puddle" - ], - "description": "This is a localized psionic collapse that will mercilessly bombard those within with vivid hallucinations of their greatest fears.

This typically leaves the victim terrorized and without their Defenses nor with any Resistance to damage.", - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, "Neuron": { "costumes": [ "Signature_Neuron_01_NoRing" @@ -27420,22 +24300,6 @@ "gender": "GENDER_MALE", "group_name": "Praetors" }, - "Neutron Burst": { - "costumes": [ - "Puddle" - ], - "description": "Armageddon strikes you with a Neutron Burst!", - "gender": "GENDER_MALE", - "group_name": "SummerEvent" - }, - "Newsstand": { - "costumes": [ - "News_Stand_Small" - ], - "description": "Small newspaper vending machine.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Newt": { "costumes": [ "Reaper_Newt" @@ -27528,14 +24392,6 @@ "gender": "GENDER_MALE", "group_name": "Zombies" }, - "Nightmare Marker": { - "costumes": [ - "Puddle" - ], - "description": "This marker tells Malaise where he can spawn his Nightmares.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Nightmare of Malaise": { "costumes": [ "MindsOfMayhem_Malaise_Controlled_Worldspawn" @@ -27552,14 +24408,6 @@ "gender": "GENDER_FEMALE", "group_name": "Praetorians" }, - "Nightstar Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity detects Nightstar around her start location for badge tracking.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Nightwolf": { "costumes": [ "Nightwolf_01" @@ -27638,14 +24486,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "No Costumes": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "No Mind": { "costumes": [ "Model_No_Mind" @@ -27654,14 +24494,6 @@ "gender": "GENDER_MALE", "group_name": "Paragon Heroes" }, - "No Nukes": { - "costumes": [ - "Puddle" - ], - "description": "No Nukes!", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Noble Brute": { "costumes": [ "Rularuu_Brute_02" @@ -27726,14 +24558,6 @@ "gender": "GENDER_MALE", "group_name": "5thColumn" }, - "Nuclear Blast": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, "Nullifier Essence": { "costumes": [ "Longbow_Male_Lieutenant_01", @@ -27768,14 +24592,6 @@ "gender": "GENDER_FEMALE", "group_name": "Medical" }, - "Obelisk": { - "costumes": [ - "V_Dest_Obelisk_of_Pain" - ], - "description": "The Psionic Resonator channels the energy of living beings into a protective force that keeps closed the gateway between the Shadowshard and Rularuu's Cathedral of Pain. It is only strong so long as Rularuu's living servants are present. If they can be defeated, the obelisk becomes vulnerable and can be destroyed. Destroying all three Psionic Resonators at roughly the same time, is the only known way to open the gateway.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Oberst Ubelmann": { "costumes": [ "5th_Ubelmann_ASF" @@ -27873,14 +24689,6 @@ "gender": "GENDER_MALE", "group_name": "Trolls" }, - "Oil Slick": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, "Olivia Darque": { "costumes": [ "Model_Olivia_Darque" @@ -28020,14 +24828,6 @@ "gender": "GENDER_FEMALE", "group_name": "Awakened" }, - "Oracle": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Orbiana Severa": { "costumes": [ "Roman_Peasant_Female_01", @@ -28045,14 +24845,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Orbits of Control": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "The orbits are fonts of power. Magical tendrils link them to your base defenses, allowing those defenses to be controlled.", - "gender": "GENDER_MALE", - "group_name": "Control" - }, "Orphaned Spirit": { "costumes": [ "WhoChild_NightWard_Male_01" @@ -28078,14 +24870,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Ouroboros Portal": { - "costumes": [ - "Ouroboros_Portal_Hero" - ], - "description": "This portal will transport you to the Ouroboros Headquarters.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Outcast Charger": { "costumes": [ "Thug_Outcast_01", @@ -28199,14 +24983,6 @@ "gender": "GENDER_FEMALE", "group_name": "Paragon Heroes" }, - "Overload": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Awakened" - }, "Overseer": { "costumes": [ "Rularuu_Sentinel_03" @@ -28419,22 +25195,6 @@ "gender": "GENDER_MALE", "group_name": "Blackwing Industries" }, - "P1313494394": { - "costumes": [ - "v_base_object" - ], - "description": "P3763297232", - "gender": "GENDER_MALE", - "group_name": "Arcane Teleport" - }, - "P1385533319": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Teleport" - }, "P172688555": { "costumes": [ "Model_Wentworth_Male_01", @@ -28458,14 +25218,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "P2250061361": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Teleport" - }, "P2470313760": { "costumes": [ "Model_Young_Dr_Stribbling" @@ -28474,38 +25226,6 @@ "gender": "GENDER_FEMALE", "group_name": "Hired Civilians" }, - "P2710568109": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, - "P2927683950": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Anchor" - }, - "P3152262469": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "P3424322988": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Control" - }, "P3454035522": { "costumes": [ "Model_Annah_01" @@ -28550,14 +25270,6 @@ "gender": "GENDER_MALE", "group_name": "ParagonPolice" }, - "PPD Car": { - "costumes": [ - "PPD_SquadCar" - ], - "description": "This car looks like it's of sturdy construction.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "PPD Cop": { "costumes": [ "PPD_Cop_01", @@ -28722,14 +25434,6 @@ "gender": "GENDER_MALE", "group_name": "Pets" }, - "PPD SWAT Van": { - "costumes": [ - "PPD_StdVan" - ], - "description": "This truck looks like it's of sturdy construction.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "PPD Sergeant": { "costumes": [ "PPD_Sergeant_Boss_01", @@ -28867,14 +25571,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "Pain Field": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Paingod": { "costumes": [ "Destroyer_Boss_05" @@ -28958,22 +25654,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civilian" }, - "Pandora's Box": { - "costumes": [ - "SSA2_Pandora_Box" - ], - "description": "This ancient artifact holds immeasurable power within.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, - "Paper Target": { - "costumes": [ - "InvisibleActor" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Papianilla Agrippina": { "costumes": [ "Roman_Peasant_Female_01", @@ -29077,22 +25757,6 @@ "gender": "GENDER_MALE", "group_name": "The Lost" }, - "Parking Meter": { - "costumes": [ - "parking_meter" - ], - "description": "Paragon City parking meter.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Particle Scope": { - "costumes": [ - "OO_tek1_machine_01" - ], - "description": "This strange machine looks highly advanced.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Pathogen": { "costumes": [ "Pathogen_Normal" @@ -29101,14 +25765,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "Patient Disguise": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Patient Zero": { "costumes": [ "Destroyer_Lt_Male_10" @@ -29296,14 +25952,6 @@ "gender": "GENDER_MALE", "group_name": "Drudges" }, - "Patron Power Granter": { - "costumes": [ - "FX_EmpowermentMachine" - ], - "description": "This machine can grant villains a portion of the power stolen from Lord Recluse's Lieutenants.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Paul": { "costumes": [ "Citizen_Biz_Male_01", @@ -29361,14 +26009,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Pay Phone": { - "costumes": [ - "Pay_Phone" - ], - "description": "Coin operated pay phone.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Peculiar Heavy Hands": { "costumes": [ "Resistance_Heavy_Hands_01", @@ -29635,22 +26275,6 @@ "gender": "GENDER_NEUTER", "group_name": "RavennaConclave" }, - "Personal Storage Vault": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "An access point to your personal storage for Invention Salvage.", - "gender": "GENDER_MALE", - "group_name": "Storage" - }, - "Personal Story Costume": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Pestilence": { "costumes": [ "Rikti_Rider_Pestilence" @@ -29767,14 +26391,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "Phoenix": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Fire Armor" - }, "Photon Seeker": { "costumes": [ "Peacebringer_Drone" @@ -29791,14 +26407,6 @@ "gender": "GENDER_FEMALE", "group_name": "Freaklok" }, - "Pillar of Ice and Flame": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "The Aspect of the Pillar allows access to the temporal network of Ouroboros' Pillar of Ice and Flame, allowing you to revisit the past and try your hand at missions you have outleveled.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Pincher": { "costumes": [ "Model_Pincher" @@ -29862,14 +26470,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Plasma Grenade": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "NewPraetorians" - }, "Plautia Urgulanilla": { "costumes": [ "Roman_Peasant_Female_01", @@ -29914,30 +26514,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Poison Gas": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malaise" - }, - "Poison Gas Arrow": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, - "Poison Trap": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Poison" - }, "Polar Shift": { "costumes": [ "Model_Polar_Shift" @@ -30095,14 +26671,6 @@ "gender": "GENDER_MALE", "group_name": "PNCB" }, - "Portable Workbench": { - "costumes": [ - "Univ_InvStation_Portable" - ], - "description": null, - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Portal": { "costumes": [ "Pantheon_Portal" @@ -30111,14 +26679,6 @@ "gender": "GENDER_MALE", "group_name": "Mystical Construct" }, - "Portal Killer": { - "costumes": [ - "Puddle" - ], - "description": "This dimensional vortex is allowing the Rikti to bring in re-enforcements. It must be destroyed!", - "gender": "GENDER_MALE", - "group_name": "Rikti" - }, "Portal Scientist": { "costumes": [ "MaleNPC_200", @@ -30130,46 +26690,6 @@ "gender": "GENDER_MALE", "group_name": "Hapless Scientist" }, - "Portal to Malaise": { - "costumes": [ - "Puddle" - ], - "description": "This portal has been opened by Desdemona into the Seer Network. It will take you to the network node where Malaise stands guard, preventing any direct assault on Mother Mayhem.", - "gender": "GENDER_NEUTER", - "group_name": "Portals" - }, - "Portal to Mother Mayhem": { - "costumes": [ - "Puddle" - ], - "description": "This portal has been opened by Desdemona into the Seer Network. It will take you to the network node where Mother Mayhem can be found.", - "gender": "GENDER_NEUTER", - "group_name": "Portals" - }, - "Portal to Penelope Yin": { - "costumes": [ - "Puddle" - ], - "description": "This portal has been opened by Desdemona into the Seer Network. It will take you to the network node where Mother Mayhem and Penelope Yin are in continual conflict over control of Penelope's mind.", - "gender": "GENDER_NEUTER", - "group_name": "Portals" - }, - "Portal to the Beyond": { - "costumes": [ - "V_Dest_Cot_Portal_100" - ], - "description": "This strange portal is bringing in entities for different worlds, times, and realities. A sufficiently strong attack my disrupt it's energies long enough to get what you need from it.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Portal to the Well": { - "costumes": [ - "Puddle" - ], - "description": "This portal has been opened by Desdemona into the Seer Network. It will take you to the network node where the now disembodied mind of Shalice Tilman has merged with Malaise.", - "gender": "GENDER_NEUTER", - "group_name": "Portals" - }, "Porter": { "costumes": [ "Sky_Raiders_04" @@ -30202,14 +26722,6 @@ "gender": "GENDER_MALE", "group_name": "Freedom Phalanx" }, - "Positron Teleport": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Posse": { "costumes": [ "Mastermind_Thug_Minion_01_01", @@ -30296,30 +26808,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Power Crystal": { - "costumes": [ - "v_base_object" - ], - "description": "These power crystals are a common tool of supernatural types. Each generates sufficient power to provide for the needs of a small base.", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, - "Power Disruptor": { - "costumes": [ - "Puddle" - ], - "description": "In ur zonz brakin ur stuffz", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Power Drone Pet": { - "costumes": [ - "Veteran_Pet_Drone_02" - ], - "description": "Vanguard has awarded you with a prototype Power Drone.", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Power Micro-Reactor": { "costumes": [ "Rikti_PowerGenerator" @@ -30425,54 +26913,6 @@ "gender": "GENDER_MALE", "group_name": "TheFamily" }, - "Primal Heal - Brutal Swipe": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Primal Heal - Feral Blow": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Primal Heal - Primal Strike": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Primal Heal - Savage Blow": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Primal Heal - Upheaval": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Primal Heal - Vicious Strike": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Primal Wolf": { "costumes": [ "Wolf" @@ -30614,30 +27054,6 @@ "gender": "GENDER_MALE", "group_name": "5thColumnEndgame" }, - "Protected Area": { - "costumes": [ - "Puddle" - ], - "description": "Keep Out!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Protection": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Protective Phase": { - "costumes": [ - "Puddle" - ], - "description": "You are protected.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Protector Bot": { "costumes": [ "Mastermind_Protector_Bot" @@ -30737,22 +27153,6 @@ "gender": "GENDER_MALE", "group_name": "UnifiedPeoplesArmy" }, - "Proximity Bomb": { - "costumes": [ - "OO_All_Bomb_Large_01" - ], - "description": "It looks like this bomb is rigged to detonate when approached.", - "gender": "GENDER_MALE", - "group_name": "Explosive" - }, - "Proximity Mine": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Traps" - }, "Psi-Scout Lk'Onik": { "costumes": [ "Rikti_Scout_LkOnik_01" @@ -30793,14 +27193,6 @@ "gender": "GENDER_MALE", "group_name": "CircleOfThornsEndgame" }, - "Psionic Flow": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Awakened" - }, "Psionic Nexus": { "costumes": [ "Storm_Void" @@ -30809,22 +27201,6 @@ "gender": "GENDER_MALE", "group_name": "Awakened" }, - "Psionic Typhoon": { - "costumes": [ - "Puddle" - ], - "description": "The battle for full control of Penelope Yin's mind has engulfed her network node in a gigantic psionic typhoon. Simply being here is a threat to your survival!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Psionic Void": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Awakened" - }, "Psychic Defender Clone": { "costumes": [ "CMR" @@ -30833,14 +27209,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Psychic Residue": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "CircleOfThornsEndgame" - }, "Psychotic": { "costumes": [ "Awakened_Psychotic_Male_01", @@ -30885,22 +27253,6 @@ "gender": "GENDER_MALE", "group_name": "Igneous" }, - "Pumpkin": { - "costumes": [ - "Puddle" - ], - "description": "It may not be a great pumpkin, but it's the best it can be.", - "gender": "GENDER_MALE", - "group_name": "Fir Bolg" - }, - "Pumpkin Patch": { - "costumes": [ - "Puddle" - ], - "description": "The Pumpkin Kings fiery Pumpkin Bomb exploded and left this burning patch on the ground.", - "gender": "GENDER_MALE", - "group_name": "Fir Bolg" - }, "Punk": { "costumes": [ "Mastermind_Thug_Minion_01_01", @@ -30929,14 +27281,6 @@ "gender": "GENDER_MALE", "group_name": "Pets" }, - "Pylon Nuke": { - "costumes": [ - "Puddle" - ], - "description": "These devices create an instability in the interdimensional field. When several are operating at once you can redirect an Item of Power to your base.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Pyra": { "costumes": [ "Model_Pyra" @@ -31197,14 +27541,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Quicksand": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Earth Control" - }, "Quiet": { "costumes": [ "Mapserver_Monkey_Big_03" @@ -31291,30 +27627,6 @@ "gender": "GENDER_MALE", "group_name": "Freakshow" }, - "Radiance Jump 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Radiance" - }, - "Radiance Jump 2": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Radiance" - }, - "Radiance Jump 3": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Radiance" - }, "Radiant Eremite": { "costumes": [ "CoL_Radiant_Eremite_01", @@ -31343,22 +27655,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Radiation Shield Generator": { - "costumes": [ - "Reactor_Shield_Generator" - ], - "description": "This machine can apply a temporary protective radiation shield.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Radioactive Cloud": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Archery" - }, "Radiologist": { "costumes": [ "CSC_07", @@ -31406,22 +27702,6 @@ "gender": "GENDER_MALE", "group_name": "GenericVillains" }, - "Rain of Arrows": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Archery" - }, - "Rain of Fire": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Fire Blast" - }, "Raine Heartfall": { "costumes": [ "Model_Raine_Heartfall" @@ -31983,14 +28263,6 @@ "gender": "GENDER_MALE", "group_name": "Former Loyalist" }, - "Recall Disruption": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Recruited Analyst": { "costumes": [ "FemaleNPC_01", @@ -32121,14 +28393,6 @@ "gender": "GENDER_MALE", "group_name": "Vahzilok" }, - "Regenerative Lichen": { - "costumes": [ - "DE_SporePod" - ], - "description": "This strange lichen appears to be emitting a regenerative aura now being picked up by the transformed War Walker.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Regent Korol": { "costumes": [ "Arachnos_Tarantulas_Fortunata_Korol" @@ -32164,38 +28428,6 @@ "gender": "GENDER_MALE", "group_name": "5thColumnEndgame" }, - "Reinforcement Door": { - "costumes": [ - "Door_Lambda_Reinforcement_Activated" - ], - "description": "Through these doors will spawn endless waves of IDF super soldiers -- and these doors cannot be destroyed except with Molecular Acid!", - "gender": "GENDER_NEUTER", - "group_name": "Equipment" - }, - "Reject Heroes": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Reject Villains": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Relay": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Relentless Armor": { "costumes": [ "CoV_Relentless_Armor_01", @@ -32207,14 +28439,6 @@ "gender": "GENDER_MALE", "group_name": "CarnivalOfVengeance" }, - "Remote Bomb": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Devices" - }, "Renegade": { "costumes": [ "dregsmaleboss1", @@ -32249,22 +28473,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Repair Field Generator": { - "costumes": [ - "Zigursky_Generator" - ], - "description": "This generator is increasing the power level of the Council Goliath War Walker's repair field.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Repulsion Field": { - "costumes": [ - "PuddleLongViewDistance" - ], - "description": "This is the emanation of the repulsion field that surrounds Mot.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Repulsive Spine": { "costumes": [ "DA_Mot_RepulsionSpine" @@ -32273,14 +28481,6 @@ "gender": "GENDER_NEUTER", "group_name": "Mot" }, - "Repulsor": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Requiem": { "costumes": [ "5thRequiem" @@ -32307,14 +28507,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Residual Ether": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Resistance Commando": { "costumes": [ "Resistance_Commando_01", @@ -32467,30 +28659,6 @@ "gender": "GENDER_MALE", "group_name": "Blackwing Industries" }, - "Resurrection Circle": { - "costumes": [ - "v_base_object" - ], - "description": "The healing altar works similar to a hospital, teleporting defeated members to the altar and automatically healing them. Its healing is less efficient than a hospital's, but can be improved with auxiliary items.", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, - "Resurrection Font": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Coralax" - }, - "Resurrection Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Reverberant": { "costumes": [ "Reverberant" @@ -32512,14 +28680,6 @@ "gender": "GENDER_FEMALE", "group_name": "TalonsOfVengeanceEndgame" }, - "Revivication": { - "costumes": [ - "Puddle" - ], - "description": "This entity Revives Nightstar and Siege", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Richard": { "costumes": [ "Citizen_Biz_Male_01", @@ -32585,14 +28745,6 @@ "gender": "GENDER_MALE", "group_name": "PraetorianResistance" }, - "Rikti Alteration Pod": { - "costumes": [ - "V_Dest_Rikti_Pod_01" - ], - "description": "This pod is Rikti technology at it's strangest. It seems to be some kind of full-body medical device, but it's actual purpose remains unknown.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Rikti Commander": { "costumes": [ "Rikti_Armour_Minion_02" @@ -32773,22 +28925,6 @@ "gender": "GENDER_MALE", "group_name": "RogueIslandPolice" }, - "Ripple Resonator": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "GoldBrickers" - }, - "Ripple Singularity": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "GoldBrickers" - }, "Ripplesurge": { "costumes": [ "V_CapAuDiable_Ripplesurge" @@ -32902,22 +29038,6 @@ "gender": "GENDER_FEMALE", "group_name": "Striga's Sentinels" }, - "Robo-Surgery": { - "costumes": [ - "v_base_object" - ], - "description": "P52144466", - "gender": "GENDER_MALE", - "group_name": "MedicalAux" - }, - "Robotic Fabricator": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Rock Wall": { "costumes": [ "Devouring_Rock_Wall" @@ -33712,14 +29832,6 @@ "gender": "GENDER_FEMALE", "group_name": "Praetorians" }, - "STF_Recluse_Teleport": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "SWAT Equalizer": { "costumes": [ "Swat_Equalizer" @@ -33755,30 +29867,6 @@ "gender": "GENDER_MALE", "group_name": "ParagonPolice" }, - "Safe": { - "costumes": [ - "OO_Ofc1_Safe_Floor_01" - ], - "description": "This safe contains the most valuable items stored at the facility being robbed by Concrete Bill's thugs.", - "gender": "GENDER_NEUTER", - "group_name": "Security" - }, - "Safe Zone": { - "costumes": [ - "Puddle" - ], - "description": "No PvP combat may occur while in the Safe Zone.", - "gender": "GENDER_MALE", - "group_name": "Safe Zone" - }, - "Safety Valve": { - "costumes": [ - "V_Mayhem_Firehydrant_01" - ], - "description": "In case of emergency, please turn the Safety Valve to the 'Off' position.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Salamander": { "costumes": [ "Eidola_Salamander" @@ -33894,22 +29982,6 @@ "gender": "GENDER_FEMALE", "group_name": "Civilian" }, - "Sap": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Sap" - }, - "Sapper": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Sapphire Barrier Nova": { "costumes": [ "Kheldian_NPC_Nova_Warshade_XL_Blue" @@ -33999,14 +30071,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Savage Leap": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Savage Melee" - }, "Savage Siren": { "costumes": [ "Model_Savage_Siren" @@ -34169,14 +30233,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Scorpion's Surprise": { - "costumes": [ - "Base_ScorpionsSurprise" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Scott": { "costumes": [ "Citizen_Biz_Male_01", @@ -34234,14 +30290,6 @@ "gender": "GENDER_MALE", "group_name": "Civilian" }, - "Scourging Blast": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Scourging Blast" - }, "Scrapper": { "costumes": [ "Longbow_Male_Boss_04", @@ -34259,14 +30307,6 @@ "gender": "GENDER_MALE", "group_name": "Longbow" }, - "Script Teleporter": { - "costumes": [ - "Puddle" - ], - "description": "In ur zonz brakin ur stuffz", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Scrounger Brawler": { "costumes": [ "Lost_01", @@ -34425,14 +30465,6 @@ "gender": "GENDER_FEMALE", "group_name": "PraetorianIDFEndgame" }, - "Scrying Paintings": { - "costumes": [ - "v_base_object" - ], - "description": "Through these paintings you can observe events as they unfold, effectively increasing control available in the base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Sea Witch": { "costumes": [ "Sea_Witch" @@ -34484,14 +30516,6 @@ "gender": "GENDER_MALE", "group_name": "The Hammers of Justice" }, - "Security Camera": { - "costumes": [ - "Casino_Camera" - ], - "description": "This security camera is keeping a watchful eye on things. You should avoid being seen by it.", - "gender": "GENDER_MALE", - "group_name": "Security" - }, "Security Chief Rodney": { "costumes": [ "Langston_Rodney_Combat" @@ -34500,14 +30524,6 @@ "gender": "GENDER_MALE", "group_name": "GoldBrickers" }, - "Seeing Crystal": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Seeker": { "costumes": [ "KoV_StreetJustice_LT_01", @@ -34551,14 +30567,6 @@ "gender": "GENDER_FEMALE", "group_name": "PraetorianSeers" }, - "Seer Desk": { - "costumes": [ - "v_base_object" - ], - "description": "From here orders can be sent and plans can be made. This desk increases the amount of control available for your base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Seer Patient": { "costumes": [ "Seer_Patient" @@ -34593,14 +30601,6 @@ "gender": "GENDER_MALE", "group_name": "Arachnos" }, - "Self Destruct": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "PraetorianIDF" - }, "Self-Reparing Extinction War Walker": { "costumes": [ "Clockwork_Loyalist_WarWalker_Goliath_01" @@ -34627,22 +30627,6 @@ "gender": "GENDER_NEUTER", "group_name": "Carnival Essence" }, - "Sensor Array": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, - "Sentinel of Lightning": { - "costumes": [ - "Pet_NoCollision" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, "Sentry": { "costumes": [ "Rock_Beast_03", @@ -34874,14 +30858,6 @@ "gender": "GENDER_MALE", "group_name": "Demons of Diabolique" }, - "Shadow Field": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Darkness Control" - }, "Shadow Freak": { "costumes": [ "Frk_11", @@ -35102,14 +31078,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Shared Suffering": { - "costumes": [ - "Puddle" - ], - "description": "This marker checks to see how many players are near you so that the damage from Shared Suffering can be split.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Sharon": { "costumes": [ "Citizen_Biz_Fem_01", @@ -35175,14 +31143,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Shield Charge": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Shield Defense" - }, "Shield Drone Essence": { "costumes": [ "Incarnate_Pet_Drone_01" @@ -35191,14 +31151,6 @@ "gender": "GENDER_NEUTER", "group_name": "Drones Essence" }, - "Shield Drone Pet": { - "costumes": [ - "Veteran_Pet_Drone_01" - ], - "description": "Vanguard has awarded you with a prototype Shield Drone.", - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Shield Oscillator Pet": { "costumes": [ "Veteran_Pet_Clockwork_01" @@ -35324,14 +31276,6 @@ "gender": "GENDER_MALE", "group_name": "Shivan" }, - "ShivanFakeTarget": { - "costumes": [ - "ShivanFakeTarget" - ], - "description": "This meteor was the first of potentially many more to come. Mender Silos seems to believe that this is an advance scout team, part of the 'coming storm' he speaks of.", - "gender": "GENDER_MALE", - "group_name": "Shivan" - }, "Shiverstrike": { "costumes": [ "HC_Shiverstrike" @@ -35419,14 +31363,6 @@ "gender": "GENDER_MALE", "group_name": "Praetorians" }, - "Siege Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity detects Siege around his start location for badge tracking.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Sigil": { "costumes": [ "Midnight_Sigil_01" @@ -35484,62 +31420,6 @@ "gender": "GENDER_NEUTER", "group_name": "Simulation" }, - "Singularity": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique creates a Singularity!", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Singularity-Finale": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique creates a Singularity!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Singularity-Master": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique creates a Singularity!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Singularity-Step1": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique creates a Singularity!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Singularity-Step2": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique creates a Singularity!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Singularity-Step3": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique creates a Singularity!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Siphon Power": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "NPC_Pets" - }, "Sir Agnan": { "costumes": [ "Model_Sir_Agnan" @@ -35634,22 +31514,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Situational Control": { - "costumes": [ - "Puddle" - ], - "description": "This entity is counted when Maelstrom enters a state of situational control.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Sixty Second Timer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Skull Prisoner": { "costumes": [ "Thug_Prisoner_01", @@ -35693,14 +31557,6 @@ "gender": "GENDER_MALE", "group_name": "TsooEndgame" }, - "Sky Raiders Costume": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Sky Skiff": { "costumes": [ "Sky_Skiff" @@ -35767,14 +31623,6 @@ "gender": "GENDER_MALE", "group_name": "Dr. Kane's Horrors" }, - "Sleet": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Cold Domination" - }, "Slick": { "costumes": [ "Thug_Prisoner_01", @@ -35810,30 +31658,6 @@ "gender": "GENDER_MALE", "group_name": "Malta" }, - "Slow Field": { - "costumes": [ - "v_base_object" - ], - "description": "This artifact calls on the deities of speed to curse all who enter it. Those in the area of effect have their speed and attack rate reduced.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Small Cable": { - "costumes": [ - "CablePiece_sm_01" - ], - "description": "These strange living spires are somehow connected with Hamidon's ability to control the creatures of the Devouring Earth.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Small Power Crystal": { - "costumes": [ - "v_base_object" - ], - "description": "A small generator and laptop computer. Just enough power and control to get a small base up and running.", - "gender": "GENDER_MALE", - "group_name": "EnergyControl" - }, "Small Trampoline": { "costumes": [ "SmallTrampoline" @@ -35885,14 +31709,6 @@ "gender": "GENDER_MALE", "group_name": "GoldBrickers" }, - "Smelting Cauldron": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "GoldBrickers" - }, "Snake Egg": { "costumes": [ "V_Dest_egg" @@ -35909,14 +31725,6 @@ "gender": "GENDER_MALE", "group_name": "RedCaps" }, - "Sneaked In": { - "costumes": [ - "Puddle" - ], - "description": "This entity applies the Sneaked In buff.", - "gender": "GENDER_MALE", - "group_name": "SummerEvent" - }, "Sneaky Champion": { "costumes": [ "Sneaky_Freaks_09", @@ -35956,14 +31764,6 @@ "gender": "GENDER_MALE", "group_name": "Freakshow" }, - "Snow Angel": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Snow Angel" - }, "Snow Beast": { "costumes": [ "Abominable_Snowman_MiniBoss_Pet" @@ -36027,30 +31827,6 @@ "gender": "GENDER_MALE", "group_name": "NemesisAutomatons" }, - "Sonic Arrow": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, - "Sonic Bomb": { - "costumes": [ - "Destructible_Bomb_Sonic" - ], - "description": "This sonic device could deliver quite a blast if it detonates. You'll have to destroy it first.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Sonic Grenade": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "FreedomCorps" - }, "Sonic Researcher": { "costumes": [ "Model_SonicResearcher" @@ -36159,38 +31935,6 @@ "gender": "GENDER_MALE", "group_name": "Zombies" }, - "Soulshatter-Finale": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique causes a Soulshatter at your location!", - "gender": "GENDER_MALE", - "group_name": "Diabolique" - }, - "Soulshatter-Main": { - "costumes": [ - "Puddle" - ], - "description": "Diabolique causes a Soulshatter at your location!", - "gender": "GENDER_MALE", - "group_name": "Diabolique" - }, - "Sounds": { - "costumes": [ - "Puddle" - ], - "description": "Plays sounds", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Spacetime Rift": { - "costumes": [ - "Puddle" - ], - "description": "Anti-matter has opened this rift in spacetime, linking Keyes Island to his space station and enabling his amassed Warworks forces to join him in securing the reactor.", - "gender": "GENDER_NEUTER", - "group_name": "Portals" - }, "Spatial Annihilation Bomb": { "costumes": [ "Rikti_UXB" @@ -36207,46 +31951,6 @@ "gender": "GENDER_MALE", "group_name": "Hydra" }, - "Spawning Chamber": { - "costumes": [ - "Destructible_Spawn_DevEarth" - ], - "description": "This large cyst seems to call up Devouring Earth creatures from the ground. You'll have to destroy it if you want to stem the tide of monsters.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Spear Damage": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BattleMaidenAlpha" - }, - "Spear Entity": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BattleMaidenAlpha" - }, - "Spear Target": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BattleMaidenAlpha" - }, - "Spear Visual": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BattleMaidenAlpha" - }, "Spec Ops": { "costumes": [ "Mastermind_Military_Lieutenant_01", @@ -36394,14 +32098,6 @@ "gender": "GENDER_MALE", "group_name": "CircleOfThorns" }, - "Spectral Sensor": { - "costumes": [ - "OO_tek1_machine_02" - ], - "description": "This strange machine looks highly advanced.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Spectral Servant": { "costumes": [ "Hero_PhantasmDark" @@ -36494,14 +32190,6 @@ "gender": "GENDER_MALE", "group_name": "Pets" }, - "Spike Trap": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "ArachnosEndgame" - }, "Spine Carver": { "costumes": [ "FRKLK_Carver03" @@ -36591,14 +32279,6 @@ "gender": "GENDER_FEMALE", "group_name": "Pet" }, - "Spirit Signal": { - "costumes": [ - "v_base_object" - ], - "description": "The Rune Spire channels healing energies, improving an altar's healing effectiveness by {FunctionPercent}%. Rent: 100 Prestige", - "gender": "GENDER_MALE", - "group_name": "MedicalAux" - }, "Spirit Tree": { "costumes": [ "Plant_SpiritTree" @@ -36607,14 +32287,6 @@ "gender": "GENDER_MALE", "group_name": "Plant Control" }, - "Spirit Ward": { - "costumes": [ - "OO_SpiritWard" - ], - "description": "This spirit ward protects the Chapel of Enduring Light from the approach of the Apparitions and those they have possessed. If they are destroyed then the chapel will be overrun, the Carnival scattered, and Sorceress Serene will be victorious here in First Ward.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "Spirit of Death": { "costumes": [ "Pantheon_Death" @@ -36665,14 +32337,6 @@ "gender": "GENDER_FEMALE", "group_name": "Resistance" }, - "Spring Attack": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Shield Defense" - }, "Sprite": { "costumes": [ "Croatoa_Wisp_Blue" @@ -36731,14 +32395,6 @@ "gender": "GENDER_FEMALE", "group_name": "Rogue Isles Villains" }, - "Stasis Tube": { - "costumes": [ - "V_Dest_Stasis_01" - ], - "description": "This is a high-tech stasis tube used to hold human-sized medical subjects.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Statesman": { "costumes": [ "Model_Statesman" @@ -36747,22 +32403,6 @@ "gender": "GENDER_MALE", "group_name": "Freedom Phalanx" }, - "Statesman Costumes": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Static Field": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Electric Control" - }, "Statilia Messalina": { "costumes": [ "Roman_Peasant_Female_01", @@ -36788,30 +32428,6 @@ "gender": "GENDER_MALE", "group_name": "Relic" }, - "Stealth Debuffer": { - "costumes": [ - "Puddle" - ], - "description": "I see you!", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Stealth Suppression": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Steamer Chest": { - "costumes": [ - "OO_Cav_Treasure_Chest_01" - ], - "description": "The contents of this chest seem to be regenerating the health of the villains around you.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Steel Serpent": { "costumes": [ "Model_Silver_Serpent" @@ -37095,14 +32711,6 @@ "gender": "GENDER_MALE", "group_name": "Snakes" }, - "Sticky Arrow": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Trick Arrow" - }, "Sting Agent": { "costumes": [ "Villain_Wyvern_Sting_Agent_01", @@ -37113,14 +32721,6 @@ "gender": "GENDER_MALE", "group_name": "Wyvern" }, - "Stone Henge": { - "costumes": [ - "Destructible_Stones_RedcapHenge" - ], - "description": "This henge is a focal point of the Red Caps' power.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Stonethrower": { "costumes": [ "Base_Stonethrower" @@ -37169,14 +32769,6 @@ "gender": "GENDER_FEMALE", "group_name": "BanishedPantheonEndgame" }, - "Storm Void": { - "costumes": [ - "Storm_Void" - ], - "description": "The swirling energies of the storm which has engulfed Penelope Yin's network node have created voids within the flow of energy. These voids have taken shape and can be moved around. When brought close to a Vortex, the two will combine to create an Eye of the Storm. This Eye of the Storm will suppress the storm's effects on those who stand in it and will allow Metronome's voice to reach Penelope, calming her and ending her untouchable state.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Streng": { "costumes": [ "Pumpkin_Minion" @@ -37488,14 +33080,6 @@ "gender": "GENDER_MALE", "group_name": "RuluShin" }, - "Summoning Circle": { - "costumes": [ - "FW_SummoningCircleInner" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Sun Xiong": { "costumes": [ "Model_SunXiong" @@ -37568,56 +33152,6 @@ "gender": "GENDER_MALE", "group_name": "Council" }, - "Superadine Lab": { - "costumes": [ - "OO_All_Chemical_set_01" - ], - "description": "In the prescence of this lab, you can't help inhaling a whiff or two of Superadine. The substance will buff your Endurance until the lab is destroyed.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Supercomputer": { - "costumes": [ - "v_base_object_unselectable" - ], - "description": "Large bases have serious processing needs The Benedict 87 supercomputer has the ability to simultaneously process the multiple control programs needed to maintain base security.", - "gender": "GENDER_MALE", - "group_name": "Control" - }, - "Superior Defender's Bastion": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Superior Defender's Bastion" - }, - "Superior Scourging Blast": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Superior Scourging Blast" - }, - "Superior Vigilant Assault": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Superior Vigilant Assault" - }, - "Supplies": { - "costumes": [ - "V_Destructible_Props_Supplies_01", - "V_Destructible_Props_Supplies_02", - "V_Destructible_Props_Supplies_03" - ], - "description": "This is a pile of construction supplies including building materials such as boards, braces, and nails and various tools such as hammers, saws, and shovels.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Support Automaton": { "costumes": [ "MaleAutomaton_01", @@ -37798,38 +33332,6 @@ "gender": "GENDER_MALE", "group_name": "Syndicate" }, - "Swords Damage": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, - "Swords Entity": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, - "Swords Target": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, - "Swords Visual": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, "Sybil": { "costumes": [ "Ravenna_FakeSybil" @@ -37990,30 +33492,6 @@ "gender": "GENDER_MALE", "group_name": "Freedom Phalanx" }, - "Syndicate Costumes": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Syndicate Takedown Kill Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity sits in a kill volume around the Syndicate Takedown building and kills any non-boss entity unfortunate enough to be pushed off the building.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Syndicate Takedown Teleport Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity sits in a teleport volume around the Syndicate Takedown building and teleports any entity unfortunate enough to be pushed off the building.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "T.E.S.T. Leader": { "costumes": [ "Pratoria_Leader_01", @@ -38058,22 +33536,6 @@ "gender": "GENDER_MALE", "group_name": "H.D." }, - "TPN Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "TPN Technician Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity is counted by the TPN Technician to determine how effectively it suppresses propaganda.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Tac Ops Commander": { "costumes": [ "Malta_Tac_Team_Boss_01", @@ -38178,14 +33640,6 @@ "gender": "GENDER_FEMALE", "group_name": "Syndicate" }, - "Tar Patch": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Dark Miasma" - }, "Tarantula": { "costumes": [ "Arachnos_Tarantulas_01" @@ -38218,14 +33672,6 @@ "gender": "GENDER_FEMALE", "group_name": "ArachnosEndgame" }, - "Target": { - "costumes": [ - "Pet_NoCollision" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Target Dummy": { "costumes": [ "Male_TargetDummy" @@ -38234,22 +33680,6 @@ "gender": "GENDER_MALE", "group_name": "5thColumn" }, - "Targetable Dummy": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Targeting Module": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, "Taskmaster": { "costumes": [ "Drudges_Taskmaster_01", @@ -38269,38 +33699,6 @@ "gender": "GENDER_MALE", "group_name": "Tsoo" }, - "Team Transporter": { - "costumes": [ - "Team_Transport_Hero" - ], - "description": null, - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, - "Tear Gas Grenade": { - "costumes": [ - "Puddle" - ], - "description": "In the fight to keep the city safe there are times when it is too dangerous for a human officer to lead the way. For these high risk situations the PPD has designed three robotic automatons to keep them in the game. Each model is designed to take whatever punishment can be thrown at it and keep the pressure on with an impressive arsenal of its own.", - "gender": "GENDER_MALE", - "group_name": "ParagonPoliceEndgame" - }, - "Tech Lab Generator": { - "costumes": [ - "V_Destructible_Props_Tech_Generator" - ], - "description": "This is a high-tech generator used as a dedicated power source for equipment in this are", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Tech Portal": { - "costumes": [ - "V_Dest_Portal_Tech_100" - ], - "description": "By using Portal Corp. technology, Longbow can move troops instantly across great distances. Destroying this portal would cut them off further re-enforcements.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Tech Specialist?": { "costumes": [ "Rikti_Flesh_02", @@ -38328,14 +33726,6 @@ "gender": "GENDER_MALE", "group_name": "Victim of Nemesis" }, - "Tectonic Assault": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BlackKnights" - }, "Telekineticist": { "costumes": [ "Syndicate_Telekinetic_Male_01", @@ -38357,14 +33747,6 @@ "gender": "GENDER_FEMALE", "group_name": "PraetorianIDFEndgame" }, - "Teleportation Field": { - "costumes": [ - "Puddle" - ], - "description": "This is the entity that disables travel powers and kill-self powers in the neutral tutorila.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Tellurian Seedling": { "costumes": [ "Burr_Hamidon_06" @@ -38390,22 +33772,6 @@ "gender": "GENDER_MALE", "group_name": "Generator" }, - "Temp Disabler": { - "costumes": [ - "Puddle" - ], - "description": "This entity disables all powers marked with \"kDisable_Temp\" mode disallowed.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Temp Disabler Fast": { - "costumes": [ - "Puddle" - ], - "description": "This entity disables all powers marked with \"kDisable_Temp\" mode disallowed.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Tempest": { "costumes": [ "KoV_DualBlades_BS_01", @@ -38444,30 +33810,6 @@ "gender": "GENDER_NEUTER", "group_name": "Knives of Vengeance Essence" }, - "Tempest Gyre": { - "costumes": [ - "P_Apparitions_Boss_Merged" - ], - "description": "The swirling energies of the storm which has engulfed Penelope Yin's network node have created vortices within the flow of energy. These vortices have taken shape and can be moved around. These vortices are dangerous and should be eliminated as soon as possible.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Temporal Disruption": { - "costumes": [ - "Puddle" - ], - "description": "Randomly Phase Shifts Players", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Temporal Portal": { - "costumes": [ - "Destructible_5thColumnPortal_Active" - ], - "description": "These 5th Column portals have allowed the villains to hide themselves throughout time.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Tentacle": { "costumes": [ "Octopus_Arm" @@ -38484,30 +33826,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Terminal": { - "costumes": [ - "v_base_object" - ], - "description": "This terminal monitors many of the functions of base security and increases the control generated by the base computer system.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, - "Terminal Field": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Terminal Teleport": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Terra": { "costumes": [ "Devoured_05" @@ -38570,14 +33888,6 @@ "gender": "GENDER_MALE", "group_name": "Clockwork" }, - "Tesla Cage": { - "costumes": [ - "v_base_object" - ], - "description": "This device is charged with an entrapment spell that will automatically trigger when an enemy comes near. If successful, the victim is immobilized in a tesla field for a short time.", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, "Tesla Duke": { "costumes": [ "Sprocket_09", @@ -38640,14 +33950,6 @@ "gender": "GENDER_MALE", "group_name": "GoldBrickers" }, - "The Carnival's Protection": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "The Center": { "costumes": [ "The_Center" @@ -38855,14 +34157,6 @@ "gender": "GENDER_MALE", "group_name": "BanishedPantheon" }, - "The Well of the Furies": { - "costumes": [ - "Puddle" - ], - "description": "This entity will debilitate players by randomly shutting off a single player's powers every 30s.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Theadora Marcone": { "costumes": [ "Model_Theadora_Marcone" @@ -38871,14 +34165,6 @@ "gender": "GENDER_FEMALE", "group_name": "Longbow" }, - "Thirty Second Timer": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Thomas": { "costumes": [ "Citizen_Biz_Male_01", @@ -38973,22 +34259,6 @@ "gender": "GENDER_MALE", "group_name": "CircleOfThorns" }, - "Thorntrops": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Throntrops": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Thorns" - }, "Thug": { "costumes": [ "Thug_01", @@ -39016,22 +34286,6 @@ "gender": "GENDER_FEMALE", "group_name": "Cabal" }, - "Thunder Strike": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "CapauDiableDemons" - }, - "Thunderstrike": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Malaise" - }, "Thurisaz Rune": { "costumes": [ "V_Dest_LC_Trap_Fire_01" @@ -39114,14 +34368,6 @@ "gender": "GENDER_MALE", "group_name": "Tsoo" }, - "Time Bomb": { - "costumes": [ - "Puddle" - ], - "description": "10...9...8...7...6...5...", - "gender": "GENDER_MALE", - "group_name": "Crey" - }, "Time Capsule": { "costumes": [ "OO_Wrhs_Museum_Display_Case_01" @@ -39342,14 +34588,6 @@ "gender": "GENDER_MALE", "group_name": "Rularuu" }, - "Tornado": { - "costumes": [ - "Pet_NoCollision" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Storm Summoning" - }, "Tortured Soul": { "costumes": [ "Necromancy_Ghost_Warrior" @@ -39366,14 +34604,6 @@ "gender": "GENDER_MALE", "group_name": "BanishedPantheonEndgame" }, - "Tower Protection": { - "costumes": [ - "Puddle" - ], - "description": "Tower Protection", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Towering Inferno": { "costumes": [ "Model_Towering_Inferno" @@ -39398,14 +34628,6 @@ "gender": "GENDER_MALE", "group_name": "ArachnosEndgame" }, - "Toxic Waste": { - "costumes": [ - "Destructible_Barrel_Poison" - ], - "description": "This barrel contains a powerful drug that will weaken the immune system of Paragon City's people. You must destroy it before it's introduced to the water supply.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Tracker": { "costumes": [ "Seer_Tracker_01" @@ -39439,22 +34661,6 @@ "gender": "GENDER_MALE", "group_name": "PracticeRobots" }, - "Transference": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, - "Transformation Capsule": { - "costumes": [ - "Vamp_chamber" - ], - "description": "The vampyri are the very pinnacle of the Council's super soldier program. The final stages of the process are carried out within these crypt-like chambers, where a complex cocktail of hormones and mutagens are fed to the prospective vampyri intravenously. Though some fatalities inevitably occur, the resulting super soldiers are more than enough to make up for the loss.", - "gender": "GENDER_MALE", - "group_name": "Council" - }, "Transformed Rikti": { "costumes": [ "Rikti_Flesh_01", @@ -39466,22 +34672,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Transformer": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "DefenseAux" - }, - "Transfusion": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Trapdoor": { "costumes": [ "Model_Trapdoor" @@ -39525,14 +34715,6 @@ "gender": "GENDER_MALE", "group_name": "Prisoners" }, - "Trash Can": { - "costumes": [ - "TrashCan" - ], - "description": "A large outdoor trash can.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Trebuchet Drop Ship": { "costumes": [ "rikti_shuttle_lgtf" @@ -39549,14 +34731,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Tree of Wonders": { - "costumes": [ - "v_base_object" - ], - "description": "The Tree of Wonders produces Inspirations that can be used by Super Group members, for a cost. You can also feed it with unneeded Inspirations to get Influence. Rent: 100 Prestige", - "gender": "GENDER_MALE", - "group_name": "Medical" - }, "Tri-Cannon": { "costumes": [ "Pet_Tri_Turret_01" @@ -39565,54 +34739,6 @@ "gender": "GENDER_MALE", "group_name": "Arsenal Control" }, - "Triage Beacon": { - "costumes": [ - "Puddle" - ], - "description": "The Beacon is immobile, but it emits a powerful healing aura that increases the Regeneration Rate of the Mastermind's nearby allies.", - "gender": "GENDER_MALE", - "group_name": "Traps" - }, - "Trick Shot Jump 1": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Trick Shot Jump 2": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Trick Shot Jump 3": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Trick Shot Jump 4": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Arachnos" - }, - "Trip Mine": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Devices" - }, "Troll Bodyguard": { "costumes": [ "Thug_Troll_Boss_Atta" @@ -39725,17 +34851,6 @@ "gender": "GENDER_MALE", "group_name": "Trolls" }, - "Truck": { - "costumes": [ - "V_Mayhem_Vehicle_Truck01", - "V_Mayhem_Vehicle_Truck02", - "V_Mayhem_Vehicle_Truck03", - "V_Mayhem_Vehicle_Truck04" - ], - "description": "This truck looks like it's of sturdy construction.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Tub Ci": { "costumes": [ "Model_Tsoo_Tub_Ci" @@ -39777,14 +34892,6 @@ "gender": "GENDER_FEMALE", "group_name": "Resistance" }, - "Turbine Generator": { - "costumes": [ - "v_base_object" - ], - "description": "Portacio Industry's LS-3100 dual-shaft gas turbine can be configured for gas or gas/water operation. Backed by Portacio's warranty and service network, the LS-3100 is the easy-to-maintain solution to base power needs.", - "gender": "GENDER_MALE", - "group_name": "Energy" - }, "Turia Marciana": { "costumes": [ "Roman_Peasant_Female_01", @@ -39802,14 +34909,6 @@ "gender": "GENDER_FEMALE", "group_name": "Romans_Good" }, - "Twilight Grasp": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Midnight_Squad" - }, "Twinshot": { "costumes": [ "Model_Twinshot_NoRing" @@ -39977,30 +35076,6 @@ "gender": "GENDER_MALE", "group_name": "TheFamily" }, - "Underground Completion": { - "costumes": [ - "Puddle" - ], - "description": "This entity checks to see if all players in a trial have earned the completion badge.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, - "Underground Shadows": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "Underground Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity sits in a volume and defeats itself if it detects Desdemona enter the volume.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Unkempt - Female": { "costumes": [ "FemaleNPC_101", @@ -40061,14 +35136,6 @@ "gender": "GENDER_MALE", "group_name": "D.O.O.M." }, - "Unstable Enzymatic Protein Slurry": { - "costumes": [ - "OO_Poison_Bomb" - ], - "description": "Unstable Enzymatic Protein Slurry", - "gender": "GENDER_MALE", - "group_name": "Explosive" - }, "Unstable Flux": { "costumes": [ "V_CapAuDiable_Ripplepuddles" @@ -40126,14 +35193,6 @@ "gender": "GENDER_MALE", "group_name": "Rularuu" }, - "Vacuum": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Wind Control" - }, "Vacuum Bot": { "costumes": [ "Clockwork_Antimatter_Minion_01", @@ -40407,14 +35466,6 @@ "gender": "GENDER_NEUTER", "group_name": "Vanguard Shield" }, - "Vanguard Warhead": { - "costumes": [ - "Vanguard_Warhead_Crate_01" - ], - "description": "Inside this Vanguard Crate appears to be a warhead of some type.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, "Vanguard Wizard": { "costumes": [ "Vanguard_Shield_Male_Boss_01" @@ -40464,14 +35515,6 @@ "gender": "GENDER_FEMALE", "group_name": "CarnivalOfVengeance" }, - "Vent Smoke": { - "costumes": [ - "Puddle" - ], - "description": "The vents are spitting out hot flames.", - "gender": "GENDER_MALE", - "group_name": "Council" - }, "Verina Matina": { "costumes": [ "Roman_Peasant_Female_01", @@ -40514,14 +35557,6 @@ "gender": "GENDER_MALE", "group_name": "CarnivalOfVengeance" }, - "Very Large Cable": { - "costumes": [ - "CablePiece_xl_01" - ], - "description": "These strange living spires are somehow connected with Hamidon's ability to control the creatures of the Devouring Earth.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Vibia Matina": { "costumes": [ "Roman_Peasant_Female_01", @@ -40572,14 +35607,6 @@ "gender": "GENDER_MALE", "group_name": "Rikti" }, - "Victory Rush": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Pets" - }, "Viewer": { "costumes": [ "Seer_Viewer_01" @@ -40588,14 +35615,6 @@ "gender": "GENDER_FEMALE", "group_name": "PraetorianSeers" }, - "Viewing Portal": { - "costumes": [ - "v_base_object" - ], - "description": "Through this gateway thoughts fly quicker than wind. The portal generates additional control for your base.", - "gender": "GENDER_MALE", - "group_name": "ControlAux" - }, "Vigilant": { "costumes": [ "Crey_Agent_M_02", @@ -40610,14 +35629,6 @@ "gender": "GENDER_MALE", "group_name": "Crey" }, - "Vigilant Assault": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Vigilant Assault" - }, "Vince Dubrowski": { "costumes": [ "Model_Vince_Dubrowski_NoRing" @@ -40889,30 +35900,6 @@ "gender": "GENDER_MALE", "group_name": "Outcasts" }, - "Voltaic Geyser": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Earth Control" - }, - "Voltaic Sentinel": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Earth Control" - }, - "Voracious Maw": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "BanishedPantheonEndgame" - }, "Vorpal Sword": { "costumes": [ "Vorpal_Sword" @@ -40921,14 +35908,6 @@ "gender": "GENDER_MALE", "group_name": "AnimusArcana" }, - "Vortex": { - "costumes": [ - "Jack" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Wind Control" - }, "Vortex Adjutant": { "costumes": [ "Council_Nebula_Black_02" @@ -41299,14 +36278,6 @@ "gender": "GENDER_MALE", "group_name": "Nemesis" }, - "Warmth of Prometheus": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Warrant": { "costumes": [ "Model_Warrant_NoRing" @@ -41437,14 +36408,6 @@ "gender": "GENDER_MALE", "group_name": "Drudges" }, - "Water Spout": { - "costumes": [ - "Pet_NoCollision" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Coralax" - }, "Wavelength": { "costumes": [ "Model_Wavelength" @@ -41469,38 +36432,6 @@ "gender": "GENDER_MALE", "group_name": "DevouringEarth" }, - "Weakness Field": { - "costumes": [ - "v_base_object" - ], - "description": "", - "gender": "GENDER_MALE", - "group_name": "Defense" - }, - "Weapon Cache": { - "costumes": [ - "OO_Awrhs_Crate_01" - ], - "description": "Weapon Caches contains some experimental weapons that might prove to be useful on your siege of Praetoria.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Weapon Rack": { - "costumes": [ - "OO_All_WeaponRack_Archaic_02" - ], - "description": "A rack of mundane weaponry.", - "gender": "GENDER_MALE", - "group_name": "Equipment" - }, - "Webbed Passage": { - "costumes": [ - "DestructibleSpiderWeb" - ], - "description": "These are massive spider webs which block this passage.", - "gender": "GENDER_MALE", - "group_name": "Object" - }, "Webs": { "costumes": [ "DestructibleSpiderWeb2" @@ -41509,14 +36440,6 @@ "gender": "GENDER_MALE", "group_name": "Hazard" }, - "Well Watcher": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Werewolf": { "costumes": [ "Council_Nightwolf_01", @@ -41550,14 +36473,6 @@ "gender": "GENDER_MALE", "group_name": "Nemesis" }, - "Whirlpool": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Water Blast" - }, "Whispering Coyote": { "costumes": [ "Model_Whispering_Coyote_NoRing" @@ -41615,22 +36530,6 @@ "gender": "GENDER_MALE", "group_name": "RedCaps" }, - "Will of the Earth": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "DevouringEarth" - }, - "Willforge": { - "costumes": [ - "Willforge" - ], - "description": "The Willforge is a sort of quasi-sentient factory that turns raw reality into physical or even living objects. If Rularuu wills it, so shall it be! Destroy the Willforge's spawns and the obelisk they protect is vulnerable.", - "gender": "GENDER_NEUTER", - "group_name": "Objects" - }, "William": { "costumes": [ "Citizen_Biz_Male_01", @@ -41696,14 +36595,6 @@ "gender": "GENDER_MALE", "group_name": "MAGI" }, - "Willie Pete": { - "costumes": [ - "Puddle" - ], - "description": "Vanguard is a group backed by the United Nations, and headed up by Lady Grey. Their soldiers are specifically outfitted in Impervium armor and with high-tech as well as magical weaponry to fight the Rikti. Everything they do is centered around containing the Rikti menace on our world.", - "gender": "GENDER_MALE", - "group_name": "Vanguard" - }, "Wing Fang Agent": { "costumes": [ "Villain_Wyvern_Wing_Fang_Agent_01", @@ -42041,62 +36932,6 @@ "gender": "GENDER_MALE", "group_name": "D.O.O.M." }, - "World of Anguish": { - "costumes": [ - "Puddle" - ], - "description": "This entity runs the World of Anguish power used by Malaise in the first battle.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "World of Anguish - Hyper": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "World of Anguish - Large": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "World of Anguish - Master": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "World of Anguish - Medium": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "World of Anguish - Small": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, - "World of Anguish - Super": { - "costumes": [ - "Puddle" - ], - "description": "This entity destroys itself as soon as it is spawned. A Script tracks how many are defeated.", - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Worldweary Citizen": { "costumes": [ "Citizen_FirstWard_01", @@ -42247,14 +37082,6 @@ "gender": "GENDER_FEMALE", "group_name": "MAGI" }, - "ZE Syndicate Winds": { - "costumes": [ - "Puddle" - ], - "description": null, - "gender": "GENDER_MALE", - "group_name": "Objects" - }, "Zane": { "costumes": [ "Model_Zane_NoRing" diff --git a/migrations/versions/937d6b648884_add_damage_table.py b/migrations/versions/937d6b648884_add_damage_table.py new file mode 100644 index 0000000..063bdec --- /dev/null +++ b/migrations/versions/937d6b648884_add_damage_table.py @@ -0,0 +1,40 @@ +"""add damage table + +Revision ID: 937d6b648884 +Revises: 0e2640a2c350 +Create Date: 2024-07-27 08:59:17.566328 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '937d6b648884' +down_revision: Union[str, None] = '0e2640a2c350' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('damage', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('hero_id', sa.Integer(), nullable=True), + sa.Column('target', sa.String(length=256), nullable=False), + sa.Column('power', sa.String(length=256), nullable=False), + sa.Column('damage', sa.Integer(), nullable=False), + sa.Column('damage_type', sa.String(length=64), nullable=False), + sa.Column('special', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['hero_id'], ['hero.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('damage') + # ### end Alembic commands ### diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 192f95e..088f0a7 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -10,17 +10,14 @@ from datetime import datetime import cnv.database.models as models +import cnv.logger import cnv.voices.voice_builder as voice_builder import lib.settings as settings import pythoncom import voicebox +from cnv.lib.proc import send_log_lock from pedalboard.io import AudioFile from voicebox.tts.utils import get_audio_from_wav_file -import cnv.logger - -REPLAY = settings.REPLAY - - cnv.logger.init() log = logging.getLogger(__name__) @@ -208,7 +205,10 @@ def run(self): # it isn't very well named, but this will speak "message" as # character and cache a copy into cachefile. - voice_builder.create(character, message, session) + try: + voice_builder.create(character, message, session) + except Exception as err: + log.exception(err) # we've said our piece. # self.speaking_queue.task_done() @@ -229,6 +229,12 @@ class LogStream: This is a streaming processor for the log file. Its kind of sorely deficient in more than one way but seems to be at least barely adequate. """ + + # if we don't get any new lines in this many seconds, double check to make + # sure we're actually reading the most recent log file. I think a float + # would work here too. + READ_TIMEOUT = 60 + # what channels are we paying attention to, which self.parser function is # going to be called to properly extract the data from that log entry. channel_guide = { @@ -269,8 +275,7 @@ def __init__( There is some character data there we need. Then we skip to the end and start tailing. """ - all_files = glob.glob(os.path.join(logdir, "*.txt")) - self.filename = max(all_files, key=os.path.getctime) + self.logdir = logdir self.announce_badges = badges # TODO: these should be exposed on the configuration page self.npc_speak = npc @@ -283,43 +288,54 @@ def __init__( self.caption_speaker = None self.caption_color_to_speaker = {} - print(f"Tailing {self.filename}...") - # grab a file handle - self.handle = open( - os.path.join(logdir, self.filename), - encoding="utf-8" - ) - # carry these along for I/O self.speaking_queue = speaking_queue self.event_queue = event_queue + + self.logfile = None + self.first_tail = True + log.debug(f'(init) Setting {self.logfile=}') self.find_character_login() + def open_latest_log(self): + all_files = glob.glob(os.path.join(self.logdir, "*.txt")) + filename = max(all_files, key=os.path.getctime) + + log.debug(f'(oll) Setting {self.logfile=}') + self.logfile = filename + return open( + os.path.join(self.logdir, filename), + encoding="utf-8" + ) + def find_character_login(self): """ Skim through and see if can find the beginning of the current characters login. """ - self.handle.seek(0, 0) hero_name = None - for line in self.handle.readlines(): - try: - datestr, timestr, line_string = line.split(None, 2) - except ValueError: - continue - lstring = line_string.split() - if lstring[0] == "Welcome": - # Welcome to City of Heroes, - self.is_hero = True - hero_name = " ".join(lstring[5:]).strip("!") - # we want to notify upstream UI about this. - elif lstring[0:5] == ["Now", "entering", "the", "Rogue", "Isles,"]: - # 2024-04-17 17:10:27 Now entering the Rogue Isles, Kim Chee! - self.is_hero = False - hero_name = " ".join(lstring[5:]).strip("!") - + # we want the most recent entries of specific strings. This might be + # better done backwards + with self.open_latest_log() as handle: + for line in handle: + try: + datestr, timestr, line_string = line.split(None, 2) + except ValueError: + continue + + lstring = line_string.split() + if lstring[0] == "Welcome": + # Welcome to City of Heroes, + self.is_hero = True + hero_name = " ".join(lstring[5:]).strip("!") + # we want to notify upstream UI about this. + elif lstring[0:5] == ["Now", "entering", "the", "Rogue", "Isles,"]: + # 2024-04-17 17:10:27 Now entering the Rogue Isles, Kim Chee! + self.is_hero = False + hero_name = " ".join(lstring[5:]).strip("!") + if hero_name: self.hero = Hero(hero_name) @@ -327,23 +343,13 @@ def find_character_login(self): self.event_queue.put(("SET_CHARACTER", self.hero.name)) self.speaking_queue.put((None, f"Welcome back {self.hero.name}", "system")) + if not settings.REPLAY: + send_log_lock() else: self.speaking_queue.put((None, "User name not detected", "system")) log.warning("Could NOT find hero name.. good luck.") - # now move the file handle to the end, we - # will starting parsing everything for real this - # time. - if REPLAY: - # this will process the whole file, essentailly re-playing the - # session. This is very handy for diagnostics since it makes your - # most recent chat log a canned example you can send through the - # engine over and over. Super helpful. - self.handle.seek(0, 0) - else: - # this is what users will expect. - self.handle.seek(0, io.SEEK_END) def channel_chat_parser(self, lstring): speaker, dialog = " ".join(lstring[1:]).split(":", maxsplit=1) @@ -353,23 +359,55 @@ def channel_chat_parser(self, lstring): def tell_chat_parser(self, lstring): # why is there an extra colon for Tell? IDK. if lstring[1][:3] == "-->": + # note: target names with spaces are not parsed # ["[Tell]", "-->Toxic", "Timber:", "pls"] # this is a reply to a tell, or an outbound tell. + + # these are self-tells so we have to catch both of them, but we only + # need to process one of them. For no particular reason + # we're going to use the --> inbound message. + + # [Tell] :Ghlorius: [SIDEKICK] name="Ghlorius" + # [Tell] -->Ghlorius: [SIDEKICK] name="Ghlorius" dialog = ( " ".join(lstring[1:]).split(":", maxsplit=1)[-1].strip() ) + + if dialog.split()[0] == "[SIDEKICK]": + # player attribute self-reporting + try: + key, value = dialog.split()[1].split('=') + except ValueError: + log.warning(f'Invalid SIDEKICK: {dialog}') + return "__self__", None + + settings.set_config_key( + key, + value.strip('"'), + cf='state.json' + ) + + # don't try and speak it. + return "__self__", None + speaker = "__self__" else: # ["[Tell]", ":Dressy Bessie:", "I", "can", "bump", "you"] # ["[Tell]", ":StoneRipper:", "it:s", "underneath"] try: + # ["", "Dressy Bessie", "I can bump you"] + # ['', 'StoneRipper', ' it:s underneath'] _, speaker, dialog = " ".join(lstring[1:]).split( ":", maxsplit=2 ) - # ["", "Dressy Bessie", "I can bump you"] - # ['', 'StoneRipper', ' it:s underneath'] + # ignore SIDEKICK self-tells + if dialog.split()[0] == "[SIDEKICK]": + + return "__self__", None + except ValueError: # I didn't note the string that caused me to add this. Oops. + # logging at info so I can maybe catch it in the future. log.info(f'1 ADD DOC: {lstring=}') speaker, dialog = " ".join(lstring[1:]).split(":", maxsplit=1) @@ -377,9 +415,24 @@ def tell_chat_parser(self, lstring): return speaker, dialog def caption_parser(self, lstring): + """ + Caption messages are a liitle.. fun. Usually the first message from a + given speaker identifies the speaker by name but subsequent messages do + not. They do consistently use the same background color for each + speaker. So we notice when a named speaker introduces themselves then + we associate that bgcolor with that speaker so we can use the same + voice. + + A good test for this is the back and forth dialog between Dana and + Matthew. + + The key data here are the strings in CAPTION_SPEAKER_INDICATORS linking + introduction messages to speakers. It will need to be significantly + expanded to cover more 'caption' speakers. + """ # [Caption] My Shadow Simulacrum will destroy Task Force White Sands! # [Caption] Positron here. I'm monitoring your current progress in the sewers. - log.info(f'CAPTION: {lstring}') + log.debug(f'CAPTION: {lstring}') dialog = plainstring(" ".join(lstring[1:])) dialog = dialog.replace('*', '') # the stupid TTS engine say "asterisk" and it is tediously dumb. @@ -425,8 +478,13 @@ def channel_messager(self, lstring): parser = getattr(self, guide['parser']) speaker, dialog = parser(lstring) - log.debug(f"Adding {speaker}/{dialog} to reading queue") - self.speaking_queue.put((speaker, dialog, guide['name'])) + # sometimes people don't say anything, like "..." so we drop any + # non-dialog messages. + if dialog and dialog.strip(): + if (settings.REPLAY and settings.SPEECH_IN_REPLAY) or not settings.REPLAY: + log.debug(f"Adding {speaker}/{dialog} to reading queue") + self.speaking_queue.put((speaker, dialog, guide['name'])) + elif guide is None: log.debug(f'{channel=}') else: @@ -436,142 +494,236 @@ def tail(self): """ read any new lines that have arrives since we last read the file and process each of them. + + We're in a multiprocessing.Process() while True, so the expectation is + that we aren't going anywhere. + + self.handle needs to be an open, read-able file handle. """ lstring = "" - previous = "" - for line in self.handle.readlines(): - line = line.strip() - if line: - try: - datestr, timestr, line_string = line.split(None, 2) - except ValueError: - continue - previous = lstring - lstring = line_string.replace(".", "").strip().split() - # "['Hasten', 'is', 'recharged']" - if lstring[0][0] == "[": - self.channel_messager(lstring) - - elif self.announce_badges and lstring[0] == "Congratulations!": - self.speaking_queue.put((None, (" ".join(lstring[4:])), "system")) - - elif lstring[0] == "You": - # ["You", "have", "quit", "your", "team"] - # ["Pew Pew Die Die Die has quit the league. - # ["Ice-Mech", "has", "quit", "the", "team"] - # ["You", "are", "now", "fighting", "at", "level", "9."] - if lstring[1] == "are": - if ( - self.announce_levels and lstring[2:3] == ["now", "fighting"] - ) and ( - " ".join(previous[-4:]).strip(".") - not in [ - "have quit your team", - "has quit the team", - "has joined the team", - "has joined the league", - "has quit the league", - ] - ): - level = lstring[-1].strip(".") - self.speaking_queue.put( - ( - None, - f"Congratulations. You have reached Level {level}", - "system", - ) - ) + with self.open_latest_log() as handle: + if self.first_tail: + log.debug(f'Found new logfile {self.logfile}') + self.find_character_login() + + if settings.REPLAY: + # start at the beginning of the log + log.debug('Seeing to beginning of log file') + handle.seek(0, 0) + else: + # seek to the end of the file in the typical case + log.debug('Seeing to end of log file') + handle.seek(0, io.SEEK_END) + + # else when replay is true this will process the whole file, + # essentailly re-playing the session. This is very handy for + # diagnostics since it makes your most recent chat log a canned + # example you can send through the engine over and over. Super + # helpful. + + # typical loop + last_working = time.time() + log.debug('Entering primary log evaluation loop') + while settings.REPLAY or (time.time() - last_working < self.READ_TIMEOUT): + # read the next line, or return "" if we're at EOF + line = handle.readline().strip() + + if line: + last_working = time.time() + try: + datestr, timestr, line_string = line.split(None, 2) + except ValueError: + continue + + lstring = line_string.replace(".", "").strip().split() + # "['Hasten', 'is', 'recharged']" + if lstring[0][0] == "[": + self.channel_messager(lstring) + + elif self.announce_badges and lstring[0] == "Congratulations!": + self.speaking_queue.put((None, (" ".join(lstring[4:])), "system")) + + elif lstring[0] == "You": + # # ["You", "have", "quit", "your", "team"] + # # ["Pew Pew Die Die Die has quit the league. + # # ["Ice-Mech", "has", "quit", "the", "team"] + # # ["You", "are", "now", "fighting", "at", "level", "9."] + # # ["You", carefully remove an odd piece of paper from a pile of medical refuse."] + # if lstring[1] == "are": + # if ( + # self.announce_levels and lstring[2:3] == ["now", "fighting"] + # ) and ( + # " ".join(previous[-4:]).strip(".") + # not in [ + # "have quit your team", + # "has quit the team", + # "has joined the team", + # "has joined the league", + # "has quit the league", + # ] + # ): + # level = lstring[-1].strip(".") + # self.speaking_queue.put( + # ( + # None, + # f"Congratulations. You have reached Level {level}", + # "system", + # ) + # ) + + if self.hero and lstring[1] == "gain": + # You gain 104 experience and 36 influence. + # You gain 15 experience, work off 15 debt, and gain 14 influence. + # You gain 26 experience and work off 2,676 debt. + # You gain 70 experience. + # You gain 2 stacks of Blood Frenzy! + # I'm just going to make the database carry the burden, so much easier. + # is this string stable enough to get away with this? It's friggin' + # cheating. + log.debug(lstring) + # You gain 250 influence. + + inf_gain = None + xp_gain = None + + for inftype in ["influence", "information"]: + try: + influence_index = lstring.index(inftype) - 1 + inf_gain = int( + lstring[influence_index].replace(",", "") + ) + except ValueError: + pass - elif self.hero and lstring[1] == "gain": - # You gain 104 experience and 36 influence. - # You gain 15 experience, work off 15 debt, and gain 14 influence. - # You gain 26 experience and work off 2,676 debt. - # You gain 70 experience. - # You gain 2 stacks of Blood Frenzy! - # I'm just going to make the database carry the burden, so much easier. - # is this string stable enough to get away with this? It's friggin' - # cheating. - log.debug(lstring) - # You gain 250 influence. - - inf_gain = None - xp_gain = None - - for inftype in ["influence", "information"]: try: - influence_index = lstring.index(inftype) - 1 - inf_gain = int( - lstring[influence_index].replace(",", "") - ) + if 'experience' in lstring: + xp_gain = lstring[lstring.index('experience') - 1] + elif 'experience,' in lstring: + xp_gain = lstring[lstring.index('experience,') - 1] + + if xp_gain: + xp_gain = int(xp_gain.replace(",", "")) except ValueError: - pass - - try: - if 'experience' in lstring: - xp_gain = lstring[lstring.index('experience') - 1] - elif 'experience,' in lstring: - xp_gain = lstring[lstring.index('experience,') - 1] - - if xp_gain: - xp_gain = int(xp_gain.replace(",", "")) - except ValueError: - pass - - # try: - # did_i_defeat_it = previous.index("defeated") - # foe = " ".join(previous[did_i_defeat_it:]) - # except ValueError: - # # no, someone else did. you just got some - # # points for it. Lazybones. - # foe = None - - # we _could_ visualize the percentage of kills - # by each player in the party. - if inf_gain or xp_gain: - log.debug(f"Awarding xp: {xp_gain} and inf: {inf_gain}") - with models.db() as session: - new_event = models.HeroStatEvent( + pass + + # try: + # did_i_defeat_it = previous.index("defeated") + # foe = " ".join(previous[did_i_defeat_it:]) + # except ValueError: + # # no, someone else did. you just got some + # # points for it. Lazybones. + # foe = None + + # we _could_ visualize the percentage of kills + # by each player in the party. + if inf_gain or xp_gain: + if not settings.REPLAY or settings.XP_IN_REPLAY: + log.debug(f"Awarding xp: {xp_gain} and inf: {inf_gain}") + with models.db() as session: + new_event = models.HeroStatEvent( + hero_id=self.hero.id, + event_time=datetime.strptime( + f"{datestr} {timestr}", "%Y-%m-%d %H:%M:%S" + ), + xp_gain=xp_gain, + inf_gain=inf_gain, + ) + session.add(new_event) + session.commit() + if lstring[1] == "hit": + # You hit Abomination with your Assassin's Psi Blade for 43.22 points of Psionic damage. + m = re.fullmatch( + r"You hit (?P.*) with your (?P.*) for (?P[0-9]*) points of (?P.*) damage(?P over time| \(ASSASSIN STRIKE\)| \(CRITICAL\))?.?", + " ".join(lstring) + ) + if m: + target, power, damage, damagetype, special = m.groups() + if special is None: + special = "" + else: + special = special.strip("()").title() + + d = models.Damage( hero_id=self.hero.id, - event_time=datetime.strptime( - f"{datestr} {timestr}", "%Y-%m-%d %H:%M:%S" - ), - xp_gain=xp_gain, - inf_gain=inf_gain, + target=target, + power=power, + damage=int(damage), + damage_type=damagetype, + special=special ) - session.add(new_event) - session.commit() - - elif lstring[0] == "Welcome": - # Welcome to City of Heroes, - self.hero = Hero(" ".join(lstring[5:]).strip("!")) - - # we want to notify upstream UI about this. - self.event_queue.put(("SET_CHARACTER", self.hero.name)) - - elif lstring[-2:] == ["is", "recharged"]: - log.debug('Adding RECHARGED event to event_queue...') - self.event_queue.put( - ("RECHARGED", " ".join(lstring[0:lstring.index("is")])) - ) - - elif lstring[-2:] == ["the", "team"]: - name = " ".join(lstring[0:-4]) - action = lstring[-3] # joined or quit - self.speaking_queue.put((None, f"Player {name} has {action} the team", "system")) - - elif lstring[:2] == ["The", "name"]: - # The name Toothbreaker Jones keeps popping up, and these Skulls were nice enough to tell you where to find him. Time to pay him a visit. - dialog = plainstring(" ".join(lstring)) - self.speaking_queue.put((None, dialog, "system")) - - # else: - # log.warning(f'tag {lstring[0]} not classified.') - # - # Team task completed. - # A new team task has been chosen. - - + + with models.db() as session: + session.add(d) + session.commit() + else: + dialog = plainstring(" ".join(lstring)) + log.warning(f'hit failed regex: {dialog}') + + elif lstring[1] in ["carefully", "look", "find"]: + dialog = plainstring(" ".join(lstring)) + self.speaking_queue.put((None, dialog, "system")) + + elif lstring[0] == "Welcome": + # Welcome to City of Heroes, + self.hero = Hero(" ".join(lstring[5:]).strip("!")) + + # we want to notify upstream UI about this. + self.event_queue.put(("SET_CHARACTER", self.hero.name)) + + elif lstring[-2:] == ["is", "recharged"]: + log.debug('Adding RECHARGED event to event_queue...') + self.event_queue.put( + ("RECHARGED", " ".join(lstring[0:lstring.index("is")])) + ) + + elif lstring[-2:] == ["the", "team"]: + name = " ".join(lstring[0:-4]) + action = lstring[-3] # joined or quit + self.speaking_queue.put((None, f"Player {name} has {action} the team", "system")) + + elif lstring[:2] == ["The", "name"]: + # The name Toothbreaker Jones keeps popping up, and these Skulls were nice enough to tell you where to find him. Time to pay him a visit. + dialog = plainstring(" ".join(lstring)) + self.speaking_queue.put((None, dialog, "system")) + + elif lstring[0:1] == ["Your", "combat"]: + # 2024-07-26 19:01:05 Your combat improves to level 23! Seek a trainer to further your abilities. + level = int(lstring[5].strip('!')) + self.speaking_queue.put( + ( + None, + f"Congratulations. You have reached Level {level}", + "system", + ) + ) + + # this will update the character tab + settings.set_config_key( + 'level', level, cf='state.json' + ) + + # single word things to speak, mostly clicky descriptions + elif lstring[0] in ["Something's", "This", "You've"]: + # Something's not right with this spot on the floor... + # This blotch of petroleum on the ground seems fresh, perhaps leaked by a 'zoombie' and a sign that they're near. You take a photo and send it to Watkins. + # You've found a photocopy of a highly detailed page from a medical notebook, with wildly complex notes about cybernetics. + dialog = plainstring(" ".join(lstring)) + if (settings.REPLAY and settings.SPEECH_IN_REPLAY) or not settings.REPLAY: + self.speaking_queue.put((None, dialog, "system")) + + # else: + # log.warning(f'tag {lstring[0]} not classified.') + # + # Team task completed. + # A new team task has been chosen. + else: + # no need to heat up the house + time.sleep(0.125) + + # not exactly a _problem_ but this should be rare. + log.warning(f'Chat Log READ_TIMEOUT ({self.READ_TIMEOUT}) exceeded.') + self.first_tail = False class Hero: # keep this update for cheap parlor tricks. diff --git a/src/cnv/database/db.py b/src/cnv/database/db.py index 79c66c2..61d2df0 100644 --- a/src/cnv/database/db.py +++ b/src/cnv/database/db.py @@ -1,12 +1,12 @@ import logging import os -import sys from contextlib import contextmanager from pathlib import Path import alembic.config import cnv.database.models as models +import cnv.lib.settings as settings from sqlalchemy import create_engine from sqlalchemy_utils import create_database, database_exists @@ -14,12 +14,6 @@ engine = create_engine(f"sqlite:///{sqlite_database_filename}", echo=True) -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) - log = logging.getLogger(__name__) alembic_ini = os.path.abspath( @@ -85,3 +79,11 @@ def build_migrate(): else: log.info('Checking for database migration...') alembic.config.main(argv=alembicArgs) + + if not settings.REPLAY or settings.SESSION_CLEAR_IN_REPLAY: + log.info('Clearing session storage...') + + with models.Session(models.engine) as session: + # delete all Damage table rows + session.query(models.Damage).delete() + session.commit() diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index d5ebb9e..a6d8a6f 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -3,6 +3,7 @@ import random import re import sys +import copy import tkinter as tk from contextlib import contextmanager from datetime import datetime @@ -72,16 +73,15 @@ def category_int2str(inint): except ValueError: return '' -# obsolete? ENGINE_COSMETIC_TO_ID = { + 'Amazon Polly': 'amazonpolly', + 'Azure': 'azure', + 'Eleven Labs': 'elevenlabs', 'Google Text-to-Speech': 'googletts', + 'OpenAI': 'openai', 'Windows TTS': 'windowstts', - 'Eleven Labs': 'elevenlabs', - 'Amazon Polly': 'amazonpolly' } -language_code_regex = "en-.*" - class Character(Base): __tablename__ = "character" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) @@ -213,15 +213,26 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel # in TK baggage. # but.. we can accesss the cache?. does that introduce a - # sequence dependency? - all_values = diskcache(f"{engine_key}_{config_meta.key}") + # sequence dependency (yes) + all_values = list( + diskcache(f"{engine_key}_{config_meta.key}") + ) if all_values is None: log.warning(f'Cache {engine_key}_{config_meta.key} is empty') value = "" - else: - # it's a dict, keyey on voice_name - # language_code_regex = "en-.*" + + # just creating this should be enough to populate the + # engine cache. + engine.get_engine(engine_key)(None, None, None, None) + all_values = list( + diskcache(f"{engine_key}_{config_meta.key}") + ) + + if all_values: + # it's a dict, key in a voice_name + language_code_regex = settings.get_language_code_regex() + if language_code_regex and 'language_code' in all_values[0].keys(): # pass through languages that satisfy the regex out = [] @@ -229,26 +240,32 @@ def create_character(cls, name: str, category: int, session: Connectable) -> Sel code = v.get('language_code', '') if re.match(language_code_regex, code): out.append(v) - all_values = out + all_values = list(out) # if we have a gender, filter out the voices that don't # have the same gender. + pre_gender_filter = copy.copy(all_values) if gender and 'gender' in all_values[0].keys(): def gender_filter(voice): - return voice['gender'] == gender - all_values = filter(gender_filter, all_values) + return voice['gender'].title() == gender.title() + all_values = list(filter(gender_filter, all_values)) + + if len(all_values) == 0: + log.debug('Gender filter removed all voice name entries') + all_values = pre_gender_filter # does the preset have any more guidance? # use the preset if there is one. Otherwise # choose randomly from the available options. - log.debug(f"{all_values=}") + log.debug(f"{all_values=} {engine_key}/{config_meta.key}") if config_meta.key in preset: value = preset[config_meta.key] else: - chosen_row = random.choice(list(all_values)) - log.debug(f'Random selection: {chosen_row}') - value = chosen_row[config_meta.key] + if all_values: + chosen_row = random.choice(all_values) + log.debug(f'Random selection: {chosen_row}') + value = chosen_row[config_meta.key] # do we have a numeric value, with a min/max and some # hints about useful granularity? @@ -670,6 +687,17 @@ class EffectSetting(Base): def __str__(self): return f"" +class Damage(Base): + __tablename__ = "damage" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + hero_id = mapped_column(ForeignKey("hero.id")) + target: Mapped[str] = mapped_column(String(256)) + power: Mapped[str] = mapped_column(String(256)) + damage: Mapped[int] = mapped_column(Integer) + damage_type: Mapped[str] = mapped_column(String(64)) + # assassin strike, critical, etc.. + special: Mapped[str] = mapped_column(String(32)) + class Hero(Base): __tablename__ = "hero" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) diff --git a/src/cnv/effects/effects.py b/src/cnv/effects/effects.py index bb5b38c..4d01850 100644 --- a/src/cnv/effects/effects.py +++ b/src/cnv/effects/effects.py @@ -27,23 +27,34 @@ def __init__( from_, to, _type=float, - *args, digits=None, resolution=0, **kwargs + *args, + digits=None, + resolution=0, + **kwargs ): super().__init__(parent, *args, **kwargs) self.columnconfigure(0, minsize=125, uniform="effect") self.columnconfigure(1, weight=2, uniform="effect") - if isinstance(_type, int): - variable = tk.IntVar( + if isinstance(_type, int) or digits==0: + parent.tkvars[pname] = tk.IntVar( name=f"{parent.label.lower()}_{pname}", value=default ) else: - variable = tk.DoubleVar( + parent.tkvars[pname] = tk.DoubleVar( name=f"{parent.label.lower()}_{pname}", value=default ) + parent.display_tkvars[pname] = tk.StringVar( + name=f"{parent.label.lower()}_{pname}_display", + value=str(default) + ) + + if digits is not None: + parent.digits[pname] = digits + # label for the setting ctk.CTkLabel( self, @@ -55,45 +66,30 @@ def __init__( # TODO: # mark ticks/steps? if resolution: - steps = int((to - from_) / resolution) + steps = (to - from_) / resolution else: steps = 20 - if steps > 50: - log.warning(f'Resolution for {label} is too detailed') + if steps > 100: + highest_recommended = (to - from_) / 100 + log.warning(f'[{parent.label}] Resolution {resolution} for {label} is too detailed. Maybe {highest_recommended}?') - # widget for viewing/changing the value + # widget for changing the value ctk.CTkSlider( self, - variable=variable, + variable=parent.tkvars[pname], from_=from_, to=to, orientation='horizontal', number_of_steps=steps ).grid(row=0, column=1, sticky='ew') + # label for the current value ctk.CTkLabel( self, - textvariable=variable + textvariable=parent.display_tkvars[pname] ).grid(row=0, column=2, sticky='e') - # tk.Scale( - # self, - # from_=from_, - # to=to, - # orient='horizontal', - # variable=variable, - # *args, - # digits=digits, - # resolution=resolution, - # **kwargs - # ).grid(row=0, column=1, sticky='ew') - - setattr(parent, pname, variable) - parent.parameters.append(pname) - if digits: - parent.digits[pname] = digits - class LCombo(ctk.CTkFrame): """ @@ -110,7 +106,7 @@ def __init__(self, ): super().__init__(parent, *args, **kwargs) - variable = tk.StringVar(value=default) + parent.tkvars[pname] = tk.StringVar(value=default) self.columnconfigure(0, minsize=125, uniform="effect") self.columnconfigure(1, weight=2, uniform="effect") @@ -126,17 +122,12 @@ def __init__(self, options = ctk.CTkComboBox( self, values=list(choices), - variable=variable, + variable=parent.tkvars[pname], state='readonly' ) - # options['values'] = list(choices) - # options['state'] = 'readonly' options.grid(row=0, column=1, sticky='ew') - setattr(parent, pname, variable) - parent.parameters.append(pname) - class LBoolean(ctk.CTkFrame): def __init__( @@ -153,7 +144,7 @@ def __init__( self.columnconfigure(0, minsize=125, uniform="effect") self.columnconfigure(1, weight=2, uniform="effect") - variable = tk.BooleanVar( + parent.tkvars[pname] = tk.BooleanVar( name=f"{pname}", value=default ) @@ -170,14 +161,11 @@ def __init__( ctk.CTkSwitch( self, text="", - variable=variable, + variable=parent.tkvars[pname], onvalue=True, offvalue=False ).grid(row=0, column=1, sticky='ew') - setattr(parent, pname, variable) - parent.parameters.append(pname) - class EffectParameterEditor(ctk.CTkFrame): label = "Label" @@ -185,11 +173,25 @@ class EffectParameterEditor(ctk.CTkFrame): def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) - self.parent = parent # parent is the effectlist + # parent is the effectlist, it allows us to remove ourselves. + self.parent = parent + + # database id for this unique, configured effect (why is this a tk.?) self.effect_id = tk.IntVar() - self.parameters = [] + + # tk.var for each parameter + self.tkvars = {} + + # tk.var for how the value of each parameter should be displayed + self.display_tkvars = {} + + # storage bucket for tkVar traces for each parameter self.traces = {} + + # how many digits should we display after the decimal point? self.digits = {} + + # delete icon self.trashcan = Feather( 'trash-2', size=22 @@ -253,7 +255,7 @@ def reconfig(self, varname, lindex, operation): persist that change. Make the database reflect the UI. """ - log.debug(f'reconfig triggered by {varname}/{lindex}/{operation}') + log.info(f'reconfig triggered by {varname}/{lindex}/{operation}') effect_id = self.effect_id.get() with models.Session(models.engine) as session: @@ -270,14 +272,14 @@ def reconfig(self, varname, lindex, operation): log.info(f'Sync to db {effect_setting}') found.add(effect_setting.key) try: - new_value = str(getattr(self, effect_setting.key).get()) - digits = self.digits.get(effect_setting.key, None) - if digits is not None: - formatstr = "{:.%sf}" % digits - new_value = formatstr.format(float(new_value)) - getattr(self, effect_setting.key).set(new_value) + new_value = self.tkvars[effect_setting.key].get() + + if effect_setting.key in self.digits: + formatted_value = self.cosmetic(effect_setting.key, new_value) + log.info(f'Setting widget to {formatted_value} (!= {new_value})') + self.display_tkvars[effect_setting.key].set(formatted_value) else: - log.info(f'{effect_setting.key} not in digits {self.digits}') + log.debug(f'{effect_setting.key} not in digits {self.digits}') except AttributeError: log.error(f'Invalid configuration. Cannot set {effect_setting.key} on a {self} effect.') continue @@ -309,6 +311,11 @@ def reconfig(self, varname, lindex, operation): if change: session.commit() + def cosmetic(self, key, value): + digits = self.digits.get(key, None) + formatstr = "{:.%sf}" % digits + formatted_value = formatstr.format(float(value)) + return formatted_value def load(self): """ @@ -338,11 +345,14 @@ def load(self): found.add(setting.key) - tkvar = getattr(self, setting.key, None) + tkvar = self.tkvars.get(setting.key, None) if setting.key not in self.traces: if tkvar: tkvar.set(setting.value) + if setting.key in self.digits: + formatted_string = self.cosmetic(setting.key, setting.value) + self.display_tkvars[setting.key].set(formatted_string) else: log.error( f'Invalid configuration. ' @@ -414,11 +424,14 @@ def get_effect(self): log.debug('get_effect()') effect = voicebox.effects.Filter.build( btype='bandpass', - freq=(self.low_frequency.get(), self.high_frequency.get()), - order=self.order.get(), + freq=( + self.tkvars['low_frequency'].get(), + self.tkvars['high_frequency'].get() + ), + order=self.tkvars['order'].get(), rp=None, rs=None, - ftype=self.type_.get() + ftype=self.tkvars['type_'].get() ) return effect @@ -484,7 +497,10 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.Filter.build( btype='bandstop', - freq=(self.low_frequency.get(), self.high_frequency.get()), + freq=( + self.tkvars['low_frequency'].get(), + self.tkvars['high_frequency'].get() + ), order=self.order.get(), rp=None, rs=None, @@ -542,11 +558,11 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.Filter.build( btype='lowpass', - freq=self.frequency.get(), - order=self.order.get(), + freq=self.tkvars['frequency'].get(), + order=self.tkvars['order'].get(), rp=None, rs=None, - ftype=self.type_.get() + ftype=self.tkvars['type_'].get() ) return effect @@ -601,11 +617,11 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.Filter.build( btype='highpass', - freq=self.frequency.get(), - order=self.order.get(), + freq=self.tkvars['frequency'].get(), + order=self.tkvars['order'].get(), rp=None, rs=None, - ftype=self.type_.get() + ftype=self.tkvars['type_'].get() ) return effect @@ -655,9 +671,9 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): log.debug('get_effect()') effect = voicebox.effects.Glitch( - chunk_time=self.chunk_time.get(), - p_repeat=self.p_repeat.get(), - max_repeats=int(self.max_repeats.get()) + chunk_time=self.tkvars['chunk_time'].get(), + p_repeat=self.tkvars['p_repeat'].get(), + max_repeats=int(self.tkvars['max_repeats'].get()) ) return effect @@ -673,11 +689,11 @@ def __init__(self, parent, *args, **kwargs): self, pname="max_amplitude", label='Max-Amplitude', - desc="Maximum amplitude in Hz", + desc="Maximum amplitude", default=0.0, from_=-1, to=1, - digits=2, + digits=1, resolution=0.1 ).pack(side='top', fill='x', expand=True) @@ -691,8 +707,8 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.Normalize( - max_amplitude=self.max_amplitude.get(), - remove_dc_offset=self.remove_dc_offset.get(), + max_amplitude=self.tkvars['max_amplitude'].get(), + remove_dc_offset=self.tkvars['remove_dc_offset'].get(), ) return effect @@ -719,7 +735,7 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.PitchShift( - semitones=self.semitones.get(), + semitones=self.tkvars['semitones'].get(), ) ) return effect @@ -808,12 +824,12 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Reverb( - room_size=self.room_size.get(), - damping=self.damping.get(), - wet_level=self.wet_level.get(), - dry_level=self.dry_level.get(), - width=self.width.get(), - freeze_mode= 1 if self.freeze_mode.get() else 0 + room_size=self.tkvars['room_size'].get(), + damping=self.tkvars['damping'].get(), + wet_level=self.tkvars['wet_level'].get(), + dry_level=self.tkvars['dry_level'].get(), + width=self.tkvars['width'].get(), + freeze_mode= 1 if self.tkvars['freeze_mode'].get() else 0 ) ) return effect @@ -841,7 +857,7 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Bitcrush( - bit_depth=self.bit_depth.get() + bit_depth=self.tkvars['bit_depth'].get() ) ) return effect @@ -920,11 +936,11 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self, values=None): if values is None: values = { - 'rate_hz': self.rate_hz.get(), - 'depth': self.depth.get(), - 'centre_delay_ms': self.centre_delay_ms.get(), - 'feedback': self.feedback.get(), - 'mix': self.mix.get() + 'rate_hz': self.tkvars['rate_hz'].get(), + 'depth': self.tkvars['depth'].get(), + 'centre_delay_ms': self.tkvars['centre_delay_ms'].get(), + 'feedback': self.tkvars['feedback'].get(), + 'mix': self.tkvars['mix'].get() } # The returned array may contain up to (but not more than) the same @@ -960,7 +976,7 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Clipping( - threshold_db=self.threshold_db.get(), + threshold_db=self.tkvars['threshold_db'].get(), ) ) return effect @@ -1024,10 +1040,10 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Compressor( - threshold_db=self.threshold_db.get(), - ratio=self.ratio.get(), - attack_ms=self.attack_ms.get(), - release_ms=self.release_ms.get() + threshold_db=self.tkvars['threshold_db'].get(), + ratio=self.tkvars['ratio'].get(), + attack_ms=self.tkvars['attack_ms'].get(), + release_ms=self.tkvars['release_ms'].get() ) ) return effect @@ -1079,9 +1095,9 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Delay( - delay_seconds=self.delay_seconds.get(), - feedback=self.feedback.get(), - mix=self.mix.get(), + delay_seconds=self.tkvars['delay_seconds'].get(), + feedback=self.tkvars['feedback'].get(), + mix=self.tkvars['mix'].get(), ) ) return effect @@ -1109,7 +1125,7 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Distortion( - drive_db=self.drive_db.get(), + drive_db=self.tkvars['drive_db'].get(), ) ) return effect @@ -1138,7 +1154,7 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.Gain( - gain_db=self.gain_db.get(), + gain_db=self.tkvars['gain_db'].get(), ) ) return effect @@ -1192,9 +1208,9 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.PedalboardEffect( pedalboard.HighShelfFilter( - cutoff_frequency_hz=self.cutoff_frequency_hz.get(), - gain_db=self.gain_db.get(), - q=self.q.get() + cutoff_frequency_hz=self.tkvars['cutoff_frequency_hz'].get(), + gain_db=self.tkvars['gain_db'].get(), + q=self.tkvars['q'].get() ) ) return effect @@ -1267,9 +1283,9 @@ def get_effect(self): pedalboard.LadderFilter( mode=getattr(pedalboard.LadderFilter.Mode, self.mode.get().split()[0]), # self.mode_choices.index(), - cutoff_hz=self.cutoff_hz.get(), - resonance=self.resonance.get(), - drive=self.drive.get() + cutoff_hz=self.tkvars['cutoff_hz'].get(), + resonance=self.tkvars['resonance'].get(), + drive=self.tkvars['drive'].get() ) ) return effect @@ -1293,7 +1309,7 @@ def __init__(self, parent, *args, **kwargs): from_=0, to=1000, digits=0, - resolution=1 + resolution=10 ).pack(side='top', fill='x', expand=True) LScale( @@ -1320,9 +1336,9 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.RingMod( - carrier_freq=self.carrier_freq.get(), - blend=self.blend.get(), - carrier_wave=getattr(np, self.carrier_wave.get()) + carrier_freq=self.tkvars['carrier_freq'].get(), + blend=self.tkvars['blend'].get(), + carrier_wave=getattr(np, self.tkvars['carrier_wave'].get()) ) return effect @@ -1343,7 +1359,7 @@ def __init__(self, parent, *args, **kwargs): from_=0, to=200, digits=0, - resolution=1 + resolution=2 ).pack(side='top', fill='x', expand=True) LScale( @@ -1409,12 +1425,12 @@ def __init__(self, parent, *args, **kwargs): def get_effect(self): effect = voicebox.effects.Vocoder.build( - carrier_freq=self.carrier_freq.get(), - min_freq=self.min_freq.get(), - max_freq=self.max_freq.get(), - bands=self.bands.get(), - bandwidth=self.bandwidth.get(), - bandpass_filter_order=self.bandpass_filter_order.get() + carrier_freq=self.tkvars['carrier_freq'].get(), + min_freq=self.tkvars['min_freq'].get(), + max_freq=self.tkvars['max_freq'].get(), + bands=int(self.tkvars['bands'].get()), + bandwidth=self.tkvars['bandwidth'].get(), + bandpass_filter_order=self.tkvars['bandpass_filter_order'].get() ) return effect diff --git a/src/cnv/engines/amazonpolly.py b/src/cnv/engines/amazonpolly.py index 07b93a4..8715202 100644 --- a/src/cnv/engines/amazonpolly.py +++ b/src/cnv/engines/amazonpolly.py @@ -275,7 +275,8 @@ def get_voice_names(self, gender=None): log.debug(f'Excluding {voice["Name"]}') if not out: - log.warning('No voices exist that support this language/gender. Ignoring gender.') + allowed_language_codes = settings.get_voice_language_codes() + log.warning(f'No voices exist that support language={allowed_language_codes}/gender={self.gender}. Ignoring gender.') out = secondary out = sorted(list(out)) diff --git a/src/cnv/engines/azure.py b/src/cnv/engines/azure.py index c7d4249..eab5c6f 100644 --- a/src/cnv/engines/azure.py +++ b/src/cnv/engines/azure.py @@ -225,7 +225,7 @@ def get_speech(self, text: StrOrSSML) -> Audio: audio_buffer = bytes(10000000) size = stream.read_data(audio_buffer) - log.info('Creating numpy buffer') + log.debug('Creating numpy buffer') samples = np.frombuffer( audio_buffer[:size], dtype=np.int16 diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 673ca0d..dfb17f8 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -114,6 +114,7 @@ class TTSEngine(ctk.CTkFrame): def __init__(self, parent, rank, name, category, *args, **kwargs): log.debug(f'Initializing TTSEngine {parent=} {rank=} {name=} {category=}') super().__init__(parent, *args, **kwargs) + self.rank = rank self.parent = parent self.name = name @@ -124,8 +125,8 @@ def __init__(self, parent, rank, name, category, *args, **kwargs): self.widget = {} self.set_config_meta(self.config) - self.draw_config_meta(self) + self.load_character(category=category, name=name) self.repopulate_options() @@ -209,6 +210,8 @@ def load_character(self, category, name): # Retrieve configuration settings from the DB # and use them to set values on widgets # settings.how_did_i_get_here() + if name is None: + return self.loading = True self.name = name @@ -311,28 +314,39 @@ def draw_config_meta(self, parent): # create the tk.var for the value of this widget varfunc = getattr(tk, m.varfunc) - self.config_vars[m.key] = varfunc(value=m.default) + log.debug(f'Stashing {varfunc} in config_vars[{m.key}]') + + if m.key in self.config_vars: + for trace in self.config_vars[m.key].trace_info(): + log.debug('Removing duplicate trace...') + self.config_vars[m.key].trace_remove(trace[0], trace[1]) + + self.config_vars[m.key] = varfunc() + self.config_vars[m.key].set(m.default) # create the widget itself if m.varfunc == "StringVar": self._tkStringVar(index + 1, m.key, parent) elif m.varfunc == "DoubleVar": self._tkDoubleVar(index + 1, m.key, parent, m.cfgdict) + self.config_vars[m.key].trace_add("write", self.reconfig) elif m.varfunc == "BooleanVar": self._tkBooleanVar(index + 1, m.key, parent) + self.config_vars[m.key].trace_add("write", self.reconfig) else: # this will fail, but at least it will fail with a log message. log.error(f'No widget defined for variables like {varfunc}') # changes to the value of this widget trip a generic 'reconfig' # handler. - self.config_vars[m.key].trace_add("write", self.reconfig) + def _tkStringVar(self, index, key, frame): # combo widget for strings self.widget[key] = ctk.CTkComboBox( frame, variable=self.config_vars[key], + command=self.reconfig, state="readonly" ) # self.widget[key]["state"] = "readonly" @@ -401,9 +415,10 @@ def reconfig(self, *args, **kwargs): combo widgets need to repopulate. """ if self.loading: + log.warning('Voice config change while loading... (ignoring)') return - # log.info(f'reconfig({args=}, {kwargs=})') + log.debug(f'reconfig({args=}, {kwargs=})') with models.db() as session: character = models.Character.get( name=self.name, @@ -415,6 +430,8 @@ def reconfig(self, *args, **kwargs): for m in self.get_config_meta(): config[m.key] = self.config_vars[m.key].get() + log.debug(f'GUI config values are: {config}') + models.set_engine_config(character.id, self.rank, config) self.repopulate_options() @@ -429,15 +446,17 @@ def repopulate_options(self): if not all_options: log.error(f'{m.gatherfunc=} returned no options ({self.cosmetic})') - self.widget[m.key].configure(values=all_options) + if m.key in self.widget: + # log.info(f'{all_options=}') + self.widget[m.key].configure(values=all_options) - if self.config_vars[m.key].get() not in all_options: - # log.info(f'Expected to find {self.config_vars[m.key].get()!r} in list {all_options!r}') - self.config_vars[m.key].set(all_options[0]) + if self.config_vars[m.key].get() not in all_options: + # log.info(f'Expected to find {self.config_vars[m.key].get()!r} in list {all_options!r}') + self.config_vars[m.key].set(all_options[0]) def _gender_filter(self, voice): if hasattr(self, 'gender') and self.gender: - log.debug(f'{self.gender.title()} ?= {voice["gender"].title()}') + # log.debug(f'{self.gender.title()} ?= {voice["gender"].title()}') try: return self.gender.title() == voice["gender"].title() except KeyError: diff --git a/src/cnv/engines/googlecloud.py b/src/cnv/engines/googlecloud.py index a06593c..995386e 100644 --- a/src/cnv/engines/googlecloud.py +++ b/src/cnv/engines/googlecloud.py @@ -224,7 +224,7 @@ def get_voice_names(self, gender=None): voice_name = self.config_vars["voice_name"].get() if voice_name not in out: # our currently selected voice is invalid. Pick a new one. - log.error(f'Voice {voice_name} is now invalid') + log.error(f'Voice {voice_name} is now invalid ({gender})') self.config_vars["voice_name"].set(out[0]) return out else: diff --git a/src/cnv/engines/openai.py b/src/cnv/engines/openai.py index 24c0b6c..1bdd600 100644 --- a/src/cnv/engines/openai.py +++ b/src/cnv/engines/openai.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from tkinter import ttk from typing import Union +from cnv.lib.settings import diskcache import numpy as np import voicebox @@ -95,34 +96,76 @@ class OpenAI(TTSEngine): auth_ui_class = OpenAIAuthUI config = ( - ('Voice Name', 'voice', "StringVar", "", {}, "get_voice_names"), + ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), ('Voice Model', 'model', "StringVar", "", {}, "get_models"), ('Speed', 'speed', "DoubleVar", 1.0, {'min': 0.25, 'max': 4.0, 'resolution': 0.25}, None), ) def get_models(self): - return [ - 'tts-1', - 'tts-1-hd' - ] + all_models = diskcache( + f"{self.key}_model" + ) + if all_models is None: + all_models = diskcache( + f"{self.key}_model", [ + {'model': 'tts-1'}, + {'model': 'tts-1-hd'} + ] + ) + return [k['model'] for k in all_models] + + def get_voices(self): + all_voices = diskcache( + f"{self.key}_voice" + ) + if all_voices is None: + all_voices = diskcache( + f"{self.key}_voice", [{ + 'name': 'alloy', + 'gender': 'Female' + }, { + 'name': 'nova', + 'gender': 'Female' + }, { + 'name': 'shimmer', + 'gender': 'Female' + }, { + 'name': 'fable', + 'gender': 'Female' + }, { + 'name': 'echo', + 'gender': 'Male' + }, { + 'name': 'onyx', + 'gender': 'Male' + }, { + 'name': 'fable', + 'gender': 'Male' + }]) + + return all_voices def get_voice_names(self, gender=None): - if gender and gender.upper() == "FEMALE": - return ['alloy', 'nova', 'shimmer', 'fable'] - elif gender and gender.upper() == "MALE": - return ['echo', 'onyx', 'fable'] + all_voices = self.get_voices() + + out = set() + for voice in all_voices: + if self._gender_filter(voice): + out.add(voice['name']) + + out = sorted(list(out)) + + if out: + # voice_name = self.config_vars["voice_name"].get() + # if voice_name not in out: + # # our currently selected voice is invalid. Pick a new one. + # self.config_vars["voice_name"].set(out[0]) + return out else: - return [ - 'alloy', - 'echo', - 'fable', - 'onyx', - 'nova', - 'shimmer' - ] + return [] def get_tts(self): - voice = self.override.get('voice', self.config_vars["voice"].get()) + voice = self.override.get('voice_name', self.config_vars["voice_name"].get()) model = self.override.get('model', self.config_vars["model"].get()) return ttsOpenAI( diff --git a/src/cnv/lib/proc.py b/src/cnv/lib/proc.py new file mode 100644 index 0000000..c615e8a --- /dev/null +++ b/src/cnv/lib/proc.py @@ -0,0 +1,67 @@ +import logging +import time + +import pyautogui as keyboard +import win32api +import win32con +import win32process +from win32gui import GetForegroundWindow, GetWindowText +import pywintypes + +log = logging.getLogger(__name__) + + +def coh_is_foreground(warn=None): + # only send keyboard activity to the city of heroes window + # if it is not the foreground window do not do anything. + foreground_window_handle = GetForegroundWindow() + pid = win32process.GetWindowThreadProcessId(foreground_window_handle) + + try: + handle = win32api.OpenProcess(win32con.PROCESS_QUERY_INFORMATION | win32con.PROCESS_VM_READ, False, pid[1]) + except pywintypes.error as err: + log.warning(err) + return False + + proc_name = win32process.GetModuleFileNameEx(handle, 0) + zone = GetWindowText(foreground_window_handle) + if warn: + log.warning(warn) + else: + log.info(f'{zone=}') + + return proc_name.split("\\")[-1] == "cityofheroes.exe" + + +def send_chatstring(message): + if not coh_is_foreground(): + log.error('cityofheroes.exe is not the foreground process') + return + + # bring the chat to foreground + keyboard.press("enter") + # disable user input? + keyboard.typewrite(message) + + +def send_log_lock(timeout=10): + # this will block until coh is the foreground + start = int(time.time()) + success = False + while not success: + success = coh_is_foreground( + warn="Waiting for City of Heroes to be the foreground process... (timeout in %s)" % ( + int((start + timeout) - time.time()) + ) + ) + + if not success: + if time.time() > (start + timeout): + log.warning('Timeout exceeded. Not locked to city of heroes. Some features unavailable.') + return + else: + time.sleep(1) + + # send to a tell to ourselves with an infodump + for var in ['name', 'level', 'primary', 'secondary', 'archetype']: + send_chatstring(f'/tell $name, [SIDEKICK] {var}="${var}"\n') \ No newline at end of file diff --git a/src/cnv/lib/settings.py b/src/cnv/lib/settings.py index 2e603d7..858d4d3 100644 --- a/src/cnv/lib/settings.py +++ b/src/cnv/lib/settings.py @@ -1,6 +1,5 @@ import logging import inspect -import sys import json import os import hashlib @@ -42,12 +41,9 @@ PERSIST_PLAYER_CHAT = True REPLAY = False - -logging.basicConfig( - level=LOGLEVEL, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) +XP_IN_REPLAY = False +SPEECH_IN_REPLAY = False +SESSION_CLEAR_IN_REPLAY = False log = logging.getLogger(__name__) @@ -108,6 +104,7 @@ def get_alias(group): def get_preset(group): return get_config_key(key=group, default={}, cf="presets.json") + def get_language_code(): """ Returns the two character language code for feeding the translator @@ -115,6 +112,10 @@ def get_language_code(): language = get_config_key('language', default="English") return LANGUAGES[language][0] +def get_language_code_regex(): + code = get_language_code() + return f"{code}-.*" + def get_voice_language_codes(): """ Returns a list of language codes that would be acceptable for allow-list diff --git a/src/cnv/logger.py b/src/cnv/logger.py index a3c9167..6e7aa17 100644 --- a/src/cnv/logger.py +++ b/src/cnv/logger.py @@ -46,7 +46,17 @@ 'handlers': ['default', ], 'level': 'DEBUG', 'propagate': False - }, + }, + 'cnv.engines.base': { + 'handlers': ['default', ], + 'level': 'INFO', + 'propagate': False + }, + 'cnv.effects.effects': { + 'handlers': ['default', ], + 'level': 'INFO', + 'propagate': False + }, } } diff --git a/src/cnv/sidekick.py b/src/cnv/sidekick.py index 1638650..cee3001 100644 --- a/src/cnv/sidekick.py +++ b/src/cnv/sidekick.py @@ -8,22 +8,19 @@ import sys from datetime import datetime, timedelta +import cnv.lib.settings as settings import cnv.logger -from cnv.database import models import colorama import customtkinter as ctk -import lib.settings as settings -import pyautogui as p -import win32api -import win32con -import win32process + +from cnv.database import models +from cnv.lib.proc import send_chatstring from tabs import ( automation, character, configuration, voices, ) -from win32gui import GetForegroundWindow, GetWindowText # this unlinks us from python so windows will # use our icon instead of the python icon in the @@ -63,14 +60,19 @@ def __init__(self, master, event_queue, speaking_queue, **kwargs): def main(): colorama.init() - #root = tk.Tk() root = ctk.CTk() + event_queue = multiprocessing.Queue() + speaking_queue = multiprocessing.Queue() + def on_closing(): global EXIT EXIT = True log.info('Exiting...') - root.destroy() + event_queue.close() + speaking_queue.close() + sys.exit() + # root.destroy() root.protocol("WM_DELETE_WINDOW", on_closing) root.iconbitmap("sidekick.ico") @@ -78,22 +80,18 @@ def on_closing(): root.geometry("720x640+200+200") root.resizable(True, True) root.title("City of Heroes Sidekick") - - event_queue = multiprocessing.Queue() - speaking_queue = multiprocessing.Queue() - #queue.Queue() - + root.grid_columnconfigure(0, weight=1) root.grid_rowconfigure(0, weight=1) buffer = ctk.CTkFrame(root) buffer.grid_columnconfigure(0, weight=1) buffer.grid_rowconfigure(0, weight=1) - + mtv = MainTabView( buffer, event_queue=event_queue, - speaking_queue=speaking_queue + speaking_queue=speaking_queue, ) mtv.grid( column=0, row=0, sticky="new" @@ -128,7 +126,7 @@ def on_closing(): # (None, f"Welcome back {value}", "system") # ) models.set_hero(name=value) - mtv.tabdict['Character'].set_hero() + mtv.tabdict['Character'].set_progress_chart() #log.debug('path set_chraracter') #char.chatter.hero = npc_chatter.Hero(value) @@ -143,36 +141,18 @@ def on_closing(): log.debug(f'Power {value} has recharged.') if value in ["Hasten", "Domination"]: if settings.get_config_key(f'auto_{value.lower()}'): - # only send keyboard activity to the city of heroes window - # if it is not the foreground window do not do anything. - foreground_window_handle = GetForegroundWindow() - pid = win32process.GetWindowThreadProcessId(foreground_window_handle) - handle = win32api.OpenProcess(win32con.PROCESS_QUERY_INFORMATION | win32con.PROCESS_VM_READ, False, pid[1]) - - proc_name = win32process.GetModuleFileNameEx(handle, 0) - zone = GetWindowText(foreground_window_handle) - log.info(f'{zone=}') - - if proc_name.split("\\")[-1] == "cityofheroes.exe": - log.info(f'Triggering {value}') - p.press("enter") - p.typewrite(f"/powexec_name \"{value}\"\n") - else: - log.info(f'Not touch the keyboard of {proc_name!r}. That would be rude.') + send_chatstring(f"/powexec_name \"{value}\"\n") else: log.info(f'auto_{value.lower()} is disabled') - # else: - # log.error('Unknown event_queue key: %s', key) - except Exception as err: log.error(f"{err=}") raise - + if last_character_update: elapsed = datetime.now() - last_character_update if elapsed > update_frequency: - mtv.tabdict['Character'].set_hero() + mtv.tabdict['Character'].set_progress_chart() last_character_update = datetime.now() root.update_idletasks() diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index af439c9..cee2804 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -2,6 +2,7 @@ import logging import multiprocessing import tkinter as tk +import json from datetime import datetime, timedelta import cnv.database.models as models @@ -16,7 +17,125 @@ log = logging.getLogger(__name__) +# when youare level X, how many xp do you need to reach the next level? +xp_table = { + 1: 106, + 2: 337, + 3: 582, + 4: 800, + 5: 1237, + 6: 1575, + 7: 1950, + 8: 2680, + 9: 3125, + 10: 3600, + 11: 4995, + 12: 6405, + 13: 7400, + 14: 9093, + 15: 11184, + 16: 13000, + 17: 15950, + 18: 19200, + 19: 23400, + 20: 28000, + 21: 36000, + 22: 45000, + 23: 56000, + 24: 69300, + 25: 85200, + 26: 108000, + 27: 135000, + 28: 166650, + 29: 203400, + 30: 254000, + 31: 314600, + 32: 386400, + 33: 470600, + 34: 571200, + 35: 701500, + 36: 854700, + 37: 1036600, + 38: 1250200, + 39: 1502550, + 40: 1692900, + 41: 1907550, + 42: 2150550, + 43: 2421900, + 44: 2729700, + 45: 3078000, + 46: 3470850, + 47: 3912300, + 48: 4410450, + 49: 4973400, + 50: 5608000, +} + class ChartFrame(ctk.CTkFrame): + def get_latest_event(self, hero_id): + try: + with models.Session(models.engine) as session: + latest_event = session.scalars( + select(models.HeroStatEvent).where( + models.HeroStatEvent.hero_id == hero_id + ).order_by( + models.HeroStatEvent.event_time.desc() + ) + ).first() + except Exception as err: + log.error('Unable to determine latest event') + log.error(err) + raise + + return latest_event + + def get_raw_samples(self, hero_id, start_time, end_time, session): + log.debug('Gathering raw samples') + try: + raw_samples = session.scalars( + select( + models.HeroStatEvent + ).where( + models.HeroStatEvent.hero_id == hero_id, + models.HeroStatEvent.event_time >= start_time, + models.HeroStatEvent.event_time <= end_time, + ) + ).all() + except Exception as err: + log.error('Error gathering data samples') + log.error(err) + raise + + return raw_samples + + def get_binned_samples(self, hero_id, start_time, end_time, session): + # do some binning in per-minute buckets in the database. + log.debug('Gathering binned samples') + + try: + samples = session.execute( + select( + func.STRFTIME('%Y-%m-%d %H:%M:00', models.HeroStatEvent.event_time).label('EventMinute'), + func.sum(models.HeroStatEvent.xp_gain).label('xp_gain'), + func.sum(models.HeroStatEvent.inf_gain).label('inf_gain') + ).where( + models.HeroStatEvent.hero_id == hero_id, + models.HeroStatEvent.event_time >= start_time, + models.HeroStatEvent.event_time <= end_time, + ).group_by( + 'EventMinute' + # func.STRFTIME('%Y-%m-%d %H:%M:00', models.HeroStatEvent.event_time) + ).order_by( + 'EventMinute' + ) + ).all() + except Exception as err: + log.error('Error gathering data samples') + log.error(err) + raise + + return samples + def __init__(self, parent, hero, *args, **kwargs): super().__init__(parent, *args, **kwargs) log.debug('Initializing ChartFrame()') @@ -25,14 +144,13 @@ def __init__(self, parent, hero, *args, **kwargs): else: log.debug(f'Drawing graph for {hero}') self.hero = hero - # draw graph for $category progress, total per/minute - # binned to the minute for the last hour. # the figure that will contain the plot fig = Figure( figsize = (5, 2), dpi = 100 ) + fig.tight_layout(pad=0.01) # adding the subplot @@ -40,22 +158,9 @@ def __init__(self, parent, hero, *args, **kwargs): ax.margins(x=0, y=0) ax.tick_params(axis='x', rotation=60) - self.category = "xp" - log.debug(f'Retrieving {self.category} data') - try: - with models.Session(models.engine) as session: - latest_event = session.scalars( - select(models.HeroStatEvent).where( - models.HeroStatEvent.hero_id == self.hero.id - ).order_by( - models.HeroStatEvent.event_time.desc() - ) - ).first() - except Exception as err: - log.error('Unable to determine latest event') - log.error(err) - raise - + # find the most recent event to determine the timestamp for the + # right edge of the graph. + latest_event = self.get_latest_event(self.hero.id) log.debug(f'latest_event: {latest_event}') if latest_event: @@ -64,51 +169,19 @@ def __init__(self, parent, hero, *args, **kwargs): log.debug(f'No previous events found for {self.hero.name}') end_time = datetime.now() + # the left edge is two hours before the right edge. start_time = end_time - timedelta(minutes=120) - log.debug(f'Graphing {self.category} gain between {start_time} and {end_time}') + log.debug(f'Graphing xp gain between {start_time} and {end_time}') with models.db() as session: - log.debug('Gathering samples') - try: - raw_samples = session.scalars( - select( - models.HeroStatEvent - ).where( - models.HeroStatEvent.hero_id == self.hero.id, - models.HeroStatEvent.event_time >= start_time, - models.HeroStatEvent.event_time <= end_time, - ) - ).all() - except Exception as err: - log.error('Error gathering data samples') - log.error(err) - raise - - # do some binning in per-minute buckets in the database. - log.debug('Gathering binned samples') - try: - samples = session.execute( - select( - func.STRFTIME('%Y-%m-%d %H:%M:00', models.HeroStatEvent.event_time).label('EventMinute'), - func.sum(models.HeroStatEvent.xp_gain).label('xp_gain'), - func.sum(models.HeroStatEvent.inf_gain).label('inf_gain') - ).where( - models.HeroStatEvent.hero_id == self.hero.id, - models.HeroStatEvent.event_time >= start_time, - models.HeroStatEvent.event_time <= end_time, - ).group_by( - 'EventMinute' - # func.STRFTIME('%Y-%m-%d %H:%M:00', models.HeroStatEvent.event_time) - ).order_by( - 'EventMinute' - ) - ).all() - except Exception as err: - log.error('Error gathering data samples') - log.error(err) - raise - - log.debug(f'Found {len(samples)} binned samples, {len(raw_samples)} raw values') + raw_samples = self.get_raw_samples( + hero.id, start_time, end_time, session + ) + binned_samples = self.get_binned_samples( + hero.id, start_time, end_time, session + ) + + log.debug(f'Found {len(binned_samples)} binned samples, {len(raw_samples)} raw values') data_timestamp = [] data_xp = [] @@ -120,7 +193,7 @@ def __init__(self, parent, hero, *args, **kwargs): sum_xp = 0 sum_inf = 0 - for row in samples: + for row in binned_samples: # per bin log.debug(f'row: {row}') datestring, xp_gain, inf_gain = row @@ -168,49 +241,73 @@ def __init__(self, parent, hero, *args, **kwargs): samples_xp = [] samples_inf = [] for row in raw_samples: - # log.debug(f"{row=}") - #event, xp, inf = row samples_timestamp.append(row.event_time) samples_xp.append(row.xp_gain) samples_inf.append(row.inf_gain) - - oldest = datetime.strptime(samples[0][0], "%Y-%m-%d %H:%M:%S") - newest = datetime.strptime(samples[-1][0], "%Y-%m-%d %H:%M:%S") - - log.debug(f"drawing graph between {oldest} and {newest}") - - duration = (newest - oldest).total_seconds() - if duration == 0: - return - - # avg_xp_per_minute = 60 * sum_xp / duration - # avg_inf_per_minute = 60 * sum_inf / duration + + log.debug(f"drawing graph between {start_time} and {end_time}") - # shifting to per minute should push it up to where its - # visible/interesting (it doesn't) - # ax.axhline(y=avg_xp_per_minute, color='blue') + # scatter plot of each actual XP gain event with a blue star ax.scatter(samples_timestamp, samples_xp, c="darkblue", marker='*') + # left axis label ax.set_ylabel('Experience') - # samples + + # second axis for influence gain ax2 = ax.twinx() + + # right axis label ax2.set_ylabel('Influence') + # scatter plot for each INF gain event with a green $ ax2.scatter(samples_timestamp, samples_inf, c="darkgreen", marker=r'$\$$', s=75) - #ax2.axhline(y=avg_inf_per_minute, color='green') log.debug(f'Plotting:\n\n [{len(data_timestamp)}]{data_timestamp=}\n{data_xp=}\n[{len(rolling_data_xp)}]{rolling_data_xp=}\n') try: + # binned averages over per-minute time intervals ax.plot(data_timestamp, data_xp, drawstyle="steps", color='blue') + + # blue dotted line with discs for the current rolling average + # over the last five minutes. ax.plot(data_timestamp, rolling_data_xp, 'o--', color='blue') + # binned averages per minute for influence; this is kinda junk since + # it very (very) frequently lands right on top of the xp bins. ax2.plot(data_timestamp, data_inf, drawstyle="steps", color='green') except Exception as err: log.error(err) log.error("ERROR!!! %s/%s/%s", data_timestamp, data_xp, rolling_data_xp) + # labels on the time axis every ten minutes seems about right + # without too much clutter. ax.xaxis.set_major_formatter(mdates.DateFormatter('%I:%M')) ax.xaxis.set_major_locator(mdates.MinuteLocator(interval=10)) + # do we know what level the character is? + level = settings.get_config_key( + 'level', default=None, cf='state.json' + ) + if level: + # subtle color bands + xp_needed = xp_table[int(level)] + + # the higher your level, the longer a level up is reasonably + # expected to take. Lets yank a formula out of our keister. + typical_time = 10 + (2 * int(level)) + + # you will level up every half hour, anything faster than that + # is green. + typical = int(xp_needed / typical_time) + + # it will take two hours to gain a level + poor = int(xp_needed / (3 * typical_time)) + + # green_band + ax.axhspan(ymin=typical, ymax=ax.get_ylim()[1], facecolor='#00FF00', alpha=0.05) + # yellow_band + ax.axhspan(ymin=poor, ymax=typical, facecolor='#FFFF00', alpha=0.05) + # red_band + ax.axhspan(ymin=0, ymax=poor, facecolor='#FF0000', alpha=0.05) + # creating the Tkinter canvas # containing the Matplotlib figure canvas = FigureCanvasTkAgg( @@ -223,6 +320,146 @@ def __init__(self, parent, hero, *args, **kwargs): log.debug('graph constructed') +class DamageFrame(ctk.CTkScrollableFrame): + """ + Damage is different than XP/Inf. I'm not interested in rates or projections. + I want to know how much damage each power is doing and how often I use them. + We will scope to this session, so we will do this in memory. + """ + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.grid_rowconfigure(0, weight=0) + self.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(self, text="Power Name").grid(column=0, row=0, sticky='ew') + + self.grid_columnconfigure(1, weight=1) + ctk.CTkLabel(self, text="Hit Rate").grid(column=1, row=0, sticky='ew') + + self.grid_columnconfigure(2, weight=1) + ctk.CTkLabel(self, text="Type").grid(column=2, row=0, sticky='ew') + + self.grid_columnconfigure(3, weight=1) + ctk.CTkLabel(self, text="Special").grid(column=3, row=0, sticky='ew') + + self.grid_columnconfigure(4, weight=1) + ctk.CTkLabel(self, text="Average").grid(column=4, row=0, sticky='ew') + + self.grid_columnconfigure(5, weight=1) + ctk.CTkLabel(self, text="Total").grid(column=5, row=0, sticky='ew') + + hline = tk.Frame(self, borderwidth=1, relief="solid", height=2) + hline.grid(column=0, row=1, columnspan=6, sticky="ew") + + + def log_damage(self): + # is 'Damage' the selected tab? + log.warning("log_damage") + # how much of the math in python vs sqlite? + with models.db() as session: + all_damage = session.scalars( + select(models.Damage) + ).all() + + powers = {} + for row in all_damage: + powers.setdefault(row.power, { + 'hit': 0, + 'miss': 0, + 'typed': {} + }) + + powers[row.power]['hit'] += 1 + # we don't track miss yet + + powers[row.power]['typed'].setdefault( + (row.damage_type, row.special), + { + 'count': 0, + 'total': 0 + } + ) + powers[row.power]['typed'][(row.damage_type, row.special)]['count'] += 1 + powers[row.power]['typed'][(row.damage_type, row.special)]['total'] += row.damage + + row_index = 2 + for powername in powers: + p = powers[powername] + height = len(p['typed']) + if height > 1: + # one more for "all" + height += 1 + + # Power Name + ctk.CTkLabel( + self, + text=powername + ).grid( + column=0, + row=row_index, + rowspan=height, + sticky="ew", + padx=5 + ) + + hits = 0 + total_damage = 0 + tries = 0 + for damage_type, special in p['typed']: + key = (damage_type, special) + # how many times has this power hit? + # if we do it across all damage types it seem more accurate than + # it really is, but unless we know the "base" type? + # TODO: this should be better + hits += p['typed'][key]['count'] + total_damage += p['typed'][key]['total'] + + # Hit Rate + perc = 100 + ctk.CTkLabel( + self, text=f"{hits} of {tries}: {perc}%" + ).grid( + column=1, + row=row_index, + rowspan=height + ) + + p['typed'][('Total', '')] = {'total': total_damage, 'count': hits} + for damage_type, special in p['typed']: + key = (damage_type, special) + if damage_type == "Total": + hline = tk.Frame(self, borderwidth=1, relief="solid", height=1) + hline.grid(column=2, row=row_index, columnspan=4, sticky="ew") + row_index += 1 + + ctk.CTkLabel( + self, text=damage_type, corner_radius=0, padx=0 + ).grid(column=2, row=row_index) + + ctk.CTkLabel( + self, text=special, corner_radius=0, padx=0 + ).grid(column=3, row=row_index) + + # avg + ctk.CTkLabel( + self, + text=f"{p['typed'][key]["total"] / p['typed'][key]["count"]:.2f}", + corner_radius=0, padx=0 + ).grid(column=4, row=row_index) + + ctk.CTkLabel( + self, text=p['typed'][key]["total"], + corner_radius=0, padx=0 + ).grid(column=5, row=row_index) + + row_index += 1 + + hline = tk.Frame(self, borderwidth=1, relief="groove", height=2) + hline.grid(column=0, row=row_index + 1, columnspan=6, sticky="ew") + + row_index += 2 + + class ChatterService: def start(self, event_queue, speaking_queue): log.info('ChatterService.start()') @@ -231,7 +468,6 @@ def start(self, event_queue, speaking_queue): speaking_queue.put((None, "Attaching to most recent log...", 'system')) logdir = "G:/CoH/homecoming/accounts/VVonder/Logs" - #logdir = "g:/CoH/homecoming/accounts/VVonder/Logs" badges = True team = True npc = True @@ -298,6 +534,7 @@ def attach_chatter(self): Not sure exactly how I want to do this. I think the best long term option is to just launch a process and be done with it. """ + if self.attached: # we are already attached, I guess we want to stop. self.p.terminate() @@ -312,7 +549,7 @@ def attach_chatter(self): target=self.cs.start, args=( self.event_queue, - self.speaking_queue + self.speaking_queue, ) ) self.p.start() @@ -322,11 +559,14 @@ def attach_chatter(self): class CharacterTab(ctk.CTkFrame): def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): super().__init__(parent, *args, **kwargs) + hero = models.get_hero() + + #self.update_xpinf() self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=0) - self.rowconfigure(1, weight=0) - self.rowconfigure(2, weight=1) + self.rowconfigure(1, weight=1) + # self.rowconfigure(2, weight=1) self.name = tk.StringVar() self.chatter = Chatter(self, event_queue, speaking_queue) @@ -335,40 +575,69 @@ def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): self.total_exp = tk.IntVar(value=0) self.total_inf = tk.IntVar(value=0) - totals_frame = self.totals_frame() - totals_frame.grid(column=0, row=1, sticky="ew") + buffer = ctk.CTkFrame(self) + buffer.grid_rowconfigure(0, weight=1) + buffer.grid_columnconfigure(0, weight=1) + self.character_subtabs = ctk.CTkTabview( + buffer, anchor="nw", height=512, command=self.subtab_selected + ) + + self.graph = self.character_subtabs.add('Graph') + self.graph.grid_columnconfigure(0, weight=1) + self.graph.grid_rowconfigure(0, weight=1) + ChartFrame(self.graph, hero).grid(column=0, row=0, sticky="nsew") + + damage = self.character_subtabs.add('Damage') + damage.grid_columnconfigure(0, weight=1) + damage.grid_rowconfigure(0, weight=1) + + self.damageframe = DamageFrame(damage) + self.damageframe.grid(column=0, row=0, sticky="nsew") + + experience = self.character_subtabs.add('Experience') + influence = self.character_subtabs.add('Influence') + + self.character_subtabs.grid(column=0, row=0, sticky="new") + buffer.grid(column=0, row=1, sticky="nsew") + + #frame = ctk.CTkFrame(self) + #ctk.CTkLabel(frame, text="Experience:").grid(column=0, row=0) + #ctk.CTkLabel(frame, textvariable=self.total_exp).grid(column=1, row=0, padx=(15, 0)) + #ctk.CTkLabel(frame, text="Influence:").grid(column=2, row=0, padx=(30, 0)) + #ctk.CTkLabel(frame, textvariable=self.total_inf).grid(column=3, row=0, padx=(15, 0)) self.start_time = datetime.now() - self.set_hero() + + def subtab_selected(self, *args, **kwargs): + selected_tab = self.character_subtabs.get() + if selected_tab == "Damage": + self.damageframe.log_damage() + self.damageframe.pack(fill="both", expand=True) + else: + log.warning(f'Unknown tab: {selected_tab}') - def totals_frame(self): - """ - Frame for displaying xp and influence totals - """ - frame = ctk.CTkFrame(self) - ctk.CTkLabel(frame, text="Experience").grid(column=0, row=0) - ctk.CTkLabel(frame, textvariable=self.total_exp).grid(column=1, row=0) - ctk.CTkLabel(frame, text="Influence").grid(column=0, row=1) - ctk.CTkLabel(frame, textvariable=self.total_inf).grid(column=1, row=1) - return frame def update_xpinf(self): hero_id = settings.get_config_key('hero_id', cf='state.json') with models.db() as session: - total_exp, total_inf = session.query( - func.sum(models.HeroStatEvent.xp_gain), - func.sum(models.HeroStatEvent.inf_gain) - ).where( - models.HeroStatEvent.hero_id == hero_id, - models.HeroStatEvent.event_time >= self.start_time - ).all()[0] # first (only) row + try: + total_exp, total_inf = session.query( + func.sum(models.HeroStatEvent.xp_gain), + func.sum(models.HeroStatEvent.inf_gain) + ).where( + models.HeroStatEvent.hero_id == hero_id, + models.HeroStatEvent.event_time >= self.start_time + ).all()[0] # first (only) row + except IndexError: + total_exp = 0 + total_inf = 0 self.total_exp.set(total_exp) self.total_inf.set(total_inf) - def set_hero(self, *args, **kwargs): + def set_progress_chart(self, *args, **kwargs): """ Invoked at init(), but also whenever the character changes (logout to character select) and more critically, every N seconds to refresh the graph. @@ -377,12 +646,14 @@ def set_hero(self, *args, **kwargs): if hasattr(self, "progress_chart"): self.progress_chart.grid_forget() - self.total_exp.set(0) - self.total_inf.set(0) + #self.total_exp.set(0) + #self.total_inf.set(0) try: self.update_xpinf() - self.progress_chart = ChartFrame(self, hero) - self.progress_chart.grid(column=0, row=2, sticky="nsew") - # side="top", fill="both", expand=True) except Exception as err: log.error(err) + + self.progress_chart = ChartFrame(self.graph, hero) + self.graph.grid_columnconfigure(0, weight=1) + self.graph.grid_rowconfigure(0, weight=1) + self.progress_chart.grid(column=0, row=0, sticky="nsew") diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 20f4da7..56f37b7 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -1,7 +1,6 @@ """Voice Editor component""" import logging import os -import sys import tkinter as tk from tkinter import ttk @@ -21,12 +20,6 @@ from voicebox.sinks import Distributor, SoundDevice, WaveFile from voicebox.tts.utils import get_audio_from_wav_file -logging.basicConfig( - level=settings.LOGLEVEL, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) - log = logging.getLogger(__name__) ENGINE_OVERRIDE = {} @@ -63,7 +56,10 @@ def __init__(self, rank, *args, **kwargs): self.options.pack(side="left", fill="x", expand=True) self.play_btn = ctk.CTkButton( - frame, text="Play", width=80, command=self.play_cache + frame, + text="Play", + width=80, + command=self.play_cache ) regen_btn = ctk.CTkButton( @@ -88,7 +84,6 @@ def __init__(self, rank, *args, **kwargs): # must be called after self.play_btn exists self.populate_phrases() - def set_translated(self, *args, **kwargs): """ @@ -134,14 +129,14 @@ def choose_phrase(self, *args, **kwargs): wavfilename = audio.mp3file_to_wavfile( mp3filename=cachefile ) - self.play_btn["state"] = "normal" + self.play_btn.configure(state="normal") # and display the wav self.show_wave(wavfilename) return log.debug(f'Cached mp3 {cachefile} does not exist.') self.clear_wave() - self.play_btn["state"] = "disabled" + self.play_btn.configure(state="disabled") def populate_phrases(self): log.debug('** populate_phrases() called **') @@ -348,7 +343,10 @@ def say_it(self, use_secondary=False): message=message ), ] - for phrase in all_phrases: + for phrase in all_phrases: + if phrase.text in ["", ]: + continue + log.debug(f'{phrase=}') # this is an existing phrase @@ -367,6 +365,9 @@ def say_it(self, use_secondary=False): WaveFile(cachefile + '.wav') ]) + # make sure the destination directory exists + os.makedirs(os.path.dirname(cachefile), exist_ok=True) + log.debug(f'effect_list: {effect_list}') log.debug(f"Creating ttsengine for {character.name}") @@ -533,7 +534,7 @@ def change_selected_engine(self, a, b, c): if not engine_cls: # that didn't work.. try the default engine - log.warning(f'Invalid Engine: {engine_name}. Using default {settings.DEFAULT_ENGINE} engine.') + log.warning(f'Invalid Engine: {engine_name!r}. Using default {settings.DEFAULT_ENGINE} engine.') engine_cls = engines.get_engine(settings.DEFAULT_ENGINE) self.engine_parameters = engine_cls( @@ -676,12 +677,7 @@ def load_effects(self, name, category): ) # not very DRY - effect_config_frame = effect_class( - self, - # borderwidth=1, - # relief="groove", - # style="Effect.TFrame" - ) + effect_config_frame = effect_class(self) effect_config_frame.grid(row=index, column=0, sticky="new") #pack(side="top", fill="x", expand=True) effect_config_frame.effect_id.set(effect.id) @@ -691,12 +687,6 @@ def load_effects(self, name, category): self.next_effect_row = index + 1 - # if not has_effects: - # self.buffer = ttk.Frame(self, width=1, height=1).pack(side="top") - # else: - # if self.buffer: - # self.buffer.pack_forget() - log.debug("Rebuilding add_effect") self.add_effect_combo.grid_forget() self.add_effect_combo = AddEffect(self, self) @@ -705,8 +695,8 @@ def load_effects(self, name, category): column=0, sticky="new" ) - #(side="top", fill='x', expand=True) - + + def add_effect(self, effect_name): """ Add the chosen effect to the list of effects the user can manipulate. @@ -737,16 +727,7 @@ def add_effect(self, effect_name): # with an apply(Audio) that returns an Audio; An "Audio" is a pretty # simple object wrapping a np.ndarray of [-1 to 1] samples. # - # ttk.Style().configure( - # "EffectConfig.TFrame", - # highlightbackground="black", - # relief="groove" - # ) - effect_config_frame = effect( - self, - # style="EffectConfig.TFrame", - # borderwidth=1 - ) + effect_config_frame = effect(self) self.add_effect_combo.grid_forget() effect_config_frame.grid( column=0, @@ -777,10 +758,10 @@ def add_effect(self, effect_name): session.commit() session.refresh(effect) - for key in effect_config_frame.parameters: - # save the current effect configuration - tkvar = getattr(effect_config_frame, key) - value = tkvar.get() + for key in effect_config_frame.tkvars: + # save the current effect configuration, these will presumably + # just be the defaults. + value = effect_config_frame.tkvars[key].get() new_setting = models.EffectSetting( effect_id=effect.id, @@ -790,7 +771,8 @@ def add_effect(self, effect_name): session.add(new_setting) session.commit() - effect_config_frame.effect_id.set(effect.id) + effect_config_frame.effect_id.set(effect.id) + effect_config_frame.load() def remove_effect(self, effect_obj): log.debug(f'Removing effect {effect_obj}') From 9ebe049f8c5d54ceefe161d31af449132848206d Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 27 Jul 2024 18:12:08 -0700 Subject: [PATCH 24/32] hide xp and inf tabs they don't work yet --- src/cnv/tabs/character.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index cee2804..2f46088 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -594,18 +594,12 @@ def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): self.damageframe = DamageFrame(damage) self.damageframe.grid(column=0, row=0, sticky="nsew") - experience = self.character_subtabs.add('Experience') - influence = self.character_subtabs.add('Influence') + # experience = self.character_subtabs.add('Experience') + # influence = self.character_subtabs.add('Influence') self.character_subtabs.grid(column=0, row=0, sticky="new") buffer.grid(column=0, row=1, sticky="nsew") - #frame = ctk.CTkFrame(self) - #ctk.CTkLabel(frame, text="Experience:").grid(column=0, row=0) - #ctk.CTkLabel(frame, textvariable=self.total_exp).grid(column=1, row=0, padx=(15, 0)) - #ctk.CTkLabel(frame, text="Influence:").grid(column=2, row=0, padx=(30, 0)) - #ctk.CTkLabel(frame, textvariable=self.total_inf).grid(column=3, row=0, padx=(15, 0)) - self.start_time = datetime.now() def subtab_selected(self, *args, **kwargs): From 82899744cb01b3829a274264b14901c7a1c79f44 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 27 Jul 2024 18:12:32 -0700 Subject: [PATCH 25/32] decrease odds of being blocked --- src/cnv/lib/proc.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cnv/lib/proc.py b/src/cnv/lib/proc.py index c615e8a..d4e25d0 100644 --- a/src/cnv/lib/proc.py +++ b/src/cnv/lib/proc.py @@ -63,5 +63,8 @@ def send_log_lock(timeout=10): time.sleep(1) # send to a tell to ourselves with an infodump - for var in ['name', 'level', 'primary', 'secondary', 'archetype']: - send_chatstring(f'/tell $name, [SIDEKICK] {var}="${var}"\n') \ No newline at end of file + #for var in ['name', 'level', 'primary', 'secondary', 'archetype']: + send_chatstring('/tell $name, [SIDEKICK] name="$name";level="$level"\n') + send_chatstring('/tell $name, [SIDEKICK] primary="$primary";secondary="$secondary"\n'); + send_chatstring('/tell $name, [SIDEKICK] archetype="$archetype"\n') + From 76d91294fe967c11530c45b050a4b6acb7f1832e Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 27 Jul 2024 18:13:08 -0700 Subject: [PATCH 26/32] chatter side of multivariable tells --- src/cnv/chatlog/npc_chatter.py | 55 +++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 088f0a7..e8b2775 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -375,17 +375,18 @@ def tell_chat_parser(self, lstring): if dialog.split()[0] == "[SIDEKICK]": # player attribute self-reporting - try: - key, value = dialog.split()[1].split('=') - except ValueError: - log.warning(f'Invalid SIDEKICK: {dialog}') - return "__self__", None + for keyvalue in dialog.split(';'): + try: + key, value = keyvalue.split('=') + except ValueError: + log.warning(f'Invalid SIDEKICK: {dialog}') + return "__self__", None - settings.set_config_key( - key, - value.strip('"'), - cf='state.json' - ) + settings.set_config_key( + key, + value.strip('"'), + cf='state.json' + ) # don't try and speak it. return "__self__", None @@ -394,19 +395,26 @@ def tell_chat_parser(self, lstring): else: # ["[Tell]", ":Dressy Bessie:", "I", "can", "bump", "you"] # ["[Tell]", ":StoneRipper:", "it:s", "underneath"] + speaker = None + dialog = None try: # ["", "Dressy Bessie", "I can bump you"] # ['', 'StoneRipper', ' it:s underneath'] - _, speaker, dialog = " ".join(lstring[1:]).split( - ":", maxsplit=2 - ) - # ignore SIDEKICK self-tells - if dialog.split()[0] == "[SIDEKICK]": - - return "__self__", None + full_string = " ".join(lstring[1:]) + + if ':' in full_string: + _, speaker, dialog = full_string.split( + ":", maxsplit=2 + ) + # ignore SIDEKICK self-tells + try: + if dialog.split()[0] == "[SIDEKICK]": + return "__self__", None + except IndexError: + pass except ValueError: - # I didn't note the string that caused me to add this. Oops. + # 2024-07-27 17:17:07 [Tell] You are banned from talking for 2 minutes, 0 seconds. # logging at info so I can maybe catch it in the future. log.info(f'1 ADD DOC: {lstring=}') speaker, dialog = " ".join(lstring[1:]).split(":", maxsplit=1) @@ -545,6 +553,13 @@ def tail(self): self.speaking_queue.put((None, (" ".join(lstring[4:])), "system")) elif lstring[0] == "You": + if lstring[1] == "found": + # You found a face mask that is covered in some kind of mold. It appears to be pulsing like it's breathing. You send a short video to Watkins for evidence. + dialog = plainstring(" ".join(lstring)) + if (settings.REPLAY and settings.SPEECH_IN_REPLAY) or not settings.REPLAY: + self.speaking_queue.put((None, dialog, "system")) + + # # ["You", "have", "quit", "your", "team"] # # ["Pew Pew Die Die Die has quit the league. # # ["Ice-Mech", "has", "quit", "the", "team"] @@ -572,7 +587,7 @@ def tail(self): # ) # ) - if self.hero and lstring[1] == "gain": + elif self.hero and lstring[1] == "gain": # You gain 104 experience and 36 influence. # You gain 15 experience, work off 15 debt, and gain 14 influence. # You gain 26 experience and work off 2,676 debt. @@ -687,7 +702,7 @@ def tail(self): dialog = plainstring(" ".join(lstring)) self.speaking_queue.put((None, dialog, "system")) - elif lstring[0:1] == ["Your", "combat"]: + elif lstring[0:2] == ["Your", "combat"]: # 2024-07-26 19:01:05 Your combat improves to level 23! Seek a trainer to further your abilities. level = int(lstring[5].strip('!')) self.speaking_queue.put( From 4f8613574dff8e6ec180146d577266689ce48f1f Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 28 Jul 2024 14:40:50 -0700 Subject: [PATCH 27/32] multiple fixes for the damage panel, added accuracy --- src/cnv/chatlog/npc_chatter.py | 54 ++++++++++++++++------------------ src/cnv/tabs/character.py | 34 ++++++++++++++------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index e8b2775..12aea1f 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -553,40 +553,13 @@ def tail(self): self.speaking_queue.put((None, (" ".join(lstring[4:])), "system")) elif lstring[0] == "You": - if lstring[1] == "found": + if lstring[1] in ["found", "stole"]: # You found a face mask that is covered in some kind of mold. It appears to be pulsing like it's breathing. You send a short video to Watkins for evidence. + # You stole the money! dialog = plainstring(" ".join(lstring)) if (settings.REPLAY and settings.SPEECH_IN_REPLAY) or not settings.REPLAY: self.speaking_queue.put((None, dialog, "system")) - - # # ["You", "have", "quit", "your", "team"] - # # ["Pew Pew Die Die Die has quit the league. - # # ["Ice-Mech", "has", "quit", "the", "team"] - # # ["You", "are", "now", "fighting", "at", "level", "9."] - # # ["You", carefully remove an odd piece of paper from a pile of medical refuse."] - # if lstring[1] == "are": - # if ( - # self.announce_levels and lstring[2:3] == ["now", "fighting"] - # ) and ( - # " ".join(previous[-4:]).strip(".") - # not in [ - # "have quit your team", - # "has quit the team", - # "has joined the team", - # "has joined the league", - # "has quit the league", - # ] - # ): - # level = lstring[-1].strip(".") - # self.speaking_queue.put( - # ( - # None, - # f"Congratulations. You have reached Level {level}", - # "system", - # ) - # ) - elif self.hero and lstring[1] == "gain": # You gain 104 experience and 36 influence. # You gain 15 experience, work off 15 debt, and gain 14 influence. @@ -679,6 +652,29 @@ def tail(self): dialog = plainstring(" ".join(lstring)) self.speaking_queue.put((None, dialog, "system")) + elif lstring[0] == "MISSED": + # MISSED Mamba Blade!! Your Contaminated Strike power had a 95.00% chance to hit, you rolled a 95.29. + m = re.fullmatch( + r"MISSED (?P.*)!! Your (?P.*) power had a (?P[0-9\.]*)% change to hit, you rolled a (?P[0-9\.]*).", + " ".join(lstring) + ) + if m: + target, power, change_to_hit, roll = m.groups() + + # Okay to tuck a "miss" in here? + d = models.Damage( + hero_id=self.hero.id, + target=target, + power=power, + damage=0, + damage_type=None, + special=None + ) + + with models.db() as session: + session.add(d) + session.commit() + elif lstring[0] == "Welcome": # Welcome to City of Heroes, self.hero = Hero(" ".join(lstring[5:]).strip("!")) diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index 2f46088..bfbb9a2 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -329,6 +329,9 @@ class DamageFrame(ctk.CTkScrollableFrame): def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) + self.create_grid_header() + + def create_grid_header(self): self.grid_rowconfigure(0, weight=0) self.grid_columnconfigure(0, weight=1) ctk.CTkLabel(self, text="Power Name").grid(column=0, row=0, sticky='ew') @@ -349,12 +352,16 @@ def __init__(self, parent, *args, **kwargs): ctk.CTkLabel(self, text="Total").grid(column=5, row=0, sticky='ew') hline = tk.Frame(self, borderwidth=1, relief="solid", height=2) - hline.grid(column=0, row=1, columnspan=6, sticky="ew") + hline.grid(column=0, row=1, columnspan=6, sticky="ew") + + def refresh_damage_panel(self): + log.warning("refresh_damage_panel") + # clear any old data + for widget in self.winfo_children(): + widget.destroy() + self.create_grid_header() - def log_damage(self): - # is 'Damage' the selected tab? - log.warning("log_damage") # how much of the math in python vs sqlite? with models.db() as session: all_damage = session.scalars( @@ -411,13 +418,15 @@ def log_damage(self): # if we do it across all damage types it seem more accurate than # it really is, but unless we know the "base" type? # TODO: this should be better - hits += p['typed'][key]['count'] + if damage_type is not None: + hits += p['typed'][key]['count'] + tries += p['typed'][key]['count'] total_damage += p['typed'][key]['total'] # Hit Rate - perc = 100 + perc = 100 * float(hits) / float(tries) ctk.CTkLabel( - self, text=f"{hits} of {tries}: {perc}%" + self, text=f"{hits} of {tries}: {perc:0.2f}%" ).grid( column=1, row=row_index, @@ -443,12 +452,12 @@ def log_damage(self): # avg ctk.CTkLabel( self, - text=f"{p['typed'][key]["total"] / p['typed'][key]["count"]:.2f}", + text=f"{p['typed'][key]["total"] / p['typed'][key]["count"]:,.2f}", corner_radius=0, padx=0 ).grid(column=4, row=row_index) ctk.CTkLabel( - self, text=p['typed'][key]["total"], + self, text=f"{p['typed'][key]["total"]:,}", corner_radius=0, padx=0 ).grid(column=5, row=row_index) @@ -605,7 +614,7 @@ def __init__(self, parent, event_queue, speaking_queue, *args, **kwargs): def subtab_selected(self, *args, **kwargs): selected_tab = self.character_subtabs.get() if selected_tab == "Damage": - self.damageframe.log_damage() + self.damageframe.refresh_damage_panel() self.damageframe.pack(fill="both", expand=True) else: log.warning(f'Unknown tab: {selected_tab}') @@ -647,6 +656,11 @@ def set_progress_chart(self, *args, **kwargs): except Exception as err: log.error(err) + try: + self.damageframe.refresh_damage_panel() + except Exception as err: + log.error(err) + self.progress_chart = ChartFrame(self.graph, hero) self.graph.grid_columnconfigure(0, weight=1) self.graph.grid_rowconfigure(0, weight=1) From 747b9bd5c0b0887b8423567e0720c9ddbe6840d1 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 28 Jul 2024 16:03:10 -0700 Subject: [PATCH 28/32] for real working accuracy --- src/cnv/chatlog/npc_chatter.py | 10 +++++++--- src/cnv/database/db.py | 5 +---- src/cnv/database/models.py | 8 ++++++++ src/cnv/engines/base.py | 2 +- src/cnv/tabs/character.py | 4 ++-- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 12aea1f..8fa3c97 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -512,8 +512,10 @@ def tail(self): with self.open_latest_log() as handle: if self.first_tail: + # New character selected log.debug(f'Found new logfile {self.logfile}') self.find_character_login() + models.clear_damage() if settings.REPLAY: # start at the beginning of the log @@ -655,7 +657,7 @@ def tail(self): elif lstring[0] == "MISSED": # MISSED Mamba Blade!! Your Contaminated Strike power had a 95.00% chance to hit, you rolled a 95.29. m = re.fullmatch( - r"MISSED (?P.*)!! Your (?P.*) power had a (?P[0-9\.]*)% change to hit, you rolled a (?P[0-9\.]*).", + r"MISSED (?P.*)!! Your (?P.*) power had a (?P[0-9\.]*)% chance to hit, you rolled a (?P[0-9\.]*).", " ".join(lstring) ) if m: @@ -667,13 +669,15 @@ def tail(self): target=target, power=power, damage=0, - damage_type=None, - special=None + damage_type="", + special="" ) with models.db() as session: session.add(d) session.commit() + else: + log.warning('String failed regex:\n%s' % " ".join(lstring)) elif lstring[0] == "Welcome": # Welcome to City of Heroes, diff --git a/src/cnv/database/db.py b/src/cnv/database/db.py index 61d2df0..73fcf09 100644 --- a/src/cnv/database/db.py +++ b/src/cnv/database/db.py @@ -83,7 +83,4 @@ def build_migrate(): if not settings.REPLAY or settings.SESSION_CLEAR_IN_REPLAY: log.info('Clearing session storage...') - with models.Session(models.engine) as session: - # delete all Damage table rows - session.query(models.Damage).delete() - session.commit() + models.clear_damage() diff --git a/src/cnv/database/models.py b/src/cnv/database/models.py index a6d8a6f..f61a420 100644 --- a/src/cnv/database/models.py +++ b/src/cnv/database/models.py @@ -698,6 +698,14 @@ class Damage(Base): # assassin strike, critical, etc.. special: Mapped[str] = mapped_column(String(32)) + +def clear_damage(): + with db() as session: + # delete all Damage table rows + session.query(Damage).delete() + session.commit() + + class Hero(Base): __tablename__ = "hero" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index dfb17f8..b5c0396 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -415,7 +415,7 @@ def reconfig(self, *args, **kwargs): combo widgets need to repopulate. """ if self.loading: - log.warning('Voice config change while loading... (ignoring)') + log.debug('Voice config change while loading... (ignoring)') return log.debug(f'reconfig({args=}, {kwargs=})') diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index bfbb9a2..d74d62d 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -356,7 +356,7 @@ def create_grid_header(self): def refresh_damage_panel(self): - log.warning("refresh_damage_panel") + log.debug("refresh_damage_panel") # clear any old data for widget in self.winfo_children(): widget.destroy() @@ -418,7 +418,7 @@ def refresh_damage_panel(self): # if we do it across all damage types it seem more accurate than # it really is, but unless we know the "base" type? # TODO: this should be better - if damage_type is not None: + if damage_type != "": hits += p['typed'][key]['count'] tries += p['typed'][key]['count'] total_damage += p['typed'][key]['total'] From 15bbc79dd4facdfb2eaae2901a70f1abb808686d Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sun, 28 Jul 2024 16:26:42 -0700 Subject: [PATCH 29/32] remove empty rows in damage panel --- src/cnv/tabs/character.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index d74d62d..400b40c 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -435,6 +435,9 @@ def refresh_damage_panel(self): p['typed'][('Total', '')] = {'total': total_damage, 'count': hits} for damage_type, special in p['typed']: + if damage_type == "": + continue + key = (damage_type, special) if damage_type == "Total": hline = tk.Frame(self, borderwidth=1, relief="solid", height=1) From 41a686ad83b1e3646d8b41cc792ef441a66baf66 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Mon, 29 Jul 2024 09:01:31 -0700 Subject: [PATCH 30/32] fix engine selection quirky UI --- src/cnv/chatlog/npc_chatter.py | 8 ++++---- src/cnv/engines/base.py | 15 +++++++------- src/cnv/tabs/character.py | 8 +++++++- src/cnv/voices/voice_editor.py | 36 ++++++++++++++++++++++------------ 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/cnv/chatlog/npc_chatter.py b/src/cnv/chatlog/npc_chatter.py index 8fa3c97..631d04f 100644 --- a/src/cnv/chatlog/npc_chatter.py +++ b/src/cnv/chatlog/npc_chatter.py @@ -555,7 +555,7 @@ def tail(self): self.speaking_queue.put((None, (" ".join(lstring[4:])), "system")) elif lstring[0] == "You": - if lstring[1] in ["found", "stole"]: + if lstring[1] in ["found", "stole", "begin", "finished"]: # You found a face mask that is covered in some kind of mold. It appears to be pulsing like it's breathing. You send a short video to Watkins for evidence. # You stole the money! dialog = plainstring(" ".join(lstring)) @@ -632,7 +632,7 @@ def tail(self): if special is None: special = "" else: - special = special.strip("()").title() + special = special.strip("() \t\n\r\x0b\x0c").title() d = models.Damage( hero_id=self.hero.id, @@ -697,7 +697,7 @@ def tail(self): action = lstring[-3] # joined or quit self.speaking_queue.put((None, f"Player {name} has {action} the team", "system")) - elif lstring[:2] == ["The", "name"]: + elif lstring[:2] in ["The", "name", "The", "whiteboard"]: # The name Toothbreaker Jones keeps popping up, and these Skulls were nice enough to tell you where to find him. Time to pay him a visit. dialog = plainstring(" ".join(lstring)) self.speaking_queue.put((None, dialog, "system")) @@ -719,7 +719,7 @@ def tail(self): ) # single word things to speak, mostly clicky descriptions - elif lstring[0] in ["Something's", "This", "You've"]: + elif lstring[0] in ["Something's", "In", "Jones", "This", "You've", "Where"]: # Something's not right with this spot on the floor... # This blotch of petroleum on the ground seems fresh, perhaps leaked by a 'zoombie' and a sign that they're near. You take a photo and send it to Watkins. # You've found a photocopy of a highly detailed page from a medical notebook, with wildly complex notes about cybernetics. diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index b5c0396..04435a2 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -303,12 +303,12 @@ def save_character(self, name, category): def draw_config_meta(self, parent): # now we build it. Row 0 is taken by the engine selector, the rest is ours. - # column sizing is handled upstream, we need to stay clean with - parent.columnconfigure(0, minsize=125, uniform="ttsengine") - parent.columnconfigure(1, weight=2, uniform="ttsengine") + # column sizing is handled upstream, we need to stay clean + self.columnconfigure(0, minsize=125, weight=0, uniform="ttsengine") + self.columnconfigure(1, weight=2, uniform="ttsengine") for index, m in enumerate(self.get_config_meta()): - ctk.CTkLabel(parent, text=m.cosmetic, anchor="e").grid( + ctk.CTkLabel(self, text=m.cosmetic, anchor="e").grid( row=index + 1, column=0, sticky="e", padx=10 ) @@ -326,12 +326,12 @@ def draw_config_meta(self, parent): # create the widget itself if m.varfunc == "StringVar": - self._tkStringVar(index + 1, m.key, parent) + self._tkStringVar(index + 1, m.key, self) elif m.varfunc == "DoubleVar": - self._tkDoubleVar(index + 1, m.key, parent, m.cfgdict) + self._tkDoubleVar(index + 1, m.key, self, m.cfgdict) self.config_vars[m.key].trace_add("write", self.reconfig) elif m.varfunc == "BooleanVar": - self._tkBooleanVar(index + 1, m.key, parent) + self._tkBooleanVar(index + 1, m.key, self) self.config_vars[m.key].trace_add("write", self.reconfig) else: # this will fail, but at least it will fail with a log message. @@ -339,7 +339,6 @@ def draw_config_meta(self, parent): # changes to the value of this widget trip a generic 'reconfig' # handler. - def _tkStringVar(self, index, key, frame): # combo widget for strings diff --git a/src/cnv/tabs/character.py b/src/cnv/tabs/character.py index 400b40c..09a42c6 100644 --- a/src/cnv/tabs/character.py +++ b/src/cnv/tabs/character.py @@ -223,6 +223,10 @@ def __init__(self, parent, hero, *args, **kwargs): data_xp.append(xp_gain) data_inf.append(inf_gain) + + if xp_gain is None: + xp_gain = 0 + rolling_xp_list.append(xp_gain) log.debug(f'{rolling_xp_list=}') @@ -433,7 +437,9 @@ def refresh_damage_panel(self): rowspan=height ) - p['typed'][('Total', '')] = {'total': total_damage, 'count': hits} + if len(p['typed']) > 1: + p['typed'][('Total', '')] = {'total': total_damage, 'count': hits} + for damage_type, special in p['typed']: if damage_type == "": continue diff --git a/src/cnv/voices/voice_editor.py b/src/cnv/voices/voice_editor.py index 56f37b7..66bc575 100644 --- a/src/cnv/voices/voice_editor.py +++ b/src/cnv/voices/voice_editor.py @@ -407,24 +407,28 @@ def say_it(self, use_secondary=False): tk.messagebox.showerror(title="Error", message=f"Engine {engine_name} did not provide audio") -class EngineSelectAndConfigure(ttk.LabelFrame): +class EngineSelectAndConfigure(ctk.CTkFrame): """ Responsible for everything inside the "Engine" section of the detailside. There is one instance of this object per layer of engine (primary, secondary, etc..) """ def __init__(self, rank, *args, **kwargs): - kwargs['text'] = 'Engine' super().__init__(*args, **kwargs) log.debug(f'EngineSelectAndConfigure.__init__({rank=}') self.rank = rank self.engine_parameters = None - - self.columnconfigure(0, minsize=125, uniform="ttsengine") - self.columnconfigure(1, weight=2, uniform="ttsengine") - + + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + #-- Row 0 -------------------------------------- - ctk.CTkLabel(self, text="Text to Speech Engine", anchor="e").grid( + speech_engine_selection = ctk.CTkFrame(self) + + speech_engine_selection.columnconfigure(0, minsize=125, weight=0, uniform="ttsengine") + speech_engine_selection.columnconfigure(1, weight=2, uniform="ttsengine") + + ctk.CTkLabel(speech_engine_selection, text="Speech Engine", anchor="e").grid( row=0, column=0, sticky="e", padx=10 ) @@ -433,20 +437,27 @@ def __init__(self, rank, *args, **kwargs): "write", self.change_selected_engine ) + base_tts = ctk.CTkComboBox( - self, + speech_engine_selection, variable=self.selected_engine, state='readonly', values=[e.cosmetic for e in engines.ENGINE_LIST] ) - # base_tts["values"] = [e.cosmetic for e in engines.ENGINE_LIST] - # base_tts["state"] = "readonly" + base_tts.grid( column=1, row=0, sticky="new" ) - #-- Row 1 -------------------------------------- + speech_engine_selection.grid( + column=0, row=0, sticky="new" + ) + ######################################### + # end of row 0 - #-- Row 2 -------------------------------------- + # gap for engine parameters. load_character_engines will + # call set_engine which will fill this in. + + # start of row 2 self.engine_parameters = None self.phrase_selector = WavfileMajorFrame( self.rank, self @@ -470,7 +481,6 @@ def set_engine(self, engine_name): # which will in turn set a value for engine_parameters self.selected_engine.set(engine_name) - def change_selected_engine(self, a, b, c): """ 1. the user changed the engine for this character. From e6fda1269a06ba8cd819619a7ec6b37f55bb33ba Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Mon, 29 Jul 2024 09:02:27 -0700 Subject: [PATCH 31/32] minor cleanup --- src/cnv/engines/base.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cnv/engines/base.py b/src/cnv/engines/base.py index 04435a2..26842aa 100644 --- a/src/cnv/engines/base.py +++ b/src/cnv/engines/base.py @@ -116,7 +116,6 @@ def __init__(self, parent, rank, name, category, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.rank = rank - self.parent = parent self.name = name self.category = category self.override = {} @@ -125,7 +124,7 @@ def __init__(self, parent, rank, name, category, *args, **kwargs): self.widget = {} self.set_config_meta(self.config) - self.draw_config_meta(self) + self.draw_config_meta() self.load_character(category=category, name=name) self.repopulate_options() @@ -301,7 +300,7 @@ def save_character(self, name, category): session.add(new_config_setting) session.commit() - def draw_config_meta(self, parent): + def draw_config_meta(self): # now we build it. Row 0 is taken by the engine selector, the rest is ours. # column sizing is handled upstream, we need to stay clean self.columnconfigure(0, minsize=125, weight=0, uniform="ttsengine") From a03901bc9ddbe5526e72713b72b5549db73f3079 Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Thu, 1 Aug 2024 18:55:57 -0700 Subject: [PATCH 32/32] cutting a new release --- Sidekick.bat | 3 +- {src => cnv}/__init__.py | 0 {src/cnv => cnv}/alembic.ini | 0 {src/cnv => cnv/chatlog}/__init__.py | 0 {src/cnv => cnv}/chatlog/npc_chatter.py | 0 {src/cnv/chatlog => cnv/database}/__init__.py | 0 {src/cnv => cnv}/database/db.py | 0 {src/cnv => cnv}/database/models.py | 0 {src/cnv/database => cnv/effects}/__init__.py | 0 {src/cnv => cnv}/effects/effects.py | 0 {src/cnv/effects => cnv/engines}/__init__.py | 0 {src/cnv => cnv}/engines/amazonpolly.py | 0 {src/cnv => cnv}/engines/azure.py | 0 {src/cnv => cnv}/engines/base.py | 0 {src/cnv => cnv}/engines/elevenlabs.py | 0 {src/cnv => cnv}/engines/engines.py | 0 {src/cnv => cnv}/engines/googlecloud.py | 0 {src/cnv => cnv}/engines/openai.py | 0 {src/cnv => cnv}/engines/windowstts.py | 0 {src/cnv/engines => cnv/lib}/__init__.py | 0 {src/cnv => cnv}/lib/audio.py | 0 {src/cnv => cnv}/lib/gui.py | 0 {src/cnv => cnv}/lib/icons/trash-2.png | Bin {src/cnv => cnv}/lib/proc.py | 0 {src/cnv => cnv}/lib/settings.py | 0 {src/cnv => cnv}/logger.py | 0 {src/cnv => cnv}/sidekick.py | 0 {src/cnv/lib => cnv/tabs}/__init__.py | 0 {src/cnv => cnv}/tabs/automation.py | 0 {src/cnv => cnv}/tabs/character.py | 0 {src/cnv => cnv}/tabs/configuration.py | 0 {src/cnv => cnv}/tabs/voices.py | 0 {src/cnv/tabs => cnv/voices}/__init__.py | 0 {src/cnv => cnv}/voices/voice_builder.py | 0 {src/cnv => cnv}/voices/voice_editor.py | 0 src/cnv/voices/__init__.py | 0 windows_installation_3.iss | 65 ++++++++++-------- 37 files changed, 39 insertions(+), 29 deletions(-) rename {src => cnv}/__init__.py (100%) rename {src/cnv => cnv}/alembic.ini (100%) rename {src/cnv => cnv/chatlog}/__init__.py (100%) rename {src/cnv => cnv}/chatlog/npc_chatter.py (100%) rename {src/cnv/chatlog => cnv/database}/__init__.py (100%) rename {src/cnv => cnv}/database/db.py (100%) rename {src/cnv => cnv}/database/models.py (100%) rename {src/cnv/database => cnv/effects}/__init__.py (100%) rename {src/cnv => cnv}/effects/effects.py (100%) rename {src/cnv/effects => cnv/engines}/__init__.py (100%) rename {src/cnv => cnv}/engines/amazonpolly.py (100%) rename {src/cnv => cnv}/engines/azure.py (100%) rename {src/cnv => cnv}/engines/base.py (100%) rename {src/cnv => cnv}/engines/elevenlabs.py (100%) rename {src/cnv => cnv}/engines/engines.py (100%) rename {src/cnv => cnv}/engines/googlecloud.py (100%) rename {src/cnv => cnv}/engines/openai.py (100%) rename {src/cnv => cnv}/engines/windowstts.py (100%) rename {src/cnv/engines => cnv/lib}/__init__.py (100%) rename {src/cnv => cnv}/lib/audio.py (100%) rename {src/cnv => cnv}/lib/gui.py (100%) rename {src/cnv => cnv}/lib/icons/trash-2.png (100%) rename {src/cnv => cnv}/lib/proc.py (100%) rename {src/cnv => cnv}/lib/settings.py (100%) rename {src/cnv => cnv}/logger.py (100%) rename {src/cnv => cnv}/sidekick.py (100%) rename {src/cnv/lib => cnv/tabs}/__init__.py (100%) rename {src/cnv => cnv}/tabs/automation.py (100%) rename {src/cnv => cnv}/tabs/character.py (100%) rename {src/cnv => cnv}/tabs/configuration.py (100%) rename {src/cnv => cnv}/tabs/voices.py (100%) rename {src/cnv/tabs => cnv/voices}/__init__.py (100%) rename {src/cnv => cnv}/voices/voice_builder.py (100%) rename {src/cnv => cnv}/voices/voice_editor.py (100%) delete mode 100644 src/cnv/voices/__init__.py diff --git a/Sidekick.bat b/Sidekick.bat index 1903826..02f87aa 100644 --- a/Sidekick.bat +++ b/Sidekick.bat @@ -1,2 +1,3 @@ call venv\Scripts\activate.bat -venv\Scripts\python.exe src\cnv\sidekick.py +venv\Scripts\python.exe cnv\sidekick.py +pause \ No newline at end of file diff --git a/src/__init__.py b/cnv/__init__.py similarity index 100% rename from src/__init__.py rename to cnv/__init__.py diff --git a/src/cnv/alembic.ini b/cnv/alembic.ini similarity index 100% rename from src/cnv/alembic.ini rename to cnv/alembic.ini diff --git a/src/cnv/__init__.py b/cnv/chatlog/__init__.py similarity index 100% rename from src/cnv/__init__.py rename to cnv/chatlog/__init__.py diff --git a/src/cnv/chatlog/npc_chatter.py b/cnv/chatlog/npc_chatter.py similarity index 100% rename from src/cnv/chatlog/npc_chatter.py rename to cnv/chatlog/npc_chatter.py diff --git a/src/cnv/chatlog/__init__.py b/cnv/database/__init__.py similarity index 100% rename from src/cnv/chatlog/__init__.py rename to cnv/database/__init__.py diff --git a/src/cnv/database/db.py b/cnv/database/db.py similarity index 100% rename from src/cnv/database/db.py rename to cnv/database/db.py diff --git a/src/cnv/database/models.py b/cnv/database/models.py similarity index 100% rename from src/cnv/database/models.py rename to cnv/database/models.py diff --git a/src/cnv/database/__init__.py b/cnv/effects/__init__.py similarity index 100% rename from src/cnv/database/__init__.py rename to cnv/effects/__init__.py diff --git a/src/cnv/effects/effects.py b/cnv/effects/effects.py similarity index 100% rename from src/cnv/effects/effects.py rename to cnv/effects/effects.py diff --git a/src/cnv/effects/__init__.py b/cnv/engines/__init__.py similarity index 100% rename from src/cnv/effects/__init__.py rename to cnv/engines/__init__.py diff --git a/src/cnv/engines/amazonpolly.py b/cnv/engines/amazonpolly.py similarity index 100% rename from src/cnv/engines/amazonpolly.py rename to cnv/engines/amazonpolly.py diff --git a/src/cnv/engines/azure.py b/cnv/engines/azure.py similarity index 100% rename from src/cnv/engines/azure.py rename to cnv/engines/azure.py diff --git a/src/cnv/engines/base.py b/cnv/engines/base.py similarity index 100% rename from src/cnv/engines/base.py rename to cnv/engines/base.py diff --git a/src/cnv/engines/elevenlabs.py b/cnv/engines/elevenlabs.py similarity index 100% rename from src/cnv/engines/elevenlabs.py rename to cnv/engines/elevenlabs.py diff --git a/src/cnv/engines/engines.py b/cnv/engines/engines.py similarity index 100% rename from src/cnv/engines/engines.py rename to cnv/engines/engines.py diff --git a/src/cnv/engines/googlecloud.py b/cnv/engines/googlecloud.py similarity index 100% rename from src/cnv/engines/googlecloud.py rename to cnv/engines/googlecloud.py diff --git a/src/cnv/engines/openai.py b/cnv/engines/openai.py similarity index 100% rename from src/cnv/engines/openai.py rename to cnv/engines/openai.py diff --git a/src/cnv/engines/windowstts.py b/cnv/engines/windowstts.py similarity index 100% rename from src/cnv/engines/windowstts.py rename to cnv/engines/windowstts.py diff --git a/src/cnv/engines/__init__.py b/cnv/lib/__init__.py similarity index 100% rename from src/cnv/engines/__init__.py rename to cnv/lib/__init__.py diff --git a/src/cnv/lib/audio.py b/cnv/lib/audio.py similarity index 100% rename from src/cnv/lib/audio.py rename to cnv/lib/audio.py diff --git a/src/cnv/lib/gui.py b/cnv/lib/gui.py similarity index 100% rename from src/cnv/lib/gui.py rename to cnv/lib/gui.py diff --git a/src/cnv/lib/icons/trash-2.png b/cnv/lib/icons/trash-2.png similarity index 100% rename from src/cnv/lib/icons/trash-2.png rename to cnv/lib/icons/trash-2.png diff --git a/src/cnv/lib/proc.py b/cnv/lib/proc.py similarity index 100% rename from src/cnv/lib/proc.py rename to cnv/lib/proc.py diff --git a/src/cnv/lib/settings.py b/cnv/lib/settings.py similarity index 100% rename from src/cnv/lib/settings.py rename to cnv/lib/settings.py diff --git a/src/cnv/logger.py b/cnv/logger.py similarity index 100% rename from src/cnv/logger.py rename to cnv/logger.py diff --git a/src/cnv/sidekick.py b/cnv/sidekick.py similarity index 100% rename from src/cnv/sidekick.py rename to cnv/sidekick.py diff --git a/src/cnv/lib/__init__.py b/cnv/tabs/__init__.py similarity index 100% rename from src/cnv/lib/__init__.py rename to cnv/tabs/__init__.py diff --git a/src/cnv/tabs/automation.py b/cnv/tabs/automation.py similarity index 100% rename from src/cnv/tabs/automation.py rename to cnv/tabs/automation.py diff --git a/src/cnv/tabs/character.py b/cnv/tabs/character.py similarity index 100% rename from src/cnv/tabs/character.py rename to cnv/tabs/character.py diff --git a/src/cnv/tabs/configuration.py b/cnv/tabs/configuration.py similarity index 100% rename from src/cnv/tabs/configuration.py rename to cnv/tabs/configuration.py diff --git a/src/cnv/tabs/voices.py b/cnv/tabs/voices.py similarity index 100% rename from src/cnv/tabs/voices.py rename to cnv/tabs/voices.py diff --git a/src/cnv/tabs/__init__.py b/cnv/voices/__init__.py similarity index 100% rename from src/cnv/tabs/__init__.py rename to cnv/voices/__init__.py diff --git a/src/cnv/voices/voice_builder.py b/cnv/voices/voice_builder.py similarity index 100% rename from src/cnv/voices/voice_builder.py rename to cnv/voices/voice_builder.py diff --git a/src/cnv/voices/voice_editor.py b/cnv/voices/voice_editor.py similarity index 100% rename from src/cnv/voices/voice_editor.py rename to cnv/voices/voice_editor.py diff --git a/src/cnv/voices/__init__.py b/src/cnv/voices/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/windows_installation_3.iss b/windows_installation_3.iss index b88be6a..2a45f51 100644 --- a/windows_installation_3.iss +++ b/windows_installation_3.iss @@ -3,7 +3,7 @@ #define SourcePath "\\?\Volume{cc498e86-0000-0000-0000-602200000000}\Users\jason\Desktop\coh_npc_voices" #define MyAppName "Sidekick" -#define MyAppVersion "3.0" +#define MyAppVersion "3.1" #define MyAppPublisher "Jason Kane" #define MyAppURL "https://github.com/jason-kane/coh_npc_voices" #define MyAppExeName "Sidekick.bat" @@ -46,33 +46,42 @@ Source: "{#SourcePath}\config.json"; DestDir: "{app}"; Flags: ignoreversion Source: "{#SourcePath}\sidekick.ico"; DestDir: "{app}"; Flags: ignoreversion Source: "{#SourcePath}\requirements.txt"; DestDir: "{app}"; Flags: ignoreversion Source: "{#SourcePath}\win_install.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\__init__.py"; DestDir: "{app}\src\cnv\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\chatlog\__init__.py"; DestDir: "{app}\src\cnv\chatlog\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\chatlog\npc_chatter.py"; DestDir: "{app}\src\cnv\chatlog\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\database\__init__.py"; DestDir: "{app}\src\cnv\database\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\database\db.py"; DestDir: "{app}\src\cnv\database\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\database\models.py"; DestDir: "{app}\src\cnv\database\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\effects\__init__.py"; DestDir: "{app}\src\cnv\effects\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\effects\effects.py"; DestDir: "{app}\src\cnv\effects\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\engines\__init__.py"; DestDir: "{app}\src\cnv\engines\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\engines\engines.py"; DestDir: "{app}\src\cnv\engines\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\lib\__init__.py"; DestDir: "{app}\src\cnv\lib\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\lib\audio.py"; DestDir: "{app}\src\cnv\lib\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\lib\settings.py"; DestDir: "{app}\src\cnv\lib\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\tabs\__init__.py"; DestDir: "{app}\src\cnv\tabs\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\tabs\automation.py"; DestDir: "{app}\src\cnv\tabs\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\tabs\character.py"; DestDir: "{app}\src\cnv\tabs\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\tabs\configuration.py"; DestDir: "{app}\src\cnv\tabs\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\tabs\voices.py"; DestDir: "{app}\src\cnv\tabs\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\voices\__init__.py"; DestDir: "{app}\src\cnv\voices\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\voices\generate_defaults.py"; DestDir: "{app}\src\cnv\voices\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\voices\voice_builder.py"; DestDir: "{app}\src\cnv\voices\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\voices\voice_editor.py"; DestDir: "{app}\src\cnv\voices\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\__init__.py"; DestDir: "{app}\src\cnv\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\alembic.ini"; DestDir: "{app}\src\cnv\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\logger.py"; DestDir: "{app}\src\cnv\"; Flags: ignoreversion -Source: "{#SourcePath}\src\cnv\sidekick.py"; DestDir: "{app}\src\cnv\"; Flags: ignoreversion -Source: "{#SourcePath}\src\__init__.py"; DestDir: "{app}\src\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\__init__.py"; DestDir: "{app}\cnv\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\chatlog\__init__.py"; DestDir: "{app}\cnv\chatlog\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\chatlog\npc_chatter.py"; DestDir: "{app}\cnv\chatlog\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\database\__init__.py"; DestDir: "{app}\cnv\database\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\database\db.py"; DestDir: "{app}\cnv\database\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\database\models.py"; DestDir: "{app}\cnv\database\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\effects\__init__.py"; DestDir: "{app}\cnv\effects\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\effects\effects.py"; DestDir: "{app}\cnv\effects\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\__init__.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\amazonpolly.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\azure.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\base.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\elevenlabs.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\engines.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\googlecloud.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\openai.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\engines\windowstts.py"; DestDir: "{app}\cnv\engines\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\lib\icons\*.png"; DestDir: "{app}\cnv\lib\icons"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\lib\__init__.py"; DestDir: "{app}\cnv\lib\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\lib\audio.py"; DestDir: "{app}\cnv\lib\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\lib\gui.py"; DestDir: "{app}\cnv\lib\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\lib\proc.py"; DestDir: "{app}\cnv\lib\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\lib\settings.py"; DestDir: "{app}\cnv\lib\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\tabs\__init__.py"; DestDir: "{app}\cnv\tabs\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\tabs\automation.py"; DestDir: "{app}\cnv\tabs\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\tabs\character.py"; DestDir: "{app}\cnv\tabs\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\tabs\configuration.py"; DestDir: "{app}\cnv\tabs\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\tabs\voices.py"; DestDir: "{app}\cnv\tabs\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\voices\__init__.py"; DestDir: "{app}\cnv\voices\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\voices\generate_defaults.py"; DestDir: "{app}\cnv\voices\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\voices\voice_builder.py"; DestDir: "{app}\cnv\voices\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\voices\voice_editor.py"; DestDir: "{app}\cnv\voices\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\__init__.py"; DestDir: "{app}\cnv\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\alembic.ini"; DestDir: "{app}\cnv\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\logger.py"; DestDir: "{app}\cnv\"; Flags: ignoreversion +Source: "{#SourcePath}\cnv\sidekick.py"; DestDir: "{app}\cnv\"; Flags: ignoreversion Source: "{#SourcePath}\aliases.json"; DestDir: "{app}\"; Flags: ignoreversion Source: "{#SourcePath}\all_npcs.json"; DestDir: "{app}\"; Flags: ignoreversion Source: "{#SourcePath}\migrations\*"; DestDir: "{app}\migrations\"; Flags: ignoreversion recursesubdirs createallsubdirs