From ac3bcf3d67bad30c5c84fe1768f5db102ac03abb Mon Sep 17 00:00:00 2001 From: Jason Kane Date: Sat, 6 Apr 2024 20:18:36 -0700 Subject: [PATCH] sidekick --- CHANGELOG.md | 4 + pyproject.toml | 6 +- src/coh_npc_voices/db.py | 21 ++- src/coh_npc_voices/npc_chatter.py | 183 ++++++++++++++++--- src/coh_npc_voices/sidekick.py | 271 ++++++++++++++++++++++++++++ src/coh_npc_voices/voice_builder.py | 11 +- src/coh_npc_voices/voice_editor.py | 24 ++- 7 files changed, 474 insertions(+), 46 deletions(-) create mode 100644 src/coh_npc_voices/sidekick.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4acb222..7c2cc44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2024-04-6 +- Starting on full sidekick UI +- Many accumulated fixes + ## [1.0.0] - 2024-03-31 - Added UI diff --git a/pyproject.toml b/pyproject.toml index 8506fed..ccc7a10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coh_npc_voices" -version = "1.0.0" +version = "1.1.0" authors = [ { name="Jason Kane" }, ] @@ -22,7 +22,8 @@ dependencies = [ "google-cloud-texttospeech", "voicebox-tts", "wheel", - "setuptools" + "setuptools", + "matplotlib" ] [build-system] @@ -35,6 +36,7 @@ Issues = "https://github.com/jason-kane/coh_npc_voices/issues" [project.scripts] coh_voices = "coh_npc_voices.voice_editor:main" +sidekick = "coh_npc_voices.sidekick:main" [tool.hatch.metadata] allow-direct-references = true \ No newline at end of file diff --git a/src/coh_npc_voices/db.py b/src/coh_npc_voices/db.py index 85c0a5c..fb7dfe1 100644 --- a/src/coh_npc_voices/db.py +++ b/src/coh_npc_voices/db.py @@ -16,8 +16,8 @@ db_connection = None -def get_cursor(): - if db_connection: +def get_cursor(fresh=False): + if not fresh and db_connection: return db_connection.cursor() else: prepare_database() @@ -99,7 +99,22 @@ def prepare_database(): value VARCHAR(256) ) """) - commit() + cursor.execute(""" + CREATE TABLE hero_stat_events ( + id INTEGER PRIMARY KEY, + hero INTEGER NOT NULL, + event_time DATEIME NOT NULL, + xp_gain INTEGER, + inf_gain INTEGER + ) + """) + cursor.execute(""" + CREATE TABLE hero ( + id INTEGER PRIMARY KEY, + name VARCHAR(256) + ) + """) + commit() def clean_customer_name(in_name): if in_name: diff --git a/src/coh_npc_voices/npc_chatter.py b/src/coh_npc_voices/npc_chatter.py index 4d8bfec..4c1f7b1 100644 --- a/src/coh_npc_voices/npc_chatter.py +++ b/src/coh_npc_voices/npc_chatter.py @@ -41,9 +41,10 @@ # speech-to-text chat input (whisper?) class TightTTS(threading.Thread): - def __init__(self, q): + def __init__(self, speaking_queue, event_queue): threading.Thread.__init__(self) - self.q = q + self.speaking_queue = speaking_queue + self.event_queue = event_queue self.daemon = True # so we can do this much once. @@ -63,7 +64,7 @@ def __init__(self, q): def run(self): pythoncom.CoInitialize() while True: - raw_message = self.q.get() + raw_message = self.speaking_queue.get() try: name, message, category = raw_message except ValueError: @@ -122,7 +123,7 @@ def run(self): # ok, what kind of voice do we want for this NPC? # We might have some fancy voices - cursor = db.get_cursor() + cursor = db.get_cursor(fresh=True) character_id = cursor.execute( "SELECT id FROM character WHERE name=? AND category=?", (name, category) @@ -144,11 +145,17 @@ def run(self): ).fetchone() voice_builder.create(character_id[0], message, cachefile) - self.q.task_done() + self.speaking_queue.task_done() class LogStream: - def __init__(self, logdir: str, tts_queue: queue, badges: bool, npc: bool, team: bool): + def __init__( + self, + logdir: str, + speaking_queue: queue, + event_queue: queue, + badges: bool, npc: bool, team: bool + ): """ find the most recent logfile in logdir note which file it is, open it and skip @@ -162,11 +169,40 @@ def __init__(self, logdir: str, tts_queue: queue, badges: bool, npc: bool, team: self.tell_speak = True self.caption_speak = True self.announce_levels = True + self.hero = None print(f"Tailing {self.filename}...") self.handle = open(os.path.join(logdir, self.filename)) + + self.speaking_queue = speaking_queue + self.event_queue = event_queue + + # skim through and see if can find the hero name + 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.hero = Hero(" ".join(lstring[5:]).strip('!')) + + # we want to notify upstream UI about this. + if self.event_queue: + self.event_queue.put( + ('SET_CHARACTER', self.hero.name) + ) + + if self.hero is None: + log.info('Could NOT find hero name.. good luck.') + + # now move the file handle to the end, we + # will starting parsing everything for real this + # time. self.handle.seek(0, io.SEEK_END) - self.tts_queue = tts_queue + def tail(self): # read any new lines that have arrives since we last read the file and process @@ -177,7 +213,7 @@ def tail(self): if line.strip(): # print(line.split(None, 2)) try: - _, _, line_string = line.split(None, 2) + datestr, timestr, line_string = line.split(None, 2) except ValueError: continue @@ -190,7 +226,7 @@ def tail(self): dialog = re.sub(r"", "", dialog).strip() dialog = re.sub(r"", "", dialog).strip() - self.tts_queue.put((name, dialog, 'npc')) + self.speaking_queue.put((name, dialog, 'npc')) elif self.team_speak and lstring[0] == "[Team]": #['2024-03-30', '23:29:48', '[Team] Khold: ugg, I gotta roll, nice little team. dangerous without supports\n'] @@ -198,7 +234,7 @@ def tail(self): log.debug(f'Adding {name}/{dialog} to reading queue') dialog = re.sub(r"", "", dialog).strip() dialog = re.sub(r"", "", dialog).strip() - self.tts_queue.put((name, dialog, 'player')) + self.speaking_queue.put((name, dialog, 'player')) elif self.tell_speak and lstring[0] == "[Tell]": # why is there an extra colon for Tell? IDK. @@ -214,7 +250,7 @@ def tail(self): name, dialog = " ".join(lstring[1:]).split(":", maxsplit=1) log.debug(f'Adding {name}/{dialog} to reading queue') - self.tts_queue.put((name, dialog, 'player')) + self.speaking_queue.put((name, dialog, 'player')) elif self.caption_speak and lstring[0] == "[Caption]": # 2024-04-02 20:09:50 [Caption] My Shadow Simulacrum will destroy Task Force White Sands! @@ -224,31 +260,118 @@ def tail(self): dialog = re.sub(r"", "", dialog).strip() dialog = re.sub(r"", "", dialog).strip() dialog = re.sub(r"", "", dialog).strip() - self.tts_queue.put((name, dialog, 'player')) + self.speaking_queue.put((name, dialog, 'player')) elif self.announce_badges and lstring[0] == "Congratulations!": - self.tts_queue.put( + self.speaking_queue.put( (None, (" ".join(lstring[4:])), 'system') ) - elif ( - self.announce_levels and lstring[0:3] == ["You", "are", "now", "fighting"] - ) and ( - " ".join(previous[-4:]) not in [ - "have quit your team", - "has joined the team" - ] - ): - # 2024-04-03 20:23:51 You are now fighting at level 4. - level = lstring[-1].strip('.') - self.tts_queue.put(( - None, - f"Congratulations. You've reached Level {level}", - 'system' - )) + elif (lstring[0] == "You"): + if lstring[1] == "are": + if (self.announce_levels and lstring[2:3] == ["now", "fighting"]) and ( + " ".join(previous[-4:]) not in [ + "have quit your team", + "has joined the team" + ]): + level = lstring[-1].strip('.') + self.speaking_queue.put(( + None, + f"Congratulations. You've reached Level {level}", + 'system' + )) + + elif self.hero and lstring[1] == "gain": + # 2024-04-05 21:43:45 You gain 104 experience and 36 influence. + # 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. + + # You gain 250 influence. + try: + influence_index = lstring.index("influence") - 1 + inf_gain = int(lstring[influence_index]) + except ValueError: + inf_gain = None - else: - log.debug(f'tag {lstring[0]} not classified.') + try: + xp_index = lstring.index('experience') - 1 + xp_gain = int(lstring[xp_index]) + except ValueError: + xp_gain = None + + 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 + + cursor = db.get_cursor(fresh=True) + # datestr, timestr + # leaving foe out for now, its a rough query + cursor.execute(""" + INSERT INTO hero_stat_events ( + hero, + event_time, + xp_gain, + inf_gain + ) VALUES ( + ?, ?, ?, ? + ) + """, (self.hero.id, f"{datestr} {timestr}", xp_gain, inf_gain)) + db.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) + ) + + #else: + # log.warning(f'tag {lstring[0]} not classified.') + +class Hero: + # keep this update for cheap parlor tricks. + columns = ("id", "name", ) + + def __init__(self, hero_name): + self.load_hero(hero_name) + + def load_hero(self, hero_name): + cursor = db.get_cursor() + self.raw_row = cursor.execute(""" + SELECT + * + FROM + hero + WHERE + name=? + """, (hero_name, )).fetchone() + + if self.raw_row is None: + self.create_hero(hero_name) + + for i, c in enumerate(self.columns): + setattr(self, c, self.raw_row[i]) + + def create_hero(self, hero_name): + cursor = db.get_cursor() + cursor.execute(""" + INSERT INTO hero ( + name + ) VALUES ( + ? + ) + """, (hero_name, )) + db.commit() + cursor.close() + # this is a disaster waiting to happen. + return self.load_hero(hero_name) def main() -> None: diff --git a/src/coh_npc_voices/sidekick.py b/src/coh_npc_voices/sidekick.py new file mode 100644 index 0000000..b92b451 --- /dev/null +++ b/src/coh_npc_voices/sidekick.py @@ -0,0 +1,271 @@ +""" +There is more awesome to be had. +""" + +"""Hello World application for Tkinter""" + +import logging +import multiprocessing +import os +from datetime import datetime, timedelta +import queue +import sys +import tkinter as tk +from tkinter import font, ttk + +import db +import effects +import engines + +import voice_editor +import npc_chatter +from pedalboard.io import AudioFile +from voicebox.sinks import Distributor, SoundDevice, WaveFile + +from matplotlib import pyplot +from matplotlib.figure import Figure +from matplotlib.backends.backend_tkagg import ( + FigureCanvasTkAgg, + NavigationToolbar2Tk +) + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) + +log = logging.getLogger("__name__") + +class ChartFrame(tk.Frame): + def __init__(self, parent, hero, category, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.category = category + + if not hero: + return + else: + log.info(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, 5), + dpi = 100 + ) + + # list of squares + # y = [i**2 for i in range(101)] + + # adding the subplot + plot1 = fig.add_subplot(111) + + # plotting the graph + # plot1.plot(y) + #data = get_total_xp(starttime, endtime, bin_size=60) + cursor = db.get_cursor() + + # end time should be the most recent timestamp + # not now() + log.info(f'Retrieving {self.category} data') + try: + latest_event = cursor.execute(""" + SELECT + event_time + FROM + hero_stat_events + WHERE + hero=? + ORDER BY + event_time DESC + LIMIT 1 + """, (self.hero.id, )).fetchone() + except Exception as err: + log.error(err) + raise + + log.info(f'latest_event: {latest_event}') + + if latest_event: + end_time = datetime.fromisoformat(latest_event[0]) + else: + log.info(f'No previous events found for {self.hero.name}') + end_time = datetime.now() + + start_time = end_time - timedelta(minutes=60) + log.info(f'Graphing {self.category} gain between {start_time} and {end_time}') + + try: + samples = cursor.execute(""" + SELECT + STRFTIME('%Y-%m-%d %H:%M:00', event_time) as EventMinute, + SUM(xp_gain), + SUM(inf_gain) + FROM + hero_stat_events + WHERE + hero = ? AND + event_time > ? AND + event_time <= ? + GROUP BY + STRFTIME('%Y-%m-%d %H:%M:00', event_time) + ORDER BY + EventMinute + """, (self.hero.id, start_time, end_time)).fetchall() + except Exception as err: + log.error(err) + raise + + log.info(f'Found {len(samples)} samples') + + data_x = [] + data_y = [] + + for row in samples: + data_x.append( + datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") + ) + log.info(row) + + if self.category == "xp": + data_y.append(row[1]) + elif self.category == "inf": + data_y.append(row[2]) + + log.info(f'Plotting {data_x}:{data_y}') + # data = ((0, 1), (1, 10), (2, 10), (3, 500), (5, 200)) + plot1.plot(data_x, data_y) + + # creating the Tkinter canvas + # containing the Matplotlib figure + canvas = FigureCanvasTkAgg( + fig, + master = self + ) + canvas.draw() + + # placing the canvas on the Tkinter window + canvas.get_tk_widget().pack() + + # creating the Matplotlib toolbar + toolbar = NavigationToolbar2Tk( + canvas, + self + ) + toolbar.update() + + # placing the toolbar on the Tkinter window + canvas.get_tk_widget().pack() + log.info('graph constructed') + +class CharacterTab(tk.Frame): + def __init__(self, parent, event_queue, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.name = tk.StringVar() + self.chatter = voice_editor.Chatter(self, event_queue) + self.chatter.pack(side="top", fill="x") + + self.set_hero() + # self.xp_chart = ChartFrame(self, self.chatter.hero, 'xp') + # self.xp_chart.pack(side="top", fill="both", expand=True) + + #self.inf_chart = ChartFrame(self, self.chatter.hero, 'inf') + #self.inf_chart.pack(side="top", fill="both", expand=True) + + def set_hero(self, *args, **kwargs): + log.info(f'set_hero({self.chatter.hero})') + if hasattr(self, "xp_chart"): + self.xp_chart.pack_forget() + + self.xp_chart = ChartFrame(self, self.chatter.hero, 'xp') + self.xp_chart.pack(side="top", fill="x", expand=True) + + if hasattr(self, "inf_chart"): + self.inf_chart.pack_forget() + + self.inf_chart = ChartFrame(self, self.chatter.hero, 'inf') + self.inf_chart.pack(side="top", fill="x", expand=True) + + # character.pack(side="top", fill="both", expand=True) + + # character.name.trace_add('write', set_hero) + + + +def main(): + db.prepare_database() + root = tk.Tk() + # root.iconbitmap("myIcon.ico") + root.geometry("640x480+200+200") + root.resizable(True, True) + root.title("City of Heroes Sidekick") + + notebook = ttk.Notebook(root) + event_queue = multiprocessing.Queue() + + character = CharacterTab(notebook, event_queue) + character.pack(side="top", fill="both", expand=True) + notebook.add(character, text='Character') + + voices = tk.Frame(notebook) + voices.pack(side="top", fill="both", expand=True) + notebook.add(voices, text='Voices') + + cursor = db.get_cursor() + first_character = cursor.execute("select id, name, category from character order by name").fetchone() + cursor.close() + + if first_character: + selected_character = tk.StringVar(value=f"{first_character[2]} {first_character[1]}") + else: + selected_character = tk.StringVar() + + detailside = voice_editor.DetailSide(voices, selected_character) + listside = voice_editor.ListSide(voices, detailside) + + listside.pack(side="left", fill="both", expand=True) + detailside.pack(side="left", fill="both", expand=True) + notebook.pack(fill="both", expand=True) + + # in the mainloop we want to know if event_queue gets a new + # entry. + #root.mainloop() + last_character_update = None + + # update the graph(s) this often + update_frequency = timedelta(minutes=1) + + while True: + try: + event_action = event_queue.get(block=False) + # we got an action (no exception) + log.info('Event Received: %s', event_action) + key, value = event_action + if key == "SET_CHARACTER": + log.info('path set_chraracter') + character.chatter.hero = npc_chatter.Hero(value) + log.info('Calling set_hero()...') + character.set_hero() + last_character_update = datetime.now() + else: + log.error('Unknown event_queue key: %s', key) + + except Exception: + pass + + if last_character_update: + if (datetime.now() - last_character_update) > update_frequency: + character.set_hero() + last_character_update = datetime.now() + + root.update_idletasks() + root.update() + + +if __name__ == '__main__': + if sys.platform.startswith('win'): + multiprocessing.freeze_support() + main() \ No newline at end of file diff --git a/src/coh_npc_voices/voice_builder.py b/src/coh_npc_voices/voice_builder.py index 702d725..32aed8e 100644 --- a/src/coh_npc_voices/voice_builder.py +++ b/src/coh_npc_voices/voice_builder.py @@ -119,7 +119,16 @@ def create(character_id, message, cachefile): num_channels=input.num_channels ) as output: while input.tell() < input.frames: - output.write(input.read(1024)) + retries = 5 + success = False + while not success and retries > 0: + try: + output.write(input.read(1024)) + success = True + except RuntimeError as err: + log.errror(err) + retries -= 1 + log.info(f'Created {cachefile}') #audio = pydub.AudioSegment.from_wav(cachefile + ".wav") diff --git a/src/coh_npc_voices/voice_editor.py b/src/coh_npc_voices/voice_editor.py index 3cc70d9..61548cb 100644 --- a/src/coh_npc_voices/voice_editor.py +++ b/src/coh_npc_voices/voice_editor.py @@ -614,10 +614,11 @@ def refresh_character_list(self): class ChatterService: - def start(self): - q = queue.Queue() - npc_chatter.TightTTS(q) - q.put((None, "Attaching to most recent log...", 'system')) + def start(self, event_queue): + speaking_queue = queue.Queue() + + 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" @@ -625,20 +626,23 @@ def start(self): team = True npc = True - ls = npc_chatter.LogStream(logdir, q, badges, npc, team) + ls = npc_chatter.LogStream( + logdir, speaking_queue, event_queue, badges, npc, team + ) while True: - ls.tail() + ls.tail() class Chatter(tk.Frame): attach_label = 'Attach to Log' detach_label = "Detach from Log" - def __init__(self, parent, *args, **kwargs): + 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 cursor = db.get_cursor() response_tuple = cursor.execute(""" @@ -710,7 +714,7 @@ 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) + self.p = multiprocessing.Process(target=self.cs.start, args=(self.event_queue, )) self.p.start() log.info('Attached') @@ -723,7 +727,7 @@ def main(): root.resizable(True, True) root.title("Character Voice Editor") - chatter = Chatter(root) + chatter = Chatter(root, None) chatter.pack(side="top", fill="x") editor = tk.Frame(root)