diff --git a/pyproject.toml b/pyproject.toml index a06443a..5a5f38f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "google-cloud-texttospeech", "matplotlib", "pyfiglet", + "pyfeather", "pypiwin32", "setuptools", "sqlalchemy-utils", diff --git a/requirements.txt b/requirements.txt index 148fd02..4f7e5ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,6 +62,7 @@ prompt-toolkit==3.0.43 proto-plus==1.23.0 protobuf==4.25.3 pure-eval==0.2.2 +tkfeather==1.0.0 pyasn1-modules==0.3.0 pyasn1==0.5.1 pycparser==2.21 diff --git a/src/coh_npc_voices/effects.py b/src/coh_npc_voices/effects.py index 3f89424..1dbaa48 100644 --- a/src/coh_npc_voices/effects.py +++ b/src/coh_npc_voices/effects.py @@ -7,6 +7,7 @@ import pedalboard import voicebox from sqlalchemy import select +from tkfeather import Feather log = logging.getLogger(__name__) @@ -144,6 +145,7 @@ def __init__(self, parent, *args, **kwargs): self.effect_id = tk.IntVar() self.parameters = [] self.traces = {} + self.trashcan = Feather("trash-2", size=24) topbar = ttk.Frame(self) ttk.Label( @@ -164,7 +166,7 @@ def __init__(self, parent, *args, **kwargs): ttk.Button( topbar, - text="X", + image=self.trashcan.icon, style="CloseFrame.TButton", command=self.remove_effect ).pack(side="right") @@ -207,15 +209,15 @@ def reconfig(self, varname, lindex, operation): """ log.info(f'reconfig triggered by {varname}/{lindex}/{operation}') effect_id = self.effect_id.get() - key = varname.split('_')[-1] # I know, I feel dirty. + # key = "_".join(varname.split('_')[1:]) # I know, I feel dirty. with models.Session(models.engine) as session: # fragile, varname is what is coming off the trace trigger - log.info(f'Reading effects settings when {effect_id=} and key={key}') + log.info(f'Reading effects settings when {effect_id=}') effect_settings = session.scalars( select(models.EffectSetting).where( - models.EffectSetting.effect_id==effect_id, - models.EffectSetting.key==key + models.EffectSetting.effect_id==effect_id + # models.EffectSetting.key==key ) ).all() @@ -238,9 +240,11 @@ def reconfig(self, varname, lindex, operation): else: log.info(f'Value for {effect_setting.key} has not changed') - log.info(found) + log.info(f"{found=}") + change = False for effect_setting_key in self.traces: if effect_setting_key not in found: + change = True log.info(f'Expected key {effect_setting_key} does not exist in the database') value = self.traces[effect_setting_key].get() log.info(f'Creating new EffectSetting({effect_id}, key={effect_setting_key}, value={value})') @@ -250,43 +254,56 @@ def reconfig(self, varname, lindex, operation): value=value ) session.add(new_setting) - log.info('commiting db session') - session.commit() + + if change: + log.info('commiting db session') + session.commit() - def load(self, session=None): + def load(self): """ reflect the current db values for each effect setting to the tk.Variable tied to the widget for that setting. """ - if session is None: - session = models.Session(models.engine) + #if session is None: + # session = models.Session(models.engine) effect_id = self.effect_id.get() + log.info(f'Loading {effect_id=}') - effect_settings = session.scalars( - select(models.EffectSetting).where( - models.EffectSetting.effect_id == effect_id - ) - ).all() + with models.db() as session: + effect_settings = session.scalars( + select(models.EffectSetting).where( + models.EffectSetting.effect_id == effect_id + ) + ).all() - for setting in effect_settings: - log.info(f'Working on {setting}') + found = set() + for setting in effect_settings: + log.info(f'Working on {setting}') + + if setting.key in found: + log.info(f'Duplicate setting for {setting.key=} where {effect_id=}') + session.delete(setting) + continue - tkvar = getattr(self, setting.key, None) + found.add(setting.key) - if setting.key not in self.traces: - if tkvar: - tkvar.set(setting.value) - else: - log.error( - f'Invalid configuration. ' - f'{setting.key} is not available for ' - f'{self}') - - tkvar.trace_add("write", self.reconfig) - self.traces[setting.key] = tkvar + tkvar = getattr(self, setting.key, None) + + if setting.key not in self.traces: + if tkvar: + tkvar.set(setting.value) + else: + log.error( + f'Invalid configuration. ' + f'{setting.key} is not available for ' + f'{self}') + + tkvar.trace_add("write", self.reconfig) + self.traces[setting.key] = tkvar + session.commit() # scipy iir filters IIR_FILTERS = ['butter', 'cheby1', 'cheby2', 'ellip', 'bessel'] @@ -609,9 +626,9 @@ def __init__(self, parent, *args, **kwargs): pname="max_amplitude", label='Max-Amplitude', desc="Maximum amplitude in Hz", - default=1.0, - from_=0, - to=3, + default=0.0, + from_=-1, + to=1, digits=2, resolution=0.1 ).pack(side='top', fill='x', expand=True) diff --git a/src/coh_npc_voices/engines.py b/src/coh_npc_voices/engines.py index 2d83cde..53bf611 100644 --- a/src/coh_npc_voices/engines.py +++ b/src/coh_npc_voices/engines.py @@ -3,7 +3,9 @@ import os import sys import tempfile +import numpy as np import time +from io import BytesIO import tkinter as tk from dataclasses import dataclass, field from tkinter import ttk @@ -48,26 +50,29 @@ def get_speech(self, text: StrOrSSML) -> Audio: voice.set_rate(self.rate) voice.set_voice(self.voice) - with tempfile.NamedTemporaryFile() as tmp: - # just need the safe filename - tmp.close() - # this can: - # File "C:\Users\jason\Desktop\coh_npc_voices\venv\Lib\site-packages\tts\sapi.py", line 93, in say - # self.voice.Speak(message, flag) - # _ctypes.COMError: (-2147200958, None, ('XML parser error', None, None, 0, None)) - - success = False - while not success: - try: - # create a temporary wave file - voice.create_recording(tmp.name, text) - success = True - except Exception as err: - log.error(err) - log.error("Text was: %s", text) - time.sleep(0.1) + 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 + ) - audio = voicebox.tts.utils.get_audio_from_wav_file(tmp.name) return audio @@ -462,7 +467,7 @@ class GoogleCloud(TTSEngine): config = ( ('Language Code', 'language_code', "StringVar", 'en-US', {}, "get_language_codes"), ('Voice Name', 'voice_name', "StringVar", "", {}, "get_voice_names"), - ('Speaking Rate', 'speaking_rate', "DoubleVar", 1, {'min': 0.25, 'max': 2.75, 'digits': 3, 'resolution': 0.25}, None), + ('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) ) diff --git a/src/coh_npc_voices/voice_editor.py b/src/coh_npc_voices/voice_editor.py index 20e3357..e3d627e 100644 --- a/src/coh_npc_voices/voice_editor.py +++ b/src/coh_npc_voices/voice_editor.py @@ -20,6 +20,7 @@ from pedalboard.io import AudioFile from scipy.io import wavfile from sqlalchemy import delete, desc, select, update +from tkfeather import Feather from voicebox.sinks import Distributor, SoundDevice, WaveFile from voicebox.tts.utils import get_audio_from_wav_file @@ -560,7 +561,7 @@ def load_effects(self): category, name = raw_name.split(maxsplit=1) - with models.Session(models.engine) as session: + with models.db() as session: character = models.get_character(name, category, session) if character is None: @@ -597,7 +598,7 @@ def load_effects(self): effect_config_frame.effect_id.set(effect.id) self.effects.append(effect_config_frame) - effect_config_frame.load(session=session) + effect_config_frame.load() if not has_effects: self.buffer = ttk.Frame(self, width=1, height=1).pack(side="top") @@ -763,23 +764,39 @@ class DetailSide(ttk.Frame): """ def __init__(self, parent, selected_character, *args, **kwargs): super().__init__(parent, *args, **kwargs) + self.parent = parent self.selected_character = selected_character self.listside = None + self.trashcan = Feather("trash-2", size=24) - self.canvas = tk.Canvas(self, borderwidth=0, background="#ffffff") - self.frame = ttk.Frame(self.canvas) - self.vsb = tk.Scrollbar(self, orient="vertical", command=self.canvas.yview) - self.canvas.configure(yscrollcommand=self.vsb.set) + self.vsb = tk.Scrollbar(self, orient="vertical") + self.vsb.pack(side="right", fill="y", expand=False) - self.vsb.pack(side="right", fill="y") + 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.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" + (0, 0), window=self.frame, anchor="nw", + tags="self.frame" ) + self.frame.bind("", self.onFrameConfigure) - # self.frame.pack(side='top', fill='x') + self.canvas.bind("", self.onCanvasConfigure) + ### name_frame = ttk.Frame(self.frame) @@ -798,7 +815,7 @@ def __init__(self, parent, selected_character, *args, **kwargs): ttk.Button( name_frame, - text="X", + image=self.trashcan.icon, style="RemoveCharacter.TButton", command=self.remove_character ).pack(side="right") @@ -849,13 +866,40 @@ def __init__(self, parent, selected_character, *args, **kwargs): self.effect_list = EffectList(self.frame, selected_character) self.effect_list.pack(side="top", fill="x", expand=True) + self.bind('', self._bound_to_mousewheel) + self.bind('', self._unbound_to_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 _on_mousewheel(self, event): + 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""" - self.canvas.configure(scrollregion=self.canvas.bbox("all")) + # 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, raw_name): """ @@ -1021,8 +1065,21 @@ def __init__(self, parent, detailside, *args, **kwargs): self.list_items = tk.Variable(value=[]) self.refresh_character_list() - self.listbox = tk.Listbox(self, height=10, listvariable=self.list_items) - self.listbox.pack(side="top", expand=True, fill=tk.BOTH) + listarea = ttk.Frame(self) + self.listbox = tk.Listbox(listarea, height=10, listvariable=self.list_items) + self.listbox.pack(side="left", expand=True, fill=tk.BOTH) + vsb = tk.Scrollbar( + listarea, + orient='vertical', + command=self.listbox.yview + ) + self.listbox.configure(yscrollcommand=vsb.set) + + self.bind('', self._bound_to_mousewheel) + self.bind('', self._unbound_to_mousewheel) + + vsb.pack(side='right', fill='y') + listarea.pack(side="top", expand=True, fill=tk.BOTH) action_frame = ttk.Frame(self) ttk.Button( @@ -1037,6 +1094,15 @@ def __init__(self, parent, detailside, *args, **kwargs): self.listbox.select_set(0) self.listbox.bind("<>", self.character_selected) + def _bound_to_mousewheel(self, event): + self.listbox.bind_all("", self._on_mousewheel) + + def _unbound_to_mousewheel(self, event): + self.listbox.unbind_all("") + + def _on_mousewheel(self, event): + self.listbox.yview_scroll(int(-1*(event.delta/120)), "units") + def apply_list_filter(self, a, b, c): self.refresh_character_list()