From e64ee373d3c2aa9f2cdbb69444cbe1631e955295 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Thu, 5 Dec 2024 15:54:03 -0500 Subject: [PATCH 001/244] one instance --- src/FreeScribe.client/client.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 82f0986f..7d582d93 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -43,6 +43,11 @@ import sys from UI.DebugWindow import DualOutput import traceback +import fcntl + +if sys.platform == "win32": + import ctypes + from ctypes import wintypes dual = DualOutput() sys.stdout = dual @@ -1300,3 +1305,37 @@ def _load_stt_model_thread(): root.mainloop() p.terminate() + +def bring_to_front(): + if sys.platform == "win32": + # Bring the existing application instance to the front on Windows + hwnd = ctypes.windll.user32.GetForegroundWindow() + ctypes.windll.user32.ShowWindow(hwnd, 5) # SW_SHOW + ctypes.windll.user32.SetForegroundWindow(hwnd) + elif sys.platform == "darwin": + # Bring the existing application instance to the front on macOS + # TODO FOR MAC + pass + +if __name__ == "__main__": + lock_file = '/tmp/freescribe-client.lock' if sys.platform == "darwin" else 'freescribe-client.lock' + try: + # Try to create and lock the lock file + with open(lock_file, 'w') as f: + if sys.platform == "win32": + msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1) + elif sys.platform == "darwin": + # TODO FOR MAC + pass + # If successful, run the application + if sys.platform == "win32": + root = tk.Tk() + user_input = UserInput(root) # Assuming UserInput is a class you have defined + user_input.pack() + root.mainloop() + elif sys.platform == "darwin": + # TODO FOR MAC + pass + except IOError: + # If the lock file is already locked, bring the existing instance to the front + bring_to_front() From 11f84611b78e3cb8aac4dd708d2ed36274659e61 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Thu, 5 Dec 2024 16:08:49 -0500 Subject: [PATCH 002/244] revert --- src/FreeScribe.client/client.py | 39 --------------------------------- 1 file changed, 39 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 7d582d93..82f0986f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -43,11 +43,6 @@ import sys from UI.DebugWindow import DualOutput import traceback -import fcntl - -if sys.platform == "win32": - import ctypes - from ctypes import wintypes dual = DualOutput() sys.stdout = dual @@ -1305,37 +1300,3 @@ def _load_stt_model_thread(): root.mainloop() p.terminate() - -def bring_to_front(): - if sys.platform == "win32": - # Bring the existing application instance to the front on Windows - hwnd = ctypes.windll.user32.GetForegroundWindow() - ctypes.windll.user32.ShowWindow(hwnd, 5) # SW_SHOW - ctypes.windll.user32.SetForegroundWindow(hwnd) - elif sys.platform == "darwin": - # Bring the existing application instance to the front on macOS - # TODO FOR MAC - pass - -if __name__ == "__main__": - lock_file = '/tmp/freescribe-client.lock' if sys.platform == "darwin" else 'freescribe-client.lock' - try: - # Try to create and lock the lock file - with open(lock_file, 'w') as f: - if sys.platform == "win32": - msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1) - elif sys.platform == "darwin": - # TODO FOR MAC - pass - # If successful, run the application - if sys.platform == "win32": - root = tk.Tk() - user_input = UserInput(root) # Assuming UserInput is a class you have defined - user_input.pack() - root.mainloop() - elif sys.platform == "darwin": - # TODO FOR MAC - pass - except IOError: - # If the lock file is already locked, bring the existing instance to the front - bring_to_front() From 4943de3405f76ff471669dc42e75b4208db021f7 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 11:10:02 -0500 Subject: [PATCH 003/244] check if app is running based on app title --- src/FreeScribe.client/client.py | 40 ++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 82f0986f..a444b4e1 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -43,16 +43,43 @@ import sys from UI.DebugWindow import DualOutput import traceback +from ctypes import WinDLL +import sys dual = DualOutput() sys.stdout = dual sys.stderr = dual +APP_NAME = 'AI Medical Scribe' # Application name - -# GUI Setup -root = tk.Tk() -root.title("AI Medical Scribe") +# function to check if another instance of the application is already running +def has_running_instance() -> bool: + """ + Check if another instance of the application is already running. + Returns: + bool: True if another instance is running, False otherwise + """ + U32DLL = WinDLL('user32') + # get the handle of any window matching 'APP_NAME' + hwnd = U32DLL.FindWindowW(None, APP_NAME) + print("Running instance check") + if hwnd: # if a matching window exists... + print('Another instance of the application is already running.') + # focus the existing window + U32DLL.ShowWindow(hwnd, 5) + U32DLL.SetForegroundWindow(hwnd) + # bail + return True + return False + +# check if another instance of the application is already running. +# if false, create a new instance of the application +# if true, exit the current instance +if not has_running_instance(): + root = tk.Tk() + root.title(APP_NAME) +else: + sys.exit(0) # settings logic app_settings = SettingsWindow() @@ -1297,6 +1324,7 @@ def _load_stt_model_thread(): root.bind("<>", load_stt_model) -root.mainloop() - p.terminate() + +if __name__ == '__main__': + root.mainloop() # run as usual \ No newline at end of file From 7e111791e92eefd55f831d01f63317970b794a4e Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 11:13:59 -0500 Subject: [PATCH 004/244] move to utils for reuseability --- src/FreeScribe.client/client.py | 23 ++--------------------- src/FreeScribe.client/utils/utils.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 21 deletions(-) create mode 100644 src/FreeScribe.client/utils/utils.py diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a444b4e1..a3c0d669 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -43,8 +43,8 @@ import sys from UI.DebugWindow import DualOutput import traceback -from ctypes import WinDLL import sys +from utils.utils import window_has_running_instance dual = DualOutput() sys.stdout = dual @@ -52,30 +52,11 @@ APP_NAME = 'AI Medical Scribe' # Application name -# function to check if another instance of the application is already running -def has_running_instance() -> bool: - """ - Check if another instance of the application is already running. - Returns: - bool: True if another instance is running, False otherwise - """ - U32DLL = WinDLL('user32') - # get the handle of any window matching 'APP_NAME' - hwnd = U32DLL.FindWindowW(None, APP_NAME) - print("Running instance check") - if hwnd: # if a matching window exists... - print('Another instance of the application is already running.') - # focus the existing window - U32DLL.ShowWindow(hwnd, 5) - U32DLL.SetForegroundWindow(hwnd) - # bail - return True - return False # check if another instance of the application is already running. # if false, create a new instance of the application # if true, exit the current instance -if not has_running_instance(): +if not window_has_running_instance(APP_NAME): root = tk.Tk() root.title(APP_NAME) else: diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py new file mode 100644 index 00000000..9868d5da --- /dev/null +++ b/src/FreeScribe.client/utils/utils.py @@ -0,0 +1,24 @@ + +from ctypes import WinDLL + +# function to check if another instance of the application is already running +def window_has_running_instance(app_name: str) -> bool: + """ + Check if another instance of the application is already running. + Parameters: + app_name (str): The name of the application + Returns: + bool: True if another instance is running, False otherwise + """ + U32DLL = WinDLL('user32') + # get the handle of any window matching 'app_name' + hwnd = U32DLL.FindWindowW(None, app_name) + print("Running instance check") + if hwnd: # if a matching window exists... + print('Another instance of the application is already running.') + # focus the existing window + U32DLL.ShowWindow(hwnd, 5) + U32DLL.SetForegroundWindow(hwnd) + # bail + return True + return False From b5d371618dcce6f88bf92083201fba953568b76f Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 12:23:02 -0500 Subject: [PATCH 005/244] only one instance of debug window --- src/FreeScribe.client/UI/DebugWindow.py | 3 +++ src/FreeScribe.client/client.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/DebugWindow.py b/src/FreeScribe.client/UI/DebugWindow.py index 969a0c30..ebb57041 100644 --- a/src/FreeScribe.client/UI/DebugWindow.py +++ b/src/FreeScribe.client/UI/DebugWindow.py @@ -10,6 +10,7 @@ import sys from datetime import datetime from collections import deque +from utils.utils import window_has_running_instance class DualOutput: buffer = None @@ -77,6 +78,8 @@ def __init__(self, parent): :param parent: Parent tkinter window :type parent: tk.Tk or tk.Toplevel """ + if window_has_running_instance("Debug Output"): + return self.window = tk.Toplevel(parent) self.window.title("Debug Output") self.window.geometry("650x450") diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a3c0d669..e92f84e5 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -52,7 +52,6 @@ APP_NAME = 'AI Medical Scribe' # Application name - # check if another instance of the application is already running. # if false, create a new instance of the application # if true, exit the current instance From 99e38126522347afb9b207bde200e4be94b29449 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 12:27:49 -0500 Subject: [PATCH 006/244] cleanup --- src/FreeScribe.client/client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index e92f84e5..1d467edf 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1304,7 +1304,6 @@ def _load_stt_model_thread(): root.bind("<>", load_stt_model) -p.terminate() +root.mainloop() -if __name__ == '__main__': - root.mainloop() # run as usual \ No newline at end of file +p.terminate() From 360ad1c0d268c921913e87a60734236233a8ae37 Mon Sep 17 00:00:00 2001 From: Pemba Norsang Sherpa Date: Fri, 6 Dec 2024 12:32:06 -0500 Subject: [PATCH 007/244] Update src/FreeScribe.client/utils/utils.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/utils/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index 9868d5da..dc3826ac 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -17,7 +17,8 @@ def window_has_running_instance(app_name: str) -> bool: if hwnd: # if a matching window exists... print('Another instance of the application is already running.') # focus the existing window - U32DLL.ShowWindow(hwnd, 5) + SW_SHOW = 5 + U32DLL.ShowWindow(hwnd, SW_SHOW) U32DLL.SetForegroundWindow(hwnd) # bail return True From 9f6e04172520dd1ed1a0840d2d40607b67e82ddb Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 13:00:38 -0500 Subject: [PATCH 008/244] use mutex instead --- src/FreeScribe.client/client.py | 3 ++- src/FreeScribe.client/utils/utils.py | 31 ++++++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 1d467edf..dfddd5ef 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -44,7 +44,7 @@ from UI.DebugWindow import DualOutput import traceback import sys -from utils.utils import window_has_running_instance +from utils.utils import window_has_running_instance, bring_to_front dual = DualOutput() sys.stdout = dual @@ -59,6 +59,7 @@ root = tk.Tk() root.title(APP_NAME) else: + bring_to_front(APP_NAME) sys.exit(0) # settings logic diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index dc3826ac..0858f3b8 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -1,5 +1,6 @@ from ctypes import WinDLL +import ctypes # function to check if another instance of the application is already running def window_has_running_instance(app_name: str) -> bool: @@ -11,15 +12,23 @@ def window_has_running_instance(app_name: str) -> bool: bool: True if another instance is running, False otherwise """ U32DLL = WinDLL('user32') - # get the handle of any window matching 'app_name' + # Define the mutex name + MUTEX_NAME = 'Global\\FreeScribe_Instance' + ERROR_ALREADY_EXISTS = 183 + + # Create a named mutex + mutex = ctypes.windll.kernel32.CreateMutexW(None, False, MUTEX_NAME) + already_running = ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS + return already_running + +def bring_to_front(app_name: str): + """ + Bring the window with the given handle to the front. + Parameters: + hwnd: The handle of the window to bring to the front. + """ + U32DLL = WinDLL('user32') + SW_SHOW = 5 hwnd = U32DLL.FindWindowW(None, app_name) - print("Running instance check") - if hwnd: # if a matching window exists... - print('Another instance of the application is already running.') - # focus the existing window - SW_SHOW = 5 - U32DLL.ShowWindow(hwnd, SW_SHOW) - U32DLL.SetForegroundWindow(hwnd) - # bail - return True - return False + U32DLL.ShowWindow(hwnd, SW_SHOW) + U32DLL.SetForegroundWindow(hwnd) From 3e1c795b468cb251a5188519214fa2b9ca6efa19 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 13:02:49 -0500 Subject: [PATCH 009/244] cleanup --- src/FreeScribe.client/client.py | 2 +- src/FreeScribe.client/utils/utils.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index dfddd5ef..4504121e 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -55,7 +55,7 @@ # check if another instance of the application is already running. # if false, create a new instance of the application # if true, exit the current instance -if not window_has_running_instance(APP_NAME): +if not window_has_running_instance(): root = tk.Tk() root.title(APP_NAME) else: diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index 0858f3b8..9784d896 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -1,17 +1,13 @@ -from ctypes import WinDLL import ctypes # function to check if another instance of the application is already running -def window_has_running_instance(app_name: str) -> bool: +def window_has_running_instance() -> bool: """ Check if another instance of the application is already running. - Parameters: - app_name (str): The name of the application Returns: bool: True if another instance is running, False otherwise """ - U32DLL = WinDLL('user32') # Define the mutex name MUTEX_NAME = 'Global\\FreeScribe_Instance' ERROR_ALREADY_EXISTS = 183 @@ -25,9 +21,9 @@ def bring_to_front(app_name: str): """ Bring the window with the given handle to the front. Parameters: - hwnd: The handle of the window to bring to the front. + app_name (str): The name of the application window to bring to the front """ - U32DLL = WinDLL('user32') + U32DLL = ctypes.WinDLL('user32') SW_SHOW = 5 hwnd = U32DLL.FindWindowW(None, app_name) U32DLL.ShowWindow(hwnd, SW_SHOW) From 6ea785428d55bef8f482726417d1d9d9738f45bf Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 13:19:38 -0500 Subject: [PATCH 010/244] one instance of debug window --- src/FreeScribe.client/UI/DebugWindow.py | 18 ++++++++++++++---- src/FreeScribe.client/UI/MainWindowUI.py | 3 ++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/UI/DebugWindow.py b/src/FreeScribe.client/UI/DebugWindow.py index ebb57041..16c3b564 100644 --- a/src/FreeScribe.client/UI/DebugWindow.py +++ b/src/FreeScribe.client/UI/DebugWindow.py @@ -10,7 +10,7 @@ import sys from datetime import datetime from collections import deque -from utils.utils import window_has_running_instance +from utils.utils import bring_to_front class DualOutput: buffer = None @@ -78,9 +78,12 @@ def __init__(self, parent): :param parent: Parent tkinter window :type parent: tk.Tk or tk.Toplevel """ - if window_has_running_instance("Debug Output"): + self.parent = parent + if self.parent.debug_window_open: + bring_to_front("Debug Output") return - self.window = tk.Toplevel(parent) + self.parent.debug_window_open = True + self.window = tk.Toplevel(parent.root) self.window.title("Debug Output") self.window.geometry("650x450") @@ -110,6 +113,9 @@ def __init__(self, parent): copy_button = tk.Button(self.window, text="Copy to Clipboard", command=self._copy_to_clipboard) copy_button.pack(side=tk.LEFT, pady=10, padx=10) + # custom function to close the window + self.window.protocol("WM_DELETE_WINDOW", self.close_window) + self.refresh_output() def _copy_to_clipboard(self): @@ -134,4 +140,8 @@ def refresh_output(self): top_line_index = self.text_widget.index("@0,0") self.text_widget.delete("1.0", tk.END) self.text_widget.insert(tk.END, content) - self.text_widget.see(top_line_index) \ No newline at end of file + self.text_widget.see(top_line_index) + + def close_window(self): + self.parent.debug_window_open = False + self.window.destroy() \ No newline at end of file diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index 1e1270ac..8d4f7bda 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -34,6 +34,7 @@ def __init__(self, root, settings): self.scribe_template = None self.setting_window = SettingsWindowUI(self.app_settings, self, self.root) # Settings window self.root.iconbitmap(get_file_path('assets','logo.ico')) + self.debug_window_open = False # Flag to indicate if the debug window is open self.current_docker_status_check_id = None # ID for the current Docker status check self.current_container_status_check_id = None # ID for the current container status check @@ -225,7 +226,7 @@ def _create_help_menu(self): # Add Help menu help_menu = tk.Menu(self.menu_bar, tearoff=0) self.menu_bar.add_cascade(label="Help", menu=help_menu) - help_menu.add_command(label="Debug Window", command=lambda: DebugPrintWindow(self.root)) + help_menu.add_command(label="Debug Window", command=lambda: DebugPrintWindow(self)) help_menu.add_command(label="About", command=lambda: self._show_md_content(get_file_path('markdown','help','about.md'), 'About')) def _destroy_help_menu(self): From 38de786198942657703ac13492b90135b58e3417 Mon Sep 17 00:00:00 2001 From: Pemba Norsang Sherpa Date: Fri, 6 Dec 2024 14:27:01 -0500 Subject: [PATCH 011/244] Update src/FreeScribe.client/utils/utils.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/utils/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index 9784d896..50edae4c 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -14,8 +14,7 @@ def window_has_running_instance() -> bool: # Create a named mutex mutex = ctypes.windll.kernel32.CreateMutexW(None, False, MUTEX_NAME) - already_running = ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS - return already_running + return ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS def bring_to_front(app_name: str): """ From 9e55f6e9df51b6720f5a8890c0d7af04f79b1fa3 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 14:29:00 -0500 Subject: [PATCH 012/244] todo comment --- src/FreeScribe.client/utils/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index 9784d896..684e3416 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -23,6 +23,8 @@ def bring_to_front(app_name: str): Parameters: app_name (str): The name of the application window to bring to the front """ + + # TODO - Check platform and handle for different platform U32DLL = ctypes.WinDLL('user32') SW_SHOW = 5 hwnd = U32DLL.FindWindowW(None, app_name) From 3fe0e46547d983c3b3dbdcca44df09c68bdefc24 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Fri, 6 Dec 2024 14:43:31 -0500 Subject: [PATCH 013/244] close mutex when application close --- src/FreeScribe.client/client.py | 5 ++++- src/FreeScribe.client/utils/utils.py | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 4504121e..f23b5ce9 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -44,7 +44,7 @@ from UI.DebugWindow import DualOutput import traceback import sys -from utils.utils import window_has_running_instance, bring_to_front +from utils.utils import window_has_running_instance, bring_to_front, close_mutex dual = DualOutput() sys.stdout = dual @@ -62,6 +62,9 @@ bring_to_front(APP_NAME) sys.exit(0) +# Register the close_mutex function to be called on exit +atexit.register(close_mutex) + # settings logic app_settings = SettingsWindow() diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index d870b6d0..358fb0d3 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -1,6 +1,13 @@ import ctypes +# Define the mutex name and error code +MUTEX_NAME = 'Global\\FreeScribe_Instance' +ERROR_ALREADY_EXISTS = 183 + +# Global variable to store the mutex handle +mutex = None + # function to check if another instance of the application is already running def window_has_running_instance() -> bool: """ @@ -8,9 +15,7 @@ def window_has_running_instance() -> bool: Returns: bool: True if another instance is running, False otherwise """ - # Define the mutex name - MUTEX_NAME = 'Global\\FreeScribe_Instance' - ERROR_ALREADY_EXISTS = 183 + global mutex # Create a named mutex mutex = ctypes.windll.kernel32.CreateMutexW(None, False, MUTEX_NAME) @@ -29,3 +34,13 @@ def bring_to_front(app_name: str): hwnd = U32DLL.FindWindowW(None, app_name) U32DLL.ShowWindow(hwnd, SW_SHOW) U32DLL.SetForegroundWindow(hwnd) + +def close_mutex(): + """ + Close the mutex handle to release the resource. + """ + global mutex + if mutex: + ctypes.windll.kernel32.ReleaseMutex(mutex) + ctypes.windll.kernel32.CloseHandle(mutex) + mutex = None From 1b460f779ca4816af10298bedeec2fde9903daa6 Mon Sep 17 00:00:00 2001 From: Naitik Patel Date: Mon, 9 Dec 2024 14:19:22 -0500 Subject: [PATCH 014/244] Bug Fix: buttons not visible on settings page --- src/FreeScribe.client/UI/Widgets/AudioMeter.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/Widgets/AudioMeter.py b/src/FreeScribe.client/UI/Widgets/AudioMeter.py index f80f9062..af82b1d8 100644 --- a/src/FreeScribe.client/UI/Widgets/AudioMeter.py +++ b/src/FreeScribe.client/UI/Widgets/AudioMeter.py @@ -57,6 +57,7 @@ def __init__(self, master=None, width=400, height=100, threshold=750): self.running = False self.threshold = threshold self.destroyed = False # Add flag to track widget destruction + self.error_message_box = None # Add error message box attribute self.setup_audio() self.create_widgets() @@ -90,6 +91,10 @@ def cleanup(self, event=None): if hasattr(self, 'monitoring_thread') and self.monitoring_thread: self.monitoring_thread.join(timeout=1.0) + # Cancel error message if scheduled + if self.error_message_box is not None: + self.error_message_box.destroy() + def destroy(self): """ Override the destroy method to ensure cleanup. @@ -206,7 +211,12 @@ def toggle_monitoring(self): frames_per_buffer=self.CHUNK, ) except (OSError, IOError) as e: - tk.messagebox.showerror("Error", f"Please check your microphone settings under the speech2text settings tab. Error opening audio stream: {e}") + # show error message in thread-safe way + error_message = f"Please check your microphone settings under the speech2text settings tab. Error opening audio stream: {e}" + # create a new Tk instance to show the error message + self.error_message_box = tk.Tk() + self.error_message_box.withdraw() + self.master.after(0, lambda: tk.messagebox.showerror("Error", error_message, master=self.error_message_box)) self.monitoring_thread = Thread(target=self.update_meter) self.monitoring_thread.start() From d5b194dbd90e25316cfa3dd2788ff14212bc524c Mon Sep 17 00:00:00 2001 From: Naitik Patel Date: Mon, 9 Dec 2024 14:40:41 -0500 Subject: [PATCH 015/244] hiding the architecture setting if only one option is available --- src/FreeScribe.client/UI/SettingsWindowUI.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 0c06ea07..bcf2e259 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -243,7 +243,8 @@ def create_llm_settings(self): left_row += 1 #6. GPU OR CPU SELECTION (Right Column) - tk.Label(left_frame, text="Local Architecture").grid(row=left_row, column=0, padx=0, pady=5, sticky="w") + self.local_architecture_label = tk.Label(left_frame, text="Local Architecture") + self.local_architecture_label.grid(row=left_row, column=0, padx=0, pady=5, sticky="w") architecture_options = self.settings.get_available_architectures() self.architecture_dropdown = ttk.Combobox(left_frame, values=architecture_options, width=15, state="readonly") if self.settings.editable_settings["Architecture"] in architecture_options: @@ -254,6 +255,12 @@ def create_llm_settings(self): self.architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") + # hide architecture dropdown if architecture only has one option + if len(architecture_options) == 1: + self.local_architecture_label.grid_forget() + self.architecture_dropdown.grid_forget() + + left_row += 1 # 5. Models (Left Column) From ea1940878c217b85cc197c28be07ffbb85546fc3 Mon Sep 17 00:00:00 2001 From: Naitik Patel Date: Mon, 9 Dec 2024 15:38:27 -0500 Subject: [PATCH 016/244] increasing the width of the dropbox --- src/FreeScribe.client/UI/SettingsWindowUI.py | 17 ++++++++++------- .../UI/Widgets/MicrophoneSelector.py | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index bcf2e259..b7383e8d 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -81,8 +81,8 @@ def open_settings_window(self): """ self.settings_window = tk.Toplevel() self.settings_window.title("Settings") - self.settings_window.geometry("600x450") # Set initial window size - self.settings_window.minsize(600, 500) # Set minimum window size + self.settings_window.geometry("700x400") # Set initial window size + self.settings_window.minsize(700, 400) # Set minimum window size self.settings_window.resizable(True, True) self.settings_window.grab_set() self.settings_window.iconbitmap(get_file_path('assets','logo.ico')) @@ -181,7 +181,7 @@ def create_whisper_settings(self): # create the whisper model dropdown slection tk.Label(left_frame, text="Whisper Model").grid(row=3, column=0, padx=0, pady=5, sticky="w") whisper_models_drop_down_options = ["medium", "small", "tiny", "tiny.en", "base", "base.en", "small.en", "medium.en", "large"] - self.whisper_models_drop_down = ttk.Combobox(left_frame, values=whisper_models_drop_down_options, width=13) + self.whisper_models_drop_down = ttk.Combobox(left_frame, values=whisper_models_drop_down_options, width=20) self.whisper_models_drop_down.grid(row=3, column=1, padx=0, pady=5, sticky="w") try: @@ -230,6 +230,9 @@ def create_llm_settings(self): right_frame = ttk.Frame(self.llm_settings_frame) right_frame.grid(row=0, column=1, padx=10, pady=5, sticky="nw") + self.llm_settings_frame.columnconfigure(0, weight=1) + self.llm_settings_frame.columnconfigure(1, weight=1) + left_row = 0 right_row = 0 @@ -246,7 +249,7 @@ def create_llm_settings(self): self.local_architecture_label = tk.Label(left_frame, text="Local Architecture") self.local_architecture_label.grid(row=left_row, column=0, padx=0, pady=5, sticky="w") architecture_options = self.settings.get_available_architectures() - self.architecture_dropdown = ttk.Combobox(left_frame, values=architecture_options, width=15, state="readonly") + self.architecture_dropdown = ttk.Combobox(left_frame, values=architecture_options, width=20, state="readonly") if self.settings.editable_settings["Architecture"] in architecture_options: self.architecture_dropdown.current(architecture_options.index(self.settings.editable_settings["Architecture"])) else: @@ -266,7 +269,7 @@ def create_llm_settings(self): # 5. Models (Left Column) tk.Label(left_frame, text="Models").grid(row=left_row, column=0, padx=0, pady=5, sticky="w") models_drop_down_options = [] - self.models_drop_down = ttk.Combobox(left_frame, values=models_drop_down_options, width=15, state="readonly") + self.models_drop_down = ttk.Combobox(left_frame, values=models_drop_down_options, width=20, state="readonly") self.models_drop_down.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") self.models_drop_down.bind('<>', self.on_model_selection_change) thread = threading.Thread(target=self.settings.update_models_dropdown, args=(self.models_drop_down,)) @@ -612,7 +615,7 @@ def _create_general_settings(self): # 1. LLM Preset (Left Column) tk.Label(frame, text="Settings Presets:").grid(row=row, column=0, padx=0, pady=5, sticky="w") llm_preset_options = ["Local AI", "ClinicianFocus Toolbox", "Custom"] - self.llm_preset_dropdown = ttk.Combobox(frame, values=llm_preset_options, width=15, state="readonly") + self.llm_preset_dropdown = ttk.Combobox(frame, values=llm_preset_options, width=20, state="readonly") if self.settings.editable_settings["Preset"] in llm_preset_options: self.llm_preset_dropdown.current(llm_preset_options.index(self.settings.editable_settings["Preset"])) else: @@ -665,7 +668,7 @@ def _create_entry(self, frame, label, setting_name, row_idx): """ tk.Label(frame, text=label).grid(row=row_idx, column=0, padx=0, pady=5, sticky="w") value = self.settings.editable_settings[setting_name] - entry = tk.Entry(frame) + entry = tk.Entry(frame, width=25) entry.insert(0, str(value)) entry.grid(row=row_idx, column=1, padx=0, pady=5, sticky="w") self.settings.editable_settings_entries[setting_name] = entry diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneSelector.py b/src/FreeScribe.client/UI/Widgets/MicrophoneSelector.py index 32d0a2af..3be9ca1e 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneSelector.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneSelector.py @@ -75,7 +75,7 @@ def __init__(self, root, row, column, app_settings): self.label = tk.Label(root, text="Select a Microphone:") self.label.grid(row=row, column=0, pady=5, sticky="w") - self.dropdown = ttk.Combobox(root, state="readonly", width=15) + self.dropdown = ttk.Combobox(root, state="readonly", width=20) self.dropdown.grid(row=row, pady=5, column=1) # Populate microphones in the dropdown From 4dc50fa9774bf29ed79baca1eced7fd18b82c72a Mon Sep 17 00:00:00 2001 From: Naitik Patel Date: Tue, 10 Dec 2024 12:13:41 -0500 Subject: [PATCH 017/244] disabling/enabling clear button on toggle recording --- src/FreeScribe.client/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index da8548a1..3d307a05 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -420,6 +420,7 @@ def disable_recording_ui_elements(): upload_button.config(state='disabled') response_display.scrolled_text.configure(state='disabled') timestamp_listbox.config(state='disabled') + clear_button.config(state='disabled') def enable_recording_ui_elements(): window.enable_settings_menu() @@ -428,6 +429,7 @@ def enable_recording_ui_elements(): toggle_button.config(state='normal') upload_button.config(state='normal') timestamp_listbox.config(state='normal') + clear_button.config(state='normal') def cancel_processing(): From 76face0b4853dbaaba3a66cbe766ece7a127f444 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 14:40:40 -0500 Subject: [PATCH 018/244] Create the warning bar --- src/FreeScribe.client/UI/MainWindowUI.py | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index 8d4f7bda..a40da5b3 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -36,6 +36,8 @@ def __init__(self, root, settings): self.root.iconbitmap(get_file_path('assets','logo.ico')) self.debug_window_open = False # Flag to indicate if the debug window is open + self.warning_bar = None # Warning bar + self.current_docker_status_check_id = None # ID for the current Docker status check self.current_container_status_check_id = None # ID for the current container status check @@ -123,6 +125,36 @@ def create_docker_status_bar(self): self._background_availbility_docker_check() self._background_check_container_status(llm_dot, whisper_dot) + def create_warning_bar(self, text, row=3, column=0, columnspan=14, pady=10, padx=10, sticky='nsew'): + # Add a yellow footer that says check microphone settings + # Create the frame for the Docker status bar, placed at the bottom of the window + self.warning_bar = tk.Frame(self.root, bd=1, relief=tk.SUNKEN, background="gold") + self.warning_bar.grid(row=4, column=0, columnspan=14, sticky='nsew') + + # Add LLM container status label + text_label = tk.Label( + self.warning_bar, + text="No audio input detected for 10 seconds. Please check your microphone input device in whisper settings. Also, adjust your microphone cutoff level in advanced settings.", + padx=5, + foreground="black", # Text color set to yellow + background="gold" # Matches the frame's background + ) + text_label.pack(side=tk.LEFT) + + # Add a close button to the left of the warning bar + close_button = tk.Button( + self.warning_bar, + text="X", + command=self.destroy_warning_bar, + foreground="black", + ) + close_button.pack(side=tk.LEFT) + + def destroy_warning_bar(self): + if self.warning_bar is not None: + self.warning_bar.destroy() + self.warning_bar = None + def disable_docker_ui(self): """ Disable the Docker status bar UI elements. From 8038ae524588c18f3c307be87d96699de64d065a Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 14:42:21 -0500 Subject: [PATCH 019/244] Apply the warnign bar if silence warning is not silent for more than 10 seconds --- src/FreeScribe.client/client.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3d307a05..c1ca4928 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -178,6 +178,7 @@ def toggle_pause(): elif current_view == "minimal": pause_button.config(text="⏸️", bg=DEFAULT_BUTTON_COLOUR) +SILENCE_WARNING_LENGTH = 10 # seconds, warn the user after 10s of no input something might be wrong def record_audio(): global is_paused, frames, audio_queue @@ -197,6 +198,7 @@ def record_audio(): current_chunk = [] silent_duration = 0 + silent_warning_duration = 0 record_duration = 0 minimum_silent_duration = int(app_settings.editable_settings["Real Time Silence Length"]) minimum_audio_duration = int(app_settings.editable_settings["Real Time Audio Length"]) @@ -209,12 +211,25 @@ def record_audio(): audio_buffer = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768 if is_silent(audio_buffer, app_settings.editable_settings["Silence cut-off"]): silent_duration += CHUNK / RATE + silent_warning_duration += CHUNK / RATE else: current_chunk.append(data) silent_duration = 0 + silent_warning_duration = 0 record_duration += CHUNK / RATE - + + # Check if we need to warn if silence is long than warn time + if silent_warning_duration >= SILENCE_WARNING_LENGTH: + + # If the warning bar is not already displayed, create it + if window.warning_bar is None: + window.create_warning_bar("No audio input detected for 10 seconds. Please check your microphone input device in whisper settings. Also, adjust your microphone cutoff level in advanced settings.") + else: + # If the warning bar is displayed, remove it + if window.warning_bar is not None: + window.destroy_warning_bar() + # If the current_chunk has at least 5 seconds of audio and 1 second of silence at the end if record_duration >= minimum_audio_duration and silent_duration >= minimum_silent_duration: if app_settings.editable_settings["Real Time"] and current_chunk: @@ -231,6 +246,9 @@ def record_audio(): stream.close() audio_queue.put(None) + # If the warning bar is displayed, remove it + if window.warning_bar is not None: + window.destroy_warning_bar() def is_silent(data, threshold=0.01): """Check if audio chunk is silent""" From a06410f12affa80862e07df90e231df8b65c2013 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 14:44:36 -0500 Subject: [PATCH 020/244] Documentation --- src/FreeScribe.client/UI/MainWindowUI.py | 36 ++++++++++++++++-------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index a40da5b3..40e6a935 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -126,32 +126,46 @@ def create_docker_status_bar(self): self._background_check_container_status(llm_dot, whisper_dot) def create_warning_bar(self, text, row=3, column=0, columnspan=14, pady=10, padx=10, sticky='nsew'): - # Add a yellow footer that says check microphone settings - # Create the frame for the Docker status bar, placed at the bottom of the window + """ + Create a warning bar at the bottom of the window to notify the user about microphone issues. + + :param text: Placeholder for text input (unused). + :param row: The row in the grid layout where the bar is placed. + :param column: The starting column for the grid layout. + :param columnspan: The number of columns spanned by the warning bar. + :param pady: Padding for the vertical edges. + :param padx: Padding for the horizontal edges. + :param sticky: Defines how the widget expands in the grid cell. + """ + # Create a frame for the warning bar with a sunken border and gold background self.warning_bar = tk.Frame(self.root, bd=1, relief=tk.SUNKEN, background="gold") self.warning_bar.grid(row=4, column=0, columnspan=14, sticky='nsew') - # Add LLM container status label + # Add a label to display the warning message in the warning bar text_label = tk.Label( - self.warning_bar, - text="No audio input detected for 10 seconds. Please check your microphone input device in whisper settings. Also, adjust your microphone cutoff level in advanced settings.", - padx=5, - foreground="black", # Text color set to yellow - background="gold" # Matches the frame's background + self.warning_bar, + text="No audio input detected for 10 seconds. Please check your microphone input device in whisper settings. Also, adjust your microphone cutoff level in advanced settings.", + padx=5, + foreground="black", # Text color + background="gold" # Matches the frame's background ) text_label.pack(side=tk.LEFT) - # Add a close button to the left of the warning bar + # Add a button to allow users to close the warning bar close_button = tk.Button( self.warning_bar, text="X", - command=self.destroy_warning_bar, - foreground="black", + command=self.destroy_warning_bar, # Call the destroy method when clicked + foreground="black" ) close_button.pack(side=tk.LEFT) def destroy_warning_bar(self): + """ + Destroy the warning bar if it exists to remove it from the UI. + """ if self.warning_bar is not None: + # Destroy the warning bar frame and set the reference to None self.warning_bar.destroy() self.warning_bar = None From f80309a9f33658e10d74b742fdb9da4dff944c08 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 14:58:58 -0500 Subject: [PATCH 021/244] Fixed wording --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index c1ca4928..764a02c2 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -224,7 +224,7 @@ def record_audio(): # If the warning bar is not already displayed, create it if window.warning_bar is None: - window.create_warning_bar("No audio input detected for 10 seconds. Please check your microphone input device in whisper settings. Also, adjust your microphone cutoff level in advanced settings.") + window.create_warning_bar("No audio input detected for 10 seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") else: # If the warning bar is displayed, remove it if window.warning_bar is not None: From ffea35d4ffa966f52c20d40f603571f9f0e9e614 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 15:00:32 -0500 Subject: [PATCH 022/244] Fixed some defaults --- src/FreeScribe.client/UI/MainWindowUI.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index 40e6a935..b8414689 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -125,7 +125,7 @@ def create_docker_status_bar(self): self._background_availbility_docker_check() self._background_check_container_status(llm_dot, whisper_dot) - def create_warning_bar(self, text, row=3, column=0, columnspan=14, pady=10, padx=10, sticky='nsew'): + def create_warning_bar(self, text, row=3, column=0, columnspan=11, pady=10, padx=10, sticky='nsew'): """ Create a warning bar at the bottom of the window to notify the user about microphone issues. @@ -139,7 +139,7 @@ def create_warning_bar(self, text, row=3, column=0, columnspan=14, pady=10, padx """ # Create a frame for the warning bar with a sunken border and gold background self.warning_bar = tk.Frame(self.root, bd=1, relief=tk.SUNKEN, background="gold") - self.warning_bar.grid(row=4, column=0, columnspan=14, sticky='nsew') + self.warning_bar.grid(row=4, column=0, columnspan=11, sticky='nsew') # Add a label to display the warning message in the warning bar text_label = tk.Label( @@ -158,7 +158,8 @@ def create_warning_bar(self, text, row=3, column=0, columnspan=14, pady=10, padx command=self.destroy_warning_bar, # Call the destroy method when clicked foreground="black" ) - close_button.pack(side=tk.LEFT) + + close_button.pack(side=tk.RIGHT) def destroy_warning_bar(self): """ From bed8420d1b2f9d350403ce5ad3e3eec5a5498316 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 15:11:58 -0500 Subject: [PATCH 023/244] Broke off silence warning check to another function --- src/FreeScribe.client/client.py | 24 ++++++++++++------- .../install_state/NVIDIA_INSTALL.txt | 0 2 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 src/FreeScribe.client/install_state/NVIDIA_INSTALL.txt diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 764a02c2..1d16cb6c 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -220,15 +220,7 @@ def record_audio(): record_duration += CHUNK / RATE # Check if we need to warn if silence is long than warn time - if silent_warning_duration >= SILENCE_WARNING_LENGTH: - - # If the warning bar is not already displayed, create it - if window.warning_bar is None: - window.create_warning_bar("No audio input detected for 10 seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") - else: - # If the warning bar is displayed, remove it - if window.warning_bar is not None: - window.destroy_warning_bar() + check_silence_warning(silent_warning_duration) # If the current_chunk has at least 5 seconds of audio and 1 second of silence at the end if record_duration >= minimum_audio_duration and silent_duration >= minimum_silent_duration: @@ -250,6 +242,20 @@ def record_audio(): if window.warning_bar is not None: window.destroy_warning_bar() +def check_silence_warning(silence_duration): + """Check if silence warning should be displayed.""" + + # Check if we need to warn if silence is long than warn time + if silence_duration >= SILENCE_WARNING_LENGTH: + + # If the warning bar is not already displayed, create it + if window.warning_bar is None: + window.create_warning_bar("No audio input detected for 10 seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") + else: + # If the warning bar is displayed, remove it + if window.warning_bar is not None: + window.destroy_warning_bar() + def is_silent(data, threshold=0.01): """Check if audio chunk is silent""" data_array = np.array(data) diff --git a/src/FreeScribe.client/install_state/NVIDIA_INSTALL.txt b/src/FreeScribe.client/install_state/NVIDIA_INSTALL.txt new file mode 100644 index 00000000..e69de29b From 368d853c95fb7b73bc51a8d0c6b38483601f3c00 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 15:19:18 -0500 Subject: [PATCH 024/244] Removed unused not needed params --- src/FreeScribe.client/UI/MainWindowUI.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index b8414689..87ac7483 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -125,7 +125,7 @@ def create_docker_status_bar(self): self._background_availbility_docker_check() self._background_check_container_status(llm_dot, whisper_dot) - def create_warning_bar(self, text, row=3, column=0, columnspan=11, pady=10, padx=10, sticky='nsew'): + def create_warning_bar(self, text): """ Create a warning bar at the bottom of the window to notify the user about microphone issues. @@ -144,7 +144,7 @@ def create_warning_bar(self, text, row=3, column=0, columnspan=11, pady=10, padx # Add a label to display the warning message in the warning bar text_label = tk.Label( self.warning_bar, - text="No audio input detected for 10 seconds. Please check your microphone input device in whisper settings. Also, adjust your microphone cutoff level in advanced settings.", + text=text, padx=5, foreground="black", # Text color background="gold" # Matches the frame's background @@ -158,7 +158,7 @@ def create_warning_bar(self, text, row=3, column=0, columnspan=11, pady=10, padx command=self.destroy_warning_bar, # Call the destroy method when clicked foreground="black" ) - + close_button.pack(side=tk.RIGHT) def destroy_warning_bar(self): From 4a4bad187f20ecbda903b0e2058244677ed80441 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 11 Dec 2024 15:36:11 -0500 Subject: [PATCH 025/244] Added record audio to try catch block to prevent errors hopefully --- src/FreeScribe.client/client.py | 98 +++++++++++++++++---------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 1d16cb6c..8d4b8f16 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -195,52 +195,58 @@ def record_audio(): messagebox.showerror("Audio Error", f"Please check your microphone settings under whisper settings. Error opening audio stream: {e}") return - - current_chunk = [] - silent_duration = 0 - silent_warning_duration = 0 - record_duration = 0 - minimum_silent_duration = int(app_settings.editable_settings["Real Time Silence Length"]) - minimum_audio_duration = int(app_settings.editable_settings["Real Time Audio Length"]) - - while is_recording: - if not is_paused: - data = stream.read(CHUNK, exception_on_overflow=False) - frames.append(data) - # Check for silence - audio_buffer = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768 - if is_silent(audio_buffer, app_settings.editable_settings["Silence cut-off"]): - silent_duration += CHUNK / RATE - silent_warning_duration += CHUNK / RATE - else: - current_chunk.append(data) - silent_duration = 0 - silent_warning_duration = 0 - - record_duration += CHUNK / RATE - - # Check if we need to warn if silence is long than warn time - check_silence_warning(silent_warning_duration) - - # If the current_chunk has at least 5 seconds of audio and 1 second of silence at the end - if record_duration >= minimum_audio_duration and silent_duration >= minimum_silent_duration: - if app_settings.editable_settings["Real Time"] and current_chunk: - audio_queue.put(b''.join(current_chunk)) - current_chunk = [] - silent_duration = 0 - record_duration = 0 - - # Send any remaining audio chunk when recording stops - if current_chunk: - audio_queue.put(b''.join(current_chunk)) - - stream.stop_stream() - stream.close() - audio_queue.put(None) - - # If the warning bar is displayed, remove it - if window.warning_bar is not None: - window.destroy_warning_bar() + try: + + current_chunk = [] + silent_duration = 0 + silent_warning_duration = 0 + record_duration = 0 + minimum_silent_duration = int(app_settings.editable_settings["Real Time Silence Length"]) + minimum_audio_duration = int(app_settings.editable_settings["Real Time Audio Length"]) + + while is_recording: + if not is_paused: + data = stream.read(CHUNK, exception_on_overflow=False) + frames.append(data) + # Check for silence + audio_buffer = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768 + if is_silent(audio_buffer, app_settings.editable_settings["Silence cut-off"]): + silent_duration += CHUNK / RATE + silent_warning_duration += CHUNK / RATE + else: + current_chunk.append(data) + silent_duration = 0 + silent_warning_duration = 0 + + record_duration += CHUNK / RATE + + # Check if we need to warn if silence is long than warn time + check_silence_warning(silent_warning_duration) + + # If the current_chunk has at least 5 seconds of audio and 1 second of silence at the end + if record_duration >= minimum_audio_duration and silent_duration >= minimum_silent_duration: + if app_settings.editable_settings["Real Time"] and current_chunk: + audio_queue.put(b''.join(current_chunk)) + current_chunk = [] + silent_duration = 0 + record_duration = 0 + + # Send any remaining audio chunk when recording stops + if current_chunk: + audio_queue.put(b''.join(current_chunk)) + except Exception as e: + # Log the error message + # TODO System logger + # For now general catch on any problems + print(f"An error occurred: {e}") + finally: + stream.stop_stream() + stream.close() + audio_queue.put(None) + + # If the warning bar is displayed, remove it + if window.warning_bar is not None: + window.destroy_warning_bar() def check_silence_warning(silence_duration): """Check if silence warning should be displayed.""" From 40c2a5f24483357b43658d9098c6b296de6ae584 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 12 Dec 2024 08:47:19 -0500 Subject: [PATCH 026/244] Moved send to AI outside of check for left over data, fixed the bug of not sending to ai on remote whisper --- src/FreeScribe.client/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3d307a05..3ea0059b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -320,10 +320,10 @@ def save_audio(): wf.writeframes(b''.join(frames)) frames = [] # Clear recorded data - if app_settings.editable_settings["Real Time"] == True and is_audio_processing_realtime_canceled.is_set() is False: - send_and_receive() - elif app_settings.editable_settings["Real Time"] == False and is_audio_processing_whole_canceled.is_set() is False: - threaded_send_audio_to_server() + if app_settings.editable_settings["Real Time"] == True and is_audio_processing_realtime_canceled.is_set() is False: + send_and_receive() + elif app_settings.editable_settings["Real Time"] == False and is_audio_processing_whole_canceled.is_set() is False: + threaded_send_audio_to_server() def toggle_recording(): global is_recording, recording_thread, DEFAULT_BUTTON_COLOUR, audio_queue, current_view, REALTIME_TRANSCRIBE_THREAD_ID From 4f82e0ab52b719009237228510211bb377499292 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 12 Dec 2024 09:10:42 -0500 Subject: [PATCH 027/244] f string for time in warning --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 8d4b8f16..0fd71ae4 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -256,7 +256,7 @@ def check_silence_warning(silence_duration): # If the warning bar is not already displayed, create it if window.warning_bar is None: - window.create_warning_bar("No audio input detected for 10 seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") + window.create_warning_bar(f"No audio input detected for {SILENCE_WARNING_LENGTH} seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") else: # If the warning bar is displayed, remove it if window.warning_bar is not None: From 2769af841d997de057a5b0397a00b84d5bea0fd5 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 12 Dec 2024 09:21:34 -0500 Subject: [PATCH 028/244] Merged nested conditions --- src/FreeScribe.client/client.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 0fd71ae4..6f7ba162 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -252,15 +252,12 @@ def check_silence_warning(silence_duration): """Check if silence warning should be displayed.""" # Check if we need to warn if silence is long than warn time - if silence_duration >= SILENCE_WARNING_LENGTH: + if silence_duration >= SILENCE_WARNING_LENGTH and window.warning_bar is None: - # If the warning bar is not already displayed, create it - if window.warning_bar is None: - window.create_warning_bar(f"No audio input detected for {SILENCE_WARNING_LENGTH} seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") - else: + window.create_warning_bar(f"No audio input detected for {SILENCE_WARNING_LENGTH} seconds. Please check your microphone input device in whisper settings and adjust your microphone cutoff level in advanced settings.") + elif silence_duration <= SILENCE_WARNING_LENGTH and window.warning_bar is not None: # If the warning bar is displayed, remove it - if window.warning_bar is not None: - window.destroy_warning_bar() + window.destroy_warning_bar() def is_silent(data, threshold=0.01): """Check if audio chunk is silent""" From a122ec23d8827a8b7f5a4f94d46ba405600213fd Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 12 Dec 2024 09:21:46 -0500 Subject: [PATCH 029/244] Made the bar take up the whole footer --- src/FreeScribe.client/UI/MainWindowUI.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index 87ac7483..53dc4296 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -139,13 +139,12 @@ def create_warning_bar(self, text): """ # Create a frame for the warning bar with a sunken border and gold background self.warning_bar = tk.Frame(self.root, bd=1, relief=tk.SUNKEN, background="gold") - self.warning_bar.grid(row=4, column=0, columnspan=11, sticky='nsew') + self.warning_bar.grid(row=4, column=0, columnspan=14, sticky='nsew') # Add a label to display the warning message in the warning bar text_label = tk.Label( self.warning_bar, text=text, - padx=5, foreground="black", # Text color background="gold" # Matches the frame's background ) From ed04feea3f1988c1ad0f117171b2d6d6cf862969 Mon Sep 17 00:00:00 2001 From: Naitik Patel Date: Thu, 12 Dec 2024 15:24:17 -0500 Subject: [PATCH 030/244] checking cuda drivers --- scripts/install.nsi | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 927e31a3..00f1893b 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -105,7 +105,7 @@ FunctionEnd Function ARCHITECTURE_SELECT_LEAVE ${If} $SELECTED_OPTION == "NVIDIA" - Call CheckNvidiaDrivers + Call CheckNvidiaDrivers ${EndIf} FunctionEnd @@ -161,7 +161,7 @@ Function .onInit ${If} $R1 != "" StrCpy $SELECTED_OPTION $R1 ${EndIf} - + NOT_SILENT_MODE: FunctionEnd @@ -217,7 +217,7 @@ Section "MainSection" SEC01 ; Add files to the installer File /r "..\dist\freescribe-client-nvidia\freescribe-client-nvidia.exe" Rename "$INSTDIR\freescribe-client-nvidia.exe" "$INSTDIR\freescribe-client.exe" - File /r "..\dist\freescribe-client-nvidia\_internal" + File /r "..\dist\freescribe-client-nvidia\_internal" ${EndIf} @@ -278,7 +278,7 @@ Section "Uninstall" MessageBox MB_RETRYCANCEL "Unable to remove old configuration. Please close any applications using these files and try again." IDRETRY RemoveConfigFiles IDCANCEL ConfigFilesFailed ${EndIf} ${EndIf} - + ; Show message when uninstallation is complete MessageBox MB_OK "FreeScribe has been successfully uninstalled." Goto EndUninstall @@ -298,7 +298,7 @@ Function CustomizeFinishPage nsDialogs::Create 1018 Pop $0 - + ${If} $0 == error Abort ${EndIf} @@ -349,6 +349,16 @@ Function InsfilesPageLeave SetAutoClose true FunctionEnd +Function CheckCudaAvailability + nsExec::ExecToStack 'nvcc --version' + Pop $0 ; Return value + + ${If} $0 != 0 + MessageBox MB_OK "CUDA is not available. Please ensure 'nvcc' is installed and added to the PATH and restart the installer. Download it from: https://developer.nvidia.com/cuda-downloads" + Quit + ${EndIf} +FunctionEnd + Function CheckNvidiaDrivers Var /GLOBAL DriverVersion @@ -362,24 +372,29 @@ Function CheckNvidiaDrivers ReadRegStr $DriverVersion HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{B2FE1952-0186-46C3-BAEC-A80AA35AC5B8}_Display.Driver" "DisplayVersion" ${EndIf} - ; No nvidia drivers detected - show error message + ; No NVIDIA drivers detected - show error message ${If} $DriverVersion == "" - MessageBox MB_OK "No valid Nvidia device deteced (Drivers Missing). This program relys on a Nvidia GPU to run. Functionality is not guaranteed without a Nvidia GPU." + MessageBox MB_OK "No valid NVIDIA device detected (Drivers Missing). This program relies on an NVIDIA GPU to run. Functionality is not guaranteed without an NVIDIA GPU." Goto driver_check_end ${EndIf} + ; Push the version number to the stack Push $DriverVersion ; Push min driver version Push ${MIN_CUDA_DRIVER_VERSION} - + Call CompareVersions Pop $0 ; Get the return value ${If} $0 == 1 - MessageBox MB_OK "Your NVIDIA driver version ($DriverVersion) is older than the minimum required version (${MIN_CUDA_DRIVER_VERSION}). Please update at https://www.nvidia.com/en-us/drivers/. Then contiune with the installation." + MessageBox MB_OK "Your NVIDIA driver version ($DriverVersion) is older than the minimum required version (${MIN_CUDA_DRIVER_VERSION}). Please update at https://www.nvidia.com/en-us/drivers/. Then continue with the installation." Abort ${EndIf} + + ; Check for CUDA availability + Call CheckCudaAvailability + driver_check_end: FunctionEnd From 4a4de3b6ee79312ece7f3a3dd77ea4414ab6fedd Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 12:14:40 -0500 Subject: [PATCH 031/244] Implmented faster whisper with architecture selection for cuda or cpu --- src/FreeScribe.client/UI/SettingsWindow.py | 4 +++ src/FreeScribe.client/UI/SettingsWindowUI.py | 16 ++++++++++++ src/FreeScribe.client/client.py | 26 +++++++++++++++----- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index a034863d..23d541e9 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -34,6 +34,7 @@ class SettingsKeys(Enum): LOCAL_WHISPER = "Built-in Speech2Text" WHISPER_ENDPOINT = "Speech2Text (Whisper) Endpoint" WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" + WHISPER_ARCHITECTURE = "Speech2Text (Whisper) Architecture" class FeatureToggle: @@ -100,8 +101,10 @@ def __init__(self): "BlankSpace", # Represents the SettingsKeys.LOCAL_WHISPER.value checkbox that is manually placed "Real Time", "BlankSpace", # Represents the model dropdown that is manually placed + "BlankSpace", # Represents the mic dropdown SettingsKeys.WHISPER_ENDPOINT.value, SettingsKeys.WHISPER_SERVER_API_KEY.value, + "BlankSpace", # Represents the architecture dropdown that is manually placed "S2T Server Self-Signed Certificates", ] @@ -172,6 +175,7 @@ def __init__(self): SettingsKeys.LOCAL_WHISPER.value: True, SettingsKeys.WHISPER_ENDPOINT.value: "https://localhost:2224/whisperaudio", SettingsKeys.WHISPER_SERVER_API_KEY.value: "", + SettingsKeys.WHISPER_ARCHITECTURE.value: "CPU", "Whisper Model": "small.en", "Current Mic": "None", "Real Time": True, diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index b7383e8d..5502737a 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -199,6 +199,22 @@ def create_whisper_settings(self): left_row += 1 + # Whisper Architecture Dropdown + self.whisper_architecture_label = tk.Label(right_frame, text=SettingsKeys.WHISPER_ARCHITECTURE.value) + self.whisper_architecture_label.grid(row=right_row, column=0, padx=0, pady=5, sticky="w") + whisper_architecture_options = self.settings.get_available_architectures() + self.whisper_architecture_dropdown = ttk.Combobox(right_frame, values=whisper_architecture_options, width=20, state="readonly") + if self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] in whisper_architecture_options: + self.whisper_architecture_dropdown.current(whisper_architecture_options.index(self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value])) + else: + # Default cpu + self.whisper_architecture_dropdown.set("CPU") + + self.whisper_architecture_dropdown.grid(row=right_row, column=1, padx=0, pady=5, sticky="w") + self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value] = self.whisper_architecture_dropdown + + right_row += 1 + # set the state of the whisper settings based on the SettingsKeys.LOCAL_WHISPER.value checkbox once all widgets are created self.toggle_remote_whisper_settings() diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 935cf652..d92cd292 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -24,7 +24,7 @@ import pyaudio import tkinter.messagebox as messagebox import datetime -import whisper # python package is named openai-whisper +from faster_whisper import WhisperModel import scrubadub import re import speech_recognition as sr # python package is named speechrecognition @@ -293,9 +293,10 @@ def realtime_text(): update_gui("Local Whisper model not loaded. Please check your settings.") break - result = stt_local_model.transcribe(audio_buffer, fp16=False) + result = faster_whisper_transcribe(audio_data) + if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): - update_gui(result['text']) + update_gui(result) else: print("Remote Real Time Whisper") if frames: @@ -606,8 +607,9 @@ def cancel_whole_audio_process(thread_id): uploaded_file_path = None # Transcribe the audio file using the loaded model - result = stt_local_model.transcribe(file_to_send) - transcribed_text = result["text"] + result = faster_whisper_transcribe(file_to_send) + + transcribed_text = result # done with file clean up if os.path.exists(file_to_send) and delete_file is True: @@ -1227,7 +1229,12 @@ def _load_stt_model_thread(): print(f"Loading STT model: {model}") try: # Load the specified Whisper model - stt_local_model = whisper.load_model(model) + device_type = "cpu" + if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == "CUDA (Nvidia GPU)": + device_type = "cuda" + + stt_local_model = WhisperModel(model, device=device_type) + print("STT model loaded successfully.") except Exception as e: # Log the error message @@ -1238,6 +1245,13 @@ def _load_stt_model_thread(): stt_loading_window.destroy() print("Closing STT loading window.") +def faster_whisper_transcribe(audio): + segments, info = stt_local_model.transcribe(audio) + result = "" + for segment in segments: + result += segment.text + " " + + return result # Configure grid weights for scalability root.grid_columnconfigure(0, weight=1, minsize= 10) From 96c28d060de18ba1aa668c238f042aa7c5e66b94 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 13:23:28 -0500 Subject: [PATCH 032/244] Added model reload on architecture change --- src/FreeScribe.client/UI/SettingsWindowUI.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 5502737a..62cea929 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -580,6 +580,7 @@ def save_settings(self, close_window=True): # save the old whisper model to compare with the new model later old_local_whisper = self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] + old_whisper_architecture = self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] old_model = self.settings.editable_settings["Whisper Model"] self.settings.save_settings( @@ -606,11 +607,14 @@ def save_settings(self, close_window=True): # loading the model after the window is closed to prevent the window from freezing # if Local Whisper is selected, compare the old model with the new model and reload the model if it has changed + print(old_whisper_architecture, self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value]) if self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and ( - old_local_whisper != self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] or old_model != - self.settings.editable_settings["Whisper Model"]): + old_local_whisper != self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] or + old_model !=self.settings.editable_settings["Whisper Model"] or + self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] != old_whisper_architecture): self.root.event_generate("<>") + def reset_to_default(self): """ Resets the settings to their default values. From 5d756d492c3e2bf8c33e4219a4bf40002a572462 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 13:25:25 -0500 Subject: [PATCH 033/244] predefine langauge in transcribe --- src/FreeScribe.client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index d92cd292..d486534a 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -293,7 +293,7 @@ def realtime_text(): update_gui("Local Whisper model not loaded. Please check your settings.") break - result = faster_whisper_transcribe(audio_data) + result = faster_whisper_transcribe(audio_buffer) if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): update_gui(result) @@ -1246,7 +1246,7 @@ def _load_stt_model_thread(): print("Closing STT loading window.") def faster_whisper_transcribe(audio): - segments, info = stt_local_model.transcribe(audio) + segments, info = stt_local_model.transcribe(audio, language="en") result = "" for segment in segments: result += segment.text + " " From 3f61631937f00557e2415f0cf7648e214847c93b Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 13:37:38 -0500 Subject: [PATCH 034/244] removed debug --- src/FreeScribe.client/UI/SettingsWindowUI.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 62cea929..f178f2d2 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -607,7 +607,6 @@ def save_settings(self, close_window=True): # loading the model after the window is closed to prevent the window from freezing # if Local Whisper is selected, compare the old model with the new model and reload the model if it has changed - print(old_whisper_architecture, self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value]) if self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and ( old_local_whisper != self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] or old_model !=self.settings.editable_settings["Whisper Model"] or From ba182cf29df0ae679ecd09c3921efcc4548bf192 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 13:41:25 -0500 Subject: [PATCH 035/244] removed unsused depencies --- client_requirements.txt | Bin 2638 -> 2554 bytes client_requirements_nvidia.txt | 2 -- 2 files changed, 2 deletions(-) diff --git a/client_requirements.txt b/client_requirements.txt index 86b1bf85e38c3f0f8ca615dba2ba61d0dca95aa0..d4cb29aad197841ee5b3f2088da6ff35f48121a7 100644 GIT binary patch delta 12 TcmX>n@=JKbHkQp?tS+noBh&b;V#k YMPT(tK($5;CP2~>NSklo$l}Qg0DN^3PXGV_ diff --git a/client_requirements_nvidia.txt b/client_requirements_nvidia.txt index 1be57a16..a139bba3 100644 --- a/client_requirements_nvidia.txt +++ b/client_requirements_nvidia.txt @@ -28,8 +28,6 @@ networkx==3.3 nltk==3.9.1 numba==0.60.0 numpy==1.26.4 -openai==1.50.2 -openai-whisper==20240927 packaging==24.1 pefile==2024.8.26 phonenumbers==8.13.46 From dc2cc5c5664dabc439e4fab3c6f98eee8d892a3f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 13:47:28 -0500 Subject: [PATCH 036/244] added faster whisper to reqs --- client_requirements.txt | Bin 2554 -> 2600 bytes client_requirements_nvidia.txt | 1 + 2 files changed, 1 insertion(+) diff --git a/client_requirements.txt b/client_requirements.txt index d4cb29aad197841ee5b3f2088da6ff35f48121a7..3f1afe33681d98132264fa6881255773af381093 100644 GIT binary patch delta 54 zcmew*yh3EdFHXHQhD3&9h7yKUh9U-ChH{1shD;#80L-^#uw^i0&;w%w23`g(1^|d? B3f=$! delta 7 OcmZ1>@=JKbFHQgsQUiSe diff --git a/client_requirements_nvidia.txt b/client_requirements_nvidia.txt index a139bba3..b33a4ba9 100644 --- a/client_requirements_nvidia.txt +++ b/client_requirements_nvidia.txt @@ -63,3 +63,4 @@ docker==7.1.0 markdown==3.7 tkhtmlview==0.3.1 llama-cpp-python==v0.2.90 +faster-whisper==1.1.0 From 7aadde59acc99fd3d3928ea7c0f563b53c8a4725 Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Fri, 13 Dec 2024 14:29:03 -0500 Subject: [PATCH 037/244] Update src/FreeScribe.client/client.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/client.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index d486534a..f828ee69 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1247,11 +1247,7 @@ def _load_stt_model_thread(): def faster_whisper_transcribe(audio): segments, info = stt_local_model.transcribe(audio, language="en") - result = "" - for segment in segments: - result += segment.text + " " - - return result + return "".join(f"{segment.text} " for segment in segments) # Configure grid weights for scalability root.grid_columnconfigure(0, weight=1, minsize= 10) From 269987479194463faad9716b7e28141daf7b274a Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 14:31:47 -0500 Subject: [PATCH 038/244] Add error handing and returned message to be printed to the window --- src/FreeScribe.client/client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f828ee69..8f9a0c14 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1246,8 +1246,14 @@ def _load_stt_model_thread(): print("Closing STT loading window.") def faster_whisper_transcribe(audio): - segments, info = stt_local_model.transcribe(audio, language="en") - return "".join(f"{segment.text} " for segment in segments) + try: + segments, info = stt_local_model.transcribe(audio, language="en") + + return "".join(f"{segment.text} " for segment in segments) + except Exception as e: + error_message = f"Transcription failed: {str(e)}" + print(f"Error during transcription: {str(e)}") # Log the error + return error_message # Configure grid weights for scalability root.grid_columnconfigure(0, weight=1, minsize= 10) From deac9057c95548db967ea4a1c5a1d4a3aad55494 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 13 Dec 2024 14:45:05 -0500 Subject: [PATCH 039/244] Changed architectures to enum to prevent hardcodes --- src/FreeScribe.client/Model.py | 3 ++- src/FreeScribe.client/UI/SettingsWindow.py | 17 +++++++++++++++-- src/FreeScribe.client/UI/SettingsWindowUI.py | 6 +++--- src/FreeScribe.client/client.py | 10 +++++----- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/FreeScribe.client/Model.py b/src/FreeScribe.client/Model.py index 252da176..79c4ec00 100644 --- a/src/FreeScribe.client/Model.py +++ b/src/FreeScribe.client/Model.py @@ -3,6 +3,7 @@ from typing import Optional, Dict, Any import threading from UI.LoadingWindow import LoadingWindow +from UI.SettingsWindow import Architectures import tkinter.messagebox as messagebox class Model: @@ -194,7 +195,7 @@ def load_model(): """ gpu_layers = 0 - if app_settings.editable_settings["Architecture"] == "CUDA (Nvidia GPU)": + if app_settings.editable_settings["Architecture"] == Architectures.CUDA.label: gpu_layers = -1 model_to_use = "gemma-2-2b-it-Q8_0.gguf" diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 23d541e9..3e14f438 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -36,6 +36,19 @@ class SettingsKeys(Enum): WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" WHISPER_ARCHITECTURE = "Speech2Text (Whisper) Architecture" +class Architectures(Enum): + CPU = ("CPU", "cpu") + CUDA = ("CUDA (Nvidia GPU)", "cuda") + + @property + def label(self): + return self.value[0] + + @property + def value(self): + return self.value[1] + + class FeatureToggle: DOCKER_SETTINGS_TAB = False @@ -555,10 +568,10 @@ def get_available_architectures(self): Returns: list: A list of available architectures for the user to choose from. """ - architectures = ["CPU"] # CPU is always available as fallback + architectures = [Architectures.CPU.value] # CPU is always available as fallback # Check for NVIDIA support if os.path.isfile(get_file_path(self.STATE_FILES_DIR, self.NVIDIA_INSTALL_FILE)): - architectures.append("CUDA (Nvidia GPU)") + architectures.append(Architectures.CUDA.label) return architectures diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index f178f2d2..3bc345e3 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -28,7 +28,7 @@ from utils.file_utils import get_file_path from UI.MarkdownWindow import MarkdownWindow from UI.Widgets.MicrophoneSelector import MicrophoneSelector -from UI.SettingsWindow import SettingsKeys, FeatureToggle +from UI.SettingsWindow import SettingsKeys, FeatureToggle, Architectures class SettingsWindowUI: @@ -208,7 +208,7 @@ def create_whisper_settings(self): self.whisper_architecture_dropdown.current(whisper_architecture_options.index(self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value])) else: # Default cpu - self.whisper_architecture_dropdown.set("CPU") + self.whisper_architecture_dropdown.set() self.whisper_architecture_dropdown.grid(row=right_row, column=1, padx=0, pady=5, sticky="w") self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value] = self.whisper_architecture_dropdown @@ -270,7 +270,7 @@ def create_llm_settings(self): self.architecture_dropdown.current(architecture_options.index(self.settings.editable_settings["Architecture"])) else: # Default cpu - self.architecture_dropdown.set("CPU") + self.architecture_dropdown.set(Architectures.CPU.label)) self.architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 8f9a0c14..d68bbc1b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -32,7 +32,7 @@ import queue import atexit from UI.MainWindowUI import MainWindowUI -from UI.SettingsWindow import SettingsWindow, SettingsKeys +from UI.SettingsWindow import SettingsWindow, SettingsKeys, Architectures from UI.Widgets.CustomTextBox import CustomTextBox from UI.LoadingWindow import LoadingWindow from UI.Widgets.MicrophoneSelector import MicrophoneState @@ -1229,9 +1229,9 @@ def _load_stt_model_thread(): print(f"Loading STT model: {model}") try: # Load the specified Whisper model - device_type = "cpu" - if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == "CUDA (Nvidia GPU)": - device_type = "cuda" + device_type = Architectures.CPU.value + if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: + device_type = Architectures.CUDA.value stt_local_model = WhisperModel(model, device=device_type) @@ -1248,7 +1248,7 @@ def _load_stt_model_thread(): def faster_whisper_transcribe(audio): try: segments, info = stt_local_model.transcribe(audio, language="en") - + return "".join(f"{segment.text} " for segment in segments) except Exception as e: error_message = f"Transcription failed: {str(e)}" From a0da8ee7d9a290db7bbdb4a56fec59e433c4c465 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 10:25:58 -0500 Subject: [PATCH 040/244] add the cuda stuff bundled to pyinstaller --- .github/workflows/release.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31458160..6b9c94c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,16 @@ jobs: - name: Run PyInstaller for NVIDIA run: | - pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\NVIDIA_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --name freescribe-client-nvidia --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py + pyinstaller \ + --additional-hooks-dir=.\scripts\hooks \ + --add-data ".\scripts\NVIDIA_INSTALL.txt:install_state" \ + --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" \ + --add-data ".\src\FreeScribe.client\markdown:markdown" \ + --add-data ".\src\FreeScribe.client\assets:assets" \ + --add-data "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\site-packages\nvidia:nvidia-drivers" \ + --name freescribe-client-nvidia \ + --icon=.\src\FreeScribe.client\assets\logo.ico \ + --noconsole .\src\FreeScribe.client\client.py # Create CPU-only executable - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp From 1308bdf9e0ffca5f7ddf10e597a636fd3a9fafba Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 10:26:14 -0500 Subject: [PATCH 041/244] add the cuda packages to nvidia requirements --- client_requirements_nvidia.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client_requirements_nvidia.txt b/client_requirements_nvidia.txt index b33a4ba9..534a6b42 100644 --- a/client_requirements_nvidia.txt +++ b/client_requirements_nvidia.txt @@ -64,3 +64,7 @@ markdown==3.7 tkhtmlview==0.3.1 llama-cpp-python==v0.2.90 faster-whisper==1.1.0 +nvidia-cudnn-cu12==9.5.0.50 +nvidia-cuda-runtime-cu12==12.4.127 +nvidia-cuda-nvrtc-cu12==12.4.127 +nvidia-cublas-cu12==12.4.5.8 \ No newline at end of file From 0d8129303e7e40754fe80800382e54d1c1123fbf Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 10:27:02 -0500 Subject: [PATCH 042/244] add the nvidia driver stuff to env path for use by application if cuda is selected --- src/FreeScribe.client/client.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index d68bbc1b..cf946a41 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1233,6 +1233,9 @@ def _load_stt_model_thread(): if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: device_type = Architectures.CUDA.value + if device_type == Architectures.CUDA.value: + set_cuda_paths() + stt_local_model = WhisperModel(model, device=device_type) print("STT model loaded successfully.") @@ -1255,6 +1258,24 @@ def faster_whisper_transcribe(audio): print(f"Error during transcription: {str(e)}") # Log the error return error_message +def set_cuda_paths(): + nvidia_base_path = get_file_path('nvidia-drivers') # Ensure this returns a Path object + + # Use `Path` operators + cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' + cublas_path = nvidia_base_path / 'cublas' / 'bin' + cudnn_path = nvidia_base_path / 'cudnn' / 'bin' + + # Convert Path objects to strings + paths_to_add = [str(cuda_path), str(cublas_path), str(cudnn_path)] + env_vars = ['CUDA_PATH', 'CUDA_PATH_V12_4', 'PATH'] + + for env_var in env_vars: + current_value = os.environ.get(env_var, '') + new_value = os.pathsep.join(paths_to_add + ([current_value] if current_value else [])) + print(new_value) + os.environ[env_var] = new_value + # Configure grid weights for scalability root.grid_columnconfigure(0, weight=1, minsize= 10) root.grid_columnconfigure(1, weight=1) From c6193e5f2416e79bd63de3be896c8b29185a6c70 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 10:34:42 -0500 Subject: [PATCH 043/244] Updated llama-cpp to cu124 to match bundled cuda version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b9c94c9..67472084 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: # Create CUDA-enabled executable - name: Install CUDA-enabled llama_cpp run: | - pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cu121 --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 + pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 - name: Install requirements run: | From d8b97ba1bc0cac701dcaecd38b1b3c091da29a31 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 10:36:46 -0500 Subject: [PATCH 044/244] Updated release to fix pyinstaller error --- .github/workflows/release.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67472084..7d7e4ee5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,16 +32,7 @@ jobs: - name: Run PyInstaller for NVIDIA run: | - pyinstaller \ - --additional-hooks-dir=.\scripts\hooks \ - --add-data ".\scripts\NVIDIA_INSTALL.txt:install_state" \ - --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" \ - --add-data ".\src\FreeScribe.client\markdown:markdown" \ - --add-data ".\src\FreeScribe.client\assets:assets" \ - --add-data "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\site-packages\nvidia:nvidia-drivers" \ - --name freescribe-client-nvidia \ - --icon=.\src\FreeScribe.client\assets\logo.ico \ - --noconsole .\src\FreeScribe.client\client.py + pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\NVIDIA_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --add-data "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\site-packages\nvidia:nvidia-drivers" --name freescribe-client-nvidia --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py # Create CPU-only executable - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp From 2730d57c2ed7acf0da7079c88c61e59e4bd54c40 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 10:46:17 -0500 Subject: [PATCH 045/244] added the cuda driver install to the github action --- .github/workflows/release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d7e4ee5..25ceaa2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,13 @@ jobs: run: | pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 + - name: Instal CUDA drivers for NVIDIA install + run: | + pip install nvidia-cudnn-cu12==9.5.0.50 + pip install nvidia-cuda-runtime-cu12==12.4.127 + pip install nvidia-cuda-nvrtc-cu12==12.4.127 + pip install nvidia-cublas-cu12==12.4.5.8 + - name: Install requirements run: | pip install -r client_requirements.txt @@ -37,6 +44,10 @@ jobs: # Create CPU-only executable - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp run: | + pip uninstall nvidia-cudnn-cu12==9.5.0.50 + pip uninstall nvidia-cuda-runtime-cu12==12.4.127 + pip uninstall nvidia-cuda-nvrtc-cu12==12.4.127 + pip uninstall nvidia-cublas-cu12==12.4.5.8 pip uninstall -y llama-cpp-python pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 From 958e1dc4e00f8c8cdf805118decb594156d34d6c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 11:49:05 -0500 Subject: [PATCH 046/244] removed circular import --- src/FreeScribe.client/Model.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/FreeScribe.client/Model.py b/src/FreeScribe.client/Model.py index 79c4ec00..252da176 100644 --- a/src/FreeScribe.client/Model.py +++ b/src/FreeScribe.client/Model.py @@ -3,7 +3,6 @@ from typing import Optional, Dict, Any import threading from UI.LoadingWindow import LoadingWindow -from UI.SettingsWindow import Architectures import tkinter.messagebox as messagebox class Model: @@ -195,7 +194,7 @@ def load_model(): """ gpu_layers = 0 - if app_settings.editable_settings["Architecture"] == Architectures.CUDA.label: + if app_settings.editable_settings["Architecture"] == "CUDA (Nvidia GPU)": gpu_layers = -1 model_to_use = "gemma-2-2b-it-Q8_0.gguf" From e9dbab94d18f619711b2e902bc3fd015234090b0 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 11:51:12 -0500 Subject: [PATCH 047/244] debug remove cpu step --- .github/workflows/release.yml | 24 ++++++++++++------------ scripts/install.nsi | 12 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25ceaa2f..ad3170dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,19 +41,19 @@ jobs: run: | pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\NVIDIA_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --add-data "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\site-packages\nvidia:nvidia-drivers" --name freescribe-client-nvidia --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py - # Create CPU-only executable - - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp - run: | - pip uninstall nvidia-cudnn-cu12==9.5.0.50 - pip uninstall nvidia-cuda-runtime-cu12==12.4.127 - pip uninstall nvidia-cuda-nvrtc-cu12==12.4.127 - pip uninstall nvidia-cublas-cu12==12.4.5.8 - pip uninstall -y llama-cpp-python - pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 + # # Create CPU-only executable + # - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp + # run: | + # pip uninstall nvidia-cudnn-cu12==9.5.0.50 + # pip uninstall nvidia-cuda-runtime-cu12==12.4.127 + # pip uninstall nvidia-cuda-nvrtc-cu12==12.4.127 + # pip uninstall nvidia-cublas-cu12==12.4.5.8 + # pip uninstall -y llama-cpp-python + # pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 - - name: Run PyInstaller for CPU-only - run: | - pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\CPU_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --name freescribe-client-cpu --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py + # - name: Run PyInstaller for CPU-only + # run: | + # pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\CPU_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --name freescribe-client-cpu --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py - name: Set up NSIS uses: joncloud/makensis-action@1c9f4bf2ea0c771147db31a2f3a7f5d8705c0105 diff --git a/scripts/install.nsi b/scripts/install.nsi index 927e31a3..4e46c568 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -206,12 +206,12 @@ Section "MainSection" SEC01 ; Set output path to the installation directory SetOutPath "$INSTDIR" - ${If} $SELECTED_OPTION == "CPU" - ; Add files to the installer - File /r "..\dist\freescribe-client-cpu\freescribe-client-cpu.exe" - Rename "$INSTDIR\freescribe-client-cpu.exe" "$INSTDIR\freescribe-client.exe" - File /r "..\dist\freescribe-client-cpu\_internal" - ${EndIf} + ; ${If} $SELECTED_OPTION == "CPU" + ; ; Add files to the installer + ; File /r "..\dist\freescribe-client-cpu\freescribe-client-cpu.exe" + ; Rename "$INSTDIR\freescribe-client-cpu.exe" "$INSTDIR\freescribe-client.exe" + ; File /r "..\dist\freescribe-client-cpu\_internal" + ; ${EndIf} ${If} $SELECTED_OPTION == "NVIDIA" ; Add files to the installer From 890aefb306e1a8c583817730bd1556efaf12507e Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 11:56:10 -0500 Subject: [PATCH 048/244] syntax error --- src/FreeScribe.client/UI/SettingsWindowUI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 3bc345e3..1b0144dc 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -270,7 +270,7 @@ def create_llm_settings(self): self.architecture_dropdown.current(architecture_options.index(self.settings.editable_settings["Architecture"])) else: # Default cpu - self.architecture_dropdown.set(Architectures.CPU.label)) + self.architecture_dropdown.set(Architectures.CPU.label) self.architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") From fcafb6c0ac9c95a689f0ad530f92de1f57834315 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:08:37 -0500 Subject: [PATCH 049/244] added error handling to the transcribe if model is not loaded --- src/FreeScribe.client/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index cf946a41..f3608c95 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1250,6 +1250,10 @@ def _load_stt_model_thread(): def faster_whisper_transcribe(audio): try: + if stt_local_model is None: + load_stt_model() + return "Speach to text model not loaded. Please try again once loaded." + segments, info = stt_local_model.transcribe(audio, language="en") return "".join(f"{segment.text} " for segment in segments) From 0584b79a64fc77a907926ef55ef6a345861b9204 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:09:08 -0500 Subject: [PATCH 050/244] Prevented enum recursion --- src/FreeScribe.client/UI/SettingsWindow.py | 9 +++++---- src/FreeScribe.client/client.py | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 3e14f438..a775373a 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -36,17 +36,18 @@ class SettingsKeys(Enum): WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" WHISPER_ARCHITECTURE = "Speech2Text (Whisper) Architecture" + class Architectures(Enum): CPU = ("CPU", "cpu") CUDA = ("CUDA (Nvidia GPU)", "cuda") @property def label(self): - return self.value[0] + return self._value_[0] @property - def value(self): - return self.value[1] + def architecture_value(self): + return self._value_[1] @@ -568,7 +569,7 @@ def get_available_architectures(self): Returns: list: A list of available architectures for the user to choose from. """ - architectures = [Architectures.CPU.value] # CPU is always available as fallback + architectures = [Architectures.CPU.label] # CPU is always available as fallback # Check for NVIDIA support if os.path.isfile(get_file_path(self.STATE_FILES_DIR, self.NVIDIA_INSTALL_FILE)): diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f3608c95..88d534f7 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1229,9 +1229,9 @@ def _load_stt_model_thread(): print(f"Loading STT model: {model}") try: # Load the specified Whisper model - device_type = Architectures.CPU.value + device_type = Architectures.CPU.architecture_value if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: - device_type = Architectures.CUDA.value + device_type = Architectures.CUDA.architecture_value if device_type == Architectures.CUDA.value: set_cuda_paths() @@ -1241,9 +1241,9 @@ def _load_stt_model_thread(): print("STT model loaded successfully.") except Exception as e: # Log the error message - print(f"An error occurred while loading STT: {e}") + print(f"An error occurred while loading STT {type(e).__name__}: {e}") stt_local_model = None - messagebox.showerror("Error", f"An error occurred while loading the STT model: {e}") + messagebox.showerror("Error", f"An error occurred while loading STT {type(e).__name__}: {e}") finally: stt_loading_window.destroy() print("Closing STT loading window.") From d2514268c2116438c71e7f2ee8f0f65447323dcb Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:15:41 -0500 Subject: [PATCH 051/244] Updated default AI endpoint to point to the container --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index a775373a..19fb5be8 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -162,7 +162,7 @@ def __init__(self): self.editable_settings = { "Model": "gpt-4", - "Model Endpoint": "https://api.openai.com/v1/", + "Model Endpoint": "https://localhost:3334/v1", "Use Local LLM": True, "Architecture": "CPU", "use_story": False, From eb63e64726929c71cd5073d85f47783fc004a62c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:18:17 -0500 Subject: [PATCH 052/244] Updated default remote model to the local llm container default --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 19fb5be8..c4da1dc3 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -161,7 +161,7 @@ def __init__(self): ] self.editable_settings = { - "Model": "gpt-4", + "Model": "gemma2:2b-instruct-q8_0", "Model Endpoint": "https://localhost:3334/v1", "Use Local LLM": True, "Architecture": "CPU", From f1dde4267700b6f6452469b91bb3b2fca74a20a2 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:20:16 -0500 Subject: [PATCH 053/244] Added the fatser_whisper params to adv settings --- src/FreeScribe.client/UI/SettingsWindow.py | 13 +++++++++++++ src/FreeScribe.client/client.py | 12 ++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index c4da1dc3..e97fa5fc 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -29,12 +29,17 @@ from UI.Widgets.MicrophoneSelector import MicrophoneState from utils.ip_utils import is_valid_url from enum import Enum +import multiprocessing class SettingsKeys(Enum): LOCAL_WHISPER = "Built-in Speech2Text" WHISPER_ENDPOINT = "Speech2Text (Whisper) Endpoint" WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" WHISPER_ARCHITECTURE = "Speech2Text (Whisper) Architecture" + WHISPER_CPU_COUNT = "Speech2Text (Whisper) CPU Thread Count" + WHSPER_COMPUTE_TYPE = "Speech2Text (Whisper) Compute Type" + WHISPER_BEAM_SIZE = "Speech2Text (Whisper) Beam Size" + WHISPER_VAD_FILTER = "Use Speech2Text (Whisper) VAD Filter" class Architectures(Enum): @@ -153,6 +158,10 @@ def __init__(self): self.adv_whisper_settings = [ "Real Time Audio Length", + SettingsKeys.WHISPER_BEAM_SIZE.value, + SettingsKeys.WHISPER_CPU_COUNT.value, + SettingsKeys.WHISPER_VAD_FILTER.value, + SettingsKeys.WHSPER_COMPUTE_TYPE.value, ] @@ -190,6 +199,10 @@ def __init__(self): SettingsKeys.WHISPER_ENDPOINT.value: "https://localhost:2224/whisperaudio", SettingsKeys.WHISPER_SERVER_API_KEY.value: "", SettingsKeys.WHISPER_ARCHITECTURE.value: "CPU", + SettingsKeys.WHISPER_BEAM_SIZE.value: 5, + SettingsKeys.WHISPER_CPU_COUNT.value: multiprocessing.cpu_count(), + SettingsKeys.WHISPER_VAD_FILTER.value: False, + SettingsKeys.WHSPER_COMPUTE_TYPE.value: "int8", "Whisper Model": "small.en", "Current Mic": "None", "Real Time": True, diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 88d534f7..e4c57bfd 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1236,7 +1236,11 @@ def _load_stt_model_thread(): if device_type == Architectures.CUDA.value: set_cuda_paths() - stt_local_model = WhisperModel(model, device=device_type) + stt_local_model = WhisperModel( + model, + device=device_type, + cpu_threads=app_settings.editable_settings["Whisper CPU Threads"], + compute_type=app_settings.editable_settings["Whisper Compute Type"],) print("STT model loaded successfully.") except Exception as e: @@ -1254,7 +1258,11 @@ def faster_whisper_transcribe(audio): load_stt_model() return "Speach to text model not loaded. Please try again once loaded." - segments, info = stt_local_model.transcribe(audio, language="en") + segments, info = stt_local_model.transcribe( + audio, + beam_size=app_settings.editable_settings["Whisper Beam Size"], + vad_filter=app_settings.editable_settings["Whisper VAD Filter"], + ) return "".join(f"{segment.text} " for segment in segments) except Exception as e: From c824c6135be26f50ed2f5e8aee62458b8016c48f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:22:29 -0500 Subject: [PATCH 054/244] changed to valid settings keys --- src/FreeScribe.client/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index e4c57bfd..732a0944 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1239,8 +1239,8 @@ def _load_stt_model_thread(): stt_local_model = WhisperModel( model, device=device_type, - cpu_threads=app_settings.editable_settings["Whisper CPU Threads"], - compute_type=app_settings.editable_settings["Whisper Compute Type"],) + cpu_threads=app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value], + compute_type=app_settings.editable_settings[SettingsKeys.WHSPER_COMPUTE_TYPE.value],) print("STT model loaded successfully.") except Exception as e: @@ -1260,8 +1260,8 @@ def faster_whisper_transcribe(audio): segments, info = stt_local_model.transcribe( audio, - beam_size=app_settings.editable_settings["Whisper Beam Size"], - vad_filter=app_settings.editable_settings["Whisper VAD Filter"], + beam_size=app_settings.editable_settings[SettingsKeys.WHISPER_BEAM_SIZE.value], + vad_filter=app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value], ) return "".join(f"{segment.text} " for segment in segments) From f96a349f64c391d2e982ee977c8d9e74abdcdb33 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:32:24 -0500 Subject: [PATCH 055/244] Moved whisper reload to seperate functio nand addeed cases if thread count or compute type changes --- src/FreeScribe.client/UI/SettingsWindow.py | 18 ++++++++++++++++++ src/FreeScribe.client/UI/SettingsWindowUI.py | 14 +------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index e97fa5fc..d5126209 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -589,3 +589,21 @@ def get_available_architectures(self): architectures.append(Architectures.CUDA.label) return architectures + + def update_whisper_model(self): + # save the old whisper model to compare with the new model later + old_local_whisper = self.editable_settings[SettingsKeys.LOCAL_WHISPER.value] + old_whisper_architecture = self.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] + old_model = self.editable_settings["Whisper Model"] + old_cpu_count = self.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value] + old_compute_type = self.editable_settings[SettingsKeys.WHSPER_COMPUTE_TYPE.value] + + # loading the model after the window is closed to prevent the window from freezing + # if Local Whisper is selected, compare the old model with the new model and reload the model if it has changed + if self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and ( + old_local_whisper != self.editable_settings_entries[SettingsKeys.LOCAL_WHISPER.value].get() or + old_model != self.editable_settings_entries["Whisper Model"].get() or + old_whisper_architecture != self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value].get() or + old_cpu_count != self.editable_settings_entries[SettingsKeys.WHISPER_CPU_COUNT.value].get() or + old_compute_type != self.editable_settings_entries[SettingsKeys.WHSPER_COMPUTE_TYPE.value].get()): + self.root.event_generate("<>") \ No newline at end of file diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 1b0144dc..b601560b 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -571,6 +571,7 @@ def save_settings(self, close_window=True): if self.get_selected_model() not in ["Loading models...", "Failed to load models"]: self.settings.editable_settings["Model"] = self.get_selected_model() + self.settings.update_whisper_model() self.settings.editable_settings["Pre-Processing"] = self.preprocess_text.get("1.0", "end-1c") # end-1c removes the trailing newline self.settings.editable_settings["Post-Processing"] = self.postprocess_text.get("1.0", "end-1c") # end-1c removes the trailing newline @@ -578,11 +579,6 @@ def save_settings(self, close_window=True): # save architecture self.settings.editable_settings["Architecture"] = self.architecture_dropdown.get() - # save the old whisper model to compare with the new model later - old_local_whisper = self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] - old_whisper_architecture = self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] - old_model = self.settings.editable_settings["Whisper Model"] - self.settings.save_settings( self.openai_api_key_entry.get(), self.aiscribe_text.get("1.0", "end-1c"), # end-1c removes the trailing newline @@ -605,14 +601,6 @@ def save_settings(self, close_window=True): if close_window: self.close_window() - # loading the model after the window is closed to prevent the window from freezing - # if Local Whisper is selected, compare the old model with the new model and reload the model if it has changed - if self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and ( - old_local_whisper != self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] or - old_model !=self.settings.editable_settings["Whisper Model"] or - self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] != old_whisper_architecture): - self.root.event_generate("<>") - def reset_to_default(self): """ From 22a00751dc8b68a2027a97380b5591df6252e5f1 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:37:39 -0500 Subject: [PATCH 056/244] added unload to STT model if one already exists on attempted load --- src/FreeScribe.client/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 732a0944..e22e6eb4 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1228,6 +1228,9 @@ def _load_stt_model_thread(): stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") print(f"Loading STT model: {model}") try: + if stt_local_model is not None: + unload_stt_model() + # Load the specified Whisper model device_type = Architectures.CPU.architecture_value if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: @@ -1252,6 +1255,13 @@ def _load_stt_model_thread(): stt_loading_window.destroy() print("Closing STT loading window.") +def unload_stt_model(): + global stt_local_model + del stt_local_model + gc.collect() + stt_local_model = None + + def faster_whisper_transcribe(audio): try: if stt_local_model is None: From a6cc52a6f1585f5238e7e9582a3a1f9a1c8c0291 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:37:56 -0500 Subject: [PATCH 057/244] Forced types on adanveced settings stuff --- src/FreeScribe.client/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index e22e6eb4..8f6dceb4 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -45,6 +45,8 @@ import traceback import sys from utils.utils import window_has_running_instance, bring_to_front, close_mutex +import gc + dual = DualOutput() sys.stdout = dual @@ -1242,7 +1244,7 @@ def _load_stt_model_thread(): stt_local_model = WhisperModel( model, device=device_type, - cpu_threads=app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value], + cpu_threads=int(app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value]), compute_type=app_settings.editable_settings[SettingsKeys.WHSPER_COMPUTE_TYPE.value],) print("STT model loaded successfully.") @@ -1270,8 +1272,8 @@ def faster_whisper_transcribe(audio): segments, info = stt_local_model.transcribe( audio, - beam_size=app_settings.editable_settings[SettingsKeys.WHISPER_BEAM_SIZE.value], - vad_filter=app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value], + beam_size=int(app_settings.editable_settings[SettingsKeys.WHISPER_BEAM_SIZE.value]), + vad_filter=bool(app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value]), ) return "".join(f"{segment.text} " for segment in segments) From daa76ff4b6b35747dfbb3943f88aa5eabc1cd982 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:38:16 -0500 Subject: [PATCH 058/244] Fixed some class access errors --- src/FreeScribe.client/UI/SettingsWindow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index d5126209..7504edd9 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -600,10 +600,10 @@ def update_whisper_model(self): # loading the model after the window is closed to prevent the window from freezing # if Local Whisper is selected, compare the old model with the new model and reload the model if it has changed - if self.settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and ( + if self.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and ( old_local_whisper != self.editable_settings_entries[SettingsKeys.LOCAL_WHISPER.value].get() or old_model != self.editable_settings_entries["Whisper Model"].get() or - old_whisper_architecture != self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value].get() or + old_whisper_architecture != self.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value].get() or old_cpu_count != self.editable_settings_entries[SettingsKeys.WHISPER_CPU_COUNT.value].get() or old_compute_type != self.editable_settings_entries[SettingsKeys.WHSPER_COMPUTE_TYPE.value].get()): - self.root.event_generate("<>") \ No newline at end of file + self.main_window.root.event_generate("<>") \ No newline at end of file From 2216553b63481e3b13430fbdeed71d93c1f8c631 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:41:21 -0500 Subject: [PATCH 059/244] Added debug prints to the unload_model --- src/FreeScribe.client/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 8f6dceb4..1a4cc665 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1259,9 +1259,11 @@ def _load_stt_model_thread(): def unload_stt_model(): global stt_local_model + print("Unloading STT model from device.") del stt_local_model gc.collect() stt_local_model = None + print("STT model unloaded successfully.") def faster_whisper_transcribe(audio): From 698e0832ab4accb3c7d13b578f7b50a43c7321fe Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:49:01 -0500 Subject: [PATCH 060/244] added back cpu steps done debugging --- .github/workflows/release.yml | 24 ++++++++++++------------ scripts/install.nsi | 12 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad3170dc..25ceaa2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,19 +41,19 @@ jobs: run: | pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\NVIDIA_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --add-data "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\site-packages\nvidia:nvidia-drivers" --name freescribe-client-nvidia --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py - # # Create CPU-only executable - # - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp - # run: | - # pip uninstall nvidia-cudnn-cu12==9.5.0.50 - # pip uninstall nvidia-cuda-runtime-cu12==12.4.127 - # pip uninstall nvidia-cuda-nvrtc-cu12==12.4.127 - # pip uninstall nvidia-cublas-cu12==12.4.5.8 - # pip uninstall -y llama-cpp-python - # pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 + # Create CPU-only executable + - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp + run: | + pip uninstall nvidia-cudnn-cu12==9.5.0.50 + pip uninstall nvidia-cuda-runtime-cu12==12.4.127 + pip uninstall nvidia-cuda-nvrtc-cu12==12.4.127 + pip uninstall nvidia-cublas-cu12==12.4.5.8 + pip uninstall -y llama-cpp-python + pip install --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu --extra-index-url https://pypi.org/simple llama-cpp-python==v0.2.90 - # - name: Run PyInstaller for CPU-only - # run: | - # pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\CPU_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --name freescribe-client-cpu --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py + - name: Run PyInstaller for CPU-only + run: | + pyinstaller --additional-hooks-dir=.\scripts\hooks --add-data ".\scripts\CPU_INSTALL.txt:install_state" --add-data ".\src\FreeScribe.client\whisper-assets:whisper\assets" --add-data ".\src\FreeScribe.client\markdown:markdown" --add-data ".\src\FreeScribe.client\assets:assets" --name freescribe-client-cpu --icon=.\src\FreeScribe.client\assets\logo.ico --noconsole .\src\FreeScribe.client\client.py - name: Set up NSIS uses: joncloud/makensis-action@1c9f4bf2ea0c771147db31a2f3a7f5d8705c0105 diff --git a/scripts/install.nsi b/scripts/install.nsi index 4e46c568..927e31a3 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -206,12 +206,12 @@ Section "MainSection" SEC01 ; Set output path to the installation directory SetOutPath "$INSTDIR" - ; ${If} $SELECTED_OPTION == "CPU" - ; ; Add files to the installer - ; File /r "..\dist\freescribe-client-cpu\freescribe-client-cpu.exe" - ; Rename "$INSTDIR\freescribe-client-cpu.exe" "$INSTDIR\freescribe-client.exe" - ; File /r "..\dist\freescribe-client-cpu\_internal" - ; ${EndIf} + ${If} $SELECTED_OPTION == "CPU" + ; Add files to the installer + File /r "..\dist\freescribe-client-cpu\freescribe-client-cpu.exe" + Rename "$INSTDIR\freescribe-client-cpu.exe" "$INSTDIR\freescribe-client.exe" + File /r "..\dist\freescribe-client-cpu\_internal" + ${EndIf} ${If} $SELECTED_OPTION == "NVIDIA" ; Add files to the installer From 3e6183501ea7c0cbb6f2d1827f346cf9d311a29a Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Mon, 16 Dec 2024 12:55:14 -0500 Subject: [PATCH 061/244] Update src/FreeScribe.client/client.py Run a check when unloading model Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/client.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 1a4cc665..e38bed13 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1259,11 +1259,14 @@ def _load_stt_model_thread(): def unload_stt_model(): global stt_local_model - print("Unloading STT model from device.") - del stt_local_model - gc.collect() - stt_local_model = None - print("STT model unloaded successfully.") + if stt_local_model is not None: + print("Unloading STT model from device.") + del stt_local_model + gc.collect() + stt_local_model = None + print("STT model unloaded successfully.") + else: + print("STT model is already unloaded.") def faster_whisper_transcribe(audio): From f68ade4063079065c50be17ed4ead8568a1bfa15 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:57:54 -0500 Subject: [PATCH 062/244] updated the default to float16 for better support to nvidia and automatically converts for cpu --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 7504edd9..4d4c295f 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -202,7 +202,7 @@ def __init__(self): SettingsKeys.WHISPER_BEAM_SIZE.value: 5, SettingsKeys.WHISPER_CPU_COUNT.value: multiprocessing.cpu_count(), SettingsKeys.WHISPER_VAD_FILTER.value: False, - SettingsKeys.WHSPER_COMPUTE_TYPE.value: "int8", + SettingsKeys.WHSPER_COMPUTE_TYPE.value: "float16", "Whisper Model": "small.en", "Current Mic": "None", "Real Time": True, From 22aa5cb62e732faceb47f46d0fd0d8198f6285e3 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 12:58:47 -0500 Subject: [PATCH 063/244] fixed typo --- src/FreeScribe.client/UI/SettingsWindow.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 4d4c295f..b1837d53 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -37,7 +37,7 @@ class SettingsKeys(Enum): WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" WHISPER_ARCHITECTURE = "Speech2Text (Whisper) Architecture" WHISPER_CPU_COUNT = "Speech2Text (Whisper) CPU Thread Count" - WHSPER_COMPUTE_TYPE = "Speech2Text (Whisper) Compute Type" + WHISPER_COMPUTE_TYPE = "Speech2Text (Whisper) Compute Type" WHISPER_BEAM_SIZE = "Speech2Text (Whisper) Beam Size" WHISPER_VAD_FILTER = "Use Speech2Text (Whisper) VAD Filter" @@ -161,7 +161,7 @@ def __init__(self): SettingsKeys.WHISPER_BEAM_SIZE.value, SettingsKeys.WHISPER_CPU_COUNT.value, SettingsKeys.WHISPER_VAD_FILTER.value, - SettingsKeys.WHSPER_COMPUTE_TYPE.value, + SettingsKeys.WHISPER_COMPUTE_TYPE.value, ] @@ -202,7 +202,7 @@ def __init__(self): SettingsKeys.WHISPER_BEAM_SIZE.value: 5, SettingsKeys.WHISPER_CPU_COUNT.value: multiprocessing.cpu_count(), SettingsKeys.WHISPER_VAD_FILTER.value: False, - SettingsKeys.WHSPER_COMPUTE_TYPE.value: "float16", + SettingsKeys.WHISPER_COMPUTE_TYPE.value: "float16", "Whisper Model": "small.en", "Current Mic": "None", "Real Time": True, @@ -596,7 +596,7 @@ def update_whisper_model(self): old_whisper_architecture = self.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] old_model = self.editable_settings["Whisper Model"] old_cpu_count = self.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value] - old_compute_type = self.editable_settings[SettingsKeys.WHSPER_COMPUTE_TYPE.value] + old_compute_type = self.editable_settings[SettingsKeys.WHISPER_COMPUTE_TYPE.value] # loading the model after the window is closed to prevent the window from freezing # if Local Whisper is selected, compare the old model with the new model and reload the model if it has changed @@ -605,5 +605,5 @@ def update_whisper_model(self): old_model != self.editable_settings_entries["Whisper Model"].get() or old_whisper_architecture != self.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value].get() or old_cpu_count != self.editable_settings_entries[SettingsKeys.WHISPER_CPU_COUNT.value].get() or - old_compute_type != self.editable_settings_entries[SettingsKeys.WHSPER_COMPUTE_TYPE.value].get()): + old_compute_type != self.editable_settings_entries[SettingsKeys.WHISPER_COMPUTE_TYPE.value].get()): self.main_window.root.event_generate("<>") \ No newline at end of file From 37df234eab2c3931b87c23795826e72fafe2cd83 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:03:26 -0500 Subject: [PATCH 064/244] Broke down the load stt_load function into smaller functions, moved cuda_path checks to its function --- src/FreeScribe.client/client.py | 55 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 1a4cc665..8fa8fd23 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1230,22 +1230,15 @@ def _load_stt_model_thread(): stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") print(f"Loading STT model: {model}") try: - if stt_local_model is not None: - unload_stt_model() - - # Load the specified Whisper model - device_type = Architectures.CPU.architecture_value - if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: - device_type = Architectures.CUDA.architecture_value - - if device_type == Architectures.CUDA.value: - set_cuda_paths() + unload_stt_model() + device_type = get_selected_whisper_architecture() + set_cuda_paths() stt_local_model = WhisperModel( model, device=device_type, cpu_threads=int(app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value]), - compute_type=app_settings.editable_settings[SettingsKeys.WHSPER_COMPUTE_TYPE.value],) + compute_type=app_settings.editable_settings[SettingsKeys.WHISPER_COMPUTE_TYPE.value],) print("STT model loaded successfully.") except Exception as e: @@ -1265,6 +1258,13 @@ def unload_stt_model(): stt_local_model = None print("STT model unloaded successfully.") +def get_selected_whisper_architecture(): + # Load the specified Whisper model + device_type = Architectures.CPU.architecture_value + if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: + device_type = Architectures.CUDA.architecture_value + + return device_type def faster_whisper_transcribe(audio): try: @@ -1285,22 +1285,23 @@ def faster_whisper_transcribe(audio): return error_message def set_cuda_paths(): - nvidia_base_path = get_file_path('nvidia-drivers') # Ensure this returns a Path object - - # Use `Path` operators - cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' - cublas_path = nvidia_base_path / 'cublas' / 'bin' - cudnn_path = nvidia_base_path / 'cudnn' / 'bin' - - # Convert Path objects to strings - paths_to_add = [str(cuda_path), str(cublas_path), str(cudnn_path)] - env_vars = ['CUDA_PATH', 'CUDA_PATH_V12_4', 'PATH'] - - for env_var in env_vars: - current_value = os.environ.get(env_var, '') - new_value = os.pathsep.join(paths_to_add + ([current_value] if current_value else [])) - print(new_value) - os.environ[env_var] = new_value + if (get_selected_whisper_architecture() == Architectures.CUDA.architecture_value) or (app_settings.editable_settings["Architecture"] == Architectures.CUDA.label): + nvidia_base_path = get_file_path('nvidia-drivers') # Ensure this returns a Path object + + # Use `Path` operators + cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' + cublas_path = nvidia_base_path / 'cublas' / 'bin' + cudnn_path = nvidia_base_path / 'cudnn' / 'bin' + + # Convert Path objects to strings + paths_to_add = [str(cuda_path), str(cublas_path), str(cudnn_path)] + env_vars = ['CUDA_PATH', 'CUDA_PATH_V12_4', 'PATH'] + + for env_var in env_vars: + current_value = os.environ.get(env_var, '') + new_value = os.pathsep.join(paths_to_add + ([current_value] if current_value else [])) + print(new_value) + os.environ[env_var] = new_value # Configure grid weights for scalability root.grid_columnconfigure(0, weight=1, minsize= 10) From 5c2049eb5bfb374a02834c88fe8022a3353ea8a3 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:07:52 -0500 Subject: [PATCH 065/244] removed debug print --- src/FreeScribe.client/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 78ab29b6..c848197a 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1303,7 +1303,6 @@ def set_cuda_paths(): for env_var in env_vars: current_value = os.environ.get(env_var, '') new_value = os.pathsep.join(paths_to_add + ([current_value] if current_value else [])) - print(new_value) os.environ[env_var] = new_value # Configure grid weights for scalability From e95418e770b61d69acbcc68d2cf477169a3de20f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:12:18 -0500 Subject: [PATCH 066/244] Fixed cuda pathing error --- src/FreeScribe.client/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index c848197a..f339a732 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -46,6 +46,8 @@ import sys from utils.utils import window_has_running_instance, bring_to_front, close_mutex import gc +from pathlib import Path + dual = DualOutput() @@ -1289,7 +1291,7 @@ def faster_whisper_transcribe(audio): def set_cuda_paths(): if (get_selected_whisper_architecture() == Architectures.CUDA.architecture_value) or (app_settings.editable_settings["Architecture"] == Architectures.CUDA.label): - nvidia_base_path = get_file_path('nvidia-drivers') # Ensure this returns a Path object + nvidia_base_path = Path(get_file_path('nvidia-drivers')) # Ensure this returns a Path object # Use `Path` operators cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' From 9312e3926b1367c7948d1935fb6d435dc5d16340 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:19:53 -0500 Subject: [PATCH 067/244] Documentation --- src/FreeScribe.client/client.py | 78 ++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f339a732..3061edf0 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1208,27 +1208,64 @@ def on_leave(e): # root.attributes('-toolwindow', True) def copy_text(widget): + """ + Copy text content from a tkinter widget to the system clipboard. + + Args: + widget: A tkinter Text widget containing the text to be copied. + """ text = widget.get("1.0", tk.END) pyperclip.copy(text) def add_placeholder(event, text_widget, placeholder_text="Text box"): + """ + Add placeholder text to a tkinter Text widget when it's empty. + + Args: + event: The event that triggered this function. + text_widget: The tkinter Text widget to add placeholder text to. + placeholder_text (str, optional): The placeholder text to display. Defaults to "Text box". + """ if text_widget.get("1.0", "end-1c") == "": text_widget.insert("1.0", placeholder_text) text_widget.config(fg='grey') def remove_placeholder(event, text_widget, placeholder_text="Text box"): + """ + Remove placeholder text from a tkinter Text widget when it gains focus. + + Args: + event: The event that triggered this function. + text_widget: The tkinter Text widget to remove placeholder text from. + placeholder_text (str, optional): The placeholder text to remove. Defaults to "Text box". + """ if text_widget.get("1.0", "end-1c") == placeholder_text: text_widget.delete("1.0", "end") text_widget.config(fg='black') def load_stt_model(event=None): + """ + Initialize speech-to-text model loading in a separate thread. + + Args: + event: Optional event parameter for binding to tkinter events. + """ thread = threading.Thread(target=_load_stt_model_thread, daemon=True) thread.start() def _load_stt_model_thread(): + """ + Internal function to load the Whisper speech-to-text model. + + Creates a loading window and handles the initialization of the WhisperModel + with configured settings. Updates the global stt_local_model variable. + + Raises: + Exception: Any error that occurs during model loading is caught, logged, + and displayed to the user via a message box. + """ global stt_local_model model = app_settings.editable_settings["Whisper Model"].strip() - # Create a loading window to display the loading message stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") print(f"Loading STT model: {model}") try: @@ -1244,7 +1281,6 @@ def _load_stt_model_thread(): print("STT model loaded successfully.") except Exception as e: - # Log the error message print(f"An error occurred while loading STT {type(e).__name__}: {e}") stt_local_model = None messagebox.showerror("Error", f"An error occurred while loading STT {type(e).__name__}: {e}") @@ -1253,6 +1289,12 @@ def _load_stt_model_thread(): print("Closing STT loading window.") def unload_stt_model(): + """ + Unload the speech-to-text model from memory. + + Cleans up the global stt_local_model instance and performs garbage collection + to free up system resources. + """ global stt_local_model if stt_local_model is not None: print("Unloading STT model from device.") @@ -1264,7 +1306,12 @@ def unload_stt_model(): print("STT model is already unloaded.") def get_selected_whisper_architecture(): - # Load the specified Whisper model + """ + Determine the appropriate device architecture for the Whisper model. + + Returns: + str: The architecture value (CPU or CUDA) based on user settings. + """ device_type = Architectures.CPU.architecture_value if app_settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] == Architectures.CUDA.label: device_type = Architectures.CUDA.architecture_value @@ -1272,6 +1319,18 @@ def get_selected_whisper_architecture(): return device_type def faster_whisper_transcribe(audio): + """ + Transcribe audio using the Faster Whisper model. + + Args: + audio: Audio data to transcribe. + + Returns: + str: Transcribed text or error message if transcription fails. + + Raises: + Exception: Any error during transcription is caught and returned as an error message. + """ try: if stt_local_model is None: load_stt_model() @@ -1286,19 +1345,24 @@ def faster_whisper_transcribe(audio): return "".join(f"{segment.text} " for segment in segments) except Exception as e: error_message = f"Transcription failed: {str(e)}" - print(f"Error during transcription: {str(e)}") # Log the error + print(f"Error during transcription: {str(e)}") return error_message def set_cuda_paths(): + """ + Configure CUDA-related environment variables and paths. + + Sets up the necessary environment variables for CUDA execution when CUDA + architecture is selected. Updates CUDA_PATH, CUDA_PATH_V12_4, and PATH + environment variables with the appropriate NVIDIA driver paths. + """ if (get_selected_whisper_architecture() == Architectures.CUDA.architecture_value) or (app_settings.editable_settings["Architecture"] == Architectures.CUDA.label): - nvidia_base_path = Path(get_file_path('nvidia-drivers')) # Ensure this returns a Path object + nvidia_base_path = Path(get_file_path('nvidia-drivers')) - # Use `Path` operators cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' cublas_path = nvidia_base_path / 'cublas' / 'bin' cudnn_path = nvidia_base_path / 'cudnn' / 'bin' - # Convert Path objects to strings paths_to_add = [str(cuda_path), str(cublas_path), str(cudnn_path)] env_vars = ['CUDA_PATH', 'CUDA_PATH_V12_4', 'PATH'] From 60b3af4fdbbed11ed6071142e004cab80e7017ec Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:25:10 -0500 Subject: [PATCH 068/244] changed cuda function to use guard clause --- src/FreeScribe.client/client.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3061edf0..84355282 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1356,20 +1356,22 @@ def set_cuda_paths(): architecture is selected. Updates CUDA_PATH, CUDA_PATH_V12_4, and PATH environment variables with the appropriate NVIDIA driver paths. """ - if (get_selected_whisper_architecture() == Architectures.CUDA.architecture_value) or (app_settings.editable_settings["Architecture"] == Architectures.CUDA.label): - nvidia_base_path = Path(get_file_path('nvidia-drivers')) - - cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' - cublas_path = nvidia_base_path / 'cublas' / 'bin' - cudnn_path = nvidia_base_path / 'cudnn' / 'bin' - - paths_to_add = [str(cuda_path), str(cublas_path), str(cudnn_path)] - env_vars = ['CUDA_PATH', 'CUDA_PATH_V12_4', 'PATH'] + if (get_selected_whisper_architecture() != Architectures.CUDA.architecture_value) or (app_settings.editable_settings["Architecture"] != Architectures.CUDA.label): + return + + nvidia_base_path = Path(get_file_path('nvidia-drivers')) + + cuda_path = nvidia_base_path / 'cuda_runtime' / 'bin' + cublas_path = nvidia_base_path / 'cublas' / 'bin' + cudnn_path = nvidia_base_path / 'cudnn' / 'bin' + + paths_to_add = [str(cuda_path), str(cublas_path), str(cudnn_path)] + env_vars = ['CUDA_PATH', 'CUDA_PATH_V12_4', 'PATH'] - for env_var in env_vars: - current_value = os.environ.get(env_var, '') - new_value = os.pathsep.join(paths_to_add + ([current_value] if current_value else [])) - os.environ[env_var] = new_value + for env_var in env_vars: + current_value = os.environ.get(env_var, '') + new_value = os.pathsep.join(paths_to_add + ([current_value] if current_value else [])) + os.environ[env_var] = new_value # Configure grid weights for scalability root.grid_columnconfigure(0, weight=1, minsize= 10) From d1ba0364ed898df4a1a7c128a6c5609018710ef5 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:32:04 -0500 Subject: [PATCH 069/244] Made transcribe function raise exception on fail --- src/FreeScribe.client/WhisperModel.py | 2 ++ src/FreeScribe.client/client.py | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 src/FreeScribe.client/WhisperModel.py diff --git a/src/FreeScribe.client/WhisperModel.py b/src/FreeScribe.client/WhisperModel.py new file mode 100644 index 00000000..c6813af1 --- /dev/null +++ b/src/FreeScribe.client/WhisperModel.py @@ -0,0 +1,2 @@ +class TranscribeError(Exception): + pass \ No newline at end of file diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 84355282..68a66725 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -48,6 +48,8 @@ import gc from pathlib import Path +from WhisperModel import TranscribeError + dual = DualOutput() @@ -296,8 +298,10 @@ def realtime_text(): if stt_local_model is None: update_gui("Local Whisper model not loaded. Please check your settings.") break - - result = faster_whisper_transcribe(audio_buffer) + try: + result = faster_whisper_transcribe(audio_buffer) + except Exception as e: + update_gui(f"\nError: {e}\n") if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): update_gui(result) @@ -611,7 +615,10 @@ def cancel_whole_audio_process(thread_id): uploaded_file_path = None # Transcribe the audio file using the loaded model - result = faster_whisper_transcribe(file_to_send) + try: + result = faster_whisper_transcribe(audio_buffer) + except Exception as e: + result = f"An error occurred ({type(e).__name__}): {e}" transcribed_text = result @@ -1334,7 +1341,7 @@ def faster_whisper_transcribe(audio): try: if stt_local_model is None: load_stt_model() - return "Speach to text model not loaded. Please try again once loaded." + raise TranscribeError("Speech2Text model not loaded. Please try again once loaded.") segments, info = stt_local_model.transcribe( audio, @@ -1346,7 +1353,7 @@ def faster_whisper_transcribe(audio): except Exception as e: error_message = f"Transcription failed: {str(e)}" print(f"Error during transcription: {str(e)}") - return error_message + raise TranscribeError(error_message) def set_cuda_paths(): """ From 4902ed792d7aa5d20b2e5770a990dc7d6c966e8c Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Mon, 16 Dec 2024 13:32:29 -0500 Subject: [PATCH 070/244] Update src/FreeScribe.client/client.py Validation check for settings Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/client.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f339a732..73ce11db 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1277,10 +1277,24 @@ def faster_whisper_transcribe(audio): load_stt_model() return "Speach to text model not loaded. Please try again once loaded." + # Validate beam_size + try: + beam_size = int(app_settings.editable_settings[SettingsKeys.WHISPER_BEAM_SIZE.value]) + if beam_size <= 0: + raise ValueError("beam_size must be greater than 0") + except (ValueError, TypeError) as e: + return f"Invalid beam_size parameter: {str(e)}" + + # Validate vad_filter + try: + vad_filter = bool(app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value]) + except (ValueError, TypeError) as e: + return f"Invalid vad_filter parameter: {str(e)}" + segments, info = stt_local_model.transcribe( audio, - beam_size=int(app_settings.editable_settings[SettingsKeys.WHISPER_BEAM_SIZE.value]), - vad_filter=bool(app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value]), + beam_size=beam_size, + vad_filter=vad_filter, ) return "".join(f"{segment.text} " for segment in segments) From 2ed74ccd739a8bf989c7f93b2fc19b85d187a6ff Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:38:51 -0500 Subject: [PATCH 071/244] Removed VAD validation because its a checkbox cant mess it up.. reworded the beamsize validation --- src/FreeScribe.client/client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index beb7bab4..bf80739f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1349,13 +1349,10 @@ def faster_whisper_transcribe(audio): if beam_size <= 0: raise ValueError("beam_size must be greater than 0") except (ValueError, TypeError) as e: - return f"Invalid beam_size parameter: {str(e)}" + return f"Invalid beam_size parameter. Please go into the settings and ensure you have a integer greater than 0: {str(e)}" # Validate vad_filter - try: - vad_filter = bool(app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value]) - except (ValueError, TypeError) as e: - return f"Invalid vad_filter parameter: {str(e)}" + vad_filter = bool(app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value]) segments, info = stt_local_model.transcribe( audio, From da3df2afdc59dfeafe8bf34439f9fb8c77983ac2 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:39:00 -0500 Subject: [PATCH 072/244] Reworded the beam size validation messages --- src/FreeScribe.client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index bf80739f..72b7545f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1347,9 +1347,9 @@ def faster_whisper_transcribe(audio): try: beam_size = int(app_settings.editable_settings[SettingsKeys.WHISPER_BEAM_SIZE.value]) if beam_size <= 0: - raise ValueError("beam_size must be greater than 0") + raise ValueError(f"{SettingsKeys.WHISPER_BEAM_SIZE.value} must be greater than 0 in advanced settings") except (ValueError, TypeError) as e: - return f"Invalid beam_size parameter. Please go into the settings and ensure you have a integer greater than 0: {str(e)}" + return f"Invalid {SettingsKeys.WHISPER_BEAM_SIZE.value} parameter. Please go into the advanced settings and ensure you have a integer greater than 0: {str(e)}" # Validate vad_filter vad_filter = bool(app_settings.editable_settings[SettingsKeys.WHISPER_VAD_FILTER.value]) From 382d4d1d42aef7cc113d7efcef0481f9f2e66aa8 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 13:44:57 -0500 Subject: [PATCH 073/244] changed to upload to the correct file on transcribe --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 72b7545f..b3bb4523 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -616,7 +616,7 @@ def cancel_whole_audio_process(thread_id): # Transcribe the audio file using the loaded model try: - result = faster_whisper_transcribe(audio_buffer) + result = faster_whisper_transcribe(file_to_send) except Exception as e: result = f"An error occurred ({type(e).__name__}): {e}" From 101ff195398a6d15c512bf438f9ff9421bf3cf8a Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 14:16:13 -0500 Subject: [PATCH 074/244] Updated the float type if its on cpu trying to use float16 --- src/FreeScribe.client/client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index b3bb4523..b438037f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1280,11 +1280,17 @@ def _load_stt_model_thread(): device_type = get_selected_whisper_architecture() set_cuda_paths() + compute_type = app_settings.editable_settings[SettingsKeys.WHISPER_COMPUTE_TYPE.value] + # Change the compute type automatically if using a gpu one. + if device_type == Architectures.CPU.architecture_value and compute_type == "float16": + compute_type = "int8" + + stt_local_model = WhisperModel( model, device=device_type, cpu_threads=int(app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value]), - compute_type=app_settings.editable_settings[SettingsKeys.WHISPER_COMPUTE_TYPE.value],) + compute_type=compute_type) print("STT model loaded successfully.") except Exception as e: From f4d948af335e7f1b19a88e1067fb8e165039f90f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 14:16:36 -0500 Subject: [PATCH 075/244] Fixed beam size being hidden by adding blank space place holder for cutoff --- src/FreeScribe.client/UI/SettingsWindow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index b1837d53..6e6a3f54 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -158,6 +158,7 @@ def __init__(self): self.adv_whisper_settings = [ "Real Time Audio Length", + "BlankSpace", # Represents the whisper cuttoff SettingsKeys.WHISPER_BEAM_SIZE.value, SettingsKeys.WHISPER_CPU_COUNT.value, SettingsKeys.WHISPER_VAD_FILTER.value, From 2aa7a8bb76aab1a78e0c57437a84654066ce3981 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 14:44:57 -0500 Subject: [PATCH 076/244] Update license to AGPL-3.0 --- LICENSE.txt | 151 ++++++++++++++++++++++++---------------------------- 1 file changed, 69 insertions(+), 82 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index f288702d..29ebfa54 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,5 +1,5 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,17 +7,15 @@ Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to +our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. +software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -72,7 +60,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Use with the GNU Affero General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single +under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General +Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published +GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's +versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file From 820e308fc0acec805c5dce945bb77dc7cc271726 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 14:50:17 -0500 Subject: [PATCH 077/244] Updated readme --- README.md | 77 ++++++++++++------------------------------------------- 1 file changed, 17 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index a81f4c1d..34d93d0f 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,34 @@ -# AI-Scribe +# FreeScribe ## Introduction -> This is a script that I worked on to help empower physicians to alleviate the burden of documentation by utilizing a medical scribe to create SOAP notes. Expensive solutions could potentially share personal health information with their cloud-based operations. It utilizes `Koboldcpp` and `Whisper` on a local server that is concurrently running the `Server.py` script. The `Client.py` script can then be used by physicians on their device to record patient-physician conversations after a signed consent is obtained and process the result into a SOAP note. +This is a application maintained extension of Dr. Braedon Hendy's AI-Scribe python script. It is maintained by the ClinicianFOCUS team at the Conestoga College SMART Center. The goal of this project is to have a easy to install Medical Scribe application. This application can run locally on your machine (No potential share of personal health data) or can connect to a Large Language Model (LLM) and Whisper (Speech2Text) Server on your network or to a remote one like ChatGPT. To download head over to our latest [releases](https://github.com/ClinicianFOCUS/FreeScribe/releases). + +Please note this application is still in alpha state. Feel free to contribute, connect, or inquire in our discord where majority of project communications occur. https://discord.gg/zpQTGVEVbH + +### Note from the original creator and active contributor Dr. Braedon Hendy: + +> This is a script that I worked on to help empower physicians to alleviate the burden of documentation by utilizing a medical scribe to create SOAP notes. Expensive solutions could potentially share personal health information with their cloud-based operations. The application can then be used by physicians on their device to record patient-physician conversations after a signed consent is obtained and process the result into a SOAP note. > > Regards, -> > Braedon Hendy -## Changelog - -- **2024-03-17** - updated `client.py` to allow for `OpenAI` token access when `GPT` button is selected. A prompt will show to allow for scrubbing of any personal health information. -- **2024-03-28** - updated `client.py` to allow for `Whisper` to run locally when set to `True` in the settings. -- **2024-03-29** - added `Scrubadub` to be used to remove personal information prior to `OpenAI` token access. -- **2024-04-26** - added alternative server file to use `Faster-Whisper` -- **2024-05-03** - added alternative server file to use `WhisperX` -- **2024-05-06** - added real-time `Whisper` processing -- **2024-05-13** - added `SSL` and OHIP scrubbing -- **2024-05-14** - `GPT` model selection -- **2024-06-01** - template options and further fine-tuning for local and remote real-time `Whisper` - ## Setup on a Local Machine -Example instructions for running on a single machine: - -I will preface that this will run slowly if you are not using a GPU but will demonstrate the capability. - -Install `Python` `3.10.9` [HERE](https://www.python.org/downloads/release/python-3109/). (if the hyperlink doesn't work https://www.python.org/downloads/release/python-3109/). Make sure you click the checkbox to select "`Add Python to Path`". - -Next, you need to install software to convert the audio file to be processed. Press `Windows key` + `R`, you can run the command line by typing `powershell`. Copy/type the following: - -```powershell -Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression -scoop install ffmpeg -``` - -If this was successful, you need to download the files that I wrote [HERE](https://github.com/1984Doc/AI-Scribe). Unzip the files (if the hyperlink doesn't work https://github.com/1984Doc/AI-Scribe). - -Run the `client.py` (it may prompt for installation of various dependencies via `pip`) - -I would recommend using the `GPT` option using an `API key`. The cost for running each model may determine the overall choice and can be selected in the `Settings` menu of the program. +To run the application on your machine just download the latest [release](https://github.com/ClinicianFOCUS/FreeScribe/releases), run the installer, and begin to use. The application is configured to run completely locally by default. ## Setup on a Server -Example instructions for running on a server with a GPU: - -Install `Python` `3.10.9` [HERE](https://www.python.org/downloads/release/python-3109/). (if the hyperlink doesn't work https://www.python.org/downloads/release/python-3109/). Make sure you click the checkbox to select "`Add Python to Path`". - -Press `Windows key` + `R`, you can run the command line by typing `cmd`. Copy/type the following, running each line by pressing `Enter`: - -```sh -pip install openai-whisper -``` - -Now you need to download the AI model (it is large). I recommend the `Mistral 7B v0.2` or `Meta Llama 3` models. These can be found on [HuggingFace.](https://huggingface.co/) - -You now need to launch the AI model with the following software that you can download [HERE](https://github.com/LostRuins/koboldcpp/releases). It will download automatically and you will need to open it (if hyperlink doesn't work https://github.com/LostRuins/koboldcpp/releases). If you have an **NVidia RTX**-based card, the below instructions can be modified using `Koboldcpp.exe` rather than `koboldcpp_nocuda.exe`. - -Once the `Koboldcpp.exe` is opened, click the `Browse` button and select the model downloaded. Now click the `Launch` button. +If you would like to run the application on a local higher performance server please refer to our other tools. -You should see a window open and can ask it questions to test! +- Local LLM Container: https://github.com/ClinicianFOCUS/local-llm-container +- Local Whisper Container: https://github.com/ClinicianFOCUS/speech2text-container +- All-in-one installer for the tools: https://github.com/ClinicianFOCUS/clinicianfocus-installer -If this was successful, you need to download the files that I wrote [HERE](https://github.com/1984Doc/AI-Scribe). Unzip the files (if the hyperlink doesn't work https://github.com/1984Doc/AI-Scribe). +# Further Documentation -Run the `server.py` file. This will download the files to help organize the text after converting from audio. +Further documentation can be found [here](https://clinicianfocus.github.io/FreeScribe) (https://clinicianfocus.github.io/FreeScribe). -Run the `client.py` file and edit the IP addresses in the `Settings` menu. +# License -# How to run with JanAI -1. Download and install janAI and configure with your LLM of choice. -2. Start the JanAI server. -3. Open the python client applications and set the Model Endpoint to your settings in the JanAI (Typically http://localhost:1337/v1/ by default) -4. Set your model to the one of choice (Gemma 2 2b recommended Model ID: "gemma-2-2b-it") -5. Save the settings -6. Click the KoboldCPP button to enable custom endpoint. +FreeScribes code is under the AGPL-3.0 License. See (LICENSE)[https://github.com/ClinicianFOCUS/FreeScribe/blob/main/LICENSE.txt] for further information. From a29088e4ed1280c2813068b85ebc1d087c5bf5b6 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 14:57:15 -0500 Subject: [PATCH 078/244] added documentation auto build github action --- .github/workflows/docs.yml | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..5934a6c6 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,62 @@ +name: Deploy Sphinx Docs to GitHub Pages + +on: + push: + branches: + - main # Replace 'main' with your default branch if needed + +permissions: + id-token: write # Grant the necessary permissions for the deploy-pages action + contents: write # Ensure content write access for deployment + pages: write # Allow deployment to GitHub Page + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 # v4.2.1 + + # Set up Python + - name: Set up Python + uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 + with: + python-version: "3.10" + + # Install dependencies and sphinx + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install sphinx sphinx-rtd-theme + + # Install requirements for documentation building + - name: Install Documentation Requirements + run: | + pip install -r client_requirements.txt + + # Build the Sphinx documentation + - name: Build Sphinx Documentation + run: | + cd ./docs + sphinx-build -b html ./ ./_build/html + + - name: Setup Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0 + + # Create a tarball of the built documentation + - name: Zip artifact + run: | + tar -czvf html.tar.gz ./docs/_build/html + + - name: Upload artifact + uses: actions/upload-pages-artifact@0252fc4ba7626f0298f0cf00902a25c6afc77fa8 # v3.0 + with: + # Upload entire repository + path: "./docs/_build/html" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e #v4.0.5 + with: + token: ${{ secrets.GITHUB_TOKEN }} From ba6b2d01ec78c1252546dadee34da94ff278e1ac Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 14:58:16 -0500 Subject: [PATCH 079/244] added the sphinx stuff --- docs/Makefile | 20 ++++++++++++++++++++ docs/_build/.gitkeep | 0 docs/_static/.gitkeep | 0 docs/_templates/.gitkeep | 0 docs/conf.py | 27 +++++++++++++++++++++++++++ docs/index.rst | 17 +++++++++++++++++ docs/make.bat | 35 +++++++++++++++++++++++++++++++++++ 7 files changed, 99 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/_build/.gitkeep create mode 100644 docs/_static/.gitkeep create mode 100644 docs/_templates/.gitkeep create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_build/.gitkeep b/docs/_build/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/_templates/.gitkeep b/docs/_templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..d0e8a510 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,27 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'FreeScribe' +copyright = '2024, ClinicianFOCUS' +author = 'ClinicianFOCUS' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ['_static'] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..92b8d398 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,17 @@ +.. FreeScribe documentation master file, created by + sphinx-quickstart on Mon Dec 16 14:54:36 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +FreeScribe documentation +======================== + +Add your content using ``reStructuredText`` syntax. See the +`reStructuredText `_ +documentation for details. + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..954237b9 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd From 015187c16f72d23f00b73a5a787e06fa59159ae1 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 15:02:52 -0500 Subject: [PATCH 080/244] updated docs release action --- .github/workflows/docs.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5934a6c6..89cfbe17 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,13 +27,12 @@ jobs: # Install dependencies and sphinx - name: Install Dependencies run: | - python -m pip install --upgrade pip pip install sphinx sphinx-rtd-theme # Install requirements for documentation building - name: Install Documentation Requirements run: | - pip install -r client_requirements.txt + pip install -r client_requirements_nvidia.txt # Build the Sphinx documentation - name: Build Sphinx Documentation From 0bd76df9f02a35c04db80e9c5d67b90ca49359ce Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 15:07:19 -0500 Subject: [PATCH 081/244] switched github runner to windows for docs --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 89cfbe17..c1c50921 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ permissions: jobs: build: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - name: Checkout From ab399be1d0263cf1378dd19ccf262ef18d2e9b70 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 15:17:11 -0500 Subject: [PATCH 082/244] Made the sphinx page declare its a WIP right now --- docs/index.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 92b8d398..9bc9160b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,9 +6,7 @@ FreeScribe documentation ======================== -Add your content using ``reStructuredText`` syntax. See the -`reStructuredText `_ -documentation for details. +This bage is currently work in progess. Please visit again soon. .. toctree:: From 10e13566c732fe078a8c9228906aba3c00c377a8 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 16 Dec 2024 15:17:59 -0500 Subject: [PATCH 083/244] typo fix --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 9bc9160b..83e022cf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ FreeScribe documentation ======================== -This bage is currently work in progess. Please visit again soon. +This page is currently work in progress. Please visit again soon. .. toctree:: From 153ef12a31e512030145d807d19afd8e47b4fcca Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Mon, 16 Dec 2024 15:21:20 -0500 Subject: [PATCH 084/244] Update src/FreeScribe.client/client.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index b438037f..a40318f4 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1370,7 +1370,7 @@ def faster_whisper_transcribe(audio): except Exception as e: error_message = f"Transcription failed: {str(e)}" print(f"Error during transcription: {str(e)}") - raise TranscribeError(error_message) + raise TranscribeError(error_message) from e def set_cuda_paths(): """ From 3f48ca5078a7b6babec7e8bdce9510cac9bf5787 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 12:38:05 -0500 Subject: [PATCH 085/244] Updated settings help to contain only relevant settings --- .../markdown/help/settings.md | 378 ++++++++++-------- 1 file changed, 213 insertions(+), 165 deletions(-) diff --git a/src/FreeScribe.client/markdown/help/settings.md b/src/FreeScribe.client/markdown/help/settings.md index a8b6a202..1e4616d2 100644 --- a/src/FreeScribe.client/markdown/help/settings.md +++ b/src/FreeScribe.client/markdown/help/settings.md @@ -1,170 +1,218 @@ # Settings Documentation ## General Settings -- **Show Welcome Message** - - Description: Display welcome message on startup - - Default: `true` - - Type: boolean -- **Show Scrub PHI** - - Description: Enable/Disable Scrub PHI (Only for local llm and private network RFC 18/19) - - Default: `false` - - Type: boolean +### Show Welcome Message +**Description**: Display welcome message on application startup +**Default**: `true` +**Type**: boolean + +### Show Scrub PHI +**Description**: Enable/Disable Scrub PHI (Only for local llm and private network RFC 18/19). Scrub PHI is used to remove potentially sensitive data before feeding it to a Large Language Model. Please note it is still your responsibility to ensure all data is being sent contains no sensitive data. +**Default**: `false` +**Type**: boolean + ## Whisper Settings -- **Whisper Endpoint** - - Description: API endpoint for Whisper service - - Default: `https://localhost:2224/whisperaudio` - - Type: string -- **Whisper Server API Key** - - Description: API key for Whisper service authentication - - Default: `None` - - Type: string -- **Whisper Model** - - Description: Whisper model to use for speech recognition - - Default: `small.en` - - Type: string -- **Local Whisper** - - Description: Use local Whisper instance instead of cloud service - - Default: `false` - - Type: boolean -- **Real Time** - - Description: Enable real-time processing - - Default: `false` - - Type: boolean +### Whisper Endpoint +**Description**: API endpoint for Whisper service. This sends a wav file from the client to the endpoint. Default is set to the Local Whisper container provided by ClinicianFOCUS +**Default**: `https://localhost:2224/whisperaudio` +**Type**: string + +### Whisper Server API Key +**Description**: API key for Whisper service authentication +**Default**: `None` +**Type**: string + +### Whisper Model +**Description**: Whisper model to use for speech recognition. Only applies to the local model. + +Size of the model to use (tiny, tiny.en, base, base.en, +small, small.en, distil-small.en, medium, medium.en, distil-medium.en, large-v1, +large-v2, large-v3, large, distil-large-v2, distil-large-v3, large-v3-turbo, or turbo), +a path to a converted model directory, or a CTranslate2-converted Whisper model ID from +the HF Hub. When a size or a model ID is configured, the converted model is downloaded +from the Hugging Face Hub. + +**Default**: `small.en` +**Type**: string + +### Local Whisper +**Description**: Use local Whisper instance instead of a remote whisper service. +**Default**: `true` +**Type**: boolean + +### Real Time +**Description**: Enable real-time processing. This will send audio chunks to the whisper when silence is detected and 5 seconds of audio has been recorded. This setting is recommended as you will get real time transcription of your conversation. It is also the most efficient. +**Default**: `true` +**Type**: boolean + ## LLM Settings -- **Model Endpoint** - - Description: API endpoint URL for the model service - - Default: `https://api.openai.com/v1/` - - Type: string -- **Use Local LLM** - - Description: Toggle to use a locally hosted language model instead of cloud service - - Default: `false` - - Type: boolean +### Model Endpoint +**Description**: API endpoint URL for the Large Language Model. It must be to a OpenAI api style. +**Default**: `https://localhost:3334/v1/` +**Type**: string + +### Use Local LLM +**Description**: Toggle to use a locally built in language model instead of the remote service. +**Default**: `true` +**Type**: boolean + ## Advanced Settings -- **use_story** - - Description: Enable story context for generation - - Default: `false` - - Type: boolean -- **use_memory** - - Description: Enable memory context for generation - - Default: `false` - - Type: boolean -- **use_authors_note** - - Description: Enable author's notes in generation - - Default: `false` - - Type: boolean -- **use_world_info** - - Description: Enable world information in context - - Default: `false` - - Type: boolean -- **Enable Scribe Template** - - Description: Enable Scribe template functionality - - Default: `false` - - Type: boolean -- **max_context_length** - - Description: Maximum number of tokens in the context window - - Default: `5000` - - Type: integer -- **max_length** - - Description: Maximum length of generated text - - Default: `400` - - Type: integer -- **rep_pen** - - Description: Repetition penalty factor - - Default: `1.1` - - Type: float -- **rep_pen_range** - - Description: Token range for repetition penalty - - Default: `5000` - - Type: integer -- **rep_pen_slope** - - Description: Slope of repetition penalty curve - - Default: `0.7` - - Type: float -- **temperature** - - Description: Controls randomness in generation (higher = more random) - - Default: `0.1` - - Type: float -- **tfs** - - Description: Tail free sampling parameter - - Default: `0.97` - - Type: float -- **top_a** - - Description: Top-A sampling parameter - - Default: `0.8` - - Type: float -- **top_k** - - Description: Top-K sampling parameter - - Default: `30` - - Type: integer -- **top_p** - - Description: Top-P (nucleus) sampling parameter - - Default: `0.4` - - Type: float -- **typical** - - Description: Typical sampling parameter - - Default: `0.19` - - Type: float -- **sampler_order** - - Description: Order of sampling methods to apply - - Default: `[6, 0, 1, 3, 4, 2, 5]` - - Type: string (JSON array) -- **singleline** - - Description: Output single line responses only - - Default: `false` - - Type: boolean -- **frmttriminc** - - Description: Trim incomplete sentences from output - - Default: `false` - - Type: boolean -- **frmtrmblln** - - Description: Remove blank lines from output - - Default: `false` - - Type: boolean -- **Use best_of** - - Description: Enable best-of sampling - - Default: `false` - - Type: boolean -- **best_of** - - Description: Number of completions to generate and select from - - Default: `2` - - Type: integer -- **Real Time Audio Length** - - Description: Length of audio segments for real-time processing (seconds) - - Default: `5` - - Type: integer -- **Use Pre-Processing** - - Description: Enable text pre-processing - - Default: `true` - - Type: boolean -- **Use Post-Processing** - - Description: Enable text post-processing - - Default: `false` - - Type: boolean -## Docker Settings -- **LLM Container Name** - - Description: Docker container name for LLM service - - Default: `ollama` - - Type: string -- **LLM Caddy Container Name** - - Description: Docker container name for Caddy reverse proxy - - Default: `caddy-ollama` - - Type: string -- **LLM Authentication Container Name** - - Description: Docker container name for authentication service - - Default: `authentication-ollama` - - Type: string -- **Whisper Container Name** - - Description: Docker container name for Whisper service - - Default: `speech-container` - - Type: string -- **Whisper Caddy Container Name** - - Description: Docker container name for Whisper Caddy service - - Default: `caddy` - - Type: string -- **Auto Shutdown Containers on Exit** - - Description: Automatically stop Docker containers on application exit - - Default: `true` - - Type: boolean -- **Use Docker Status Bar** - - Description: Show Docker container status in UI - - Default: `false` - - Type: boolean \ No newline at end of file + + + + + + +### temperature +**Description**: Controls randomness in generation (higher = more random). Gives the LLM more freedom and creativity. More [here](https://platform.openai.com/docs/api-reference/chat/create#chat-create-temperature) +**Default**: `0.1` +**Type**: float + + + + + + + +### top_p +**Description**: Top-P (nucleus) sampling parameter. More info [here](https://platform.openai.com/docs/api-reference/chat/create#chat-create-top_p). +**Default**: `0.4` +**Type**: float + + + + + +### Use best_of +**Description**: Enable best-of sampling +**Default**: `false` +**Type**: boolean + +### best_of +**Description**: Number of completions to generate and select from. More [here](https://platform.openai.com/docs/api-reference/completions/create#completions-create-best_of). +**Default**: `2` +**Type**: integer + +### Real Time Audio Length +**Description**: Length of audio segments for real-time processing (seconds) +**Default**: `5` +**Type**: integer + +### Use Pre-Processing +**Description**: Enable text pre-processing +**Default**: `true` +**Type**: boolean + +### Use Post-Processing +**Description**: Enable text post-processing +**Default**: `false` +**Type**: boolean + + From 6c9595f62c4c79dac80e2818aa9d18393bfb80ab Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 12:38:31 -0500 Subject: [PATCH 086/244] Added a contributing section to readme.md --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 34d93d0f..d7174144 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ If you would like to run the application on a local higher performance server pl Further documentation can be found [here](https://clinicianfocus.github.io/FreeScribe) (https://clinicianfocus.github.io/FreeScribe). +## Contributing + +We welcome contributions to the FreeScribe project! To contribute: + +1. Fork the [repository](https://github.com/ClinicianFOCUS/FreeScribe). +2. Create a new branch (`git checkout -b feature/your-feature`). +3. Make your changes and commit them (`git commit -m 'Add some feature'`). +4. Push to the branch (`git push origin feature/your-feature`). +5. Open a pull request. + +Please ensure your code adheres to our coding standards and includes appropriate tests. + # License FreeScribes code is under the AGPL-3.0 License. See (LICENSE)[https://github.com/ClinicianFOCUS/FreeScribe/blob/main/LICENSE.txt] for further information. From 856bdc7bef53decc96003caa4646e4dd18713e25 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 12:39:15 -0500 Subject: [PATCH 087/244] Removed unused section in the welcome --- src/FreeScribe.client/markdown/welcome.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/FreeScribe.client/markdown/welcome.md b/src/FreeScribe.client/markdown/welcome.md index a5fe7c46..d7611d45 100644 --- a/src/FreeScribe.client/markdown/welcome.md +++ b/src/FreeScribe.client/markdown/welcome.md @@ -11,8 +11,7 @@ The FreeScribe project leverages advanced machine learning models to transcribe - **Real-time Transcription**: Transcribe conversations in real-time using advanced speech recognition models. - **Medical Note Generation**: Automatically generate structured medical notes from transcriptions. - **User-Friendly Interface**: Intuitive and easy-to-use interface for healthcare professionals. -- **Customizable Settings**: Customize the application settings to suit your workflow. -- **Docker Integration**: Easily manage the application using Docker containers. +- **Customizable Settings**: Customize the application settings to suit your workflow.+` ## Discord Community @@ -20,18 +19,6 @@ Join our Discord community to connect with other users, get support, and collabo [Join our Discord Community](https://discord.gg/6DnPENSn) -## Contributing - -We welcome contributions to the FreeScribe project! To contribute: - -1. Fork the [repository](https://github.com/ClinicianFOCUS/FreeScribe). -2. Create a new branch (`git checkout -b feature/your-feature`). -3. Make your changes and commit them (`git commit -m 'Add some feature'`). -4. Push to the branch (`git push origin feature/your-feature`). -5. Open a pull request. - -Please ensure your code adheres to our coding standards and includes appropriate tests. - ## License This project is licensed under the MIT License. See the [LICENSE](https://github.com/ClinicianFOCUS/FreeScribe/blob/main/LICENSE.txt) file for more information. From 2c0ade897d35856fbb22b139437b3cfa566ee1fe Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 12:40:16 -0500 Subject: [PATCH 088/244] removed unused settings from the SettingsUI window as some where only used for KoboldCPP. Left commented incase we return. --- src/FreeScribe.client/UI/SettingsWindow.py | 35 +++++++++++--------- src/FreeScribe.client/markdown/help/about.md | 11 ++++-- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index b1837d53..0f7a42fb 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -133,27 +133,32 @@ def __init__(self): ] self.adv_ai_settings = [ - "use_story", - "use_memory", - "use_authors_note", - "use_world_info", + ############################################################################################## + # Stuff that is commented is related to KobolodCPP API and not used in the current version # + # Maybe use it in the future? commented out for now, goes hand in hand with API style # + ############################################################################################## + + # "use_story", + # "use_memory", + # "use_authors_note", + # "use_world_info", "Use best_of", "best_of", - "max_context_length", - "max_length", - "rep_pen", - "rep_pen_range", - "rep_pen_slope", + # "max_context_length", + # "max_length", + # "rep_pen", + # "rep_pen_range", + # "rep_pen_slope", "temperature", "tfs", - "top_a", + # "top_a", "top_k", "top_p", - "typical", - "sampler_order", - "singleline", - "frmttriminc", - "frmtrmblln", + # "typical", + # "sampler_order", + # "singleline", + # "frmttriminc", + # "frmtrmblln", ] self.adv_whisper_settings = [ diff --git a/src/FreeScribe.client/markdown/help/about.md b/src/FreeScribe.client/markdown/help/about.md index 855e2941..6ede0360 100644 --- a/src/FreeScribe.client/markdown/help/about.md +++ b/src/FreeScribe.client/markdown/help/about.md @@ -1,9 +1,14 @@ -# AI-Scribe +# FreeScribe ## Introduction -> This is a script that I worked on to help empower physicians to alleviate the burden of documentation by utilizing a medical scribe to create SOAP notes. Expensive solutions could potentially share personal health information with their cloud-based operations. It utilizes `Koboldcpp` and `Whisper` on a local server that is concurrently running the `Server.py` script. The `Client.py` script can then be used by physicians on their device to record patient-physician conversations after a signed consent is obtained and process the result into a SOAP note. +This is a application maintained extension of Dr. Braedon Hendy's AI-Scribe python script. It is maintained by the ClinicianFOCUS team at the Conestoga College SMART Center. The goal of this project is to have a easy to install Medical Scribe application. This application can run locally on your machine (No potential share of personal health data) or can connect to a Large Language Model (LLM) and Whisper (Speech2Text) Server on your network or to a remote one like ChatGPT. To download head over to our latest [releases](https://github.com/ClinicianFOCUS/FreeScribe/releases). + +Please note this application is still in alpha state. Feel free to contribute, connect, or inquire in our discord where majority of project communications occur. Join our [discord](https://discord.gg/zpQTGVEVbH) ([discord.gg/zpQTGVEVbH](https://discord.gg/zpQTGVEVbH)). + +### Note from the original creator and active contributor Dr. Braedon Hendy: + +> This is a script that I worked on to help empower physicians to alleviate the burden of documentation by utilizing a medical scribe to create SOAP notes. Expensive solutions could potentially share personal health information with their cloud-based operations. The application can then be used by physicians on their device to record patient-physician conversations after a signed consent is obtained and process the result into a SOAP note. > > Regards, -> > Braedon Hendy From 2faf2769b12813a1d697579aaae92d0b2fa9ce9a Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 14:46:17 -0500 Subject: [PATCH 089/244] Updated gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9e7a7014..510e476e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ freescribe-client.spec freescribe-client-cpu.spec freescribe-client-nvidia.spec scripts/FreeScribeInstaller.exe + +_build From 049b7e12bdd558aabb7bff3e6409121a44fcc984 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 15:40:34 -0500 Subject: [PATCH 090/244] Remote connection documentation --- docs/images/installer_api_key_highlighted.png | Bin 0 -> 19628 bytes docs/images/installer_llm_endpoint.png | Bin 0 -> 19636 bytes docs/images/installer_screen_1.png | Bin 0 -> 19576 bytes docs/images/jan_ai.png | Bin 0 -> 53179 bytes docs/setting_remote_connection.rst | 118 ++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 docs/images/installer_api_key_highlighted.png create mode 100644 docs/images/installer_llm_endpoint.png create mode 100644 docs/images/installer_screen_1.png create mode 100644 docs/images/jan_ai.png create mode 100644 docs/setting_remote_connection.rst diff --git a/docs/images/installer_api_key_highlighted.png b/docs/images/installer_api_key_highlighted.png new file mode 100644 index 0000000000000000000000000000000000000000..d9eeda92984a0457f181d5c59430db7f36dad401 GIT binary patch literal 19628 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 literal 0 HcmV?d00001 diff --git a/docs/images/installer_llm_endpoint.png b/docs/images/installer_llm_endpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..8926ea2f220611d1c7768485a2ff2ecc41f5f01c GIT binary patch literal 19636 zcmeFZbyS<(w=POckwSse;#!>I#hn7dixXT56e;eWKugggr4%Uc#T^nnr4+ZK0fM^+ zw;(6|e&?RC_t^X1z0Vo<{&yK8gYo9gnpy8!bItk8XU>@@Ee$0CJSsd43=9GlWq=L_ z#seh!^$`~v{Z8MinhN^g15X_#IgH9-nr-yOBRg4jSqzMtc>EhnEcEqbH)SJF3=G1~ zzpn>9uEp;#Fy5%B0A%(2%=hOXzol6RN#EHKWlF!=U=6Y9d)V~{P>458)5L7eNx~VG z@wuCkLHbxN_RUKDbhBXwW{>M;dtbln(ZN;v`IJQ)Q)T2%p7L2Rh&N2Y;pro_>{N1>m-v3gg_t7|!#$(}m0cg}Z1a{fL;Y3~?2c|6L)kMV z2*g4|czuO6Oblbxis%*Ln%Td3%^&fwgLxMWnx1Z|kI2UjBBvR=?v90?ypbSUM`nKR z!a zH@V8N zQZIA)f#~hzP%3BD?eXrROAC`qgKxwQLmyTy}4&|7O-0%Y!PDOrX|^%ciM(p z7_ifPwaK=fjr1W~Mn*=FCx?B%d}nM@6kw-ayek6Pcl9op+au>MC~$WYS35Ftz;w9S zr?9&a86O>~B_fOj_M4psWx;}QATfQc)dH5ywYPZE;k{*4Z=BiI^C4b?Zv$QL$sXH0 zcFbPHfsIr>L=ak)6K=MPI;VfFh2>ZIsIhb^D$<_g%Lr-@ya78MQ@dQWxEJ4+?@o0hyXy-)oE`z& z62>xDnO$dHq{&+gtgt;RXp!8yuy%X#X&uhnnJF{-gmRJJ)&h;N6OvO_7O-t>t%F!OK`uZG*hT zsS$&YVUn{sE0 zCUB|*%w{mKP3#*j6V&ErAYIr~aW7%dHO_vg&pZTne!bh+SWl8-7ErRAzxP$`R5Wpj z=x#f(7rua60*RfNd{@$Ons8?%vVQms_v;w3>-7(Al3`5{_YQ4XidHLW}Z`T{=(<^ zv!9>f)_5Uv$0R%tsW4G`6z&8)3rH|NZ@0@D9Z^EKn%mMW6^C{uzDaIErrJ&3flOq; zcPH8DRm`lx_)q-PsA>a`j9pwRrDJ!`zhtDvR5o2Z2;L9CVsPC!8`p&>?IacT7j}sC zBoyw>BJwUjQyVO0;EyO-%F@xHCOt)k`5py>AAD@WYP7$T1bs-?1kQEPH1(>3sb1AI zkAO6AVlv4)6(RD!#)fAJcQISQw*jtH~b61`wbgkh$s38b=XmR z$n@5D0H9A4Q!=rGTk(^xD2be*job8Q&)cm>L8I))dh8b9?XFAEJv|>cFZumhTP!X? z#%nJ(46Jdyghbtt>eA+XhdDrj@vU6+B1;O738jTH7SqUlbC}Jv6|i|qJ{;-fFh<01 zFKz*yU1u;GA3K;*aAsS+StbJY3VmX6E96M@zc@RK6>~#R&STC8wO<4J!ZV|E?%beu zo|d>5=qTwJ{CGPY;>%=GAlEQube?!CB*I@gIAEx8(^|g0&Tx{n&fB`Db1HS;>XLXd zC>f(tHZbuz7z98<#Y4LKVGV3{Z3op}Cn{pf@ zZGV4qc!mAAALjsO0n^vhBe__9351Zo)o<}iXc4==IXOa*TTG1DNL$-vKKruogS~0b z{3Xlaf!(71ZV2;?qegA^{Z5&MP)`d^Ft{^&*T)^)~1% zZL+z{U$Aq)--Le7=!1hg?>@i93>!aJ5pIF{g68VPhZ?%$SrOQox|B2Hvo9jx4S|~X zbpEHK)9p#2AGfD+huY`jHpPf$C z+E6*tKePGj?It=K+G+{#`;7Zb^qkPGJ0tZn8x{P?73_=ea({l|wjj2-v~D5!Nd|P! z?{p8ne~r?llLhp91(Rq8zsB-EcJbt#9%I=I;?_C4ySspiL_C4rtn;4Xz)Xak?82%a z)y~r{-q<=BUwwgCwAssSnxT&3cZDCn7U9qI4q;5X@RLHFj$elW2;VT1=rDX>wzQ9` zsKYCHS0&bWCm28&^nOVGm#H*{%l+(remlLDpV*%V=LquPvg}~xwh^88@!h2bx|A2W z5p-i6_8?^y`~JnaFQE?|P394K^Ick_;l>uQ||UZj@ZOE(jK- zV?+Bc?mTC(#y1Jk@<#x@2x@R&3~d^*8$8BbzS4<^Ed%&sjAIoK{IB5>psgZMINBZw z!uwol{^@qadso$I>9>T#%>z#!CKZF~phk6)$IfN^DX-}`uiS2<@E$;aCK8=ov>`z? ze^gH&1TFY)#)@Km!F&dFFxz0+ZOJd}a-vLsC!2QJaGCY3X2{wp?e0k9R+cf>9_ax| zT!L_H>y-vs0#BxxjNY~+l+NDyawXM*z^UT&;5czDRz{>p)~ZhyCOt3wFq}6;U^oju z7%a^=%9oeGckFp`G{#xAQjxFZcHXqfM1@xp8NXCsH}X3}VB;r}NX`Z%cf8C4Os-28 zzT;G}wx*?M#461s<@KY}2nXJxipw%b!N5R{FR4GHN5)VA|JEtCmNX^|Jv@A}_ zGM685c_@HOi)acqUvJnXRDySNUZ8dSJml+H!b$rt5M8ItF;h~-C&M4jU>kF}^N7~q zu{LKm9677a;mqsBcLiyS_5O)s->8f#+saHA1WLxjJT0b&(tQ)!54B`O;D`Xt%v}yC zj48)j8PqkQ&CT0L_Q0>%JhW9``qKdA?jypV>OaU# zHwpRr)vDa}5>nc0(HR_!G>I&}*v+(7A|^l`*6;>VvT0;!jk7NF$yN5=&8d$$iv@K| zM9&(ZOx7e4i8s}nJe*9clDhdsBWhU1B5s$-QBqkz1=(ODvb;A=)AJhkel`_Rd-_nh z(WI(R+z6O6xF}{;N}3tNc2fHz>11OnfF*FKZ`wl9x7v%KXJj>R+R-CxCO|#B5MgRK zziG`f32BHKnLlls1}E~^yia_c6iVB~{wBQQ;2gauDV3g%?Z*_cTm~D?vAhFy!`3Fq zC=hsRF*D=g7fgyW_)?rJSj~5TKy=tU$@!}z-kMPA#s?HV8s#2G?>)Mt^xVt~ya&VS z?Gk$07jG2f1yYqC+yviw%ttOk3?JNe@!l4qJ#xEaRhw}0ZU@egED%d_suE_v21`y( z8gAOFhKL#&%t)eC=(zt2^ z1=X=j)(QhOy`Czp5zO3HLgC6GCL%}u$;3%eQy|Yg2?J@jd}!%G-1Xr=WzkX88FZP5 z7Z&~;NdxSl8{6#Ee4^mh#a*zYG1Vpu02DD|Lvp+aaw16Cj`^|^@WX59QdZg<)m=>$44Qm`5##} zs=UJU?kU&4#A$@3S7g)`U24$nr}5J6_=D)l=D82J`I$_b-N-k`d9Rn0j(45I$y|T^ zm>hUfgL4KOC@fBr_m<1P-b-hiOT+ncxBVSYraAM#r)@}TV)DaWP! zZETy%?#K$^i4sT9J6x-~1paQKG1{CzK#6rUs$2*VkG|acV8(5aIFp%fDxC5Wcva~YwGdjRIP@$8?6Et3lnlFGG}9MqJtdRO zEwf&&ahzn7I_Ou&IsIPw?9Hn9Jq7Spm0s?TkG|UDvH+s~#z1#?ZBq5-O0To~ZxiH$ zy~JShq+EcAXdxs6*y1CrPaQaEZrm_1r{udFH|;}>;t9}-WgCb0XjY}v875oDkqe!l zEVLvy?M3`vjhmKc%+t-7OEAUzZm%b$OZoNr;D-@pJgM*l|AUJT`oqpD#D~`}av}<) z*w}EyKICL+JPLA&@u(@H=B#m+g7Kd(h2o4@*a+Do38=+lmmh_(MD4SNrPR` zxAKWKaT2rTKfMrS5i3}w8~W!yWn#@L&SC>8$vJ~G#*INQRTa53f#+jv7OfLD0hZiK z=k8InL@W+6TjUd^-qe~_Ut@RMNzq4^gSLS~3-bY!*8UAg#&Y})JSHT*6oPSla`Fl^ z>-8pG?0IneIVUceMrZKtmgQiY7=FiG4xZEFix9lM;w+w=kLI@)T!;Dmyq=WX{l@G2 zw%ZW47`&?YSY!ufJ+;qBsmgrQs*Gbe8qC9xa%RMe?pioNe(O}$M7^z@uKmsrD%$m` z-cob;x%7zS^eVBY*z~5d2a%DjuRofAuD4aiiaG548A)rvm8I@7A0q)1x@Q4^aFFa1 zg0RDPn^i|Bnbz>Q{YBj?y6%iQh71Y{fMHv5qD4p!-r?(?#ofT5vO|5#N)GoV1jii9 zV620*8QZH?#NY{pWx6Scidvr{vysV&beZv6QZa`vaKc%_Oa7U4s-j^or`b4%cc6%+ zb=w;#E@!D(z@ujz-Q3V^X_twV@;H!Cx$lJosD9m~oKTCumd4xdg@6HKQ@RZl=#vkH ziku6zcQI**F;n~M(0q7GoGD05TKud^+5&^&vAc@F=h8-&L2vixQ9C>y*?7xuU3kzz zyq5y4p7RwJmfxK(Z(cPYy)(>@s$JPooxCGzwI?Avv8>Ex+aV?NV~AI#8hP8hPiY3t z8diP=)`&`=c;|t!{>QJ^Eqdq0rLLXfhLIHHI^04%i7)i1fq5J|+Mc2_g7`EiP~BYA zC-*FWS5Q=1rFm=vZkdmB#HmY>&9*Sj+~g;P&l4W+XeX$3YTpbwYd`mKUJQ4pG>V() zs(_4u0;~hm$V#NNVNY0C6b%|PWER0{4c0Qu)FSh-Y>dakFpd)Mw*yTMq<{2<_D&Rz zH1B8gvJ?{1(<542P1n{Ry|Pwh_(4wgmQS!4aZ@Tcl611hal?`$bC%oXa6?0~rYSEg z+g>`A?{~SG8PI;6(Zr>DyKAsIaN>1U*>*m=#S|}TML4#`Y>=+@w#Cd#2>@7aD7uyH za|RL*blHqGaBmi4ULGE&AFa~6=vqoq<3_k`Za7~aSCqMT&v{Ph5$^>Z_FVpqsOrkt z9`}M?%7E{)50ktte*Ia>Av`t<1V^*bx8f1qZ7DUnsF1cFW>z$ALSO)P!GbZ9;q0=e zPo1S90r54AFU6yh(2iu3ndLqEW*aca*ldFycExGGc*W}%gdBHZj}9r#gB>RYtZkCL zV4Q5C3g#n`8zYS)o@a3x#rlxC!=8(+}D-Xxa{hnH$&3ta9COEt~w* zf%A4*U@BEro}Z`hu|xw-+0FL?9}vkLv-dAZ(X(MQURs*qcz9p~!?Hp%T1^MPOFvJ! zHwuee4Q|?$Hihjm`RyJ)<|=8{7@FB)g`M$A7ZN>Pdk1|(u007gO>%E3V(+~G9|hH)aaABK9hID>1@D6I6{#qAg(4`S z81EK0{8y-<$7;%9XT{-rr6sN}{$f*|mUP;17B${v3_KV$P17qr{SK zZpAfQrU$o~2d1RG{yW;m90$G@$1Xv4yzuT~&O7Sl9iSw=Wlj^-ZiMFf21j{j$SB83L?m{38eAmk-L9+C6 z4jIa#dEL4|$hEQ$BA zA;3qE=ftECofzzi;5xgZpP2UzSawNfQHwr%fUVylc=}gksn5QsjZQW`kXd*VMyw5N zlKEal_!Lh|)SB!V&o)mh>DoiYkXa$R0~us z+Qi$N>r2&dkq+hNlBT2aTlx(KKLU)V)Kw<76^}c2^}x z$k&;^uB1uB~gxgUuOuP`!5U zi!yham9scFnoHA1tJjd+iyJt|@9d(Av7F=1%SNg-rHWrBWK~B+I-|usy|R09bqR0J z@FjhBSEEhKTx!{493bjBb^qXz+1`9a?f%ATi~e$@O`JqHDeuo;ZS!BmVNhorvdV`( zJH&)})Hdu1+AayG&n72^k=L;fk4T?z?vi&OxVJgxzKXYe_H-aeF;cz7xrA64e+Uw) zj;bD1 ziMO~3px-FPfL(3Gmpw%)S|Gu#hhwfaJDgAkCE%9MTfoh6;3l!;{TS!vD*b(Arz=hO z$;icuFE8v<_8sEls54XIX!?2l>d#zxj-NQhK+>C@%6r`8-~zk^`)do*fdD_=d0TJ@ z@8Wwst!EC+Y7_cEl_VQ%=YFz^*9~X6tzKgKeQlMi9OZ>OKRfItrrVwlOn&$&eWjIR z!)ux&y+K;CRPOx+WqBYGV*dDc+F8Knme%)Z$GH=YyF=TwH%dNsJ8)K-e^Bt<7nGYe z9{I{ostDOUPIE&LIw}=3XFrSGG~_kNcdp9Zg|JQ2yX>w)%Ds}a@n#}6+85hSc*>2X zE1S@3llK*G)D0jdcZsMfmw%15$HJmu*uE#<=yH^8wQyy(Y-TqCOuF!zn+(dnyZ#<6 za*N4jX%TlVQY<$aKdzY_dJ$Z%aj%M~-yY}7=hL*P0e7tVR?VZX8${akD&O0Uq&G?o-KQA^~5PDSOYxgkb+ zdP*gLXWM5+ahc*ldr60nl)YtQ{ex}a2b#fqoQ+b=+iB|O!!lbYQD8U+UrQ7_>9JVh zozngE%t2hlDe^lJ+E*-PhU3i6zOtTr158m`!e3*A@zE!#EmT$Urz{%y-<0sQUTzUS zdgw1&S?VAbcDogNA2vQLsvA5Ks4Qe^0E~hzihZaXk;Ko{GQ6*yy@1RYtw`i?p8q`~ zVV;hCKS?LaKYuXq%`b00vQrNBb7CzNwd&)E~D zJuTB&g)Lj^e%Msj=WHW^{Kna z=KDE_f$a`t(F_VYy(LC(;(Y#|s~Sfz&W}Rsv})HG{yoQ8z8ktN^W?|UPnq&m2Jo~> zlFO=0K7J-2R5HXmv6yRpKyRd9SZYK6Yo{T%g8@xQFAt{Hy{dabjw#P}*iEgJAYd)7 zks+FkT;-P0Kfo_}YCt3g&0LLt`&w#bWT2A`zEHYCMIq`=V{voCHu)gLf9cDQ@`#IQ z&_GP3uD^0&%Pkpc{!XYduo;z{x_?~F^O~a_3!1vwM-ByAw`9l&_ZZEzxiz*Jg@u}y z7ZxSyR%-SJ(dtF^7Tw?p(k>Yv9qG&RASJ&u_95Z>g=L>bF2L*3Ml*jMZDk2u6 zXQw^|_Smn}oAH_1kAdxb z=*fr}rh%!qRAtbj9L;{PJa8Jbxcs&i?{Kx5gNPvO&pjPIUdel$R}|4pi^8|T6ne|| zD+9dS>nbh-o5szpSNM)P3VjV0E+6(;iu!%t2p_NV_fWa=4~_ACB*&%#4F`BJIq==jRto6@(h z=IMaOe9D2aNPtSi$fOE%4O@6p`KR4rR=5!sR`0+_3VXLiPd)|JliZdfP1>iQ`222)32%Jk z=zA`ZE{dCREir-NWj4xKydB&l|1O2$rWuecd_<=sQF3cB>{9 zV|uduu>JkJxupuLB=dgVm?4{=(pOZB!wOi*%Wh{#Q<%OdMm)#tIpbezZTH*49)*QnaoFR$ZaqMRK|{%8JZz#G=| zg~yY2dO7mQR4S(IkLME6#t!lW4$y6Y2(pIl`LuUHK}Cg_?j8tD6OT^0)>N*EyJ#<> z3BJyfk#*7F@tFAVT5H6@g|d`9zYojNipzr38ck!4ek&SrE&G#f?lhlS@yq0+1oMNk zyQGk$sst>(kj-Ka2{hf*k@3RgV`-lLy&0;Ae0Qhia;}cg`TEHk`5FT+?+67i#V3o0 zk`%Aw=2G8)8G%^?jvr{eU-JRmH_=gvU|7xZ|1&fC-y>5M?;%&}BJ=MEpP}R8cRkdJ zPtgo)3?UMQge}rAquI>9xXu5_y8icM|2v}gKN+$bub97~iza@LAL=i>0!`}(QP=HC z3@*Fx6!+}S0H??72+@47O|J@`s{lnAq}c99J9lwf#jI6)z}9q(qX>fQI1NN6{=$yw zxjzDGLqOGU{AW>5%xb>0{1M&gf~8YR1^2lzQv4M9E!Q&E8H6T}`Ipu{`7YAc=V{!+ zqovN7%j^LL!7|tDqYo!k`N%620u7irP94>?w0AT<)I>JetO#4_h*Ytje3GeftF@0;yjov>Y^8EZ$WMx4W2M+lw^p%4^z7}sA-}>C9 zJZ!ac|J)~CHJeHHwQ+i&~ zcDr^jrP5TQed1$@xhI!d$X|kf~3~JK*FpzBw#0o2G3b6+jVEIot;z}o-&Juq z{*o6Q(xBmSFbCFp0c@MhtQsjRBA$yr_{vLr`wj!W1f{if3l;XC+9MHG-kEIT1m(Pn zi7u`sQ?wSOpLIVq?0aBjiXBYKKq3J%hm)@8Ij?TCY@|8n5lJ3~i1|f-MTWX^sRwAZxvG)& z2MSXxo(z8@Nmc^@zEvi=HC+83CogqYrpwV62~rHURBw;S>R0&t;P1av{X=iPe5b1S zmjo($_J5l;`PXTDGX>)yi&Z2hj2J?1MP7-eo7+<`jQ;BC%2sUy9fq2o*KxEG;m}1eby!%~zwXQDKHV5h4TUZQ3^<_$h@Y*muP+aYEDoyron-u*jLpWx z6W6AS;@jjLL7%ylZgty1lb^P0?Lu=!06~rCWD6en8oQXU&b6{*qGl6oL5-|1*g5;x z;Udg46&bh5-HrOX)dP7KSA%$MncL?>Y&CHKLN5KgtnnNa=_z-$IvdTry7S~wQ}8@< zO{SZbi+GeU+g*$9>cISR^Y!TBV*HQ(N4u;aoE$Y9hxoyHfioujXBTe(Mdgq2k$DaK z*PVX?>L(tM7M?tZurbqnHN5^T21d=kS_l#73yL8+Hx^9qbP?Y<`yDV_+3hl6+%Yjq z=7>Zh<*Y9hm7dO=S*lGQ23?zk%xcT2etgfgqh(3RrcaK5+}-)~Zm^hc9;w#zj?a=a z!3_$I&8NGN*}qXD%Zp|fyv^F-D~&RoDc%wjp|#VHhMaP=l|s<0vR!#VhS7g+!v(TgHVFW+vAS%Uq4!LsLRi`N@dHMz*^qsu3QYvkM=q{nZHlHEz9Tk z)JxuZMZ<23Q4-;3uovkK>Dy}eI`|AwG+Ly%aQ0c0u{yn#xbZcJVinUWwg;pPwt%=} znqq5RK!JavskoxJXKjMuptNO}JfpacdXQgFDNRpAd7T+S569%J?HQ#LFbcw>a`qB! zBC>kQDeev@fQ6)Qwujjs1WpM2-=m^2; zsca4M))I#{jezd;&aBKn-cl!SAz#_ndnaNFkj|S_hm9q)&auBfO*Z8&!xVe z=?jqueh1o*8rPdR*Mc{KR#FV!W9NV*g@&d6#Rt zfS~U7N1s~4r%t=9C?$@~M=1E$E#zpX=X?PQ%%s{9J~G;)=4qmHDZ!jq^4_Y`1nadb z5Lyl700kP4mSn472wLX@ua!wzlfE$ton3lQcH?-6pSa&*_*lPLv`tnHXt(KVKHfp- zxRwnTnAATkR4e%0{Gd5SELYmeDF0)DZIn?*;cMwV;J%bqJMCHLd{vFJTI+CMjGaaE z!RG`e(Z^G88XwjAp?r@SSkxWe{`Mpw*-EO+Sjb^w)vH-nl;}cj%H-|CGP=7O8y&_ID&`UIT0yZsc#d%p1b%QU1OC ztW91srsndL#)FAN3Z?Ay(h)B=ZPz=^ntKY>CL9cicCFZ(W(}c9`3*k?3ZJPf0y9ij zH_PDO;BB|`*|=IVYs9mP6I{yKlmpbS@2nah&q$dTKR-fbDF3=mM=vHrM>8c3@6l%2 z*~<_BVg;g^^e#9803f@Ah@t%r*&%cYjaJzI9fA^jx!D>|Blgk%WFwXPuX%6Z%nmls zM)I(;^O^9o?Jm6^}SMHqB z0P24_Br;?-L)?(bb}^J@Bh7>cV|0t}i&96rUEeIzUS9NZ|5DiuKX{SkhRdU$C0($lu{sOk&HM{NJ%x}p1 zE=1+FAcZ5o-2`3Q4h!P&zoyMxntlc7WB@RFlrN3rJPDvgmyP}fq0GX3PlNZTGQ@gO z9Yjm8Q=K}Sy>60)c%WIV(#vt(tBjmy`dih&|B9`uaIAdxBX?_0KyIPn0<&?bM3X_N69mZ4f#U8?j>x9|B0HZMba%4OZROXJ%r@Dj-LW zEbOa2NQXT|XRb;0%7K@^W-A;PY{TXc_VC;)>j>AG2co&HgGjG$8mP6VswrtXibZ@x ztmdDcV4*QbSg{=gIW#EZY7SW(pSpZ!ut+Jv11}es) z*Q60Zy`z6i~+mhd$HaFN#!fMV^U^DL> zGkB(y=sRiJBy*@c7bmd!m(MnMQw*JZ;?KPCTzK~S@$S)))F4Ao_u4^l?>71^~ z{U;AyGIeQ&MtVZC1JwP0tWc#u9S&v@W%7nJo(BA>?#A-QeGy)JPe&$nnShC)gES?q zJa|NcsbGg@;Gzw!#H`AVZM2xX@N{_&PMM>}YYX5OF+u77d5jitkw}0auqsfMd;7f`X9j76+M_j33kfj$zHcSgr+tASGCOdsK>5NYKD_!Z>B3sG6%A^ z&KNKkaTxQyy=m`9F+Xx#%+k-XRoPQy8@9~BJaZq=>wG^Sj1X%Z({|Nxg$4VSo~ZL> z?WG$Aenov{qvD<1UQMo|JDZ{^KuDM8n>R?DkM>?L5DVQuxmSNm?BDloPgrJxiSiW< z@%eH)H;}{4VYEEr3GG?KYc-i`6l!#GI@UOaueMRWD%51;*LNk9v5`JKM2}zu3d1V) zCMaNDEg|Kto1MhBA#hwugGF_#d0)ptme((Qb87Izbuqo* zMU%7wxyx-zrMz|G0>@fdlWEvqKEgSs4M6AZNczeu1E619X>-Q75&e#B3#m(UZMnxI z9h}*;0WF$=$jfCk=G#I#)MI^aHILX)wJx?-A>e2ZG?Sa+^9bmxbrJLt>c|M&ei|Si zvHAI*aIILshWGj@EL$Nht-HX7REFOR-RViiwnKgy|CT|f(W4FnzCdbT46g98hR#8QqdLO zY+G-HVxPZ=h_uK1-gv10p>_BgkY$k;zyh?yrznwQnbvF554W92BQ>^3q-5m4okbxm zN222(wi8|b(+Qn3+T;?|UrK9C2t_BTguN$ogvfT*?W=*sojR17z@l}I0B9xcdXRhG z5zRgiN?pjxxy4CdXm%w)K*t%`nKh;1J*e;Eq|`K%Z*~54dNHEk!CTAr2lSvJ@;3J= zae}DAuzy*|_%gdJ!vi~{Ps6Uft1p)7W_l=7xb7eL=L0mjxsTIE2Umtkb%ZKLu4i8= zmjEZ~;#ppt=twX{z$x=tY|74QvZ@~3JI@XP@3i07|Bis>6PxXO{9N**=YZ$dw5V4Y z;mkJcZmGXuOtWIO^OI;PeD1BQmQt#>GW{{Y>{unzH+K|IX|u`14(_IEHuOxobH33- z!OPC5Mmu-sicMUrnqu6wUSBR*EYCxD_^hY0UPIIZdSv<$E(RJqde%UMgtl{^L86T{ zFezlyT>|Bp79tqAO12eEK-A5;&DR-`_715*%g$LRB=KZCN!=p~S~KV&29*vHn!-I8_x+1! zc=TV{n6;9juD_Rirl6YMCcKpdy~w?*%hvQoH5=p|>0~ZUw@rx#6z$%kXT<1&3^k>g zo`2%x_GSwb92NP4%Jmk~r1!}&q@uY@2y{$Z2-&%tPWm%!nMM&rAGxLXdr^1NBA;7>u9@JD8tM^ml2lwU6fU=L}yA2F~oY|lva->-PdkFq9(w2jNyYv8MajW2jh2;dMW7RqqZ|fc53k$#8s~GgaZpQ250o-}tY_V@yufm{JjTG&k z!>=OHckH=>1nkmA#sq%2coO?>P(BeBX1tzt{A-ViyFSUeQ*NgV;j?oIM(CZH8b{BC zW20N27M)b)zjvYvC^lo2(IQxNwjyKzTX(2s*j{bp=gDr)T?^8(F=$x5jMD{tnC$Uw zDYtU-cfjgappgXJzdq21ZTY*!UUE9qtVDI8gWvR>TZ=jyIvEe{hzFBgA)t1GjSA1O zS!{82h5Rp6&|gAQ@RiJ%m42JaPDI&2Kv{i*LBTq~R{d=0j<##r8?zoL$2-qE(BqWx z2yVxTRtajY%2}OCr$nv+!*G)@^Y9hJbN#%&=J(zVa}msaY5H_J|%k8;hZ`pug~6m8+Q3-oKzBH!6{2A12(W0aj-6N z@i5aMo^qkBbWzfS40})IX1p`1u@U7wMW0@weEp4OQ{I+#DXlZH&e8qs)+%z@OxGEfY_ek$A8qf*Xwx1 zUk!uh&7i7a!?)brISOBO&D8x2sIxShayS4RtojP&wM^Oby0Xg;90K3=kydnJp9Fb1 zkeA0=8Y!r0jhi`^*GW3fuXcY>v2!w6@2L8OM&BH)7xDFm1v_i;4QQ6;{r`K=1P5pN z0+o=R?FxCP@gJedbd9wiY9ESjc(ecKz61%L<#2|W-);qTkPJ;G=~|eZ(=7y@43&Qp z8S(!&b-k>tt?N9NKodSok)K{X!FlkACnc>!=0R1L7YYDM;VTYg;?r^uUgrs}sQInm zVo$tsq+L=SrkmL=_5y?isWwklmD&bCExMVmACKNG{+>;oBa$5Tn@N)nyYiVRgVUhw z*HB-ujcmj3HTZ||ca;HId@Lt91KTCv}c z_yF19C+U%vU5)rW1<8k%jgoO42hf&s#^&a+2>oSB9~*V{Zcoz8HjhqEKJp~{A-}av zlf}zXrVXFTH_rSTV=(jS$Ml=c{E5`?WWXwmgYN;QsGc2i7T#=7;O#WDos*A-cCB-@A{MYGm}FcJawe4SH+W zfACIH)Oxp%vJi(9hb@cvjkI1~^RTF>*o1DlgZIxV?(KOrsHwHGzU#a5VZNEaBb&Q( z5ucDeYusp`+4n9mHT{0x$mW>69?S7#lJd6GZDdF|bP;1YwlRGD`d_{})6`!~~0qaf~yYXi4Gu_%G@7%f!;ofB^VQ$@sU0LvOKp+*-evRe3+ad?K}No@B|SiGtway=N`WL#en z%11r3Pv}<%QT+N}m@Aio{%g&WCneeSFXl2egCR`~1%2T@mW6Kqf%TxB!%0J?M$N^r zH2ty`Lk`ip=FcF|o2jOOThnS~zAR$Aqs{U8c62a|wiDSqLEQY{In)bek*dUQzbB=W zKG5$tlq(Uj`0-OaVlnnnt90zE)~w;Gs`tkthQgA!X<7XF$NKvw>p=~5W*eNFT|@iy zF3{h+zV)z|jErpmz%(y3OtZdejtx>Msc4q*VX620&Z;=I7~y_5|CBfuD;Ockv;sR8 z^7zh*{WtRSI;yln10tiJf9p~stu$2EL2JFlgXtn8|KjbX&`osBJO9T-(!UFF|9gEK z|NoHx-Aj$aGbdk$G)$EothU|p6I z^Y$Py{Yul4B6SFt6$tmackg+?J2~;)9bpae@#n@L(h@#u1>j(wQBRsE1lN0&_L=osW z#Sj&ikIhln(EuF6sSgRdrGKa5Qve!9?1#?IYzW7FifFS+bkA@;|5+2iDVz}_R4G~R z-^^_x8u7eBlaarjv~~Ch-O<-sf|g5z3fgy`5*=PQo=fH}55;$+L+=px`!Fb}o;8c5 zCD7@-bV$sp28zGw#((Z!@mI`rgbmG%qN7CSgRO3E`+ZbD<=0g+B1am>MWd1ZuiO@f zyG|A8=*T|;xl>jZCw;jTx{)Js6_P>}aPgZ2WNR({yO2Xdc;7q$QxqP zsc{Z}KBOD2Yt&wEBD*z*y&2HtZbH84RdRsCh6d7AQRGeWckEpj;G_Dt_b!>l4TTrBIv8ybO-T(byF zzZF^*j`f_L$=Yfoau#E;Gmn+vQO$J0}9OiO*(%$cX7lQh?4E2a*v5cMq0A3yiBn!W;9&n>2*M)PBxu{#;!&iiPEwi&hG?uX`#b$;B z?!$`?P*DqV{dAC|Wp>e7RVLz6PHvccZf~`v!Nl2Rb!IbZt0@uT=yF0f?4B5Fr&4c< z{%M7{Z=LC5o8|A&r<$K(zbaaLr(nm{4XqeIUj6T%wnOr%$RA(q1*5gt3+|I}O7Oao zN_-TbMm7aZp77E=Uj6JGakNJt;kE|&?UXrNs)qOHa(Sllt^Q9ZmF%0_w!7-nxZP>M^4La%-1ItGz=*vHQ;-ACw`l zFn7qaPFnC@G4q9caTDHrUaD4NCr{lJtvuY_uVEE@;ljqroaXmI1 zz6c`vZu+}JoQ%MOrk6OP7c}Vaq0d0)tgkzkgnPkXFezu)Jl^D>{YM(z|Hm@I$$>@&lQ><>Yqu>PEU#?Xvoog!}%E^;eDul%ma~Ix!|D z=CzC4lY@kUhbgG1%3nUuEc!oo4XV(?(GL!WxiB&HTKJt2kbt1%FtgywnY3ndbH4{R zHa57Ax)=7rrFapYeg{pPZXHd+$T!Wjx`c-^Y9yecu*B?LmRrFY7q|&_d3s|^gCG7c~ z9fz>_sx`=FbWOI(`_SgA5U2*)!eM>Empt0AGpqYxvS4@7)-Vu`K}0_>Ig0kc^ge9$p&_GG5gEW?J|7GnKImq;-y zbE9g!$_)4)V~rY)RqJ{jc}<1$%Jq2i$@N%Ze?4YC);+!)J7r#3s?H}@<$VZkJAgY6 zY{hkEi@0v9F|32#xo^#$i_L4S_5g+pbz{HBwY$DQlLNz>h{omMgA%a!12;%zeLYId z_BgLShP$mWZhalsEH=iPbHzOIsJ1G* z1yWUatG%ksUx)2??o+QVz>XE$RdDMmQm3YWTmonLPBrh$b)`?bbKX~$CH`BmSIyP` z4D35#?0a?av%xCWOUiJi{3{0nu(gY1@6RN_@Ft=$D#7(Cj4ysp!Z(d>>^Y}n?rX2% z4GHeM_v4bAtx&%PXPBe7QU?Gv*SwC_lX$gTMk`GTIuSr#T&tHIrPwqgQTPZBtzd(8{%>8=cP9vn#mE`@IXbf*68kdsm zC46rH?w1gMaP-RL;B(l2#u+$Wo}Y0E7MK zC*y>-lIcow{<_*aZVZUIlE$#R7|SMTaokH2lVNM;eA+*<+x7v(?a9IK51Loy>OJu+qj;<{$8y4aiaHUa^P>9 z6HO8F+vbz*oaH1U!DRn!^YFJX(fez%1=d8e_DA4v_M`u=&o^6BC%7|#NL*(@A zv(Mt9i!L&Ul4IpfL|JE5(9XwXmxKZ zg-x%ZZ$k<1D&`=Sx7=0i^lu#)Q|0~Qi!b8hi!b)%flIc@1##Wo-K@NcD4vA2be+>k za$sXWsJZma9Bg{xununPR0IDty3Du#>IkY!3Uph#a&vRjx@YxN^5&awqOh>g92y-f zZ=xwes>`9FA>`-h>km;@;Kn|Y0~&eciSPa z)B(*K*tGXBesnxmh+G(u76{)!8fJPqYo_j8W!PNFE z!<%Rdk+6~v&~)w_d@8fT+8*2%3Gjxtm(bSzrtCPgb?TaxAHb#O&%_Tt`mQkrw?+Q` ze*4?s;_%_a`tNb7lLMGpvu5e;@>GLcFuaL~h&+aUK}1AE9>ee^A|mn_KQCG#A|mn@ zR^CKJL>}XpEC1ej;|+6=h=_J1SQ5lw!6`Ay$fUwsvK-+i~)L_|bHlUZQKj2T})e*C!psXF<|x{{I- ms5bLuA|fK1OkR2A75x8QDkN&(fRg?I0000H#c2FpB#MyAS60;v)>YB2>7y$1dzFUx|OKpd(o)4I~PZ56)RLiBHX1^%% z+V_p`{4ItGPNm%gA2WISaD_^*?!Ac?JE+cq1y^_g1a~!8F-7bk4F7?>W%g-ZeMm?U zSO2Aw;`f$Ty|!uNp5M=zspX*8Obe{M%D2)zX&TGQIgw1=-QB;CRHM;xNugU?9JvJ- z;lV5qS)@rxC&O<9NCMx8I4s^%adBOm(6!4cC{U>BEOnit6VAm3VE57@)KK?dB33<8 z-E9#K{as@lPKWQv`b>&gnvNw{`>>8s=Ba_{NjmXqRL@ea$M%u1{huL*YFFCMLCBd> z)8T%`UgxlIze4y;KG0N()p1AE+*q+kp~ChElBri`gwa3#4EHgMZxmSe2+9*KgWCfB^NQ>H)mW^`KuH%1Gnj8!cj9_)o0 zN!4y)QULJ<@l6z7HzdDL@@RTCVj{hs1!wp?8f^K)nkwS(34SpkxyDhfdq6JvTF!v? z^R0&zk5G7~m4L*DDPv1QJ6+EKVTZX&WeurVmJy-3y{qnomOt*2Lrw7e*Ur*+d~aRe zZb4-1sG@zmH>7g&sO)N;4aFWu{3@U)Qg{QJD5&+mcebWq3;@ir^!e}@c%@(R&`4Qf z_GE`Jr{`34bTNCe8CSG(Gj)mp^%L^tLH?BRsQ5oB2YIattLnGY^LJn^R1l4;C20oF zUXA7D53No<9t0ayQi8VBm3&psi5FL7+elYN?r0Ke$Sn_ms|9f--IlN;(;4TS6dB^o z{p>tK1~?6B&ntj^7pK*Xt0S3YYpS}N)GaOzP_L>QDv;uybyAe7tjd4R%76E^V5ur_ zA0dzw9};3w%{w;p4q|R@j=eyLI;o&t|v&sG@i0U>8zcH?WqGu7N;WbpUd~%|+7*xdEM4HU>8>;suk~a60o9-G%c1j$FVx>%_p7cpY2sTTo zUNi+rWGHd9AZhtJEAp%0x~Y9|+krC{rPY&AZ12$(UCViy(V#g`Q7YVY-l<-34iJh= z8qF3?k|xcYH8k*~OVHE%c4FXBgx!`*(97<0@2B;=obc$PJ6(j*=-t(yTCU(s5lZ6B zkufblH~er%K_lwq_=tg@pUgMx)_};>hh+mEN)qX71^wO((Tfk$mnFKS$43k2gAg6% z61KByXYy#rJ9>+@E(C>_ql>lRaj9%#z80eqmjJCfg!S-ccrRMk zc+a8WU632kgXmKebH6Or_O*^17j;<=|CMsPt^Tr`_bTBEXgx4FPEj0M%4@ZRSNn1i3v5&kESF*5H22Vmho1@Qb zm1H8U`|pE58e26$kp60vQTY%(oh-?o9Kyam0qi!V%>eTL<^u#~JXwCy5T9aKj1p(? zci+fM*o%gh2?dAE*A51EreA?$(a|9)HN#hW0Gol=s^F*}i9EYRfkOp+_1yiz=|hUS zZyCEa7_P3BxH~f%4LW`zA6_m-5aWY0ECs z)**GeWkwdHw-n$_vp3sVyW7eU{IGWOW7Z7p&2_JzUh3I332Z^?@c4Lh@8Xm_m1Ms$uqn%9NN;&+W5T-O?u9-Of--X1UN zgiC7?OkQ_I6duD5`>zv6QGgqKL2L^tot^d>E7&vJo%VDo*26Ot2(%i;o}H?nyYM}x}@eme8d^)eNcM?D+pJ(bMPbM!pk`s4*+@D zU}MU<01f=%+&J3Y0@4r%q1Q8OP^#TmMBH^eV$uu?97_paTT5>92(#uL|LA#mJm;v_ zLJ)USe151)~fz<@aNFm`0@#tV*_ZfDe^9~!Gn;Q2_^!YGeV+l5V9!E$`` zUZ+On5!il)Lhv=smDU@jHYLB&uRcgczwu4&XT1_8o;`SNCGgA9%(CEe?lov2QO<63 zOeExk)}ns}+qOxkjr%-=?x*H`4u*4FU$mrI#0gMN4#ry#sl{(;-sd{GSxC8gnwGJZ z_`*qIM$vChr`DQ?O6B+{KU7XCL|rRRSA5=orpeUn1IHxZ8?f&Qw&}Y%X?&1*Z-X3g`9Ck>O)kg3{5A7#oHNTN z`u&tt%dl8FWI)?I<;24A3;KM%IF0VY=F*X#$(3GKHSo4kM71+g)Jn)pTN_{D{4v1^ z13MEDH{z0T)?@A2fY_RZ+#80PSM4GP#r!;0101JbkixAw5(WV8opZzduh#49(Sc~`7&c;wdca8$i*$+^B7a zvJB^7Oq%CT{rPU0PnG(OvK;gZv&%D(^)8+RJzK+7mZY)QR?oS$tcJuvpkc{Tt%3Sl z!+R3nqeSb$_OxIH@3oN5sy({*1<86{;`CViT{p5_6SxMR$A`q=^K<9>^wGkf3n{(X z%K7;!qcbvsQ$UCuwqx`kLsu`F1%dgQrQPQv2|-wdBfmJtmvqGEr#TG>ddaife!Jrkd$NDVp&+Uo^Lz*IcvbD$RZ_ zIOz;C_I{_t?Hka1lvo@)9?pH>s*+&c;d&4*T;$#_Sddm4*{CFFDWPd2WkyR=CB6D$ zW)9_c^QYfO1w_whedO_ZX1kqvyl&jP8M@y$yv?FNG{SP5U0m8RCh4K{P20iwo?Px! zLaB=t!jUG7Lu=J0oV%|o!e6&6)Ek1jP>JwWN4>F11MXgPXtZ{%)wceGCwneXi^?xxDUn@kCIV+{wu$kAtWrFK z+x!=6Q!euoCAj>%KqtFMXdjd1>0B-A-bRXTD~!82D0UZ-<0q9HS&*?sjQy_n4wzmj z9)P2ar!Q#J;Cq%F{?lA6NK>^t`i3SQYojgI>X*KK!)mTHJ5$%Y=fYD}e3d{``q!G8 zuMMezntG+>@^W(Rybym&uO*kN9~)*Fi7cZ;Qqk$`H?ny>K{MO3T{f#z{VE2+(X$hm zkv~How5LIc+P&J0@ShBuw7sT$ctBv8eNW8wL&!_2ht5Hwq{DT;US}^T{aDZNW@2Py zEIXTz)7rZy!pN8%`prm1PHx%Zp%d;*mB0k(i49pqSjjpeG^fqjt_1#(|Y?v0azB0f$cI zy0p*`mdv>w(Cg7sL!*;)`Dt_$nWqlKe@@b#o8HSpnEpIN((N?bY-`3<`dIS(7QNM# z-6i6Pp<3#i=lX;sGIKlf;!kjG^JzQaBzrs58;8S@i=hSdqws=s+m2Y(8-p9~^Np^( z!)r~6nwyUj2Orq|Hjk0lbFkwsWX-drpBk$~SeJ#NEZIU`;+gR##~i|^z~-IUO)#2a z>(lmxRybWCII2kJ z?#*&kSR)B0z9c}B6;Fy%VH!;P8*H5HL;sJGM42LECzVSNr?>YML)MUhMPw_^PmJz#caegD3LtIPQiYga>bHkT*K? zXItbofB0n2)1Jhl^Re$78Y8wVUtGqIa!`mR&$J!vV6a0#d*Zxh1QmpPIL*&Syq+e@ z62)`Bz5cdR)N6SD=ODh}19$OCx6ptVhQZJV@9LXQO{N1Y+i=~^pkIhZ_Q){oj*#RG z{%7$FLaS58b6%!DK>PEN=(xDRBK7>Qy@wm8SZG;$2LJt*x)T{>qSW2%hb@Hm1Q*Wa z^Q4mc4TO-Rmi0%kn8a2Jv z3L~W*X^rc)&!p zl5J>oG#sSgQBUYZz61TFC0g2M&^A+etkC`}&^^|DS{NX{mSA*6m^1479QIW&q|FN$ z2>sq@8EpIRi^wQGdq5t$Zk~OYymevx2F2I6NlKA2dhT_56f>+5o0o#aW!c~@gPfDj z5sD(Z_>bBVQ`%8Uu(dU*=Bu+Uc?AW9Lo5Fu4`{RB3GCEOFflMv^Ba+uKY?3XZs02x zDoyEVyyaRb{gGxb=UP-yq;_m>lG#|Gt2GT7kB>_WXY%p5Eh?&Q;c=;$8+bA1Y?LbD za7Wwsp?;hax@qf0u98-x^M;aqp?i1`FW~{7^-PV9`BbgxS$ElNe&su0AQr74+abbh zRV12zFUKS147x}!kU*x)Kga7MPTuAjzBG0LJs5W$2Nef?ZnhH774~`;Z`+Zj5B^-I z#;JgkX?}gyH#SXyGD07hOo*}d@izV*KXA&Jb&~=UjmH-n1YLUNVD%ze>{=?26?9L+ z#m87x<(6%vfR5N(3C1&QZ$F~R4ON4@>9uVnRnd}_c$GhX~<#on3eS$4vYcjv%%`lCyWVgMZ zqDCfp*Cw-trJYO%M|xdSBa_A6y5`(;o!kHrh@IUb%xI3kH#!}!{4_^vA>DJgBc>zo zw712!H4W}Xo~2gy-bwVjbaez>KnU#%Bc}=?F>ojM!<*{bNyH(@aNP^VmtFn9Upo3* zK2@7oi`g?{iOO9?!KX3}(8`wUBan%y2@}V7BXtYwQ$J}*q7C0~Qc^pENR3gNu4$$P zp7+uaLBBzIIe9Z7$jOlG#C1Q--mpCK6d}eR;wZ$#9c>5S=7SS5MjBYFW^a^1ZTy`v9s`q{$NW9qD&`{`lA* z2d?F8{$O|5Uv>!4NEE(ag}dz+-8~&Bxe1w_C4<(f;z-1&nhPM19@Q6*pDup#$=gTd za}o|cEge}JHF`J1^hh__b$6S7Fb%f&?YOEgb_}TC4En8A12>`c*}2f2^v911Z#AF6 z4BMfDK2HE%hP<}tjWvn!q(eZDhPlX#U7y!L>}_OHon_WSq?2hG`v%6@u?WQt9qEY-l`R3k{|MgfEL&7BWvG2yPW?s(kJQo(pP7Yt7k z=JYBq!F$+zihFk?p?)OKqmq0r)eDxc054H{mrLTioi%+@zVL|j--{#6paFUKIU4at zySe>2UY}`0IdouUxpDa47ji4Vso@TrRf{A%bf(*yvAd?S+^{^ILZht4 z-S61}q`$k)ZlgxKBYO{vHoP`&f9M}*^(T>v7>7yy*p|LfMfSBkrcCwB->FR4xhNXV z$L<_Gz7vug7`C)>ZsoxAmv?0MD0?&j%X>pDeGfl??^ae-k?xx=Ox_)QKIZvEiPDF5 z?sQ5g)o0e-P0a1_s7gu^j)2`Wk$2K>Dd0Ab&cT&(LuI`23Kd(Au%N)gt`_70 zq=j(yp|qo8<(@r8{j{;nhX!FO`sc(2#)u#Z$3VW_r!>Sr1Hz5%T1pz1rBCwMQayLH z&tJg9khg4M+L6dq+5lOj!y=#k#_hhACPL}CIvLmcKJ*w@@&&zCxA+Nw&K0CUkLc ztb+8%gT`g7eJ%)5^!19iw4o&QYD3EZ$gAnNDa1lAhJQ$wA@jIX)mZDm-(q4i;09#f zChg^UHyyGL@74smXx3_MCb4w`yV)Tvdt_yu%<9XFuQtxbanqK`KhCKDT;8o;maJQV z31_Xe20NVC?Y5Wg&IDfJJz6XcpZh3Il;j9XO-H}W6GE=|qb^^^V=`MV`bk!oKb&q? z(^$oCE6gw)0lrT)uhbZZysTkl>u$$4QIR!U{lIa(K3=y|rc`m-uC>6VMcz(HKto;w zt|Vo#7|zY?A!!cvO?>JT{vTr$_f+E-$`>)W7^rX!uHHbi5^rKYsz!>swC1A#B!%9X%Z(uwdM?@nNY^e0bjjzZ;+<^(@i6}Bt_hKQ}o^o}Zv ze-sy*7Fw=Ax;ozuZ(N{Thj( z>G1PAlWX(*=o?NCkGa-R*V&J8VD14aW1;kk7QndA%`RI&0h74-gZ#qh1HDPvs!=Io zf_R9qhl1eG-UfldcdBpYHHG;2^PjWxa*`<`I%WB58qf96riuZXz)h16ggcwb!Crv1B`U427~ zh~2Mqkf*l71#>mjn0V3Bka%GL(eZtTt6Ox+;^kii%he<8@nxPe8p_vyEG@I`H@*!O zE+o}ZD=s#8+}wFmdcRZRT=5HsKQ)8cHY!j4l!FKJXnebi4|}GAEkZjmf0=3Nf0z?6 z$kem#yp$!f)KRLn9F^Re>NJ))h#uIk@50b?VV7z-&?~SsDQ&HTv-(mvdyT&BN6e60 z*7zzW)COipbx!^##3JT>yC9&qYY=YN2ERUflonw#Nc(F^b*syod>y8F6;62cTul7Y zd_84G*JjzA-#6%d-aP)r^2t=Y$o^vAX`kV>UydDZM>G@N>8KL-qHhKS&@x+3MzZUD z503<(={xHm0{v6gcue;BI`mTjQ_jJ7HEtG1N>=gC_tWG9nHdTi)og$X8b)S+SZo#u ze`*6ploE_<^swUjcXXjHFT!eNTkn4^b$NK)7u>|*+3IyvQT8s-yu@Gc#_2}Jxm)G+ zAzxPIbp-4uSilHxvwU{;<$*@4am`9x$*(pW@o=?T6n~3aVC*(Rf@L~AxP~ESMrE(l zsW<`FnqHQMb&9N#oJMuoXv=Ct_)KV$F_4o1o6pT&a!yRyigL{4jNV3IqJ#9l>57EgX)TFSMh9 zn}*RTd|<--6_ZW=#WJ@WQ?bj@QO0%12_3L27)N6YBFiF!p1M}0#M=S~O|=gFDKy8) zo5Qd3B!Om(ZWh8WJ6dsW22`3`T76Paf8RO#mhc=77>v~PfwGNZt7yf+&nDSd)Spjo z3v6vCWv^iyq-^`Bw^#a9riHeqOAJnIV67ai*(Tj^Q=W5e?$w1`El#Z#r$@MmOL2fT3$L4_tTd5#~kePvnOV=muU=dq)A^tUlhP z5MzT(zWQc7LpP`}Xm#W$7|c+KTRupSZGqd;#xb1*A9@aaQ7>tjRD^MH_1^Kfi+l+; z-KID3)QxcmB*vGPwO^a|mABY>-&kdO8ma6>P?3h~YG%F{)UP$+t;A4H ze~d<4DW0h)(fJx_8azxR&8}2S%B6T>M`RU5TUJ|kkvvl|$Z8mIoAp8;`j0)R=9M0B z19^y2KA*GrF$tl7uQeWaieP58>q!ujhsvlfu(rRA-CId?-R@oCO8*LbqeCz&BX!ts z%j!3p8T{AZpzZApV|FkgG3jA|r^c<54Wb<9;5YcEcSUx$MC%rzRt_t8l=Eq&!W#Ey z!(+bFXk=y#0X+-Xa<4tJg>&<_WmI2=W!PR)9u}tgQW|)C=v7hRaMUkGlW5qn83^_p4k(`>U<+qGQ4>)clg78LNik(vwB zKR8+pgYDy~Ay?ro^TE=SzPNq6h`B>Ld{cYGfn z7Mo`c7uE%?pQDLYH|#|$+&QE^Fu+&;%*OX91bS;tg87kZOxM;hY4 z(p~al?i$9fswI7Sl(hWR8!?YF_npGM2N9zKL4oiZ8 zbs0X_fu|a8^RDxRMbEbHYBd3_U$dyVWDNC1E$u&99Vy|~KR?fH{3vD>{x3JxScoZl zvhJAl=}Zz#pikhM6Z43Pl~w7!9Xu@0=lNNd;nm3rB5>fiTx4P5Yj zee3!g;C2hNZoI{6*0rK^;9x+MOg&#kR?~%rkDot$?-FwQV(4lEzdH`S%&NbFkcg*O z6UUD|^8720=I;ITMRxB$NT&a=`~L@(H9^zRaB$A-uhg0eus+MWwsA4A2*jzdImJXJ zxnMB(U*)yXS&^zN8K({lkp7*u5Q+cP?*Bx+{m+k}#VYEZS4ZvTHg~ug)Yh&W(_x3k)k2bMYLA$4jYW?IyRXp;WPiG{`c3ayNsi7gff4CD zTHRuMQRoC4n%4aB_3AU7Ms-8%mlFK!+7hCe7N$`@Wx?0mVo4u~pg*WTmaf9kN+q9- zE{u$;+qYj@m)D@JZ`CHY4}*n*Qeck0G2p_t#6sbpDpcji{_Enn_mgWG3$n%~=-$i(`9pW=T4vuhk8t>8{;tHf+- zL>h9B@=2}IWJ+NhF2%yI#nM_wfz6$5J&lqwIq%q{)hAPNSesEQF>$|_T!zJ+6wEJS z#R`F2PhV`dpqq)0>2A5!y9Zj-uzBLt`lpIEi5OW`t-0FLV-)q%q;cnu#-dZnFyw3H zOyKsjF|mlZt}f5<`M<4bYWQoydJ{}h!W+iBoXSUq95hX;h}?ncS1+yjR)wFB^_QM< zDi-fH-Fb>9uIKs%r=iO$y>HiRn9$A1($8WujssF$H!X_f-h6L&UNY6N<#mblr&uKeR~sW%@Av-ZgJ_cTX6JIkIjfO*Lc$s z%#zpf<2Z-;Ps*a#8Nm%g1lCn4`nJbqzR#)1eq(? zt4%|dLo2J&WiMaNr^gw}IRF3W6HDmVh{O|CmNTjIAisdFWpUwz>_YG6J;{UNnBR`L zV<(@DJBR6_L+P7Uld6*5iE}mkM z{cmt(OnWUgxb41A$3JlusFneQwDn5$pX1&1-yKb*4XsheX#BQ?@@pkrGunrThriYi z7}(kStQCo|FiRZUQ+pXuq+Zardy>M5vqfZcdrMtMoUE)}){5c0L{4YK2RH?&%bEcP z52v`ytOz=BAv2=^PQ8=U{X;;v6`6BL>GtsW1sTmBVlR`Pl8f} z4j^hc?t{=m>Dc>Mw1B3?c12evoOTlk&fGA76i zg@b3RHL#&a)iyOU8lPpxN1od3Zh~ZO?ERS%w{TXh+DA&C_;ttgEM}RS+i@?2f|p=n zQt;AJv+mI!jRQ>NcDj!&bsy>(MkB3^C>y;O?dIxs8wVcU`xw%c2#@P)ptDslI;Ix8pCwhynXI%vN}&bo;-A6fGAYMRi@Q`Tt{% zV4AM`dO}aDv)uK4CmFx8x1OG*eo4c7mS;m{r~AEk?+x6M_TR{t8 z<0EpuT{&@0-a#9B!Ur>rj&u2;v%zw5p{e^kc9R-+$4Y7b+%~g|EJ~#&{8-hqe#ZuC z_f~oM7l~u_gOBAM&kVhfbIeMV?#Cm&f|!I!0(lu2$g_}e0#p5cnUc1GLON{8kp;U# zPCY~o{6#^4_r=q#_Ny0#sINbA9Gx`MH&Fl{v*$%AR_N_-;99D2n~iCTy@!>Ek2`~t z-WMUQaJ!p}(tF^_B{jVtLP@Sp&6_2H=KSuszfKgEep;wE&I5Slf>`p6Tg(@@`T9nMM#?C0C% z6Hp{aG3vvw0Dt)N&PTDV3t4`W=SBtr)Kwng`UXVD0=3yS`Q1X^#NSQ4#=g#c3(z>I zNymKSn)m%0UsL4$6P{DI?B zprsQqeG(M2RNQeNy5qzqqixDQ#e7i$;`ZQ&jZL!&=-O05R=vI^XnlQjps0As+EA(KBu9L$LDBW* z!PDv^z*3~(@3olU7f~VIj+lFMJseqwUPK4Zrl|V*Db0FCVGu7Ng;IhGv2&TTUfM*H=6$8JuT{8w z7_U>9~@5fA1B9G>-gs1dQ<8{F(%phO8B zFSSMW03C&@Y}wx!yQe7SC5J%Y!G8~Em0_Fwu7!>y2x-FBDD3_@Q5y)(Fw3vFF7y7W z2{}kz3T^aWY6j5ZOi~3unP-zOfG^}kZ@RW9X5IUp+)sR;lr)?f|G5XB#6iGIcjycA z|2A6w34*-r7oPh)UGLOS@i(x(+a1mH$9!eJ+EN`EKQ`2b^xrNbKCl-FYuss2%DDIX zuT4iL^4=xSIFqR4AL)-Ddlvf_6SBuD+ETmzSL{wF;GcLs_}_RvO^Xp%LR@tz3yu=X zIpB0lrT#vN3t9+wb zt^Ata;_jEKOM{EL;fLK|lazoB6cc5i(6Xw=1lRyMqO> z)D^k34WE-&QHj9aV*F$^OAWuutiMgyuGzE}N9mNkl9H0#4*G?=IFRnh|0efD_}GX` zONM@MkWd3af`*F(SBf9Sl3hbVB3XUK0e(G2wFS8v-Q9rC{#!(fJt96cVnXClpI_EO|IpcnV zBDx<^r_Yxo&9~K_mZAi_1?`Y(%l!2Q+Y%L=;y7Sr zDM}Y>nDJX0bq_TB_Qt+b>TGZNwk6lt_b<6EF^hXEso(53PN~sMe+d&FbBVoo_COarn0_9tCMg=!b z+6JbTN+{9Y9{0;X$2lYXOfDjDD3!iVO>Jg$7yC!euMb!Lr5DW3#CHo4tch?Q)1y_6 z55dE;Q__Y%e`ZzI=+}rbKP$_91RhQrkoD!K+6K8lFx- z#jWytudN>6*R>u67Ls_7OIcYF0(mW}g`YMAW-9{2kK#dOk#6BKEV zJtdyLecNw6WQY7+;#BZ<%(F5l66%i;GK84ZC1SDW!^hp{!dbS0vJTNaj31=DIIok%Km+%O$+5u1PI-E35OX$fuka1{5O@Dji61f(0(gY(nJ zraVy8Qa?_vu5Ppz9uI+e}F&fu)F6*@HH3oqenlKGtHaNG`bXgXt|Rc*);&dltmLg4grB^M7Rf?hV=VW~{3ujMZ6)7yhN z6gK^pcNg5~-lMXFCUQ~5_mx{;c+lXET1Bbw+^-+pv{b=HJ6oL*=n_u-@=ry^{7|ss z%dFWT9d?eiE=Zbwk0MxE>DiI%MK2Y)B+y;{K5PwO*VX#Gs z(`OCgYfpX;Y>-7zmNok{f^AD{fzyr?zwl?XYP$sI!QCB;`_Gf1DUUyFZn-crQX3lC z(({+7k=I^Be|o#AH%g(nSlc&Bbwqn~*&3%Mer+Psv;#W#IZJ`7^M;DC&)K$j?`R&x zE@5z>PFP=oWE2&e^Kn7n+8KpsH4SeXXjYh}J*2#9NG^6txu`H9HJ4~M&bpqA>}vFG z=9qQsQL(c&u%c}qK02lYc}m!p`KMYuE5<{Kvp8cAxUG?-H}t`64L+IoRgcJeSiee^ z)i4yqhW(yyxlNm|?vFDi%5ug>m@VcX2@GzT-ni9L4RSu2LM#e?NmL<5;Ih_oe6^Hu z8FSR8#eV_W;@u~MB!IZfLtM_k;J>zwNg-k#Dj_b8!8v6xK2^)a;C7>FFj`-12zNir z4=R4M`EzVp@LsiI(mK_%qKtpU*^~Smm9bwj9h7B%S++?BW8euU6X_n z9`5mk*6KNHG^J|Xf1&#d4&7TzwbQ>DLxz>Y9$DD6u8v$AUG#5P=Hi}lDJnt!uo^~< z2bQjL?$wkiytO4y4t7t{u=ypHf(8I8DKxOy1lm)|SXlV^eWC z4N0Adl$ql1!dfR<8RME8R%M@mfQCRdahf?ep4bp!2OL_x6kp;hAWK0*-QlRrcQwrV zXDpxND;~lL5s%%6Ik;rfz{5K+Vwj`GwXDpECYnft<7bDUzqpAD|Mvf?Zgb8buPK?r z^da9@XFWNh$<=3@OuqgOWYdr&%M%Q_19ObqV}BuH++_zN&Sp&BXF;zlXM>b{j>I$f z!KGD?HtzL(=W5q(O~@>+q$lSiS@W0#@AyLJDiZIDkdhLQPPkO*&dfo*)8f1i2*ZYU zwq6g-+G<%7HR7Bu#*m2ki9ILEgq%8rVn=B>#__?Do;iVKS3?H}`>Yr{Gk8^^H#v;X z|8|zGONSlRa``t}v1dPIpW^Z+6^-ZmrKoTkh??DNQ{+Gp;eHa0+E8Z{3$DtCO4=wr zCAbYGzg3}fI{QDM|3+a~8HdufNe#*+u^@+7r3~{pfw`-GJFc#DhlodYF2Q(S7T@vj zUdg`Kd7M~(@6+@>P8oe!2GI8~2XrmvR74U6R}v+#zT+VX(dG0bm-qar%s_oU$fm@B zGm-sElRas-gu_h#svqTTpUvwh8%D$F;!x{OaWpM{IKtR2cwJ`B1>Dg2()SKw`&U%F zz31^)93cCBTK0FlcayW@Hf%P5-+xFEO&BT5f6GXEcyyE`i+em)R#lb6OA1g~S&1XQ z<%s0?5uSwS8A3MHmyni8oOu-3qmzcv+O=E>IXXJxZo%dsc1L^lWs9nO8YL<^M8awWTm`UAK(0qVys%$e{Y3tnybvuL6u(KBnMI_p zuWy0dk=bI9t%mxvUlwZ?FR|nDW{&ZYfR?kG5lV#N( z*Vur0%#Fmtj#1ZV*|`tnO17cnuUg%h!G%8toaqTuH!MPh4VqlmTxd2A2~bJ~1;qDl zEDW^1)P6>yZ`W=D%Yw(r7{mpynxEXA73V7zo%wd+2SHwW8*{XSgfpr{%lbX2I{A!O zH=Yenec)|BKBFi)vCr;`-P~qie7`joP*1pi%va@MRVa7$;4G@|?KlN_Ku3**`gT8% zxuX5atrD51))mTHSyK4pcuP~sZ5B8$V4dQ7emu*VR{Rmp;dA4ucYoqb@K9%>lZG2OikV`5jQ;v#l7Hb#^P}2r_L-f)xYx0> z2B3lm_ctfmOLytin@;>N7xGsyE>M?;)AV0)wp4J9TtBFSEZ=pcc<--d^6KMdub0)i zCo8SE>p&MC_U}+S|BH3pOVi@X>=P{gs{7?#9GgtRX8oJ7oVH^7_nSzz&kcf&$wq?_ z@%bJa@_p03?L{A{h$+v$kZeAGC6gAQAh7&HZ4|W2TmFJSJ;47vlG7i&&`C2myi+;z zP*DY7YPb74y;CzT`fcYy6bR^{hkRdi1AZx^omWxiSl9g66okk1(h`^K^TjBg_eO4) zZj#F0le@0jPG?NwsBX1(9BUHP;!V|Npr|zlyMmoI^Lyy0jbcRU(an-a${yaKAwgDm z{v{}t50%gFqEDX#3d!hNzdl{xR}l%0&6E!5Cv*^Tq|BLy7K1I0gT4MGC-+y8#4jzJ zhzuLumhwOz=AK;8nC-t;vVqg_$PhIny<7Shvx`I#Q+mCb%^>&qfupTzZh5R@M( zAECrHQV#4x7-hJ`nQc}@?y}i1W~2G&>6h#SCbo%`Q+1ojuA@OW4Jn}};PKW~D1 zUAEAZ zuUOluX3}2!Yq)H-lWLx9BN+wa?Hq_n&xXPtWc8H~v5INy{{lfAUsmO+s4xh}Yrp6+ zz8Ku0{^flx^ix=8rti~2(zxLv7F0Q4v(pvxHgzv7Dnycg_gJv}@ARIVtXW0x85`ia zO9Ice&1?I7`Ch|dQiL6@ zTH3#*KmAFCAuo-{hk^%&c#PS@zhL<|7crbq^DjOKym(cHjHD9}s&`sT{#SX9W>Rnl zrl+OZ)aC{OlW>|?`vT8S1D1C;KaX z6^<+KC2g`zwXyNYa}i?GWVyi(#G@jE$YGwJU|iwJ%TZi9z{I--KGz46YO)?LO=i0y z=thODCc*;{<$gYe^5?WKhKlJ(ur6#UChdVE<)_^!<6|&Q?;-Bcxx^4Uoz2f8yc$N}|0hikAKGl3I-sM^fd&pWvKj5%+Py^Rq5=pRf&_$HH2o<~IsQd{t-KwTNaY8^a zaCj0XwpQLT%az(5I?+}0w;3Zqyu-4n&fq9ji0!l=8c~v(B|36c-dRzb2A<;@-Vdx9 zKsDf6f#>0(-EWZ>rOS5>rA}95=NhMRuhW z;QHfb3HKX5qfN!GblT{rjxsmWrEVQK=;F%OnP>O67nQ9Xq@*Uw-%jDu3{IbT3t{3o z41(r;U0%0I442?!EXJ*N%@m9=UpRujB^o1*UVv<89IoOOku|#--PA}TSie+y`QYfe)tV3@YRHL5{NmJh8#h$9WA~26-R0v% zOjDrKwA_;RA}3CX`egUcNJVz7*cr$C%&Jhw2RLgTe*d52{a^K8|CzWyH~fT}s5_M$6&iL{IsoSbf}zdCu_? zze8)7bCv1(^3@5>ZN~l22HpY>9M;{_(^{-ub$DSfa_Uqn7aJ1bYG=od%g#9Jr!t6* zwk9KpBV$#0&cwvjp!}j++IbCDXy7b{;!F)p(%$;D^+{X+gK4nG?i#e;c%31Q?l;^! z>*H<-6Lnhsa=bO^p#Z9krAy9Hh+G`kGVBmtOXV+qy*60?M4544COIxH&+eh4zct=u zMYIj0xLADhMdP26$TVM{>vih~^@_Fa;h?V7&bL+0ksbE$EYiRzZIPug{%6Z9laDHm znS?hgo)Or7%vOBOcB@Fr@;QO?g;8+6KkJ{4>}&SBpQH&63JkaB_NczQ&UsVvn-2&C zcdNANd4$U2oGt;yaz@v+TK&(_jVVcS5yms^(x|j_smrBpvLx$-=ycg%t{neWm1p54 ze*SEy>45j)?mN!mmtsu>TKY8v!Tqg!f8zy3Ofr`+3UqG0cf**bdzzf%_ZalTvwK zi+r;^#%mAbM^-pDzK*LG8Dq`5;!8%gF1Pa8czCb$zsrt6BT#O(tJ}5n_2@>Iy?*FR zxFX>m!#J~Ki>esQY{4N^c+3yt7OSc}xB@#?N(e8(mV>uLrN0_O0(=Kn9DD@p=BcpW zemkzb@^&0l<3F-)o;e>oHLc8RJFrp$Xd-txA8x{qM;Bne+h$Pat$QR|t@kzAcBa(M zP;j}`{uTW2HreLVtD#H$=rmiF#Cv_lFNQf04TDiVKYOtR?jYeB)6ISf4*bUKX15^IhYw;ueuy%w@|!PJcTjCr zW!^e$zkR=YY(93Z*sg+HPmw$|{UZ`U%Xg}IPvEZiD+}Yl7wlDa*$;vJ2aRp7c81nl zrF?!Nu9UyBV*p#bNc8%QQw(z=8issatHSx>XC;h>(Ct0zbj*J3HM}7~{-Xo9WR(^2 zS7VMjiYv84P=596X!PpYa}v-ic3?x3IV{X7+o}fp`ue+!=gnVW-Wvuh$aXG|ndgKu zZ$1`0x&uvarLsK#`O>3dZr6ji8=*Z^30|M^h+$4d!pAaS0xV?s0cG{faN49F4fsI|s~T%v*O0aFeR=^nirX zO>jGuuDjrMuWu6;+~hcUE@5?O9`{`dY?tuaG9hE!v;^zrXvF;3o`?I^n!{JFK$+R2 zaky!RvCZR|SB4dEycP*xpYe%dPDCR#3s-zeLj7Vq>vlGK%rSGITuzdkbp@`P-QWJ3 z*JhiQxtiA}ug2?*=J@JnKLIZP+H%Z(_(vWM^d;ER^p*bms$$$lQ1YI8?m1j~>80inIX(UK)41rOi_D?q@v3P=L_`w=!<=Xmkc)}fuM9^0 zoZDqamQP>5n+EDS*=ODxI=S4ZoJd9kjmCCim=jF`66Vs?P6NrMC+$~GyY!3zHtzN2 zNp`8W{SU`bmY;174UJraccj_JW*KHxCFWLn&vMY3!$dzMKlH9lnrOLPF^#z(sAi| z)A55(osSD<_|4X#llR~A&p(f&M~`x+QjxRdXUv$PPkfUd?%}XbfMb{w5fOP5&t@kg zBAOr==0rq99>*{zA|moQhB*-tk;nP?y+0x%BJYv>mTw3M-=U82{L}G7L`3A~;Gqtj zIB`PX_Z>Rq(dz>f5fM#d9UUsnPa41M==;Upzr;%~y`%?-h=^$XliZg4e(SBb^sw)x zm;Q{+TYsnrh=_=2{FB_4{O;-L)x+tbaN4)zr}dtD?s*i<&&TxX)Aa*HL_{>cNJakL zfBz5M|G8D4K2AqD{X*l=Xv+>!_d>VOqd6=J{r+*lqh=_Arxf5sbc%5?+G5fmfLm5BP6nUR4RGo~XeD+}4`X8iE`IREK({<3_^aT61r z8nKsup`=Zy4VQ-A-d>#0SNQAJ$H=?ovb`}+68Qh&z5Zg~jwBEO0000g7%AxcOq-3Z)@v;xv0(%l_H44@*QbR#z{-8};{0+NHI z?gGE@+}jHTzTNWHRaGXa9%b1EUJ%(UX(`g#G3CDlk>US@* zAs|4!QTsjjXjQ&aey09R^;(aX`eoLm@8Pc>z6trp z_mqt6_T$KKs@$g}3Qu1u@qT;!@=XN8OOprPU%_9OP8X2_IktXYsDnkn2LH3Obi;{YAoO;?;`o^NMQ*_+h8WyS1MLqVvp!H%&Nn^-=Ou$!U<>JRe)rCY z8^hT;np8BFf${?8PFk)1UErldDHFQ4U?%cYfn`mLGQv?Zy1gZgJ&kgSd-8;d_To5? zl@7OiRXgeghwohwDA+8Ak5$+sp1E#Rx>(@vC#fBBO1dn65mQXqLgFh=9DOS)2wYra zNwc#P6wUSIltTXUm?CsTjy9;%Q`N}+?~j!!$s1+^R)`n*nnwB@tvUWPj!Ja$**ZYi zyOy)mz7Ji4C<74DHQ@FE$(Ule-#vN5rANo1*z+;(QyQzAy*8`gzx0%mZ)Kw)IB7m` zw0q1fikwVdvV&JMvq9IW(ZO?TzC|YhImS`op1(9ZE&~ldXyeh$6ybt|twhtWLaqA~ z;&F8GOyYcd>-?`S_6^dPIVeyxSvK1&2$tyQAr;jvUy?3aJ~VFw+kJc_qf zPN4)f7573*%xQwU0s}K-E5e>Ln=uBR?XzBVh1eLiJGf=YyBcgYOreyK#!#}Z66Afl z5ld=1a_MKsSKVqW(S}HJ=1x%jn0L=m!I`Ndg>UE#X&lnusBCvu(KKKao_^E|WM z(lGJ?2PUOig|79*$KmMYF~g!OdifVA9A?01?fm@QkA2y@c9S483SEg|hOH$kvMFE( zbYf1XO@%|yQS2^dr1@fwy620iF#4$T!+v&l+`hWhAvzm*#XX#_sK~5qg1fnf+bdu) zTV0*rWjl2Tcgb=b2VT!xZAZ4iuiEPh`Y-6CA)@YNT}X(uK$axW@V)HVu-yaZ3kbH5 znhrsR*&rvef>*%*ya&k71A^X*zBAFiarE|~ zTDyxK{!-`5Q4NqmbMF!HLnNG1R^Y~TPuR(=6aNGr))e=juzKd`R7omb##vr{DD+T! zdZ!@yu-xxzuK|&O^Lwax@jDTg@!a~uZe+=ry4C8>Pb<5YxFCt5YmW!vG_30;jB=-c ztPd?s9yh+suD)eiKduT-D#$16+EH;(kal;K`&pG z?aGQAZ_SDxZ;Q(d+-%;do0Zm@3LeGzhX|dowsRB+9gn2(;e&mVuqQDAz1W~2n(F~0 zrRcegAZGi>s=F^)CWulm!*YjAkQJ0CIA&FFxmP((l=K?h^+$&KOZ=4wqnO=0pI6oo z^38sob~Y!dF=YuF4tXk&d+1}-IrrOYJN!fib#twpkEM!!iG#=puyyK@$nU6N)kBpR znW5ww!3hZs_XmSVTV(}uy@I!-QCRA<(R;oc>0mA^2OxZewI`S+9lY=UNU9cZLZ+_5 zSBF#ZqdKS07w%iMTLV>aX__+N%6`Z0zu;M?L9#;R>*O>ADVr_9f}Vp*(V@!`#LXHL zX9joBY;h&vu$o+kpzB?5?cWh(?8_(6`#TqvbQe^{(#KhWrbc4H24%Y)ueN3@{29hj zBV*2|QL|s$+O;@*lSPFNuC+Q3M}1!1%+|Gs#Mqcc#$NDkze(`=XzIa#^r8X5{d*{2T@ zyml|k?%H#UJW*L)8=8xB?rx(iOiFN^H@(Sy_$`@Xn1Tslmdk68wLn;3adrK+GDCBX`~E)fA^DOmxdXL83(G7`t8jg^jpym9oy zRNlBtyDdeRf3{>Dif!R^LKK)-cIKce?|?m2rPMrO^%GS{zVOo-H?(_@zzfeHe7NW8 z9JXHnu}+NFg6U}m=t+OC`b9O_h_NVCO-xt`PS%nb=o3n``z@BZk#!hFDf5)sVq8p%|kT4Pb5npixWGPLHld7>tJ%$PxiEVFc~ zQJCGGHvT;m9wki0{zg3O$7^74oIu+dkgT$iu&cv6~xOAsW&1^^O zb&ch#r6ewm-B_y83;gYsUq0Hq4{IwDXNQ$vmt0PkDm--VB#qoZWT+f11L1D022*(J zJ_YKrPCRo}T`uukcWX^k4hWVb?(CfjpD?@XO}DI}T9b#3_3%gY;&Ry}Zn~KYkg_c? z3howcu<<*yDe;2lodL!)$EksT{{(Y{9PF`KZ3iimcD?dB*qA?C@YXrscL&XTn8W@* z>8LIWZ9D7QvRKan3`3Gk_Nr&t+rw7qlLQRu++<|wZv2d?dly4<0%2c2%+51JGt3FQXy0BelS^`G_Z ze`f^EndnCI9$t_$u>_s&Jd71IZ|58ivVXnRa)MIcTEsf{V7jdQC6mRPryVwOyrx1o9X!G^HhvgU)m~ z52OU#4{x(6PV-EMi{d&NMd9OTAjxABZ=4GmenRXl>0-RA)y~-T%rze}w(JG&_ku1E;SmD_wVb`91lvTb44Ckm`yGs)a5?kpPYV&Yw$3 zx%}VTNKx`%P5*ZQ1L1!HQ0?7&g7p7Fjg0(%?|Bz-3$HSgiulTAJHG4W%7}kqkrvra zp3DLAc!Gf90B5)QtXx|J#JLq**ejnrpxN51sqpCJpX-l*fsLYa~sE z0fRIb1so4+4g3Ops4Pg2tBE+DP-v>v%ikhb+S`sn+)00$r(AUP_bbfCNSvhKkh5Cyz`aR@-p^t|IBj$842ASNF3Xd-H+Z;YDE?gW~)sPG7PAU!?8CScGBM(WQ#l zyt~Pq?^I%m(Edw-JaWKVQy09RYWQKs7_Irs)UAz+C3vr{P8N5Cfdk7)YJ8OzemR0) zjMarz+BX%uY6O19%Y}yq)lTO_mN(q_%@x_3(_`2Zpm00U%mfuKT8JhdPJKs`OWD@E+ z9{JmX;I^EK}cPo=JBm|?m{CqUA&=x2KVlSkn`RAaZYL0jtR z*AvsO&eN}uc^?JcA0p)?*`X02gu>{q-wJa^~1xkTdn z=)l?TkU%)(Fc%fVW8m;+yMDVQJLVG&kDXqHZZeOKn}gDzvzobH%^uQY&apH%waewX zvLwi3#>BY_y)F!ruIxIUD*zH41?x2+oIS6ij&>?)VxC*~{akTX%3zZV@R&Aa4C5?o z;tZ!^mJncWCl}9xUQE@%iqw)}i~dN~X-m+x6vYJQ3B2~o!zsXcQ`&n2d{3zLk$`!- z*xjW|6jV{}a<9SDT=BSoO7Kh*p??nqNYUQ)kt}h3w?vDspkD5Qv!!5n7v63WMr4CM z0^LIu_AL8Selm-ab6nOdpUX8k76bgj9Tmg2LcI3;OdBJX1T`M*hfON&wj!4XdrwmL5v)hBl{h4z} zDpL(qQ$sfPyDY;)qqsNnFBdjp?x4$g^l}tc-wQwJm@P7Vs!c2e=$$ zzqMMsDqA2YaH%wh83o1ZBpzn)D`1MbO4u;QKyNN~t$-;u98)$ecd=EtLacbM^vAQG zRRy)w)liU2EWLx@R{pVyWrYDbqP~F7DV+&T3&ETq>_ zxN^`ZP#eNh;r63iuqnH2rmIn|^@f=?o+R6hZ*Ra;VJf10nJ~FoSvjzX;{xyv7 zxq9@r!{WG)+5P9)2R2GKp!~5k=EDr$0di*FVQGF?5c${Q>W3xdM8Ke#@G_4ZF^zsA zdt;z2f{q%h2ObMD02Z+#5NAS}RV|uuQp^~56N+8orWMI!cfRca`6RpB+hguz^tO#8qlEw{N%%=VJ8{BmTW%lvQsT7<8BvO-vJtZJ%nBg^%@$%;CJ)nq?3LYKEGcc5;e-;$3fs`$eo;I zZjXjHggt8tOs}($qEg)B54~JusFbE$_D`epCpt>jmKh*Cpj$1vgKPli&&(mwFW%Ce z2T)U=PCJIRj2K6L-=FtYf6{P(3&1lh4PGMJO;O3*W^O-rII>y2esRP@6!)t}Zsp9$ zj16~6gFPboFqLJ@4foSXw7~Dfq{b61 zkSXbB8ukUe{5A814W4YrELo1GC;uM z5_VM>CFUFoV?1+m90oSWCQpSv$m%6rHRhPRZ#?KiSdWcRA zwsc66V*M`*u+GN2|6I{L>p)%$QLg#UmE;5rQVmIR2y2@`!TNCZQ}#kfK>W9Clb@pH47S7a=&06viAYfw*LUM zu917?uY^^0X#AjnllXSP$g{rZ6cYxY*W!VBB5z<3Zv9ld$di0&nyxHqt{R92?)67G z29eZDjMS5#oAQd^$Yvf0jViq=XBtj>wBX6jFDN<%Yo;yus`xMVMP_ySq@VPKMj`dCeC5$xPmioe<*+Aw(^5R-g?E&*()N2Is8H$3=4|WM=DglXgO&&V z87FLj9xM#Zg3DpqOMJd6sfr#o|8hskH4E>K98Cm!K2si1ZbLL@N%i~`&;Nes9`S3U z889KIlj4!qq;$Y~IB%i`A47tc#4M*!a$uR2D%qXMS}Ro7nOg>%W-yRC#mRnQZJTM4 z>mTA+(_U(jE=8SOz~~{=!lJL&#{CE`7lG;@@|ndC35aMa7*)i@kwZOrOOfXp>BLJg zT0c9@<3p}SEQwE}P0$T?6CSvkOApndl0SY!-WpQcO8d#Kjwe6FtI+EiRXWe1`6ieX ziSh|~%d5<)l((-G&jGR|!Q_yCR}e!2@Eo9;5HzkY;wTC~<9QT@3Pw6R)V&HQ z=OS`GrEqG+$~n}l3rccG;;;>C{0K=?EjPuUTeG(dpXM1nE60&2vM+-xO*T7G~a4yLu5}TjHu^xbR9)&{EgT zO|L3v;^^~yX?guaMSqje^~cK)*yT_~M~i=`u+XnSTz3}J zG#NOlP-=qI)kPEbBvYqOi~6Q6O=X|03iyUCO}cdG#|zua07;Lzi)v(g@Q%}9|ET4T z&5Ph02AY6C_9FHR{Z~#QbC0227~N-^%a6MZcXVBEKL6ZQrqOr%ov^hpy4r2=0s zuXp_OemVFjYU})PZS58XNLBjzpcF(DTn!FvwaYNjVUfF^(Pq%ZRhG+D`CBFiDVElY zlHhqD%{%O$@9e@$j8b~N-cn|kYhbbvec*6<` z(V4aT(kn_8Vh60Q(m-?v?LcY__9A7h)rb}Q#*x}ap|5MXRVNk4^Hda&mfafjG!f6K&g|dMqm8}){fhW(tpt%J zGPtp*2Bf2B9(cD>I!|rrLzs4ZO1tJ)+wsRSBF|XQY?Pgch%c}=^?8nMIeS)lUO;HJ zk`MN%$ta}j=F9S&{HfvVz3Guy4b8}R{W@srlwBJi2ntL5V|Y&9EnziZB;sXR-t6E_ zP`c3}3f@*nM_{bKQh#<(Haz&A$?gn$OIARnB@yC$7`CTbZjfcWUen;lt1K)lLVOcA zDL1j6?Sio&isM{V%H0@cv3XN1R>?Ikp*ey`-q5B_fr%W=r0Ll?J?3QxeLPZ>bCHzM z7HOp0%?1fKKjBgB_S3q2Y0{tO;1o<>x6~<#@iAEV7*A+-n_u^h)}^o_^<;v#`6EzS z-1%B2F7JLL>B7|(hLNV)k$Zssl$JXHQ1aUW=K5xK#U%4N46FUwb|uU5M=P9F^gLuz zq)O?~;g~b6rjv5p7j>i^li&VWO265>g!O1#ETtqnDd>?nlU>S!D$dzboIb&|c*>iG zLAdj(;h8jyY(K?BMm{v3r4#L+gCaxeblZi%kd$uApGw>&XKBL<ed59-i%nrLs zhmem_N+2OPnrh!taR#23*!f}7*I|TCcjnU&8LFS(&S%!FXs?~8d$RnAOunD`!StxZ zc-xabUq1hyxN69$PfpoN!{$F!{(8{8`k@c!tN)Nfxn8Em6qvx=+50n!g{(DsK7P(f z3(RQI%`sbT4=tK=kEFfyC-V+bCE3J2nt7lv@El~q)|1Z+j(}<%N1@E?{*4|>?W1Ah zbuPCq(2vSjG=AkB^yc8o>pcBUZxFW`?CVBx&Yt^Tx&uBILqA)!NPY2olc%qsKzw6R zVnC@0TJY@eMR0`Dh4iI{&~1xq&Bw0srkU>;(QNxio~_vM&gm*FC9XB=Q}&4pVp8-L z6nbsnP-?UM7D*SW)fvlrvT}2G`%jg0%D3w%5j}}ofkA}(ZFSdVuW>rSC8)YSjlwrGtw(b)%MyDsj3qd7r6rijTz{ImsDEGz7K z5%oANgk|q%apbf7=eE3Vv0i0|>vwG&FFmMJ2K&=f-{cIwdcWS@w4sn#g1)mM;2k7< zN;uekdH<(ga!?k$SlDfRa`Q_`v;Te6K=7vUt>rJ~_EKAGRECfN>3M-%-%$m8R5C2FxwVDStvl`cn^8fT`zq#LeKRNFTb?Gv0^q&x4i9w406tw~L5| zmThkHkh(#yPhBb}4@q=W!RV0l61zs}1(KAkhma?@GZ2pc3J7eL~8Ca)l!*!^_$lA}qxdh@%c zA|{J=eR!@@qO_Irxh4=!Yl-@Ly76948zV>Kr|Xs%dK3XBb~pdL(D3MTGDcx39*^PpEXu11eYIj|JUrz0UVbcz(70UwuS!5UdVCz6>wyt*;~Y>Tl^pwb;F+Y_ z<#DMZtMfNfZLBJnuBnosB#&F0#n-Q+N>sPLpkzkAGHQ?ToKY|;hK0;V<*HL}NasCz zGk!lqTB-jNA=_`2@Rhn07O~$Zu_%6?J3vm_4Y+>JBV8W267~=aHB3p~ABbQE(I4^L zaV8}m%lZ5FeX?t}JtFJ{NoirZZ98+2#uzLd>3d47fXu%+F7>3R08j5wkto$=s zf^ndGLWJ(UjOLV@$xWDj4=F^wjy!=Q4y9wPk;|}pUNN3c+WW=I0ojBBbB_Gd!9PzP0?sRx(-*Lfp1j|@pFj#{89s{5V-|i`TKay>lz=x@JhLN%{ueq@)v7B4tm8SUhFes?7+IB-lZ zBV4^fzM5yF=%HlkO3;#=Q=8P78qW{MLwFDQ%I8Ojv)J8zhfw8-x0D~UWTo85{M9zy ziFg>c%0`O)va@SEm6N$kb2Qh!r=;<+6zS4AtvYsj4L|Synf8Ij#*2on&r>-bg=>MF zRGqgZs;YQ>eOXo`zDE;sAXaCejm`=MzZt#7&BJ684Ewt8yP50XhsaJAbphR0e8^w! z2!i=0E%@fqebiKaWUsjVQ~aTn<;_vLHF~3A^?NH_i?4GLQr1nzuw2YFtEK5rUx)>f zO|7EG(U&3hbM5+&+q67o-9DO@+Ma=ppNo90>Q0l^o0{+DSW`HOV3&Au#t5MuAv$`= zA8uR+&YI1zdad?2O}HsdyHOYA;x7Q7Z~z+SmAj4;ThMy{T^a(2J1YRT+*y| ze?A6UpyrJ8+h~#w_uib)FVt!kI{ElK#@k~sJ(hiGWA2Z}4$C?G^@Mwtb4m4|&dy0O z-j5}Ab3{x3UWN1?3gn*+2q)VXl-qK%{6kZM!Az_V<)tOJuO1z(m2d0#tCy@R>#Zjn zfN5JllZ*&bAW3Izyj>rO=j^Rbet$5iVQ%>vz1l9HaI36f`Tf@+j~66G@2JvqYFQ`~ z#zhM!{CROe%o0ze$`rgi=*ZpRLBHL7Wn3;@P?GDR+Ip9`bRjoaWLzLCxcm*96Hdyg z@I-@;2dC>cXc_m1M|YRRsf)C`k_JBKMKI9(_b)GTjf-mY!zyxz?UxN+H*_1qj2}QN z-!mMsnyt4CE;OaAl1~}6Lp;WxT<2;-@0%aG^EV|_THjbaAF(pouv%(fE~1f4UKQoE zIm%t`=z~yRlt0uQ@go&(q~tGcm4j%6=wN1!MRMLZhw(di8{Y*XTYVyIe_vmtK#54> z!4YJ+eiQ=hY;zB)#V$bFU=y=7^Ma>wI9&l|5v)F z=To1q5xr28TK=7gY&t!1cMU8F?ys!2iwOJr@=pi7nQ;2I+$N8UZO4jz1y9<%SB0VM zhmu5&i=WqK70*YD4t**?dh=kbtGqg$TETPE-ZSA5o#oO_*WYLlM4R_!^HoIl3Et+G zm~_}gz0rJBssRtsI3m?e`BLv`dB@svDeWjzT02B@WbOUeD99?s`ni=_6HeFp zZSqtqBzvJHiZIFK-0f;Z68Z@tUCNz-h#+)VX44v%t@rw#gy<|hy>}p~67ZVG(Ii;8 zgx?|2by#HZ&KIA5OCLK5-L9( zqs_8A0W&*Ey`Z2Rq3z%kB3%x0Z$Dsx$TCNYiTmcz4j8_#bEkt#PCTnxF@0I?7}Azq zQ||4gM^y|KanuUebp9H_iF1M z==;>BHTymJxpQ*pD2&OYyxB)zt5l}up)q0gnNJCm2l_988`&>gYB6kyV;CPhN~OWj z?d!|)jUeoCnF1-1J0Ec?3#YmA*pzQ76Iw!2$?`4vGT)DEdD&syVIn*d@QAX<^Ea8; zf?cK!I{-7y^|^zuB2afepl7X-Z+<@HP(zS6#*!QSzyqttCRp8!3=4LK6btncR0~;8 z^C&VpcbWc+n}N_>v>d)7lB7#wQH=Z?gcGjx*>AE!FZ##! zv>@3G+E-$pEoURiyZQuz#neS`M?fcIrXtXt%AyOm=tukyv6Xne{~ug;*jc&qRZuZrC) z`Cz{cNq377g+Q(aVVPGNim@d=Hh|B#7$v^Eqf4@+743d}fsR#y7AB5fq-BCe#Ao@{ zb^-uqE-~VL-xtQiw#jwvqViL;@loc{>=7fz*l_Ur-|NP8@2^D(HWaPx#qIt}qz%pU zLX%Utr#yrRdCB8z7=1lt&WfT{ze9IqVYN4o!ql9o>ARWpLRyx~|M5&|ox8c3-nEb| zFNCiF_})GC>n&Ay`)+kMg)0bz9kN_C7x15VZE^V(3HaYceSYq$9oaLQGE%l%#h<^;ogoAx=njXg4w1?!*SCL z42HXeK7+RA2A`74x4x=`>^9w86r9;*M=J#H-4F6zb+reK^l};U<=lC}mW2o0ni;PP!P_=VP1c2b|QBLJ1u7zlIuI|c$&EixG+?uIbQ03(?JNEHg(UX*q2I8t4RAp z)J607sU=`-*2x069K2NFq&z09@BUukbs_up(=W`DLSa_hiue+avOJs-fu7J&l#jVg zw+P}**sV^}%%Y{cLg8YhV_6>9?YFKStuSk(_c_CNOSJCAu?KPv*C4}C2kp)opaHI> zGA)3UAc}n2Q(R%qYCHq}+E?cu9PM8IgrU150`J4`HBM+#Aky~S(&TU<8C@vI%A!t~ z$gu3$LhTj;G}@=1@JM0vk9C6r9&~VOb7Su=f2h!`U{rjN7kn_#C!mt-Ia*zOC?>4x z2@&gTaN-NmJ8fi3w+st(p`^;%^EP-MKjhB5(YBT#%QRZM6oMHUawRO$fg5oTX%DHx zCxXY!p62$tAU3}XUC%&adn%NqaruGV%;Q0E2OYx0xD_h=sf$Kq*UIrYVGdA&tF!%E zn5e)MJ24!HkU#qZ-wGJ1^~%c1v-`z@09+Y!iU~XkXEk1=<|#KW<`_2kj?z|h;`h39 z(_0oC-j){1_O&se2o`09w_(|ECBCYBI!tMnc6EDNyw!%~fpP0vsDYqVrll0!`7GB= zVO8&Wh(066)@lx5{Un(iG@V;?JQp$fTs6G1=H-VD)n?{OEtJb(hb+5^e`LkCH`-@u zDM^vgt99Wu`s_m_?&b^*c!#S})k;8hNbHt<9<3Qr0-Hku*Aq1S-qo@K z2(;0d&R9>pv&%HS?wx;W#*$Z^jssliF^BoT*>%wK)i^7l7EfN97vyyH;fRe_uEA`2 z6V86)u^@!E#+kB4F&3RX6Pz_&rzTR{Madr~3IImKMrA&RxpG8)?aLcO%LCnu$@I`= ze+W$w{Xv$(R;eO|3b!UnRCVj)<2etw%a#acH&zhvb$~^Ov)p^7buY`gn$&)hozRoK z&$pduQ-w-iY!8aN@nm4FLC9f-8Kyt;<=z1HHQ9_suqz7`uvKI6-cUm6nv9juCmWWb zY`H+M+3tbo>j0?8R@wy^1J=FTWS7G#?={x)Lr-S|41*JRja8>lfNHk8&%T%>a;=L! z1l{BEngn5!`aIjUdDd&F6S#Ti!KIYO|M)aaaiER@_AmF4ti#xMDdB6)erU>NNw%BQ zseE!{m9Y@24hR@95O+dCajpnYSw7w;9O;8K`gGNsqe#UcP4mQBp=rxSq_#S1fk@he zY<@$--&KTClA?u<5?YN)=(W49yss6mk0g1$$FbbK z-m@=o4_!ueVK-*~*rr|WyBFAB2c487B@KSy3V%B%Yt=s=8m__-Hve5u|KF5x1@Kv` zs-|f&fMhh3-~^ld&2kL`=C?s4?K>HHD@FNLS77Krt&wVL!kss6&DhYC97RYa*5;?d z>m(%G3s1QF)4-Egm(-1tO95-=?Ywv+I{*NznPBXwkwaPA6>%QgaS_sW{rc27x+t&Wsjk%lj&*WAD!}G_?_2h{^-V-kmy>SXr z-WRlwk!e3|Z#t5+TDxZ|6e61Aq5az4T$e&J;;Otf&q-eo!T7XUt(VX-fQPyn10i`bk{K#d`9a{j&H|h#26GgSJu7CNlZ|$p?wfn zcB@#NYvMUoQ`vGr&a<{bzKWkWX^(P_z>k!9IC9Q5zyv6SGt7gJIpW*e!p3y`p$p-L z+Mic0{LRMR>&14`U7Ot5E)NnbOrts?CsN0!MVf6dzc1L&<}`;)H8wcv$uH= z(=d_3taHBc_Ji2v*2lSBrzc>Vabv3Ld2X=x<4DC6-k+t_!0n59Zr;1x$ae3$v4Y3+ zf}}rvgC+;>sLPjB$)5;_wV}SHmO41D{8dZbt29>@J&vHa$T$(CAv|C0#WVA|o2d!E zKd+>JH{e4z`dmCNS$QxrnIh%Of?HRSCvm~E0Iin?9cyA@mKMN%eN8H%pP%Bo_>HKh zs}M&Hf%;cXtCS4{w*Gf<)~uJ2u=3*xy1J1pK4HL7K5W!-$kLv6aIfOECR=nEI(6J> zaMAW&-%)Qr@$Q}wU(Q+K0GAw^on3uP46$iD+>1VchWM&=lmm~}oyjI5v6!L4wtz5G+EhMzOzCDardJD7=eEs+4v~KA#QNt|j=1ZO z*$^(eC11k~Q(V8|wlNe+-hR+N#_!+f;d#w&d*C*DvPwD=Jt5Y$wV3PgHhCO^t_f>0 z!}ti4BTM2Ax+JIT&xWeYQLnY+i2;hPsm80ITvVPo$Jx=cTG6f`@(vi{qvaoz=heQ{ zJSp0coz2#3uT+YDsm0sh3S5U6NSZgJ#A^%$*a%=|w^okn4;YA<>hKAI$YIIe;@XpW zq}?$-3-rpz)3}m&rs|2ML5cNJ<8Vpsm*X#P35gRYQ;zd=>F=sL5sJ8wLIkHG>mYD; zYLkWO;W*vh_>I9|&XKNR*?Nbs;*8Q}Cr^3b<#Adjc;(xMG1I0`nc=4uYqt`?h~)yp zy9E=`AeASqN>+Mx|2D|%PlRqjbuoc<;Ggn|+2X5<(&gb3QPN#Ra4J7Nd>gNr#7&^9 zmuS@p8oJ3Oe|?};LKrE_erYt_LasG<$?8P{O|sf{u8EGGC5^h<6&zb|vQCAk;Q|nm zyERl8z&r!mVb(YE?#bUJ|8*gvksgyd8+!ZMatD&)dS|BaiTJ~A)p(}V6CmF=XNlOV zYZ0MozyC*jWNE^*!WbwOuh22y_0;ty(#fo_1lkV#qlx-yosE?hH4vFojKbvd2K;GH z{Msv*#^0|fA`;Hzw!TNLdc?d9&Y}u`8q>}R)l8XZ!xD@FrMM2vsH`x(zD8;~T>*B# zN2HuXTe`68cEAnB@y8bzdbf`;KDoX(Hm}3E*+f1iA?UcUvc{UBn+qV*Oh}iL zWNO@SEFQkD*NIY(k1xm5>mel+=Y5h{RaFO|$+uR^ft#u1$SwGjk)V^oiyce11}*%` zqo(U`59sOWJJ2iJ3|tg@N(i@9(KM$`Er&BzBW0Z8-f3ZBb z8^vyRAidnw2Q)M3`T`f;^e+3h)XL>jFlyw2>2lBz@&MPh^P^%F`VxTboU@f%Z<(3= zf8ShvOGg|D@M@LgxtxoaleFBWt1TT{IT68LOu)&Bfx;eamXMs%10=Vt;YGy)vdkMS<661BWj)L93s5 zV(=RetFDvjp_m_CjW(AGcq9o>DrEF#9!gtCC`dw|waOU@j#mWx`I&bCTB<&s;IP zkzf!CvbzoVQAzkfaSlA+e4smO<=;4=&nq3pPF;6%2q2>}yI=rj7iJ!BGa_8VHkIGK z0>HK}xQ+ZnEr2qgm1eDpTl=AQ=W=9+%eL>JAtn`5Et0vM{JvGil=tOkE#F ziIKQWI+TV{4+AdL5d&m^I~$BI{aM9}PdhZ4^%XmAfdB+;HTZX`i2u(jXkebnX(43(uJ^C^m!8ON=6J&ZHfwt88iV;8OJD3dVh{Jx z)R|?>Q>a+I*z8Yb%Ey^)yLBG+xeN#MEkY`tv#Q$=npKq?KjiXdF8e>ed*=g5zu5iKCsH)+ddW60DXWS?Cn+jm31zTJ z1{60>CmMF|`NI0)4Ok_srIdTDW3OIAg<60M%9c8;V~ofD&V%9vn@REkTJ%F;?M9Y! zOw)`-$4bm^@CW4}6NMq0*-q6q*IqgB+$0OAVMOAdN(?zEe%rApy!zm-_M=*tB^yY%xnJAVo0 zgYYF2UyT+jTe~X?$pva{nQcy$0Afcy0wk~!!>cKn%=8|{xC6_!WABq9Pc4z2 z})hqWeK z-P>@uhvT=7b6A zEmQ@vRg$}20NUKt`Y?iTqGU6?=CuMbkbgGuP&@15q4cug%lL9cTG}9n+XZ-J01N+r zn;o{AwZ>|$;s1J|CgR{l<-S(GSF=ofeLX{i&PaCS_wM^GcKS;l(}dO+tj`jeCl&3~ zlC~E$@A!!?*IfHM{y`CAU@!fJ$Pv)yNpzKVY-mzwa%f6uYN!D|Kkx7Q-MUI~rvK^j zWa{?myHjI)o@=Txz2lLU%M5wv{KtOQAD;!Mn%y3Dw{4jIczMwAzvB#U5KHK(d~4-V{Mw7Ir+5BM1Ah$p`1s>r zal`47Hwqg=>0CGqY{H3vWP;Tfl{VL?>9nRK?6_F(nTtA^FZPJXaXy9FQvDE9I^;fo z)m$(HO4Hhx?Zs{#4Y`iiTESs)!Vrd=iy0^$-nLO;jx}k&YUYR7w;?iwMpQH7*3~b` zQK>iBDyyitmne7iTB68Rx9td(iQE8Wz%`&YQhSwfcqYaT$d&=! zph4bJaGX$k83%b$luzGp4vG#SZNdHKUFlti`ZI0iZ<8w{@&saQD>~XPO z8jFWmncFqJZC%L#b4g&79hU_FW04{t)s_aX3Tz zZ7w)I-Cs^TyNxim&z*W>a8nFzdv7`*Q(}v%9}=4Vx^b9k4s3K*AeRS2K$S*BG{JXS ze)crPE!D-4`ax#m!Ds$z5!Uj(i2!f+zwKrk+Gx@w|tM$%%~gA*vIjO@4={3*Q8RFN}J! zToWb6vWH3C<%Ru2M(bSFWDtPwnV{OEx!sFTbP2Kt85E9e>#aa?gkS8|Nh3DE-8y$S;ge*lsA6=XU14LS4qx?BJ&#D2lgTLDZ)K%|y`0Fy>O zA>QJpt^yi$55u#wwrj#pD!5dHo5IO;R^qowQL5~rK6@w>z&<~;*?c-R`H9coP_IWOo_wd+Rchk}-M1~C&PHS|YyQ!m zuWqGUj;6Qxh{{Ye{n3|+{o^Jm{dS7&so{l?p3vzGP}>Mvcm4}Zo?ih;F`kXD87y(~ zj+FWAmmxoVb*f&Ef1(Ha(f9}hC}cmJ-Lis3!{Kp;)epF@LsHRa`3WS*IsZ2j%|S96 z(vx*0w~8YljKn4?-kf_(Xi274W^%uHc9UV9@JZ?0Gi6byEVXp9`Y$?7j#|;VB`JLT z@NtnL;3sMZzN+g{>rjV!Ef2)ZqQ5U2@%23nT1-3h&GjD~pjZu?V^a6E z!mEp#6<4myBZ~df_fLU3cywaup7rzZ;?W{+D)N+>F9&8Ir%kP_-v(h)gDPmgu+iR7 z9<|G{6~JlzckXY=o4bR`tv2RyRYX}%k$EsIJp>)?F~F5B=2Y-CtAIuf$mi=uqIqId zMAu(LM?VhP$m%tFT`KVklm^5Hk1vG169e5`1w7yY-Hx2WG6hC@o3QO(0sa1ch_Yg- zz?A%&V%t>Cayo`?=gDs;1!sSBsK^N~PDnr@ZdR>pzwfMA>ZO4pp+P?nk%~UW=%2lC zxM#jGM9on{tR-h-u%J4kR`n4_W6848v+uJ9_|B9t;J+O7uYgIFUpWnGhCe3%OZXG0 z;Irbr0)}bnjMA86d&NuRoY*toyDcC7IBJ*IKV}^+t>zMU1>df2a&z35#CV2^6bRwM zzz6!yzdTBwguIzI`=q=3%kP713DY~}2OQOAdVkYiucRC#=U>?{h}gV_+nez!xZk$t zHKXam%cmdjgqu*Ddn)_Ck2`6XlD$0Yi^uoVU|689hen5?h=w#J@j5$=Owctfulkn# zES83TDhV=_l8%s_(l1Rw6btIoTp#;?Z%bAM_DB!!qM4dbe{?D5FUiBsV>e@KEF}1I zyiPi-YEZ-E_DCt&1k2EXnp$04UV%Nc;>yPV!`GX~L-oJ^|3q1`h3t_e$y$tk36(5E zp)j^2+sIDHRvJ{cvSy5}ERF12$i9Xw+4nIRLiT$I~oH=La zoa=gA_s8Rk?KPIxJ0SN;KO>Y!agSe*?uztVts~N)L~y4rCwfFx-xo@M&vpSpg%Em_ zn{9u0@{5l)a?S?7Qh0}@RBG59NEm7JI5zJTsM%1#%$fCyNX_TAsi z&T|=~#fM2w*tiKP!duGUaU6J6?|G#gbb%*M)$fv-4*Z5qJ8bfo>Yq8%->dkBhRM1& z0@bN_2uyALSnQvMu#ath_Z`)RiM?p+FXa9?C$dmIQqPWR5{9gsdf@+rzP^-wlOiE^ zRF=Ks*Z3UO`O(lNU9M1--4RZb1|DWAT5rd3k;A`3-1y<-(6=3pw!#X>Mk)oFnw-7+n%L>$qT{um7RrzrE1F>sZiUA8bqGckZ?P?@?;~+a?yG5`{VG% zhe$8Jzx}05MB^ruvSYh~{nr)8+BYo)n?Qp=d_8 zp(NYk`6BgE%o=Kn3yd=^CSEt6I7}z~%ZHK*F!YOe%hpBSeZ2<%D?9c~*sNDOuGG#h zy-U%(DP!jPmNe5%`N7*o3bu;cu*$w|CT#^h7iy_d6vG9F`0XRM)sw_bvn;BLeZ5d>oF{b zLN@kcbsK(u8$imNun-D2mG<@0`YK#sv3^i1Zr9(V@_hAh^9 z5fP?T+^Z?a=ZOf>aN)`vr52({7D%2auePhs1}oO&Ul%miV-DZ@Rl7Q)$Q&4lL(o@m zz9YW*YeK@@Z+Ae%swKbU7GHI>l+QuMuw~rFPerC6=?&cIBlHb2Kc}I|)%b{hvgtUc zK-`g~xzwuve(=Kv@1XXDOfT2j9;boV0)fZA-YA?x!DWnd!{4&M2{q*|LrzN*_(gGLvH-S2_;Wj^L;V^jUL zvGnx3Z^`}*)aHK6wWVf9b&e-uT~uP$om5}$g|!8#Um0_rdUHTK)8L+Sg_Pk}@4GOB zj-V``pt?yex4VRHh9(8@Ap%`UgD>?emVe;Y;Pu?1G;F_ovP(yFTDPi5+|3@NmUe&qox`C@plEy zOiRV1yrxu-JWhPW7B@#i&Zz9KT&|rswUBz@gst;O`zWU)l8vWQHHW$7&d1aP3zxL7 zr5xdDU%6fMX<~65zs_eN*&^+&riMFTZBF8x;GmRdu$5HiVZSI!!WhZ9r8qfY7IJkX3vTt~p zv?_$~$1iH;OE;xVHRSPLXru@g7t62! zM`7sr1-KsPzteq9kFDj$F>Psf~2;zKZ;mJo1rc&!sB=wQJ?)c2DJZ8a{ zj?CV!%{}=D-%lB>yM6N-4a?TQel`ob_B*T8tF~-XL{#Trf391jiLA)`YSvuBm&*Yz zqDR^C@t%24^p3Jbs`sB1&b2k`^w`0RLr}wY@mp0w%Eoohpx& ziM3ljW%vpClD!Jo5eA_k$T`m>%~9J+pQfFxkVCl|`It)UlMtf#x~Xy#MKKt|1e#V& z{3!m;FQJt8P|R8+44${MYg03%nP4?R+aJ8R;b5}8n}<72SHN^4*h+Evj@dD@vo@%K zh{0g9WvWj1;gB0P=_bZn4_yh9W@k?BZQ)NaZyr;&_`H1bXH>SaP@M45U+}%9vDbqL zmWf{X`LR!7c0>AX-C{v&MT;amWQ!z<@Bz?@B8l+{Xt8=9T(vJXPA3d6+wKv!B8s!D z?u{utXPk9m0JPM_PfJv(r8+vX(gNQ()JlS<9T0_jJsjg~zzcpAt6nynU^N$6K~vr9 z^IgFFRISNEE#AfQNA+z0NwgLhtH*m!*UHg_O_`#qv*Eewz`=syn&w-$@9t?=d=b2KrNV3FZ;YPNTU8CE?x#^;L?JMG8MuFxs=jo@0yWhX0?|k-NYhs2*{9puIxxetu91M${Wt!*K zLI**&$+u`152=a(oWF!0wobNZ+zqB?2kxYpxq$P+Hsr&Q81#hX-zTn zFKjmhR1R3{ABhd=4_oU;gS4T*`QA)x->~$5vREJ?ks6kZy;q7wyEgkyv*XAib@I!e z4)qFn)9jErxyky*E#dzBOtfWC!SmQCQITokia($4wQIg7q}oNG51{VNXY&gewd``4 zg(pyI3sc)4Ods`Vh`=bjW1g5;btPT;PC~wyo(;rEyakx`c|=v1l3 z>*TE|SAYu0vNvaT#oH&J{HpU#>q;|7NIuCnQ0Sd)e+I9<>MjJaFp!LVn3FjTYgnJ= z^xgbDB;w9HF8nTcuQ~}I3#_7i%fpgE?2ZoUok9N=S6slQCM9AIx zl_)D2o@vG0SE)u}Z!hrFL$g{HKYr2;2rn{k7yAAysF-H-FK7bn zhbDX+eEgpHN@1OWZrtMh`~bXzYIAw(4)n!kARu^tXgqx4J8U)q`lO&JGELB9ceNB2 zj#r-?tpq~_lXLtpRk**xrmIeH6x;m9l>COdX{6q05|DtwxPhz@Mu|5hn8-CL+^A4+<8T64y)ykK-1 zB#xAs@BDa7J4uhk{p)1J3W-#SIv^p=H#T=~jsY zXRz0#{i!{xGb)6JXo}@hm=Z{mpGK|toDYtz6e$^>XM0h4o{ujQLN%7P_Xx3+YmsS| z@8P8s=Yu(1?o;Kq0`ja!)DP-<%0feHR(^SRzy=$g!EEmlCIWA}ztN?rs1*{c6>;d@ zd($3Z_3sa|MIGx`RQ(c-Dm1Z-!pT0%<#Y87w2ByNFA<*=pjZrl2)7`L)4O>_ckSi0 z`+y;zL{+CG?JTU}Yv5T}5)>EVV?u!pv#rLQ@i$sKCZG)@=Kw|Mo6UU=UPngD$zf;7 zdM|2$F=$@O^GQ|6V25j%6yMbwb>1M;O#$#OUzk#i#WuGaU;b`Pj-{M;oh_OyYA0J) zPmiLVM^Our)ZW^|L$aQQzea(-v#-}$A-On=rmu}zTlBt;wd(Hd_sfY?nFqi^&FTM! z{*0*q=q~kBxgBo2sQ^U|mb@VJ%NBi3JSTvKhPqmN6hZPvSiO%%My3yy_6ppo20SO& za_1drdT+`v4xXZVF3BM0BbfEPz+Icp`2&L^a#ju7F88CM3V3CvhhyfzdqFpKgH}GQ@{G&uTYl@RbHE7cuy1@K(T!HS`AKLdak)P~cp{c}99gf~$u z*U!0QOKhAe&UhJlQI}n{lv@2csKA`?69oGVJBxhq3AWV$b0FM2)l?D2X!yfm2GmI5U6tc2)~~t z3BzpXZU<&K#7yvlLF+~S@$Xmt-!bgWd4j>kv>g(QU8_ylnMcgEo~anv{Y`&>&)wwwN~PGZyb~q$%G1&C@AxE=B(xJl8+Cfmd;NKjAMIeY z{o^zO>86YZk+vlN7ch0v1ql#Yn?LNK^r5R*1|BCa5ZOcBQH))a@X;t~Jqyhy z7gP-PWl^5T4pWgxjX2u?g~e$uTJzS(Z0&{WeVLIEqOfk0$17MyPUJ`X+y7x7fC~9+XzfpiV8~jzog#|M`Y& z!=fo3#{`Ri4om$U6$@R$2aN@pU9FArT-53GGb<8GsDc8mKrYNPm8<=?s@r`s_Z;-= zPY?RhZ)kK;>9sLMcq(op(o6a@|pn|K9RK1N!l2kib z$yWN3SnJbA>P~x-f?=<8815Z_$DL1;k-k&`6@Izp+8?O4{g0{BzpGv7Rp6hpYEOSZ zNQv#+H0kDzsW~qbad%btPmeb=?QsGnSP&4wB%c?yUc&>elp4eUwl)uhQ!rdZfrKB4 zY6Xzj({PvBgt2BtpdPAvp@<}C=wYprmfP_oMFaT_ol87-^Vy3RFVcT(-}+anOmhA> zU2#Pgx2MIS+Omk6Lvy~yblZ4lZc{quZU zUdFPwh&HE=l`7e_BU*jH$Ix3M9b0&f%2CZQWc0eP>0v?;;fHuN)|bz$vyY%yG)o1B zhY+fX&+|XB`?6{xs0-L5T+x`dX58$TUrjFQOLg7^#g@MGEzN_c3;zs+0&8I$5wnI0_X*%+q5M8mY?zrAq)N2RS#;`y_|= zO^rJ>P!E5wgeJ`3*_afx^+gr}5WtH@Z8$b=Vg{)!j47k25+v*qf3+K=zAA#;B(}D; znoUWMe6#$)Md14c(7$>x+Fuoh$B3Nt@8w2YB* z1alz-=Qs!wo|cdTRNJT?$*)3wcEbI3;ekKvteMX9Vp=_Ahoz&?WEe#4B(H+n$x1h{ zOc=^+8Uhrxh2rR~lcc^*K3o5%m+BhKipH_AS%|#FvB48WI&Ei zuO3>HAYpsYdDRx~gAbzR1*9KHkrnS_q`v_bpe&T+FF{SKKLlulX{mh*PJjtGQJJCM zFLkH7nw{3T8#C>q=#j?v;QGXy62ZeS!h=8tcPtpCw`R z4FLm3g+=h`Sp+jTc|hQ<;TRVrh8$kaIWwV=#N%$afUjK6dst6ZJEWC>D(ZjP0@#gm!^$i8Ofn9&Uh*E`S(vsT!A z{JAWg^dfjd;O%mr8dC7|6>_=$x+#Qk90;7e|LUsV!!P?#8cX?QL%EN-djFmq!57g^ z^{>Ny%UOh@KoaLy?yIv2H|8EbMpg~K$d)vh4IG+!(R5ygj5YiJ<-kDPP~0z6pzz+zxY$) z^9g7l1&kWnwlhl0jqUxhg&DJxgtp%pw*WjePz5knTTpeT!9k+_m`7wW?IpimyigY0 zfpYX}pa!yV16E>_Me&ky&TO}yxt=%9xF=s2RoO?Cm{JaMa9UR5{yiAF%BWKs``kZP zx3XE@)}iJGTG8K6?%fAP;=0AdcZ~nHxsW|gGW(Eq&)kFOL7neX01;C3hyW}g^x(75 zapmuY`{X(No^(_;rQV?L42Bw0XDRc54@$3yiQkKUprz{*HgT#udo$Zrv}+uLWwqp- z2!3NxkD%mj%J0SKSl*TSq-~;s9FbzlWGYK9!ToC4YDHBX9$cu<&Eal{4_{TGt~X6jSsQx4aIcjvOEYeV4hI)58yW2plxf~8c#>oXe`N6 z96azAmlp}#d8Rn~OoMlnnjf_8!w7kN9<$ zDz_}GmdU=RkpS$swm%tK)~oOFUmWKCeLjg@?*?4zQ!zIMF^8vnAR-GqQPq%U;5@*u z%*0R@S6*XDe^5s(O!ga_T$>L79Uk1)ncQlF zv$T_mw&@58EY=reTVV$z$?gj+U^ZiMTj<=p%K^=GfdfMgqbtRvi~lq2!Q2ORhUC~Eu}Bzg%Z4H?;~I8+WSyq=Nr$Mr}3sH z-BvPOhkNkL60SP+WXr@9^+cVsH*Coee>5=g)THd!E309_ydk-SuFwaFtZi=}h69?q z>t?+;PD^;Q|H6CAyS$@8^X2EzUDuc9ut9Mnz;xq!IzRdNf6Px34=258s+qCbdk-+k znE4OK=kgP!3L1TisiXiJH|^x)s!;ERj>yeP<}nan=~^GMnx_w&I-;*F^#_m2DYY!Q z*t)rXPRZG*t5}=iJj*J8ItY6QJqY_GOZaAd{!!}peRsJqy9SX!qQ$h%$p=$@Vy^&qkj6oIE{nU=tEMOJcD zoTO$sSH$fZSMm4uf+qPS0pY@0%lilvbp2A`mGO-0HV$3xZQ@0_O_#M+GrSba{FQ~y zt?Es85J)L@gz~qI5)&azv<>`+veI*_tihL<7MKKo?gNyG?d7m#>Ri4#AWO?3=rsl^ z?Chy09kXlvz5?svVpQ72MPFKZ#+P%P`h4!!z$f3HVA7qgSovQ4-O4G$`hL?`tP5x}m zYX_SqIIsm9k_cm&fKjsZ7tmPk0RbmdFnqAWW%tAAGw!2m&@o?P-vW_Bw!-GlpHWG0 zP~otRA&Z8dF|qH|lMBBwxoqShfoAYWVGA0OClu{sHP z2?zWERV(N>pPt3B0M1l@DhSNo z9r#{-@idLphag}^0m%^!AnCOMwB_t?+<2{k{MWDzdKe4;JkzTBcYH}KarV2p_En&^ z-!!(%$rkBtW}=50g%N$=fIQmYIuD+IlpsLW`jg32Zdcz`h-QBO8j~?`cmOJuMrYJG zP_x7^AWs|*%yAT8pTK!2DO3t*qYdCpbp$=o_-V-kZ=`6>t2us|T@C_x-JZ+%L98q2 zj#_MikKho3bsEh>dC2DqvVo&RqC=s4Cu-_f{we`y!G!9$UjjRFbT`AN4L*ikkW!fsN|az=J3Tp{ z47BIz*4N-d2tF@pV!{LZg@)^RHMl|BZ#~KhJTQZR=5{n}+@3FFb7>V2=7E5VyACCs zLP3|I+;1(fW}hGZFuQ1talU8Fhn%MvftD#uZC8NS$-;>X1gaWTxY9@UfXnmZv~t^Uk@%e6Ui1a83$dS$jKCQ4U3&0#SleF4XJBv zAe7m=HsTUXtV~kU#dSDIX7M5Y-tJ74HVp*54S`O_LNs&x(+>_1j-$d#ZWc(m z1_m`J!hW;y`Ne+8Mu-~!n4a%`iuT&Yr1%*IKFjxHT@b2xn3NV}Se#NjpP9@VydkgA z+wGDjpO4u#HZ>~9^-%?#>PPB_u>1~NTV3Q2=9UYLd!wD!C7|1FHm699iHaK=y0xSa zd2l)6woCeMQrED@7?(zA8KiqXlzaCGNY)G)KAE2!e=L>S?V7^U!KI){-3OfHdtCqL zLy0Fx$^OTCK@7t4DAws791$XTP1Y5}eTx55Z7JN-WZ?sLl>VjV02*Yy^w8zY3~9E7 zaQHgZrFwQZ!G~@9;2$z(C1I>&)9WR_!9h7d$*UG&qOf62?Vr~s>Dwq)*q`Ss8FMz2 z*|I6czPa!~rLsp|plR@~X4kt{(9dZ%nd>+dm-h$iEOUGG1A!zh8aki(8&&r(>^(W4 z^AB@a7ywf4E@w77AL;FK@{P+ju0qm7!q9|CP)vwQibxZ_+y5fOHHWTkzI+5}{^Ur> zF&UtG>^(0~3<$&3_<*A{Mw%=N%08JFder*WkHZ*Lnh=9e?TxdrJe#YR()!N>P{uC< zzCo~?XYm7WO$k9sML=Zj;V0#7!SIx4qa^G4qauWwlm%L6BdyTM>^veP8ex@n2|k+6 zTUN;nA||s7IRdW7b*=71I@`*7;amU<^ISxjOubz&7F%9em?OL?g2kiBDz@)lLxUnir z!?umv6KN;KjkL>xFe6^M-g%@Yo8sx2p{*eLf4XCloDdToKQHyc7pe^FCV`T|ZbbZb z$#celUw(f(EP{8@zemm7>FBjg-e@lb4c7>u7?ycf!PnEuf=xr|^@@sWzf(E_R&8C} zc%2PI4OKQ?=`TyaUFXY6P6on$pFK~nmqIS-yuBh=7;dXD@S6SdHO!}J?@xJnk`2jz z<0Op-*Gbybm_DwaQ@$=lGE^G+#p;7!+b~Cl8sV92eg}mlR?yba7OfsTi~*UY+({^N zuWSM0k6j7wU#R-XGu_pMh|#07fL?X^$JZ#xWA+U}p+ItcFM~jU!E))1+4PW#4r*_o zCEjMStoj;%46H_MB*>8nb#r6Nf=V1PNnO%jSocs6a=klwhrBM)scYq#jNxlHowv7K z&7yQht9lri!Buew^K1rGd2{?50b~u7Kk0e7^)*vTf!`+sxQ_ zL`s;%$XEQDP2aZYA#=^{U1O&9v=;#++BA>R4dUW_b8Jq?SjdjjY?p(*n>oCX{FcK+ z>!3?5{xy9cWUO%I-93T)Ph1w;@3eicgnubTTg`F&Q2+~C)*d=|iR)V%J+Nyz$fq)O z?bh7bn{E5`;%UMyu9ZhwOm}hyy$_aDPIR?e_a}6}JDL9}*k4FBJF4!U(6Ju_Q*nj^ zQ1P@A(5br9ZPiNldxzm!X= z^r=Q5<@uf^)1wiW%l%J!)-HYNho{$tb~ob6)R8h9Qp3)hzg1fXE3I9*3+YMKg+0s@ zbcE{-{p8U%>_$YeSYag6n3p`the?5)Qa&E}dph?0J=Xt~H(^Q~-f= zntF8Tn&2DJj(2CEXBs;|ZAH+@qYV4{%4X>}C_Z331^t-%o^B*Ow0%2s=PaM8 zbZUDQOe(c@t_gli+VBw#h+<~=ih5D+Cv)>Rs}{15Q#?Y%o&LRjbJ^6pYktAmN3jzP zYs?n~d~h2kYqnB3O}y{N^gdB$Za)^3ZAsOmT>Bn{<5lZe1a60f_s%KpuO;fxq97o_ zznuGtuB>L5SOcqehX{;0lnX>uSDJoo`Ec>VgtWmNEvKYN`t9q#bsSa!D-E-|v z;{|_!%)eK?^`t%@7qP7x?&mb4ftuaPdAs3v3#l_xQ&8NOCrO*jP#=haW5_={6kx(8j03R z7az67w$5-3m~CP+?xpSB`2jGxE6HlfH#eU=%Z%a-9AMl4b>`nkqt2r6%lr}qLT$pE zzq5Wr6yh9zTvhV>Dt|=2IcB6Imn~kuJemF^Du9Mqe0*16d`KXWbvE6C4LQA@8c63v zJeuR@gQmrFpIM2Sb+j`~P+k(`mabwpQ9#A0>`qtdbV$dMtj+{g>TM(pqz`eFRb5~i z?!?C2ah7khcSQ#GzI@>AGGJ)^$c_H9o`E)+Ee_{llXJ&Q*XvK&*md8a=y$?PB%zM* zx6f*(0Q6+wkRE4~W~(FpKrJLRW1v8qzdLi_QM6>@;eDKC1;CE^B|@~}L%lw3zZ$AP zn6<3IF=ZbrWRVej!fT#J6?#g&kBe((9_H)aaF5n|5q^QZn1fw!{YUxdHO4E#YH)3y z$j7Llke73viAK6h_saaFP3&^$9Iv*#W`K1!&At1!h}isr3zTl*O5PIcT@f&QH~8L_ zr265PdzLEg-d_~5`v^(AHT&m9P@HebtbG^AIntcydHPG~hxV3p|JeQc7MSugtde|O zh}sIJgf!5w&&+7XZtOeyt{PzMN{_39IVr1LOaACs-?D6rE{vmhxspXMKAiz$l~OXS z>{|oqNJI{&?Lx;2sSsDWPb7{)%gtGvTiAZDj@FN^y+LYuPbK6l*j^!V9M-|b;8Kq! zI9V7Ar{hU4@iFePV-R}IB&HK_gdTL{j>60bm00g*_SnMSGw(Gxy!JaTsB&B|y1lL- zNAU-W8IiltMa?F?1{PfeH)~`X(6-fC#%ja~hY`NqOXORgX^(B|ytO-?W+-37h50+n ze{Y_iS$KLXNpsRyxO^tk3Cu@Zj-eQRs25vnlpN(f`7;JBYw!X4G~O-xLY=w&&v*6{ z$qcOXyDtGd6Tv4oJTbQNHmdNnp^>?zDEW%8u7RZ=c;#_HJP)++8@1!@u&`%`4>XJKe9%uF!7k8w-s zXK8;@PBl$0tCdezi~V_@{0}DjN&J`{DvF)d+wX@o>yxmTgH0Z<54dCMtJ~9m2m#Fq zIrhrY&$WE9j@i`TcUwL2VMT)knQ`BzwApp6Vcu2jqJSw$XRi%C@*QLcU42dKb9o{p zaKr@ZK=G1PPgoKkRE|>Dl5^qL)_shyO=n@Wb#JUR)~e^Rr|6breXH6`zhHZ=E6UOH zkb6Z}*dQZ{%nkKYM%atH(uG8-t)uWdJE0TDhdOX`s`5tOtnKjbC5T0b?C^19?qvM! z%h%oR0u$kz;;s!(OkMMM&~+OuCIbYpWx{(b_GIPcs76YDx?JfdY#hUq;zPRa=Fg|a z;`B7YTiJI#Dmm2B>IXyPumVN<RuoOaCif1W&@q;m zvwF5^zMK(!{gKVUA+24_SX6j?tbhX@tU8~8`19Fq z{S{g%>640>eSlEv9<UMr>5eT?KpC+ znCQMICCO;;QapmmXCna@x~QzX^BLeEH3haZk_?=HVZ$VKp)5tUm09UIrGb>_uXr4; z`$n7EzXJcQf>W!{N!LN^XBu6nUl;EMsyUs&STMUcxt&KXNmaGrU%bBH0?&kei99|o zv{#jRVAYp5V_(wWO(&-#HFMCq*WtQg^FE0)$0%BhbG10(5cb!&r~^Ly_2MzK*CfG1 zG7xQ-w5jrncXK0}6PaQZa_}l&L6EOT7!oVkwFMb3WP2gV_=whfdmpx&Qu48t)m`e? zDyK8BIsMmo{={zDyXiI))&1LatLZkBNpM z9MMs_4apO4(5|KI4g5Tkw|Va{i!kYe?|KT-NizY>t! znJvE(VEP;h?-21|f)bl7mTuIc-y8#cv*kyyYm^vTT}*L9$3o6?b<%S~ba|pq1PT@$%mrQV zpuQ`$MBF99APLGBnQ6#W#>xR(6MtyUBKf>MuBuF^cHh}hcf=I9fn_UP_1zsRwsRbP z1(Ix1)a3DK_R#`iQpe@K&KJ{d}L#kevCTN#YI;xcXY0EWsEVm zOeoC-x{wB*bBX2-+m#EmREK!l2Fz0Fc@-`nQNegQ>CLC!WgRoB-FHkS0Wn9!!Na6q|TZ zyKl@|>Vgji;x2;?vex#>F+@n(m8@i)bYu>UtO~jN0b=Fe=E$=s(bdl(%E{v%wDK+< z)0+E-K*~}AozkD3lDwwlihV9Z<=rlYgest_mb{mB?+r!U)j=CgTCV*zX3)~LQ%g*S zj;16{;Q?44cp-NYr?_cs5%&C#_r%`#in0_QwOg@L=4^iFD8iNB9b`U0AfdOIOz3>fy>h(PyUuJUxD*{3e*Jdt|9T(_XHz>4Lw<@$%w8=K!%xB`jqS@xhCb(I$y1Vj(YH}=$rtYd##sXy}S z_>QDL$o5!(Hpn5(zW?~21P_6Lf31-FPrgOx`zo{{5x^Hh6Z+=BQAo|er}eA3FBZ@% zjqWM33tzuH{-aC(-*#o&GM-_;LD($_YwKoeQS+#V|JuLNi}pI*7~BcM#hkW>t%LCm z!7b+h3e+nDjJ3rHP)IrdmA|+8=Su|SF5{=^8U-pJq_&UdhoT6l`?w%j5lSmNXaGRX zB{Sx609D|@7$YQaW5uu&+9w6MQ6Yv#QaVv4Gh=()MbakWUz^?rU!tN#eDLQXbelTi zd7)Yvpx_!n8dzZgH>XcBfUwuLfR~tI=O!H`v^i-NOz#D^aeyho*OJhN<}sYP&;SbA zL?@6%7)VTV?wLiK#>N<6eDEJtIU%{ey;w9y_ zTqp8?2Ar5XIkPl&sryHaa+1_I`pp`tNpGD>*>GAhrsMhQ?} zu$3m|oCw*0zs?2I(?HADg_4rmewU(jxKU1<;FLF-?@iwI3Q_v7HMIq-)=VlU(lOZ< zh;5Kz$9j&IbL^`ooPWowxHOw$^tElBOV8ZhVTy=DBV(F|2M##3_oH8=o7~mVMwvRv zUXfgJCm4#MCYL#q4UjQ}*p}Zo(A_tNVj2h&HS#2$em~N-c7X_O;F1U)?lBc4XLh?& zRf?*c!6fd6Im0F;UA1(#*WZGc*n=+`tDS>7y3?EW=S3Pv{$5drO}_xeF36)a1wf{# z-9UDbGiv$YXrZ84YT$GA6_VU6H-%u~R9>&?+mHS3?jO1b~DFiXWaI{6MT( zpf6DK!@nf2P`v)oUHjTw#?uUF-t~Z*Jr=beDISU#b*zyOs`@v)BQ*XWMsIv-B$3iT z?2g#%ES-4oEeeB8D6ym91Vp}2Kx(auVY1J9>#oNvNJR-fdSc`v+ESy&y9n6cq9MJ0fGALm;l!=xm;3L?%RmulEzQFkZ*sZ(q^%#`B_}bt% zG&q2UHUxCin-uvMe~0c(gE>{O=cLk-r}EEh&mF_t4w+Hk`?znc`Uk1y zv%q2+w*}jV#*)yG#?Z{3N0Y8u>bCalw`hg!DhMg_+4^QC#QGAuWZ_BKsj2Om$?Bon z8dp*k%B83fG#V=$by?ksPxOuG-5h@MtDC**y#H9pYnb~?{eep(>?j7guwkQN*xUN* z>1`|j$+YPeznvdJlQFbbUcd~U`U|xin`_69;YhVG6ZU|^z_wmPutC5YamVlk#Md-- z#pBM`zo0B=@+CSHnv?H!*}#jLD<}-cV!k42qXm59_?Zuga_s*Yh-mp;75|u7ig7NU znfizS;nA8AgVcg$@DcqYP{$WfJ)kl;|k&-wFWf|ul@gPf5aARx+c+Vm1GR7-pE zqWWZ%i8@R|xWg7-Le}xJmAEX(IG%JZm-KEu`Hp5vxgGX$`>tNXfZmX;I=1kZpiq(V zkS*ow)Si(Qxq&&5IBF#P_CIXn{;K5H4vo~EFAd+@Oa`M)OKqmYD(vsh8Z*~qGC=9^ zN7E9{T=b^}yBqtqHMi#tK3+Of zbLeHCDA#(4*O(_--DwwzVAB+QCej~cViRzWjjif#QPfB0jQKsFYzOVZ38K*Qw(G&8 z3pI9!Chf_uCf%#=B~4;tTV!g$ICM=6$-n*j&__rJiV>(Kn$YTjjfr0rG{u3HNA3N7 z-?p8fbSUw^mJM`5Hew&mJn;kqdw2u#V%^)@$~N%2H&1RCje{Hp+0(&M0|mqLkiIi0 zqNe@1e~jTPcW)caJxO9Wb1o8^#ru-$=^H!WRk_X|lsF4a_dRt!3vFi`bjZ&dTaWgP zIL->V9|k=yk3|O^v`< z2}QoXLe1DCXl3P8VR781Nk(OHzB;TQ`)%`%rZo1Q-^dCegg}p>Z%*V$*5}Y`jJZ~p zj`nuVhuWZ=hed1Z&JPf+@Ls9gQ?*6{G-TiUP??i(I==k=m%G>gO$~ii)?eTgU1far z^wo`X=E!ae&%OYrH;*-91ni71?2-oyVUMn%mOwUAd!Qp2WxuQFdwN+&5f|`kRRa)Y zl`e}vy=T7LA$t5+vbuL-_;j_q*<%oV1>=pU0=1qkGi=& z8-D>T^eOgV)^NjxB(@H`AGu@Y+PyD})3!2xAVjvwU3+nmK6?&emlJ;w>$9s~#RN8| zS)NU>Rw$}@>{kTC1D}=zF~7rIPnWA_=k>mI+>-tsmCv{CndVA^L{~sVx{C|T)a!BF-1c?wb-P}jiPh_17 zgZ6A#YkB0NpdtG4Z2G^A*gjN-=hO1x@Y_gr)8&GvUSl^uz4`ET$@AFxDMw1QHx+B0 zykagEXuvJJRC8=5Tq=(f9`{-NOv^<%sdj5ejApr`o&KDkSGqsc8T@i}GveKa22i@*nj1b&_P#;`uQJ&j zh57E6IleBzE&R0#@R`D)Ws`Oi8dC{q)J-Za@FDvFlvx5*!S3aYJ>bjMyb4SMfl#(J zdyh0Yci0WkO90qrkkmwnK<-ak19%hIIo}F_+_L(FdI+A+0h_G#1|UNKAZm)LdmzJS zW6|+x6jEg|3FH`^UY};Y_Z1>SrWR3%?V}x>VmL|3Z(Gg`c!S}#B-o?q1iU0caLIAE z@}1|DD<5Z5zZmb~$u7A)0J~Gg*qU|Ef!H!0>aH5DUSnXYqn>QP(tf%011-B3Tua|HRKbGpj=}HQtzS+GD zBDb8!fD4fxqCP~OCTmxlMEq_-nLfjM+;YHfX#R#;f}H1G=v$@ej7i&gaSEvXD&kl9 z1d`IAX=DQ+Y(`cM>VR2P=6Ez3WYYvg)ks+E`GGZvvoFI3vDbq>q*wG9YCDu5I4E>6 zCMbeaUd9->Nt{i)uO@Gqk3qaVj*nk5eCfW}F{HbOPv0RM4Z^!sN)j}tp=k6u5F({x z57tB@>CHb}zJEDQLZY9jS03~X1+!B9Q|ste4Bc%>oX^JY%ds+N>W)E*jVM0taPugF zlkXmIx3FjOe44ZS42)x=QubNy=Tc({-36Ml;~ZLy`Pq6KChr(m`5o&i0)4#5m*$V4 z3F0b9W08qHfStq))l|r$R6*Q+9^3@PnC&-z5_xj_j zqzZM1r}yBJu-0FYwXoS7f;2a0=*IiV%hhjGs07cun3+@xy_F*7!=c*u7BHeTo3w7=r(`~+rGvs@z|+A4~U@SqQXTZWizb` z#^N=wVxO-i#Ih04UyQ)X(s4Hc7-SB_1P=Q2{x#0x{crt3TLb=CPvgHL)oiouHZJ$G zZ4bM2{5s89qX8@1p?ikTMd-E+$sDI9eej1z;4T7A%2g~S)ufOp$c1qN{aLd=gXs!D z7S= zn-G9!zC2b=U3Uo04o>Jl_c|RzIp+xfK=>vtf;<pdDTwA0n%cw)IL^Z_k#|5mA4DXmoIrt?bQvw7tPFGhfGo4{|1H)f z$8a(Z%wJPA|NNi^@Pm+9B4D$*=#$!4&+pszJpm2RxOuYgc59G0)cgcOoDyW?1F>Al zc91G}&8`MIJ=l<2ufCH2p?hf?^5&ho#EHE@gueN=oYMExO0HWvV8(lTv>ikfG(E6M z$c{&a7dO-<2e9U`CkL&=P;4%7m-lKNS|jVQ zqkM!d#EIoyIK2+;@N=tLuVk^%o$8z?bg6U(pAk9opc6Ak930YKfT*WN_M8hD<;Mp= z40%6YD^?4fA!flsr9)6Ict_VD&^Vx^dhjK?$XYx4R`v^cPCFFd`;&1^cHG9k@$CgX z$OC;p{YMK(c|ezu6t7TYPdx%IZ4=?>(=39DSkB>^v8&m_-i8brT8V7;!a3r%9%aBb z?k6hg9ypko%;zot%NPx|5%fL&-{h0U-(T?gh{!gOnQ(dyZv3Yp2K~+d8omH;(Ajab zZ9HFnA$!QWsvrdXtKI>K6J8nr;b8!+4&m!Rw{5r$g(0YwMd6b1jD?}S&*qtMU%e+z`L zv0+>AX@$ky$^6&*E#ZKLfb}rU)c6I>GCUjf&8v(=uecnD5c~CgeF!CjBfo(iy{J@` zU@PA+uLeD}m_Mb&#tSz<$#eBlF!AZ_;(sci9CC0I`0uy>|MxwFUip7B&B+(~Hvn;P zu`mv14tB#Hrw8DxzME)KT^7WDL7@+^iwmuV;+VX+hFc}D$Y1`mA-uGBS>EHK-e~w2f?s<%Bw%mn%VDtS0LVt%p zh4<47w0ItC;P8E8pyQ*E!euvwzMx-}%-U z;~7uf&wYObx9ZFGEk_mRJLkedeZ!cn#aQLgI3#AdtB^9h(Q7nziDz>pF^jqDy*I7y_m0mK~5~_sCbWBm7iw z%%D>LE}-CpYbVAM1spQB>?uX`+hQ`n3cb^_$<19r&{1@rJFC>b22*OOzy5ifIN#%uJExni7mfAns!&5u z=^KozX#ii;0F%_y62z46W9qyTN-9;q4=7?@k2#OtZNSB0XAOHnl)e(OyflXEAN_lO zLAsHxl!JFE%nZK)$*C0+nXM|CmdtftlT>r!b&!iJJL+cj)#X=q<*m)?H>b1;KrsjO zL!hmjQ|H@vQA z)zthf`85KZ>5zhCM80Kxz?_^_?8IaT*(uqs1P*zFwgDglKtAUjl=7ab%?2nQk{a3B zSfo=c3%efY+wwr~;kaQUK(JXo7k;=NAk(uwj>h@PTCN&K%{Ui5?vO15OxAR(?=ZEp zR~z}Wf6H9jqtUqO;TM6zHIm&@-AJ}uY<6|aL! zMYpw zwS5l-x(o_IOTF)13jU)qE|(HL){_Z1n17KouXeGiQqSl#lR;gMZ8pS~=mUDC_jA+s z2yF*`o|*EdbK*f#Se%QyD^C;7&|CPa zlNMcNP<~F?#p#37QiaAsei_YKWhX0z?L`+|t@`MX+oMl~E%4iOOjBAFT;$uwp5(JL z?Tile_*v1_YC?dMKN|Vl0JOAd^ZNi&K|B30=@C4&evjRx(Ghgm??ry=M~4BcxJ_$z z<9q&tX3#^~10)_Nn4H^)2IW)3v8^$>s(P>wo+2v`6t<@0tLh=3nu4vRN8yiC1X&xq zOSh;_7tL0dALTgqGOdK~7%)9^4(GJrid3;Cu{wHAN2arEn$pmN7P8$SU;r2HSj*kv z9WDW+;OfAO<`&&PW8h5@(MkD@;6N6DK;amx<22T%`0xj>(&DL-z&m{ujv0&=U%x0L zA6%lRIPh6aW8u86M~~X(EmU{yd4xaQp!o>N@GGq9_eyxtthlX-j7_-hegL{l4%xGy z*c8QZ`d=zvOg5#aC>EUwl8&I(aoQb2vST8f6`Y$3#o47lC)6w-e{k6vhi_lHY!tT` zmaL7={*5)Jwrg=AQ(WPixu&CU1=MEkeb2vN3*>`*1M)3U?%|QVhA&Q*^t$~jaf!nQ z8Dh!D_iC~)h68Q5Rkn_{6a6u}pI^0`zc+si6X5otNues$`WcfqdaLci@_LXr*Sb0o1t;Fy=xE7ff)V!N;9N3!QH zTKaj(R~0&s7fVeOpGc$j6H<(~y<6?~1S2orLZut%Q5+T zMzejqGYl4<`wAbgw7u2*M|gstCI4Ws=5)<(LFG0?o|KXftmmrudB%IbTuB{Vbx`I} zB?im#)ia3GhYPq)S#d zP;}AE`0dKo~=~4%;o&fTMA+iuIc+3UR5;?o2H$pvORu1Ly+zVMsL`jTZMe z>A*pUK7*sosZ!`nd#;6U=e}YD0q*?tgdw!YJmmXv-D3d?8v2F3=JiX&+`q>HPi-4X6#1bf0l!0vF52XjZ{V!rU zV9Twi+62mg=*$L3;bTHYo?RjSOsU8(hVTC|fKyKia6ne|DJ_kC_PxP@zs{2X&-~wU z3eF3Ny6Rv5uo-ppWhF%ORS?%_(Lb52h2nohDZc!lXH^`UK?*Dk-$MC0(8^;~E@&~V z{P#&sYGWNDWMd+Ax%K~d(cf*nIO5qWcDq3Elt}@8ikl`)?ELd0Z_)blE`_7|Z;8Tx z_Q7!)Nih7Qk3MNhjBSbHC8T-AQ!rEr{ef6 zg^UZccy@Oy9@9$5_!d2fY$F3YF--$%V+4i>G#LK~{N34GiJS%jbOdR`<=hUHHPP{a z6WePI+vvzA4%v8`_mdFFG|kLNxLA?GsO54Xo00XmJN#?L;9o}c-=e*!zU!Vwtzfm+ z=Xx|M+z)Eg4FxP=-#G7WzY2V+$OMT?Mk;12b`j49;5#jGJqOhi2=pp9pgcZ-Lsau30-Qh}15UJzC^EXO>S z`RhqTGstBXSL%MVtA7<{(F84q3hfbs+d|%&J$_Iv<^)D&BP0FySEPeYw-i2h+1r%h9VkQm6tX*O?BJjA0CHhJ6%Vq_O*`3 zD@_Gw-n^6Qo(R#Z|Gl0MObWhB^?X_vy zgFLP7tz2tFIxO+=ftae|_m=rD(ze(IK~!>#k69Dcc~#LH%Y}@MUx7}rco;cnXk}w2 zQqAn%4GpoV+#q2LI}rgLtymv*iQ$XY?#o*`V`^LV@*&i!N5%vMG9cUi{5IuSQ{O$C zAM8O~{!5L#^*k!N!UF&+*yvU+q7Je_t%-*L{uLD5JHV2TyGGS_J;qpRrdF@t(l_m@ zloCz`E%TUOEsPpLfx`qOCf~2UqL9!hj7Uq5^9%_wFi4T{T?cdplIGr#8QXGqe$4MVb}ffdYdR{npH8zIwyX>jK013lw z7P%yx%H!TV;mVjN`n?h&z@QG%Whk6D6?${HK1f4;4;W7jUV`mb$y$G}9)62mBb3t_ zQb``~r3Dio6LXMF#u*%wq-kFg;W;0lR}uj7Kg>QfLV2T9HRFbZH(ev~L?zc^>-)|u z3!I9vYqE(2#hed_-4c(r!tpk*+>LEM{!l8p@P$>8vrr)jurv%>`iQ%#Shk9hehN`s zMv96^*~hHPQdrtt zEfb+~umMFG&0kwlc3iH}crV5Gru`aCZfD1_muBI$k6E07fU{<{rh$~hi_-x4Yx=yu&LfnbGXb00-wpB6VT0n+1&EmlR4btk*K?#euz+3E<=l4Apc!=7|Ls6L zwE)VFB~_vA%JJAoGzkBcmWGdibsPR|lK~7u6Edo(kmP9s8QLRYO6zN_Rs2P^^`h5w zgj{t!5mT4Bqr(RgC;M)wK=&Ejp|i>YAp#jPKTFDM-Qkde89}w>ip=Dq@gSv2D2tu$ z;!52Bfg6e{8wV=r#VLTff^c);q`$%6V|vvwi|R-z2nC6@kcQ5tS7`=5MCw{!s&m^L zi;<)x8c@2?>)=4oG!RaJtAFx-PE*FXMK=7(tiE#TN8a3T`dFGhlJu1ZCcYh=UH$m* zvtHw&)#pNehys(U7_WWBfG8(vwHq~LMu%cGqL=jZWTXh z#%R#sXWov2lbgiZ$azJ+fo8U4%bgual)N6f22U+NTISJn9EhE`VoqLocBrbY0Lq_b zt&WZKt0!?4*9vun${y4jL*W$($>8@^kX3y~-Lwgr@auR!zcT6|u`jVHu_nU7F6po> zFq#}DH=_%D=W0>zZ?8S_6^xM|vvfBz)+e7Vq# zA`+_H%#LgEy~?*x+k8VtEu|q3(JI1ces>=O(%aW=T)csY8t7BfK!zhz^ceu)#vBvR z7GADrI~9h(!#giXYya>d?6S+I-HMy$IoMn#N*}CfV6d!YF(y!HZ(!e?_vtR~I!|uI z-(S!jV-}jmmYXMjq)jCXSk4fqdUTyuKY!>J_LC*Fa&zI%wayhMhKyK*C|;?Epd5AF zntDRYXLI3U)ssSRiB8Sn+nXX#+SB5;3A!({if6X!vj!$~qnro?ixEakGBM=XFwNX+ z2EW%`O6_QG$h^A_p23&eQm;o%ylo`>d1^o>H^iSU4tvsY ziC7d)&m|oH^XXfQ_;FXAIiexZqj`S2o`L$sHP`oq>VxYbC;Bi;2su z`MRXGy`K78%KVFn`|dupJwau}Bd=&qTMALSPzYE&|IR4q8Dh3@r`flsjl@S$US57X zS(3#AH9I-MD@u)f@bM%^HcPCyzh{-fedY#AGbWBb$yb~K5g_N8i72oj`F;K$~vIqaSN(nT>0GuU7z9BMwe9wC_c2^R- z$Gn@9+5PHJElw{aHLsT$ad}V`KUjA9aCkRV6@Q_mW-zP7jR<{J0NQ9MN*>EN?F>{G zRuTEM#mIkzN?)c1(#pg2Z}NUuU^p~`1O2)m;%Rwn6|0+o^m;t(u|>~+Q{e6X*&NpN*IKq z@P6=&C=?tiUyTYAEk@gMMl@xpbtecoc60z6cdSd z9F$y|cXs(ZKm74->(IOX#x!N^0a1F&$RWdyoENFKw0qFUa!M6YHW|lJFPZ$LmSz*KoP!a}&NW7*+Y| z+ni9tZZp7VbDyysHCBVzP)uk+>#-8*H2UVD7*f`B<@bNo80+9q(NA~>`xC>8!_a2w zd&|0LM}R&=aS7rN?ZRbY!8vd`SsV`JGx%XYYX}TW&usOY5bi=DkSe751vfvf)%V|| ze&F9zEYuwzCsLZ4ytamP0bf5gn@U;ZSPdU^VPZ+UjJegEg&P|X^(TmM2Uf0rlYBI( zxCmhIYF)3%;NM(u=NS(b=quq-_Lt-y`p>DQv7|Mqe)sr0gLC*{-WDJc*W6ssFB^Dn zo5p9*m8d=Wn_fpz@@zsf%U);f8lJZK&+6ox;ElkxXz?nI+)IJlq_#rQ)?2`wGGoav zI_7EAQAt&bZd<=51DM{;(K^F(6B)1|;ZU?f|5L3Tt;N6OGX8^8@-K1s|D}`IKi~HR znyK&F9EO#DSMjLd8w)%*62H&Sy}L#CqH6b*RJz4}>GeuJI$DZSU?Uo-GTdnJln9rE zTJE0h#J7(c|DmM0Hq~mTz;<67@Kw^AzecxYDl3cJK(#3_>yuOhAd=Z5&TX%Mph^?9 z3ZPe>*8!$S#4PWZE)L9*__#dZ6IByu;MD+*=}ftQ;auZVe3@5$rH!o-^QXW2&+)n0H~+1Fi_br)mDMXL;nSfAN0T@6lwZTU%Q2tF zG1@s|BJCWqjC3XtQ>H9lM{O2}Ny8S}@Zpa1=N)O>t{tPsW_;385sJ2mtay8OGXlsk zvEu
    lBpgE%5viftCVmqRpsv?$~7x}j^f=T=XeV714W;#a)1odWxi2q#!h>bQrI z5v0#1Aih6}Ob|X{*>RR|9`Ae_Ldheu4~q0-B!s;Fe~H!S&RLQ`6T1FK5F`SCfF_3H zf}<&K+{1$T=A&8kfszCc;M6P+{Q&*%BOpUaTIF@T*|Q@ENKgcPsQB4e7l%ZtPF4cZ zWNU)jxOpRgW7EnA72@m(KHB@BD(4&8X%h{nzY{pFGM8uu$1FMRcx;Wd4Sj3$eCp>4 zzNC^DigKy4QqFGyQbThdn;W5gzL7bhrLP&h~ zrSPq`g3)WC4rM%H`fqtRz9QeY>yxstjRA9HP@rjqs&YIM8tdB9&J@vCFwA=x8)A4j z(#15$1)=&u0yajYShlF-XWKwi;Y9HiaN+wkaQr>CR}s%A8%DxZW@BG(Yfd$JEt;wd z359+hC+;OGIo*WV;t_RdZLhg!YxO27;x!f^?j|#!>*7E1J0gxiOxWiTD^><125K}A zIrb>$gdKF5RBeEF>}f~*ipN3snf}Ms_;-j2#S;&z8{6aWR)2{N+am5frUfyaF%tv> zYK{-iGE)3>qwvs4Q;G8wE;(Gm3rHbNX$+&m^lcjW`(*an#(aziomcY?4e zDN!Kb`tPobMqIk7x^24c=oEd8mGSW#%goa+D}%ATnAi&*eoh$EPOgaVI~M z_$Jy3?f-P%QnU?u_wvdZl_}1=%F$!6!^aV&o81B~C>~j?z)m!DiNw$LeWYXcAD z-kUu-C26Yy8?UBI7GwkJIHfW!VP9@YG~zz`E83;;N{=J9vr2G1TA!z~e1c9L4qrwh zX_2iT19rXafR>1l7SuZ7X@{Lyg;1`{G}3d=fSa7S5yU^9Mp+ znTAN|d#B0!6W2sQG+VRqC-1J*$kyO+=H)<%_BcDVEm)Bb{JLV~v9wkyoT^f3b8GmD zEwG!pkRjPu@Pc%xr7^;Qq0}61uwl9+&wTKS`-KKZqI2U_&kdeRahr#DuY^e~Xhwu+ z#izCD3;>9R`+)*u^^@!e6wMsoQL60U;^vouZHD{MvZedylDvoHB@+k#`LJ&Gqv#` z_7IxMRM{i79>uc7M?;>fA&*^0-2+=Bu`tnS^_A#eoUUq6MOHa!o8Hd|1B8HsLax2WwedPzMvK5dlsf_}GutwSZ8+BKlSnE= z0ZhmkUxSyKv~bM((Tz3I zJ2ewjT|Ss!*Kf9$7+vYrwGiz4$fo>0kwFCq=;V36CrZs#uW(<2?iOLRe7SzCBxK^# zo1jk3JLIv5;!3osZR8dewW+BnG6fXvK%&ur#yNY1U$1fOr09JEX?#*=e|2BSQ*>X; zg8LYJ01Xs%-dgzjvUOygTbID>+C@3+g9`R*lUjTPS1&pijKKL;pz96scf> zCGNZJpjJY@OVzWJecX3F7PcO?KU%9uGW7r2My1 z(El^{>A!dKf6_0V-p{w0%fG365nz8JdSj3st|osd8rpxzVEnRoigtGssYpq+Jr7i9g? zy1j)j;zczkR_;qLEV6F&8xOBLL{5g?K17JcU@*@wxE{b@Zv6Mdf8W7>=fQtk3v7p% z7WzyilL2F&Dlq0Ft5%q$d;}H_52ZC`v2e^UXS+kDJ?WJ6J(Vui(C>Rg`?0w|Z85G0 zheWkz9I%xwDNZZA5}Rw-aGA+!z*b9u5PqQ~WpTCHpEpgk>B+KK5Ge5%QS^haUGG*M z87|r1mKT_b&*$%?bHRlXz}L)G$*x0AHUhn}i(3AH4Cu_)QZR!rx>UyXWY!oXu8PA~ zH)5wTs%pi2YyHj+0{Mxm!53jXU8Q(v%E5Kbr(R2(FVGU0kufyo`19z&0S?!EE6afB z-!Wuxf>6CuLg)Rx@r0Zzvhk>g#=DyqMDVq22-)y;fMyxD#aPH17}^KJcbqf4Y6LFB zZWbN!eA*Il*2M+(KhBy6_mF*MO=Q{Ck-v2BqpO7w4~Hu6JG{(0Kz46zy(d0w zRz{uxesGmKCg7SspCu_U)1FsHo9OgCMRqnHhXw-wxSrJIW44RN4@C;YB=!4FJ!Ca8 zJjEEtV`srdw${`ntCpJ-1}_$KONLRSZ+*4Bos7T#(Str}2ctrsSo7jVRln_yNc$ve zG;W^~F|?o8gyZh!hTE`4Rl$#PizSBcUg}V8xx+y-IIR227osIUKMPp^=;Ggp z$8RLV@2><#i>$59;-B@z-Al+yV?sA@M}}wFOA9`PRg=P_wCX%pY@td!Kx4bKOlB4)=FOaw;}+hSq)i8k_YU_mxJe9tjDlexYE>NwbrRWfACyP?#g7m z)j3s#e%%XwxSfIVtFh}Zk4ul~e7iac7ae|;{&X;|sj10cfV!og!SCMnjUlzB+_=4{ zmu=PiTai+?th?5_&gG9C7fLA@2m`6Z7|2frz2LO&oIS7~U#wa4mIqqREyyr>od8Uc z5#MiE3^K^o-G4^PnLsZG6S~;6y3%IqSKmCPI&yVs-J9zX-^X=nnD$v;0vGe17qN;RBLr|=}uCB!Vd?KH- zqk;GLuKE0jV*u01ozE0clKS|&J@6Q(maVpFs`WwMtk-&>UsIS`vE`HhSxd~9+eZak zlHv$A&#tYwY*wzqndl{a9+Vd5o;@e+X@_6NP|fz%}9*WXUjg^HA9??KT|tl8xh z028j4_L&f3jlkyIpe4k%BoPk_v4x8`*$M2sbLtP`avax<2&nl^loMPyPC%{K`7Zqc zdX8g}_{DnOsMlRJId;BDv@FcdS%Kfhd8TyC&qDgBAWxe+8OBcU=3&KLeT=E0B+W*@ zOp~yvWm9fMyo~(Y+Jcr zRu85$H+k@7V$ulwom0_>&vl=_(q_DDs~df8``hKb>+o|r^;9{W>B37zQ;EMle;NJn z`LTWu3rwEoP77?$pr1i8L;dE=!UB7+k*b4(LkPF7bO@Be3!j?-n1$U$XtGIaHXWBm z;dOsyB6d7mHy+I1d=S> zd<&;G4#LaJ{2Yr>%?&S`660Vt@%r3roxnDWo{|zvg68A%3^~G0XxDq<$43jRtz3b3 zlwcQ9Jd%*HVs_tvG?uc@WMim&8vb1RcMjMR%{Qd@o2YQpq;_<@t&kl4TO zZ5>#C*VgCW1h~{xHmNYId9r*}a*~bkQ{gLc7N0A(rHkaSeughheG`vt!8e?gOuR6f ze|pW2W~KDwW8337I41d76Jb#brK3eD1dH-nK?ZCiZ8$sqt+^h3=sX!eNl`qrW-rj& z{q_t?8x%e21u#)^(1asRLqjv(Uu!`1i&j4GeAw?n^F_&LExT{Py!i3yAdOyU_I~`-+#L0$+H$juWDqNP zxyFJ2yzY|^Ep+9&)uwTPB#-_eXcI=F<|V$bHc{;ryepN%o&4e!K4?PPIOVw-+U{eWn%>Cl5V z-)X3)GfjHdX@80GZ+J&=gO>gFAy{V^+Riy_?GBU}m?xquOQ1a<7OLOHAJVI>JBtc@ zcc_6*J=Cb{7yA*a|_wtG;Eb^_@*>87MPAW;6PkB!RHI zRnB{HDtoJAwnPi4N~Um5#z*VfYva+mDf7rAF&a3o););OmS`I4^`!|O@@MAt_#>fj z7FiswngYRPMXPIh1;^nypUVu^=d+S%oL*eaK(hy4Bv|ym3Ov$7t2Jcm!RexG=*teqSao7k>u)$7wrFegvcp)|7 zb)!}JC;wSAYtjxN!-b)}=pnu)#N6BJN$Vyw5#1?_ga^ciMWDCGdrLu{=G5SkzrM`z z_ZebKem7KT4$v}cB{Pqa*WQ+`(keDkf(M{5ygPU8c_8s9c~Cs3?}%8SZ4HKyh5+HBXkp;S!jiE@pAljQ{JKdNn#z*tLQydE2X&Vc^f zOlP8MdaxwMb>#K>3b-Sq#b=_mO*#wF;VpQ8%+6`^>fh_GsRYZN;Br>$Bp3>XoY1<{ zb&eaRLUc15?BzF9Ghm&9D|0U=>Mus-KgA~hvk3kXi>ZvRu5Jl6+Lu3U0DTfYqRwG8 z0qxaAP5buH!GJ+v)(KYP5l4n{_~3NB=$<~gCj*5StnI6yb|ESWvI_m#4W10^FWT-i zo~dxSfO@ssp$n^LRSOLv=Udd0I_Qb)V( zB`;BBTo4_!Fab2knz@T-5mAuO%+E`i+E!6LNnJMz>oxcVgox+TCy_oyFEsnt$1B+WFgEWsFNb_+^6d-ugfc`Te56F*0u<<=0&ta~#)*h*RH4T(b4`33H z|2{H3J$)YuPP9WAEVQVoD61rn`Xc8L48ioN$jv+Uiw;Af&XBKo-5k@yysCZ?bDY=FOYd&NH6aLgQW@wE&iRwJo?bCt+1=9LwB*+QJI0r#>HamNZ^sK)u3Dw|)Yq;ORH{ zPXZAxvUW;b0I0RBJ+B+X;WRC&51^s1>mhl0#GOboE(Cbemd}eT_!;bg!b&~ccC|yH z{o2fE?!yqC#1FOpbZTkyr_QVpk7TklPm|AV47u#*fTt9olyfDiwb5Wg#5YoRxAEAO z!Ds`guo&Y6n->5s(Yt;M&=`S8j%GC| z9~Vok0k}c7q_?fNXadIoUmUGxrCMxljM^B;7qp51%z;*X8^E=KLD5=;+MjmE`-tJ( zBH~le9H952{vGH5T~7N8yz9bE@YakyWosc%7~QH{(LXJ>-0fmSmD-9>A>=J%cF;pZ z_b_s&fATGp`xMfk8O@cS1re?ESa>R<>~yAujaGl>oGD7;Oc7ef${i)Br;S?i8IIQn zPD6o)P4J}VcB84Y3{JNph>ayehNxoEaZ;$J2pSX;6BPL0uJ0o1r)gNIOcM<1F0)EQ z5@`2+T$6swf@qGS&Y4O~{^r~Y0NB`R8AO8h1n3^@vQ<0KMSe9ax^n1eV7g;0_1eK7 zzs^i2X%0|CP%UO(mdkZ~F(Pyalb*EAjAp=V+pm(UzatUkRa9;%fj_ajTM%XHHA7qV zzM@ACSm=1Dh+LsDuO=6J=lyO2ZaAA**L=>_<1^AEnKm|cP$Ivlwo%j-3o0ZD3NB!6 zVutn>Y{VK~?R({=f6Kp6$o-!tYFdoxE zN+wKxPNt{bxR^WS41v_A&i`=nLXjbt+8g6TN4`CtcW!nVl*tLIJ6{=HMC0Gyy)blj zuxgVYs9C*|kJJTX)ov1vZ&D>zV}sU2gKzHdkj3?yTVF2|VIa-sLCdCc4NN`aSAwZGFt@f1|IgsPAf}S=ionLQP@mFi!T~I+jfKh%r#}Y~lfs z4-b&XjTVZ3NU0XN$SVHY@2&N|_1v$|f;0rb4$y7hApbI2ST?J&$b4%7iEC(vViTXq zZiJ>D5?}Hg>l$-jcCOPk0~yFzz_+SLFHtpf_m;&=g+BF|+HC#Y3T-&vXZH%!WShpg zy*EntGneIJi_2=)f5PIasX5NH{E@VymoCy_SC6{|-jT~yzqc-|hDKa;de69%>TTv2 z1o;o08Y$jiIS&pC1oW9UxG|b(M$xVNKE^b&8~tJ(uZHbe|xTO<1Q~B zbE)~~JmPTj-HVr8z!tJFe|tRc_dY*WA@g&8L+$5zp-M|$F*&;n2suGc>&0BDK~$!v z18k<_l) z+de#IOPg+6!}4~RLi9+rLpIFGv*|*ELrI#n0|#3zK$j!!_n zx^?$#QC(|>D1N7_e+7l)mvf-K<|pO zWywptvU_Unu$s3&U!x)5@2!Apnc7(m&ZPOqw;Pvwx5jKYHq99Z+ljv|Yu(?aI9X>b zAGoGd{lf3*PpsnwXU;d1G>h_%am%+QgmoNs9=G}%*yVK9(H}%i56h}@oht>tw+Szn z*Zun3OT)Ictm&8_wpE2{aBE_6F!Ndo=A!yOeICO7#^BhZ^2ohk?;J9DCf z)z8~+r{;VWNL#IbzDN_;g(VY`o;mBr?>Fn*)1dk^hpVng?+o47jI(S%9)A)=AnnV| zZ@&M;-n}lZcb0w`>WGfpKNv-y2?y(}x=5L4a~LP6Ynm_~!gPt0#4{u#Mva`P4E zsn-%O!oIOOXS{A^S1j|AQSm$CL^QiOa!K*~*o$MCi&$D=zeRv}CwrT`c1sJUmD|Mr z+WlRUooO(A=7s6$`Pt>&(K{xc{?{1j-Az13H+Q^SXga5`BZ|8po($9;znCd?=69_= zg+b96Q|U9fWM&(!a+CgtK4a#g%X4y_{KBnn{$b$LQQQdoF$dCG7UVgjbHnc>&tx1< z77@q#TQw3b-fgWpF+aJCm76-wMOPubPSp^a=zJK>H$6paMryesEJPNcS(EBHd+(eu zJ+|EWrpuFR4*6+O`sPe2*sss1b!qAx`` | API key for accessing the remote AI service. | + +--------------------------------------+--------------------------------------+--------------------------------------------------+ + | Speech2Text (Whisper) Endpoint | ``https://localhost:2224/whisper`` | API endpoint for Speech-to-Text services. | + +--------------------------------------+--------------------------------------+--------------------------------------------------+ + | Speech2Text (Whisper) API Key | ```` | API key for the Speech-to-Text server. | + +--------------------------------------+--------------------------------------+--------------------------------------------------+ + + + +By following these steps, you can successfully configure a remote AI model and Speech-to-Text connection in FreeScribe. + +How to connect to ClinicianFOCUS LLM Container +---------------------------------------------- +1. Open the **Settings** window and navigate to the **"AI Settings"** tab. +2. Configure the following fields: + + - **Model Endpoint**: Enter the API URL of your remote server. + Example: + ``https://api.openai.com/v1/`` + + .. image:: images/installer_llm_endpoint.png + :width: 600 + + - **OpenAI API Key**: Paste your OpenAI API key here. + Example: + ``The API Key provided in the installer`` + + .. image:: images/installer_api_key_highlighted.png + :width: 600 + + - **Local LLM**: Ensure this is unchecked in the FreeScribe settings. + +3. Repeat for **Whisper Settings** tab. +4. Click **"Save"** to apply the changes. + +How to connect to JanAI +----------------------- +1. Open the **Settings** window and navigate to the **"AI Settings"** tab. +2. JanAI reference screenshot: + .. image:: images/jan_ai.png + :width: 600 + + Click on Step 1 and 2 in the photo. Then proceed to the next step below. +3. Configure the following fields: + - **Model Endpoint**: Enter the API URL the JanAI server. Combine the information from steps 3 and 4. + Example: ``https://localhost:1337/v1`` + + - Note: JanAI does not require an API key. So this can be left blank. \ No newline at end of file From e870c99464394d1eafa12ef310f98e9612c7990d Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 17 Dec 2024 15:41:04 -0500 Subject: [PATCH 091/244] More documentation --- docs/_build/.gitkeep | 0 docs/how_to_use.rst | 91 ++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 34 +++++++++++++++-- docs/welcome.rst | 35 +++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) delete mode 100644 docs/_build/.gitkeep create mode 100644 docs/how_to_use.rst create mode 100644 docs/welcome.rst diff --git a/docs/_build/.gitkeep b/docs/_build/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/how_to_use.rst b/docs/how_to_use.rst new file mode 100644 index 00000000..c6cbdb16 --- /dev/null +++ b/docs/how_to_use.rst @@ -0,0 +1,91 @@ +How To Use FreeScribe +===================== +.. contents:: + :depth: 2 + +FreeScribe Installation Guide +============================= + +Follow these step-by-step instructions to install FreeScribe on your computer: + +Step 1: License Agreement +-------------------------- + +1. Launch the **FreeScribe Setup** installer. +2. Read through the license agreement carefully: + + - The software is released under the **AGPL-3.0 license**. + - It includes contributions from the ClinicianFOCUS initiative and team members. + +3. Click **"I Agree"** to accept the terms and proceed. + +Step 2: Select Installation Architecture +---------------------------------------- + +1. Choose your preferred installation architecture: + + - **CPU** (Recommended for most users): Runs on any standard computer processor. + - **NVIDIA**: Select this option if you have an NVIDIA GPU for enhanced performance. + +2. Once selected, click **"Next"**. + +Step 3: Choose Installation Location +------------------------------------ + +1. The installer will suggest a default location to install FreeScribe: + + - Example: ``C:\Program Files (x86)\FreeScribe`` + +2. To change the destination folder, click **"Browse"** and select a new directory. +3. Verify the required disk space and click **"Install"** to begin the installation. + +Step 4: Installation Progress +----------------------------- + +1. The installation will proceed, and you will see a progress bar indicating the status. +2. If prompted with any messages (e.g., configuration file conflicts), resolve them: + + - Click **"Yes"** to remove old configuration files to avoid conflicts with new versions. Recommended to prevent conflicts. + - Click **"Yes"** to remove old installation versions. Recommended to prevent conflicts. + +Step 5: Complete Installation +----------------------------- + +1. Once the installation is complete, select additional options: + + - **Run FreeScribe after installation** (checked by default). + - **Create Desktop Shortcut** for easy access. + - **Add to Start Menu** for quick navigation. + +2. Click **"Close"** to exit the installer. + +Final Notes +----------- + +- After installation, FreeScribe will launch automatically (if selected). +- You can now access the application via the Desktop Shortcut or Start Menu. + +You are all set to use FreeScribe! If you encounter any issues during installation, refer to the community or documentation for troubleshooting. + +Inside The Application +====================== + +Getting Started +--------------- + +To start using FreeScribe: + +1. Launch FreeScribe from the Desktop Shortcut or Start Menu. +2. Follow the on-screen instructions to set up your preferences. + +Workflow Example +---------------- + +1. **Start Transcription**: Click on the "Start recording" button to begin capturing audio. +2. **Generate Notes**: Once the session is complete, click on "Stop Recording" and the note will begin to automatically generate. +3. **Export**: It automatically saves the note to your clipboard to be pasted anywhere. It also can be copied by clicking the copy text button. + +Additional Help +--------------- + +For further details, visit the discord for support `Join our Discord Community `_ (`discord.gg/6DnPENSn `_). diff --git a/docs/index.rst b/docs/index.rst index 83e022cf..cfe71dda 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,13 +3,41 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -FreeScribe documentation -======================== +Welcome to the FreeScribe Project +================================= -This page is currently work in progress. Please visit again soon. +Welcome to the FreeScribe project! This project aims to provide an intelligent medical scribe application that assists healthcare professionals by transcribing conversations and generating medical notes. +Introduction +------------ + +The FreeScribe project leverages advanced machine learning models to transcribe conversations between healthcare providers and patients. It also generates structured medical notes based on the transcriptions, helping to streamline the documentation process in clinical settings. + +Discord Community +----------------- + +Join our Discord community to connect with other users, get support, and collaborate on the AI Medical Scribe project. Our community is a great place to ask questions, share ideas, and stay updated on the latest developments. + +`Join our Discord Community `_ (`discord.gg/6DnPENSn `_) + +Features +-------- + +- **Real-time Transcription**: Transcribe conversations in real-time using advanced speech recognition models. +- **Medical Note Generation**: Automatically generate structured medical notes from transcriptions. +- **User-Friendly Interface**: Intuitive and easy-to-use interface for healthcare professionals. +- **Customizable Settings**: Customize the application settings to suit your workflow. + +License +------- + +This project is licensed under the MIT License. See the `LICENSE file `_ for more information. + .. toctree:: :maxdepth: 2 :caption: Contents: + welcome + how_to_use + setting_remote_connection \ No newline at end of file diff --git a/docs/welcome.rst b/docs/welcome.rst new file mode 100644 index 00000000..a21b9631 --- /dev/null +++ b/docs/welcome.rst @@ -0,0 +1,35 @@ +.. FreeScribe documentation master file, created by + sphinx-quickstart on Mon Dec 16 14:54:36 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to the FreeScribe Project +================================= + +Welcome to the FreeScribe project! This project aims to provide an intelligent medical scribe application that assists healthcare professionals by transcribing conversations and generating medical notes. + + +Introduction +------------ + +The FreeScribe project leverages advanced machine learning models to transcribe conversations between healthcare providers and patients. It also generates structured medical notes based on the transcriptions, helping to streamline the documentation process in clinical settings. + +Discord Community +----------------- + +Join our Discord community to connect with other users, get support, and collaborate on the AI Medical Scribe project. Our community is a great place to ask questions, share ideas, and stay updated on the latest developments. + +`Join our Discord Community `_ (`discord.gg/6DnPENSn `_) + +Features +-------- + +- **Real-time Transcription**: Transcribe conversations in real-time using advanced speech recognition models. +- **Medical Note Generation**: Automatically generate structured medical notes from transcriptions. +- **User-Friendly Interface**: Intuitive and easy-to-use interface for healthcare professionals. +- **Customizable Settings**: Customize the application settings to suit your workflow. + +License +------- + +This project is licensed under the MIT License. See the `LICENSE file `_ for more information. \ No newline at end of file From 4cd82fb08e35f84b2909cfb3b824ead0485eca4f Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Tue, 17 Dec 2024 15:49:02 -0500 Subject: [PATCH 092/244] Update src/FreeScribe.client/markdown/help/settings.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/markdown/help/settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/markdown/help/settings.md b/src/FreeScribe.client/markdown/help/settings.md index 1e4616d2..70546bb5 100644 --- a/src/FreeScribe.client/markdown/help/settings.md +++ b/src/FreeScribe.client/markdown/help/settings.md @@ -12,7 +12,7 @@ ## Whisper Settings ### Whisper Endpoint -**Description**: API endpoint for Whisper service. This sends a wav file from the client to the endpoint. Default is set to the Local Whisper container provided by ClinicianFOCUS +**Description**: API endpoint for Whisper service. This sends a wav file from the client to the endpoint. Default is set to the Local Whisper container provided by ClinicianFOCUS. **Default**: `https://localhost:2224/whisperaudio` **Type**: string From f395110d66d3b3870432984f5a3bb9dc8dd981f7 Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Tue, 17 Dec 2024 15:49:08 -0500 Subject: [PATCH 093/244] Update src/FreeScribe.client/markdown/help/settings.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/markdown/help/settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/markdown/help/settings.md b/src/FreeScribe.client/markdown/help/settings.md index 70546bb5..e0e5d967 100644 --- a/src/FreeScribe.client/markdown/help/settings.md +++ b/src/FreeScribe.client/markdown/help/settings.md @@ -17,7 +17,7 @@ **Type**: string ### Whisper Server API Key -**Description**: API key for Whisper service authentication +**Description**: API key for Whisper service authentication. **Default**: `None` **Type**: string From 22748fa24c1e9e5073935aec36997ac9752b3fa5 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:32:23 -0500 Subject: [PATCH 094/244] Made github workflow create version file based on pushed version tag --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25ceaa2f..8a298321 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,11 @@ jobs: - name: Checkout uses: actions/checkout@v1 + - name: Create Version Text File for PyInstaller + run: | + echo ${{ github.ref }} > .\scripts\__version__ + shell: pwsh + - name: Install Python uses: actions/setup-python@v1 with: From 6d21d41fd6646eb2a5e0f9e2cbee8baa9154226e Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:34:22 -0500 Subject: [PATCH 095/244] Made the installer add the version file to the _internal --- scripts/install.nsi | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/install.nsi b/scripts/install.nsi index 00f1893b..eb3999fb 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -220,6 +220,9 @@ Section "MainSection" SEC01 File /r "..\dist\freescribe-client-nvidia\_internal" ${EndIf} + ; Install version file to both nvidia and cpu directories for version checking + SetOutPath "$INSTDIR\_internal" + File ".\__version__" ; add presets CreateDirectory "$INSTDIR\presets" From 780b241584c922d3b51e84198043ec7391ada3c2 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:34:52 -0500 Subject: [PATCH 096/244] Made a get version number function and added it to the setting.txt on save --- src/FreeScribe.client/UI/SettingsWindow.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 6e6a3f54..a5addccb 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -329,8 +329,9 @@ def save_settings_to_file(self): """ settings = { "openai_api_key": self.OPENAI_API_KEY, - "editable_settings": self.editable_settings + "editable_settings": self.editable_settings, # "api_style": self.API_STYLE # FUTURE FEATURE REVISION + "app_version": self.get_application_version() } with open(get_resource_path('settings.txt'), 'w') as file: json.dump(settings, file) @@ -607,4 +608,14 @@ def update_whisper_model(self): old_whisper_architecture != self.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value].get() or old_cpu_count != self.editable_settings_entries[SettingsKeys.WHISPER_CPU_COUNT.value].get() or old_compute_type != self.editable_settings_entries[SettingsKeys.WHISPER_COMPUTE_TYPE.value].get()): - self.main_window.root.event_generate("<>") \ No newline at end of file + self.main_window.root.event_generate("<>") + + def get_application_version(self): + version_str = "vx.x.x.alpha" + try: + with open(get_file_path('__version__'), 'r') as file: + version_str = file.read().strip() + except FileNotFoundError: + print("Version file not found. Using default version.") + finally: + return version_str \ No newline at end of file From d61b30541141ff0df7fb8e328833a30da13772e8 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:35:13 -0500 Subject: [PATCH 097/244] Added version to the bottom of the settings window --- src/FreeScribe.client/UI/SettingsWindowUI.py | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index b601560b..22687045 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -541,10 +541,24 @@ def create_buttons(self): This method creates and places buttons for saving settings, resetting to default, and closing the settings window. """ - tk.Button(self.main_frame, text="Save", command=self.save_settings, width=10).pack(side="right", padx=2, pady=5) - tk.Button(self.main_frame, text="Default", width=10, command=self.reset_to_default).pack(side="right", padx=2, pady=5) - tk.Button(self.main_frame, text="Close", width=10, command=self.close_window).pack(side="right", padx=2, pady=5) - tk.Button(self.main_frame, text="Help", width=10, command=self.create_help_window).pack(side="left", padx=2, pady=5) + footer_frame = tk.Frame(self.main_frame) + footer_frame.pack(side="bottom", fill="x") + + # Place the "Help" button on the left + tk.Button(footer_frame, text="Help", width=10, command=self.create_help_window).pack(side="left", padx=2, pady=5) + + # Place the label in the center + version = self.settings.get_application_version() + tk.Label(footer_frame, text=f"FreeScribe Client {version}").pack(side="left", expand=True, padx=2, pady=5) + + # Create a frame for the right-side elements + right_frame = tk.Frame(footer_frame) + right_frame.pack(side="right") + + # Pack all other buttons into the right frame + tk.Button(right_frame, text="Close", width=10, command=self.close_window).pack(side="right", padx=2, pady=5) + tk.Button(right_frame, text="Default", width=10, command=self.reset_to_default).pack(side="right", padx=2, pady=5) + tk.Button(right_frame, text="Save", width=10, command=self.save_settings).pack(side="right", padx=2, pady=5) def create_help_window(self): """ From 566b2bf60493c5ca0229aaf88e8966aa41473522 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:49:04 -0500 Subject: [PATCH 098/244] Made the whisper architecture select greyed out on remote --- src/FreeScribe.client/UI/SettingsWindowUI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 22687045..20801514 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -231,7 +231,7 @@ def toggle_remote_whisper_settings(self): # set the local option to disabled on switch to remote inverted_state = "disabled" if current_state == 0 else "normal" self.whisper_models_drop_down.config(state=inverted_state) - + self.whisper_architecture_dropdown.config(state=inverted_state) def create_llm_settings(self): From ec6eec20feb0df49a86857a87a4c3bcf53e28cd3 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:49:29 -0500 Subject: [PATCH 099/244] reorganized settings so microphone is on thright bototm and architecture select is left bottom, to group settings by local/remote --- src/FreeScribe.client/UI/SettingsWindowUI.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 20801514..f2250bb9 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -194,23 +194,23 @@ def create_whisper_settings(self): self.settings.editable_settings_entries["Whisper Model"] = self.whisper_models_drop_down # create the whisper model dropdown slection - microphone_select = MicrophoneSelector(left_frame, left_row, 0, self.settings) + microphone_select = MicrophoneSelector(right_frame, right_row, 0, self.settings) self.settings.editable_settings_entries["Current Mic"] = microphone_select - left_row += 1 + right_row += 1 # Whisper Architecture Dropdown - self.whisper_architecture_label = tk.Label(right_frame, text=SettingsKeys.WHISPER_ARCHITECTURE.value) - self.whisper_architecture_label.grid(row=right_row, column=0, padx=0, pady=5, sticky="w") + self.whisper_architecture_label = tk.Label(left_frame, text=SettingsKeys.WHISPER_ARCHITECTURE.value) + self.whisper_architecture_label.grid(row=left_row, column=0, padx=0, pady=5, sticky="w") whisper_architecture_options = self.settings.get_available_architectures() - self.whisper_architecture_dropdown = ttk.Combobox(right_frame, values=whisper_architecture_options, width=20, state="readonly") + self.whisper_architecture_dropdown = ttk.Combobox(left_frame, values=whisper_architecture_options, width=20, state="readonly") if self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] in whisper_architecture_options: self.whisper_architecture_dropdown.current(whisper_architecture_options.index(self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value])) else: # Default cpu - self.whisper_architecture_dropdown.set() + self.whisper_architecture_dropdown.set("CPU") - self.whisper_architecture_dropdown.grid(row=right_row, column=1, padx=0, pady=5, sticky="w") + self.whisper_architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value] = self.whisper_architecture_dropdown right_row += 1 From c4bdef19f51209b30e00fc0131bdc19a0b78e513 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 11:56:16 -0500 Subject: [PATCH 100/244] Clear application state on whole file cancel --- src/FreeScribe.client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a40318f4..541d0a1f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -595,6 +595,7 @@ def cancel_whole_audio_process(thread_id): print(f"An error occurred: {e}") finally: GENERATION_THREAD_ID = None + clear_application_press() loading_window = LoadingWindow(root, "Processing Audio", "Processing Audio. Please wait.", on_cancel=lambda: (cancel_processing(), cancel_whole_audio_process(current_thread_id))) From 32028fa73012c771570915c19fc9ee50a4eb455f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 12:55:23 -0500 Subject: [PATCH 101/244] Removed magic nums --- src/FreeScribe.client/UI/SettingsWindow.py | 6 ++++-- src/FreeScribe.client/UI/SettingsWindowUI.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index a5addccb..4dda8926 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -99,6 +99,8 @@ class SettingsWindow(): CPU_INSTALL_FILE = "CPU_INSTALL.txt" NVIDIA_INSTALL_FILE = "NVIDIA_INSTALL.txt" STATE_FILES_DIR = "install_state" + DEFAULT_WHISPER_ARCHITECTURE = Architectures.CPU.architecture_value + DEFAULT_LLM_ARCHITECTURE = Architectures.CPU.architecture_value def __init__(self): """Initializes the ApplicationSettings with default values.""" @@ -174,7 +176,7 @@ def __init__(self): "Model": "gemma2:2b-instruct-q8_0", "Model Endpoint": "https://localhost:3334/v1", "Use Local LLM": True, - "Architecture": "CPU", + "Architecture": SettingsWindow.DEFAULT_LLM_ARCHITECTURE, "use_story": False, "use_memory": False, "use_authors_note": False, @@ -199,7 +201,7 @@ def __init__(self): SettingsKeys.LOCAL_WHISPER.value: True, SettingsKeys.WHISPER_ENDPOINT.value: "https://localhost:2224/whisperaudio", SettingsKeys.WHISPER_SERVER_API_KEY.value: "", - SettingsKeys.WHISPER_ARCHITECTURE.value: "CPU", + SettingsKeys.WHISPER_ARCHITECTURE.value: SettingsWindow.DEFAULT_WHISPER_ARCHITECTURE, SettingsKeys.WHISPER_BEAM_SIZE.value: 5, SettingsKeys.WHISPER_CPU_COUNT.value: multiprocessing.cpu_count(), SettingsKeys.WHISPER_VAD_FILTER.value: False, diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index f2250bb9..d63552e6 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -28,7 +28,7 @@ from utils.file_utils import get_file_path from UI.MarkdownWindow import MarkdownWindow from UI.Widgets.MicrophoneSelector import MicrophoneSelector -from UI.SettingsWindow import SettingsKeys, FeatureToggle, Architectures +from UI.SettingsWindow import SettingsKeys, FeatureToggle, Architectures, SettingsWindow class SettingsWindowUI: @@ -208,7 +208,7 @@ def create_whisper_settings(self): self.whisper_architecture_dropdown.current(whisper_architecture_options.index(self.settings.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value])) else: # Default cpu - self.whisper_architecture_dropdown.set("CPU") + self.whisper_architecture_dropdown.set(SettingsWindow.DEFAULT_WHISPER_ARCHITECTURE) self.whisper_architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value] = self.whisper_architecture_dropdown From cf77a90d3ed2da0dc59d8820fedb25831d041503 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:03:24 -0500 Subject: [PATCH 102/244] Adjusted settings window to be bigger to fit everything --- src/FreeScribe.client/UI/SettingsWindowUI.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index d63552e6..d3d86ee4 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -81,8 +81,8 @@ def open_settings_window(self): """ self.settings_window = tk.Toplevel() self.settings_window.title("Settings") - self.settings_window.geometry("700x400") # Set initial window size - self.settings_window.minsize(700, 400) # Set minimum window size + self.settings_window.geometry("850x400") # Set initial window size + self.settings_window.minsize(850, 400) # Set minimum window size self.settings_window.resizable(True, True) self.settings_window.grab_set() self.settings_window.iconbitmap(get_file_path('assets','logo.ico')) From 43c8e8a03536bb2f182989d1a9d93661ec7b01ff Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:04:56 -0500 Subject: [PATCH 103/244] Made settings open into the center of main window --- src/FreeScribe.client/UI/SettingsWindowUI.py | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index d3d86ee4..409d0322 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -81,12 +81,14 @@ def open_settings_window(self): """ self.settings_window = tk.Toplevel() self.settings_window.title("Settings") - self.settings_window.geometry("850x400") # Set initial window size - self.settings_window.minsize(850, 400) # Set minimum window size + self.settings_window.geometry("775x400") # Set initial window size + self.settings_window.minsize(775, 400) # Set minimum window size self.settings_window.resizable(True, True) self.settings_window.grab_set() self.settings_window.iconbitmap(get_file_path('assets','logo.ico')) + self.display_center_to_parent() + self.main_frame = tk.Frame(self.settings_window) self.main_frame.pack(expand=True, fill='both') @@ -123,6 +125,21 @@ def open_settings_window(self): self.create_buttons() + def display_center_to_parent(self): + # Get parent window dimensions and position + parent_x = self.root.winfo_x() + parent_y = self.root.winfo_y() + parent_width = self.root.winfo_width() + parent_height = self.root.winfo_height() + + # Calculate the position for the settings window + settings_width = 775 + settings_height = 400 + center_x = parent_x + (parent_width - settings_width) // 2 + center_y = parent_y + (parent_height - settings_height) // 2 + + # Apply the calculated position to the settings window + self.settings_window.geometry(f"{settings_width}x{settings_height}+{center_x}+{center_y}") def add_scrollbar_to_frame(self, frame): """ From 4bbbc5944da4fcf07b121262bb370f17b0ce6f84 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:24:16 -0500 Subject: [PATCH 104/244] Made display to center private --- src/FreeScribe.client/UI/SettingsWindowUI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 409d0322..fb133325 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -125,7 +125,7 @@ def open_settings_window(self): self.create_buttons() - def display_center_to_parent(self): + def _display_center_to_parent(self): # Get parent window dimensions and position parent_x = self.root.winfo_x() parent_y = self.root.winfo_y() From 932f2f5df50514d15e81526f7573543086a6c2f6 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:28:40 -0500 Subject: [PATCH 105/244] Updated the private refernce --- src/FreeScribe.client/UI/SettingsWindowUI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index fb133325..00bacff9 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -87,7 +87,7 @@ def open_settings_window(self): self.settings_window.grab_set() self.settings_window.iconbitmap(get_file_path('assets','logo.ico')) - self.display_center_to_parent() + self._display_center_to_parent() self.main_frame = tk.Frame(self.settings_window) self.main_frame.pack(expand=True, fill='both') From 19b42016cf015c133376b64b879b154e82b702ae Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:41:45 -0500 Subject: [PATCH 106/244] Render welcome message post widget render in client.py --- src/FreeScribe.client/UI/MainWindowUI.py | 10 +++++++--- src/FreeScribe.client/client.py | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/MainWindowUI.py b/src/FreeScribe.client/UI/MainWindowUI.py index 53dc4296..5bfacb66 100644 --- a/src/FreeScribe.client/UI/MainWindowUI.py +++ b/src/FreeScribe.client/UI/MainWindowUI.py @@ -48,8 +48,12 @@ def load_main_window(self): """ self._bring_to_focus() self._create_menu_bar() - if (self.setting_window.settings.editable_settings['Show Welcome Message']): - self._show_welcome_message() + + # Uncomment this once the UI is refactored to this class + # For now we need to force load it after all our widgets are created + # inside client.py. This is a temporary solution. + # if (self.setting_window.settings.editable_settings['Show Welcome Message']): + # self._show_welcome_message() def _bring_to_focus(self): """ @@ -325,7 +329,7 @@ def _on_help_window_close(self, help_window, dont_show_again: tk.BooleanVar): self.setting_window.settings.save_settings_to_file() help_window.destroy() - def _show_welcome_message(self): + def show_welcome_message(self): """ Private method to display a welcome message. Display a welcome message when the application is launched. diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 541d0a1f..3ba54691 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1485,7 +1485,8 @@ def set_cuda_paths(): #set min size root.minsize(900, 400) - +if (app_settings.editable_settings['Show Welcome Message']): + window.show_welcome_message() #Wait for the UI root to be intialized then load the model. If using local llm. if app_settings.editable_settings["Use Local LLM"]: From efdfcefb497136fd6a63570a7cc27f95c3a96f41 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:52:42 -0500 Subject: [PATCH 107/244] Made content load first. Also made the window display to the center of the application. and reduced sizes so it did not take up entire screen --- src/FreeScribe.client/UI/MarkdownWindow.py | 131 +++++++++++++-------- 1 file changed, 81 insertions(+), 50 deletions(-) diff --git a/src/FreeScribe.client/UI/MarkdownWindow.py b/src/FreeScribe.client/UI/MarkdownWindow.py index 6ebdd7ee..04acb8d1 100644 --- a/src/FreeScribe.client/UI/MarkdownWindow.py +++ b/src/FreeScribe.client/UI/MarkdownWindow.py @@ -4,49 +4,41 @@ from tkhtmlview import HTMLLabel from utils.file_utils import get_file_path -""" -A class to create a window displaying rendered Markdown content. -Attributes: ------------ -window : Toplevel - The top-level window for displaying the Markdown content. -Methods: --------- -__init__(parent, title, file_path, callback=None): - Initializes the MarkdownWindow with the given parent, title, file path, and optional callback. -_on_close(var, callback): - Handles the window close event, invoking the callback with the state of the checkbox. -""" class MarkdownWindow: """ - Initializes the MarkdownWindow. + A class to display a Markdown file in a pop-up window with optional callback functionality. + Parameters: ----------- parent : widget - The parent widget. + The parent widget. title : str - The title of the window. + The title of the window. file_path : str - The path to the Markdown file to be rendered. + The path to the Markdown file to be rendered. callback : function, optional - A callback function to be called when the window is closed, with the state of the checkbox. + A callback function to be called when the window is closed, with the state of the checkbox. """ + def __init__(self, parent, title, file_path, callback=None): + try: + with open(file_path, "r") as file: + content = md.markdown(file.read(), extensions=["extra", "smarty"]) + except FileNotFoundError: + print(f"File not found: {file_path}") + messagebox.showerror("Error", "File not found") + return + + self.parent = parent self.window = Toplevel(parent) self.window.title(title) self.window.transient(parent) self.window.grab_set() - self.window.iconbitmap(get_file_path('assets','logo.ico')) + self.window.iconbitmap(get_file_path('assets', 'logo.ico')) - try: - with open(file_path, "r") as file: - content = md.markdown(file.read(), extensions=["extra", "smarty"]) - - except FileNotFoundError: - print(f"File not found: {file_path}") - self.window.destroy() - messagebox.showerror("Error", "File not found") - return + # Footer frame to hold checkbox and close button + footer_frame = tk.Frame(self.window) + footer_frame.pack(side=tk.BOTTOM, fill="x", padx=10, pady=10) # Create a frame to hold the HTMLLabel and scrollbar frame = tk.Frame(self.window) @@ -63,34 +55,73 @@ def __init__(self, parent, title, file_path, callback=None): # Configure the HTMLLabel to use the scrollbar html_label.config(yscrollcommand=scrollbar.set) + # Optional checkbox and callback handling if callback: var = tk.BooleanVar() - tk.Checkbutton(self.window, text="Don't show this message again", - variable=var).pack(side=tk.BOTTOM, pady=10) - self.window.protocol("WM_DELETE_WINDOW", - lambda: self._on_close(var, callback)) - - # Add a close button at the bottom center - close_button = tk.Button(self.window, text="Close", command=lambda: self._on_close(var, callback)) - close_button.pack(side=tk.BOTTOM) # Extra padding for separation from the checkbox + tk.Checkbutton( + footer_frame, text="Don't show this message again", variable=var + ).pack(side=tk.BOTTOM, padx=5) + + close_button = tk.Button( + footer_frame, text="Close", command=lambda: self._on_close(var, callback) + ) else: - # Add a close button at the bottom center - close_button = tk.Button(self.window, text="Close", command= self.window.destroy) - close_button.pack(side=tk.BOTTOM , pady=5) # Extra padding for separation from the checkbox + close_button = tk.Button(footer_frame, text="Close", command=self.window.destroy) - self.window.geometry("900x900") + # Add the close button + close_button.pack(side=tk.BOTTOM, padx=5) + + # Adjust window size based on content with constraints + self._adjust_window_size(html_label, scrollbar) + self._display_to_center() self.window.lift() + + def _display_to_center(self): + # Get parent window dimensions and position + parent_x = self.parent.winfo_x() + parent_y = self.parent.winfo_y() + parent_width = self.parent.winfo_width() + parent_height = self.parent.winfo_height() + width = self.window.winfo_width() + height = self.window.winfo_height() + + center_x = parent_x + (parent_width - width) // 2 + center_y = parent_y + (parent_height - height) // 2 + + # Apply the calculated position to the settings window + self.window.geometry(f"{width}x{height}+{center_x}+{center_y}") + + def _adjust_window_size(self, html_label, scrollbar): + """ + Dynamically adjusts the window size based on the content, with constraints. + + Parameters: + ----------- + html_label : HTMLLabel + The label containing the rendered Markdown content. + scrollbar : Scrollbar + The scrollbar associated with the HTMLLabel. + """ + self.window.update_idletasks() # Ensure all widgets are rendered + + content_width = html_label.winfo_reqwidth() + scrollbar.winfo_reqwidth() + 20 + content_height = html_label.winfo_reqheight() + 20 # Exclude footer height from adjustment + + width = min(content_width, 900) # Maximum width of 900 + height = min(content_height, 750) + self.window.geometry(f"{width}x{height}") - """ - Handles the window close event. - Parameters: - ----------- - var : BooleanVar - The Tkinter BooleanVar associated with the checkbox. - callback : function - The callback function to be called with the state of the checkbox. - """ def _on_close(self, var, callback): + """ + Handles the window close event. + + Parameters: + ----------- + var : BooleanVar + The Tkinter BooleanVar associated with the checkbox. + callback : function + The callback function to be called with the state of the checkbox. + """ callback(var.get()) - self.window.destroy() \ No newline at end of file + self.window.destroy() From 4515e7cb9bdd517399cfbe5bd873fbfeb9d47aeb Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 13:59:16 -0500 Subject: [PATCH 108/244] Update the versioning number we submit --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a298321..2e3b77e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,9 @@ jobs: - name: Create Version Text File for PyInstaller run: | - echo ${{ github.ref }} > .\scripts\__version__ + $tag = ${{ github.ref }} -replace 'refs/tags/', '' + echo $tag > .\scripts\__version__ + echo $tag shell: pwsh - name: Install Python From d3f909edb3f61833ef196122c13c33beb8cf3fdd Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 14:03:59 -0500 Subject: [PATCH 109/244] Fixed string ref causing error --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e3b77e3..2f8626d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - name: Create Version Text File for PyInstaller run: | - $tag = ${{ github.ref }} -replace 'refs/tags/', '' + $tag = '${{ github.ref }}' -replace 'refs/tags/', '' echo $tag > .\scripts\__version__ echo $tag shell: pwsh From acb9d717e8c8cd9cd88e0c216590aabed72980e1 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 14:05:56 -0500 Subject: [PATCH 110/244] removed debug step --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f8626d8..9d4fc7b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,6 @@ jobs: run: | $tag = '${{ github.ref }}' -replace 'refs/tags/', '' echo $tag > .\scripts\__version__ - echo $tag shell: pwsh - name: Install Python From c05f159ad4fa7e46541eb076a4db4fc003b12964 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 14:10:31 -0500 Subject: [PATCH 111/244] Fixed incorrect incrementation of row --- src/FreeScribe.client/UI/SettingsWindowUI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 00bacff9..7766e781 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -230,7 +230,7 @@ def create_whisper_settings(self): self.whisper_architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value] = self.whisper_architecture_dropdown - right_row += 1 + left_frame += 1 # set the state of the whisper settings based on the SettingsKeys.LOCAL_WHISPER.value checkbox once all widgets are created self.toggle_remote_whisper_settings() From c330e0df9087d172f52a206108ade731599ace02 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 14:20:03 -0500 Subject: [PATCH 112/244] Fixed typo in row incrementation and add safery check to destroy --- src/FreeScribe.client/UI/SettingsWindowUI.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 7766e781..68fd897e 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -230,7 +230,7 @@ def create_whisper_settings(self): self.whisper_architecture_dropdown.grid(row=left_row, column=1, padx=0, pady=5, sticky="w") self.settings.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value] = self.whisper_architecture_dropdown - left_frame += 1 + left_row += 1 # set the state of the whisper settings based on the SettingsKeys.LOCAL_WHISPER.value checkbox once all widgets are created self.toggle_remote_whisper_settings() @@ -762,6 +762,8 @@ def close_window(self): """ self.settings_window.unbind_all("") # Unbind mouse wheel event causing errors self.settings_window.unbind_all("") # Unbind the configure event causing errors - self.cutoff_slider.destroy() + + if self.cutoff_slider is not None: + self.cutoff_slider.destroy() self.settings_window.destroy() From 03dec002860c604dde45f93b2c41632d219dd582 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 14:29:38 -0500 Subject: [PATCH 113/244] Removed scribe templates and added check to show settings section for general if length of list is above zero so we arent displaying empty sections --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- src/FreeScribe.client/UI/SettingsWindowUI.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 6e6a3f54..1fd7e436 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -167,7 +167,7 @@ def __init__(self): self.adv_general_settings = [ - "Enable Scribe Template", + # "Enable Scribe Template", # Uncomment if you want to implement the feature right now removed as it doesn't have a real structured implementation ] self.editable_settings = { diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index b601560b..5cf07eeb 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -467,8 +467,9 @@ def create_processing_section(label_text, setting_key, text_content, row): row = self._create_section_header("⚠️ Advanced Settings (For Advanced Users Only)", 0, text_colour="red") # General Settings - row = self._create_section_header("General Settings", row, text_colour="black") - row = create_settings_columns(self.settings.adv_general_settings, row) + if len(self.settings.adv_general_settings) > 0: + row = self._create_section_header("General Settings", row, text_colour="black") + row = create_settings_columns(self.settings.adv_general_settings, row) # Whisper Settings row = self._create_section_header("Whisper Settings", row, text_colour="black") From e8ae8cd76b40245a569a64a42f3ee732ff8a6107 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 18 Dec 2024 14:35:05 -0500 Subject: [PATCH 114/244] log any version file io errors , print them to debug, return default --- src/FreeScribe.client/UI/SettingsWindow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 4dda8926..6039718b 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -617,7 +617,7 @@ def get_application_version(self): try: with open(get_file_path('__version__'), 'r') as file: version_str = file.read().strip() - except FileNotFoundError: - print("Version file not found. Using default version.") + except Exception as e: + print(f"Error loading version file ({type(e).__name__}). {e}") finally: return version_str \ No newline at end of file From ec60d9b2e67224c89246cbd9b7ddd51eb357d854 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 19 Dec 2024 15:49:44 -0500 Subject: [PATCH 115/244] Increased the default realtime chunk size --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 3e58cb8c..284b3fcf 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -214,7 +214,7 @@ def __init__(self): "Whisper Model": "small.en", "Current Mic": "None", "Real Time": True, - "Real Time Audio Length": 5, + "Real Time Audio Length": 10, "Real Time Silence Length": 1, "Silence cut-off": 0.035, "LLM Container Name": "ollama", From 98a59fe48c5557f686bf8d7748817c06fcfd8ae8 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 19 Dec 2024 15:50:01 -0500 Subject: [PATCH 116/244] Renamed labels with experimental --- src/FreeScribe.client/UI/SettingsWindow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 284b3fcf..ed89ec0a 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -36,10 +36,10 @@ class SettingsKeys(Enum): WHISPER_ENDPOINT = "Speech2Text (Whisper) Endpoint" WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" WHISPER_ARCHITECTURE = "Speech2Text (Whisper) Architecture" - WHISPER_CPU_COUNT = "Speech2Text (Whisper) CPU Thread Count" - WHISPER_COMPUTE_TYPE = "Speech2Text (Whisper) Compute Type" - WHISPER_BEAM_SIZE = "Speech2Text (Whisper) Beam Size" - WHISPER_VAD_FILTER = "Use Speech2Text (Whisper) VAD Filter" + WHISPER_CPU_COUNT = "Whisper CPU Thread Count (Experimental)" + WHISPER_COMPUTE_TYPE = "Whisper Compute Type (Experimental)" + WHISPER_BEAM_SIZE = "Whisper Beam Size (Experimental)" + WHISPER_VAD_FILTER = "Use Whisper VAD Filter (Experimental)" class Architectures(Enum): From f1e3d5949bae08cfc7ad2803a423f2e6738f0f0a Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Wed, 8 Jan 2025 14:10:29 -0500 Subject: [PATCH 117/244] feat: remove temp recordings on app close --- src/FreeScribe.client/client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3ba54691..de59cc5d 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -68,8 +68,15 @@ bring_to_front(APP_NAME) sys.exit(0) +def on_closing(): + temp_recording = get_resource_path('recording.wav') + if os.path.exists(temp_recording): + print("Deleting temporary recording file") + os.remove(temp_recording) + close_mutex() + # Register the close_mutex function to be called on exit -atexit.register(close_mutex) +atexit.register(on_closing) # settings logic app_settings = SettingsWindow() From 9971f53fefe4020b900ec9cd446871aa76bb75f8 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Wed, 8 Jan 2025 14:21:29 -0500 Subject: [PATCH 118/244] error handle while removing temp recordings --- src/FreeScribe.client/client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index de59cc5d..f26181a7 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -71,8 +71,11 @@ def on_closing(): temp_recording = get_resource_path('recording.wav') if os.path.exists(temp_recording): - print("Deleting temporary recording file") - os.remove(temp_recording) + try: + print("Deleting temporary recording file") + os.remove(temp_recording) + except OSError as e: + print(f"Error deleting temporary recording file: {e}") close_mutex() # Register the close_mutex function to be called on exit From 32181a264bf7e58a09aa44bb5b604defb7e54431 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Wed, 15 Jan 2025 12:52:09 -0500 Subject: [PATCH 119/244] remove realtime.wav on app close --- src/FreeScribe.client/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f26181a7..db8128e6 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -70,12 +70,19 @@ def on_closing(): temp_recording = get_resource_path('recording.wav') + temp_realtime = get_resource_path('realtime.wav') if os.path.exists(temp_recording): try: print("Deleting temporary recording file") os.remove(temp_recording) except OSError as e: print(f"Error deleting temporary recording file: {e}") + if os.path.exists(temp_realtime): + try: + print("Deleting temporary realtime recording file") + os.remove(temp_realtime) + except OSError as e: + print(f"Error deleting temporary realtime recording file: {e}") close_mutex() # Register the close_mutex function to be called on exit From 9b46250b8d0e8e9b8f124ac4855cd4ebbd6c99e3 Mon Sep 17 00:00:00 2001 From: Pemba Sherpa Date: Wed, 15 Jan 2025 12:54:09 -0500 Subject: [PATCH 120/244] refact --- src/FreeScribe.client/client.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index db8128e6..7df98900 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -68,21 +68,24 @@ bring_to_front(APP_NAME) sys.exit(0) -def on_closing(): - temp_recording = get_resource_path('recording.wav') - temp_realtime = get_resource_path('realtime.wav') - if os.path.exists(temp_recording): - try: - print("Deleting temporary recording file") - os.remove(temp_recording) - except OSError as e: - print(f"Error deleting temporary recording file: {e}") - if os.path.exists(temp_realtime): +def delete_temp_file(filename): + """ + Deletes a temporary file if it exists. + + Args: + filename (str): The name of the file to delete. + """ + file_path = get_resource_path(filename) + if os.path.exists(file_path): try: - print("Deleting temporary realtime recording file") - os.remove(temp_realtime) + print(f"Deleting temporary file: {filename}") + os.remove(file_path) except OSError as e: - print(f"Error deleting temporary realtime recording file: {e}") + print(f"Error deleting temporary file {filename}: {e}") + +def on_closing(): + delete_temp_file('recording.wav') + delete_temp_file('realtime.wav') close_mutex() # Register the close_mutex function to be called on exit From 55c66605841f252212ee08ed1cdadbc7157b6ff8 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 15 Jan 2025 15:32:10 -0500 Subject: [PATCH 121/244] add .m4a support --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3ba54691..734f9634 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1022,7 +1022,7 @@ def check_thread_status(thread, loading_window): def upload_file(): global uploaded_file_path - file_path = filedialog.askopenfilename(filetypes=(("Audio files", "*.wav *.mp3"),)) + file_path = filedialog.askopenfilename(filetypes=(("Audio files", "*.wav *.mp3 *.m4a"),)) if file_path: uploaded_file_path = file_path threaded_send_audio_to_server() # Add this line to process the file immediately From e994992a9dbbcb885071ee7dfcdf96a658e0edcd Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 16 Jan 2025 14:59:58 -0500 Subject: [PATCH 122/244] debug prints when sending to whisper --- src/FreeScribe.client/client.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3ba54691..600b0c55 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -324,7 +324,16 @@ def realtime_text(): try: verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + + print("Sending audio to server") + print("File informaton") + print(f"File: {file_to_send}") + print("File Size: ", os.path.getsize(file_to_send)) + response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) + + print("Response from whisper with status code: ", response.status_code) + if response.status_code == 200: text = response.json()['text'] if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): @@ -681,9 +690,16 @@ def cancel_whole_audio_process(thread_id): try: verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + print("Sending audio to server") + print("File informaton") + print(f"File: {file_to_send}") + print("File Size: ", os.path.getsize(file_to_send)) + # Send the request without verifying the SSL certificate response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers, files=files, verify=verify) + print("Response from whisper with status code: ", response.status_code) + response.raise_for_status() # check if canceled, if so do not update the UI From 6a1abdc1cf2a2636a16147a561c5d43559ff94bf Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 12:33:25 -0500 Subject: [PATCH 123/244] updated is_silent to use silero --- src/FreeScribe.client/client.py | 112 +++++++++++++++++--------------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3ba54691..a66d8d20 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -47,7 +47,7 @@ from utils.utils import window_has_running_instance, bring_to_front, close_mutex import gc from pathlib import Path - +import torch from WhisperModel import TranscribeError @@ -101,7 +101,7 @@ is_gpt_button_active = False p = pyaudio.PyAudio() audio_queue = queue.Queue() -CHUNK = 1024 +CHUNK = 512 FORMAT = pyaudio.paInt16 CHANNELS = 1 RATE = 16000 @@ -216,7 +216,7 @@ def record_audio(): frames.append(data) # Check for silence audio_buffer = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768 - if is_silent(audio_buffer, app_settings.editable_settings["Silence cut-off"]): + if is_silent(audio_buffer): silent_duration += CHUNK / RATE silent_warning_duration += CHUNK / RATE else: @@ -265,11 +265,20 @@ def check_silence_warning(silence_duration): # If the warning bar is displayed, remove it window.destroy_warning_bar() -def is_silent(data, threshold=0.01): - """Check if audio chunk is silent""" - data_array = np.array(data) - max_value = max(abs(data_array)) - return max_value < threshold +silero, _silero = torch.hub.load(repo_or_dir='snakers4/silero-vad', model='silero_vad') + +def is_silent(data, threshold=0.65): + """Check if audio chunk contains speech using Silero VAD""" + + # Convert audio data to tensor and ensure correct format + audio_tensor = torch.FloatTensor(data) + if audio_tensor.dim() == 2: + audio_tensor = audio_tensor.mean(dim=1) + + # Get speech probability + speech_prob = silero(audio_tensor, 16000).item() + print(f"Speech Probability: {speech_prob}") + return speech_prob < threshold def realtime_text(): global frames, is_realtimeactive, audio_queue @@ -292,52 +301,51 @@ def realtime_text(): if app_settings.editable_settings["Real Time"] == True: print("Real Time Audio to Text") audio_buffer = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768 - if not is_silent(audio_buffer): - if app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] == True: - print("Local Real Time Whisper") - if stt_local_model is None: - update_gui("Local Whisper model not loaded. Please check your settings.") - break + if app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] == True: + print("Local Real Time Whisper") + if stt_local_model is None: + update_gui("Local Whisper model not loaded. Please check your settings.") + break + try: + result = faster_whisper_transcribe(audio_buffer) + except Exception as e: + update_gui(f"\nError: {e}\n") + + if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): + update_gui(result) + else: + print("Remote Real Time Whisper") + if frames: + with wave.open(get_resource_path("realtime.wav"), 'wb') as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(p.get_sample_size(FORMAT)) + wf.setframerate(RATE) + wf.writeframes(b''.join(frames)) + frames = [] + file_to_send = get_resource_path("realtime.wav") + with open(file_to_send, 'rb') as f: + files = {'audio': f} + + headers = { + "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] + } + try: - result = faster_whisper_transcribe(audio_buffer) + verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) + if response.status_code == 200: + text = response.json()['text'] + if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): + update_gui(text) + else: + update_gui(f"Error (HTTP Status {response.status_code}): {response.text}") except Exception as e: - update_gui(f"\nError: {e}\n") - - if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): - update_gui(result) - else: - print("Remote Real Time Whisper") - if frames: - with wave.open(get_resource_path("realtime.wav"), 'wb') as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(p.get_sample_size(FORMAT)) - wf.setframerate(RATE) - wf.writeframes(b''.join(frames)) - frames = [] - file_to_send = get_resource_path("realtime.wav") - with open(file_to_send, 'rb') as f: - files = {'audio': f} - - headers = { - "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] - } - - try: - verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] - response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) - if response.status_code == 200: - text = response.json()['text'] - if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): - update_gui(text) - else: - update_gui(f"Error (HTTP Status {response.status_code}): {response.text}") - except Exception as e: - update_gui(f"Error: {e}") - finally: - #Task done clean up file - if os.path.exists(file_to_send): - f.close() - os.remove(file_to_send) + update_gui(f"Error: {e}") + finally: + #Task done clean up file + if os.path.exists(file_to_send): + f.close() + os.remove(file_to_send) audio_queue.task_done() else: is_realtimeactive = False From b587a64912b65b4892915189266afeb4388ada41 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 12:51:41 -0500 Subject: [PATCH 124/244] removed 5 seconds minimum as we now know it is only speech going to the model --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a66d8d20..fc9032a3 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -230,7 +230,7 @@ def record_audio(): check_silence_warning(silent_warning_duration) # If the current_chunk has at least 5 seconds of audio and 1 second of silence at the end - if record_duration >= minimum_audio_duration and silent_duration >= minimum_silent_duration: + if silent_duration >= minimum_silent_duration: if app_settings.editable_settings["Real Time"] and current_chunk: audio_queue.put(b''.join(current_chunk)) current_chunk = [] From 6bd454041037238c3b5dced1cd900992eada7731 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 13:04:54 -0500 Subject: [PATCH 125/244] added debug message for timeout --- src/FreeScribe.client/client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3ba54691..620d2e0a 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -47,7 +47,7 @@ from utils.utils import window_has_running_instance, bring_to_front, close_mutex import gc from pathlib import Path - +from decimal import Decimal from WhisperModel import TranscribeError @@ -428,13 +428,16 @@ def cancel_realtime_processing(thread_id): loading_window = LoadingWindow(root, "Processing Audio", "Processing Audio. Please wait.", on_cancel=lambda: (cancel_processing(), cancel_realtime_processing(REALTIME_TRANSCRIBE_THREAD_ID))) - timeout_timer = 0 - while audio_queue.empty() is False and timeout_timer < 180: + timeout_timer = 0.0 + while timeout_timer < 60.0: # break because cancel was requested if is_audio_processing_realtime_canceled.is_set(): break timeout_timer += 0.1 + timeout_timer = round(timeout_timer, 10) + if timeout_timer % 5 == 0: + print(f"Waiting for audio processing to finish.Timeout after 60 seconds. Timer: {timeout_timer}s") time.sleep(0.1) loading_window.destroy() From cbebbb3cdfd0d41b1501ccc56eaa5d529e8eabbb Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 13:05:58 -0500 Subject: [PATCH 126/244] added back audio queue condition check --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 620d2e0a..47088090 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -429,7 +429,7 @@ def cancel_realtime_processing(thread_id): timeout_timer = 0.0 - while timeout_timer < 60.0: + while audio_queue.empty() is False and timeout_timer < 60.0: # break because cancel was requested if is_audio_processing_realtime_canceled.is_set(): break From e5adfb548a160b3ca58a854be1eb433f0f131f32 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 13:31:31 -0500 Subject: [PATCH 127/244] Updated real time to use in memory to prevent issues with multiple threads accessing the same file --- src/FreeScribe.client/client.py | 50 ++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 47088090..a41597db 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -49,6 +49,7 @@ from pathlib import Path from decimal import Decimal from WhisperModel import TranscribeError +import io @@ -308,36 +309,35 @@ def realtime_text(): else: print("Remote Real Time Whisper") if frames: - with wave.open(get_resource_path("realtime.wav"), 'wb') as wf: + buffer = io.BytesIO() + with wave.open(buffer, 'wb') as wf: wf.setnchannels(CHANNELS) wf.setsampwidth(p.get_sample_size(FORMAT)) wf.setframerate(RATE) wf.writeframes(b''.join(frames)) frames = [] - file_to_send = get_resource_path("realtime.wav") - with open(file_to_send, 'rb') as f: - files = {'audio': f} - - headers = { - "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] - } - - try: - verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] - response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) - if response.status_code == 200: - text = response.json()['text'] - if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): - update_gui(text) - else: - update_gui(f"Error (HTTP Status {response.status_code}): {response.text}") - except Exception as e: - update_gui(f"Error: {e}") - finally: - #Task done clean up file - if os.path.exists(file_to_send): - f.close() - os.remove(file_to_send) + + buffer.seek(0) # Reset buffer position to start + + files = {'audio': buffer} + + headers = { + "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] + } + + try: + verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) + if response.status_code == 200: + text = response.json()['text'] + if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): + update_gui(text) + else: + update_gui(f"Error (HTTP Status {response.status_code}): {response.text}") + except Exception as e: + update_gui(f"Error: {e}") + finally: + buffer.close() audio_queue.task_done() else: is_realtimeactive = False From abafc62230d8b124370f8b915fefab773fadb951 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 13:35:37 -0500 Subject: [PATCH 128/244] Made timeout timer configurable --- src/FreeScribe.client/UI/SettingsWindow.py | 3 +++ src/FreeScribe.client/client.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 3e58cb8c..8795c708 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -40,6 +40,7 @@ class SettingsKeys(Enum): WHISPER_COMPUTE_TYPE = "Speech2Text (Whisper) Compute Type" WHISPER_BEAM_SIZE = "Speech2Text (Whisper) Beam Size" WHISPER_VAD_FILTER = "Use Speech2Text (Whisper) VAD Filter" + AUDIO_PROCESSING_TIMEOUT_LENGTH = "Audio Processing Timeout (seconds)" class Architectures(Enum): @@ -161,6 +162,7 @@ def __init__(self): # "singleline", # "frmttriminc", # "frmtrmblln", + SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value, ] self.adv_whisper_settings = [ @@ -234,6 +236,7 @@ def __init__(self): "Pre-Processing": "Please break down the conversation into a list of facts. Take the conversation and transform it to a easy to read list:\n\n", "Post-Processing": "\n\nUsing the provided list of facts, review the SOAP note for accuracy. Verify that all details align with the information provided in the list of facts and ensure consistency throughout. Update or adjust the SOAP note as necessary to reflect the listed facts without offering opinions or subjective commentary. Ensure that the revised note excludes a \"Notes\" section and does not include a header for the SOAP note. Provide the revised note after making any necessary corrections.", "Show Scrub PHI": False, + SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value: 180, } self.docker_settings = [ diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a41597db..33883f15 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -429,7 +429,7 @@ def cancel_realtime_processing(thread_id): timeout_timer = 0.0 - while audio_queue.empty() is False and timeout_timer < 60.0: + while audio_queue.empty() is False and timeout_timer < app_settings.editable_settings[SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value]: # break because cancel was requested if is_audio_processing_realtime_canceled.is_set(): break From 25c6cfe881a61df2038e8810b629351288775cca Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 13:54:46 -0500 Subject: [PATCH 129/244] Type check + print message update + move to adv general settings --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- src/FreeScribe.client/client.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 8795c708..24b5059f 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -162,7 +162,6 @@ def __init__(self): # "singleline", # "frmttriminc", # "frmtrmblln", - SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value, ] self.adv_whisper_settings = [ @@ -177,6 +176,7 @@ def __init__(self): self.adv_general_settings = [ # "Enable Scribe Template", # Uncomment if you want to implement the feature right now removed as it doesn't have a real structured implementation + SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value, ] self.editable_settings = { diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 33883f15..e452aa6b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -427,9 +427,14 @@ def cancel_realtime_processing(thread_id): loading_window = LoadingWindow(root, "Processing Audio", "Processing Audio. Please wait.", on_cancel=lambda: (cancel_processing(), cancel_realtime_processing(REALTIME_TRANSCRIBE_THREAD_ID))) + try: + timeout_length = int(app_settings.editable_settings[SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value]) + except ValueError: + # default to 3minutes + timeout_length = 180 timeout_timer = 0.0 - while audio_queue.empty() is False and timeout_timer < app_settings.editable_settings[SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value]: + while audio_queue.empty() is False and timeout_timer < timeout_length: # break because cancel was requested if is_audio_processing_realtime_canceled.is_set(): break @@ -437,7 +442,7 @@ def cancel_realtime_processing(thread_id): timeout_timer += 0.1 timeout_timer = round(timeout_timer, 10) if timeout_timer % 5 == 0: - print(f"Waiting for audio processing to finish.Timeout after 60 seconds. Timer: {timeout_timer}s") + print(f"Waiting for audio processing to finish. Timeout after {timeout_length} seconds. Timer: {timeout_timer}s") time.sleep(0.1) loading_window.destroy() From 2ab7864e0dab5c32091a7c5789e19393e179c2c2 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 14:24:04 -0500 Subject: [PATCH 130/244] comments --- src/FreeScribe.client/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index e452aa6b..35e6b499 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -438,11 +438,16 @@ def cancel_realtime_processing(thread_id): # break because cancel was requested if is_audio_processing_realtime_canceled.is_set(): break - + # increment timer timeout_timer += 0.1 + # round to 10 decimal places, account for floating point errors timeout_timer = round(timeout_timer, 10) + + # check if we should print a message every 5 seconds if timeout_timer % 5 == 0: print(f"Waiting for audio processing to finish. Timeout after {timeout_length} seconds. Timer: {timeout_timer}s") + + # Wait for 100ms before checking again, to avoid busy waiting time.sleep(0.1) loading_window.destroy() From 0b69eb7d3af8d32d81dda4a8545cb25148d5b47b Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 14:36:33 -0500 Subject: [PATCH 131/244] more comments --- src/FreeScribe.client/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 35e6b499..ac2e779f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -309,6 +309,7 @@ def realtime_text(): else: print("Remote Real Time Whisper") if frames: + # Buffer to hold the audio data. This is used to send the audio data to the server. buffer = io.BytesIO() with wave.open(buffer, 'wb') as wf: wf.setnchannels(CHANNELS) @@ -337,6 +338,7 @@ def realtime_text(): except Exception as e: update_gui(f"Error: {e}") finally: + #close buffer. we dont need it anymore buffer.close() audio_queue.task_done() else: From dd4b405e6937d0c29d6402f0cd7ccccb4dd79d0b Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 14:59:53 -0500 Subject: [PATCH 132/244] Dont make network request on empty frame object --- src/FreeScribe.client/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index ac2e779f..6bb6c91c 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -308,15 +308,18 @@ def realtime_text(): update_gui(result) else: print("Remote Real Time Whisper") + buffer = io.BytesIO() if frames: # Buffer to hold the audio data. This is used to send the audio data to the server. - buffer = io.BytesIO() with wave.open(buffer, 'wb') as wf: wf.setnchannels(CHANNELS) wf.setsampwidth(p.get_sample_size(FORMAT)) wf.setframerate(RATE) wf.writeframes(b''.join(frames)) frames = [] + else: + # Dont make the network request if frames is empty + continue buffer.seek(0) # Reset buffer position to start From 03742a339e33535a742876ae6e9a58fcf5212d06 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 15:00:17 -0500 Subject: [PATCH 133/244] close buffer on frames empty --- src/FreeScribe.client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 6bb6c91c..5cdf892f 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -319,6 +319,7 @@ def realtime_text(): frames = [] else: # Dont make the network request if frames is empty + buffer.close() continue buffer.seek(0) # Reset buffer position to start From 7e193eeb803deacc75048c42c9824784b258b946 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 22:39:02 -0500 Subject: [PATCH 134/244] errors from merge --- src/FreeScribe.client/client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 9f4bd5c8..de621932 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -354,9 +354,8 @@ def realtime_text(): verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] print("Sending audio to server") - print("File informaton") - print(f"File: {file_to_send}") - print("File Size: ", os.path.getsize(file_to_send)) + print("File information") + print("File Size: ", len(buffer)) response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) From 68fa2c349346600b3b780ebe1714addc68e4d512 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 22 Jan 2025 22:44:08 -0500 Subject: [PATCH 135/244] Fixed merge bug --- src/FreeScribe.client/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 9f4bd5c8..95ec5220 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -355,8 +355,7 @@ def realtime_text(): print("Sending audio to server") print("File informaton") - print(f"File: {file_to_send}") - print("File Size: ", os.path.getsize(file_to_send)) + print("File Size: ", len(buffer.getbuffer()), "bytes") response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) From 34f576120ebd78157b2849a809240f97c986b89f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 23 Jan 2025 15:06:19 -0500 Subject: [PATCH 136/244] updated requirements for silero --- client_requirements.txt | Bin 2600 -> 2638 bytes client_requirements_nvidia.txt | 1 + 2 files changed, 1 insertion(+) diff --git a/client_requirements.txt b/client_requirements.txt index 3f1afe33681d98132264fa6881255773af381093..7f5b831cec10b71828886904dc501c0b0c1567ba 100644 GIT binary patch delta 30 lcmZ1>a!zE!77oFDh9ZV!h75*8hEj$UhD?V1&GR`r83B+J2)+OS delta 12 TcmX>nvO;9T7LLugI7%1+Bv=I% diff --git a/client_requirements_nvidia.txt b/client_requirements_nvidia.txt index 534a6b42..7e2e4c4e 100644 --- a/client_requirements_nvidia.txt +++ b/client_requirements_nvidia.txt @@ -54,6 +54,7 @@ textblob==0.15.3 threadpoolctl==3.5.0 tiktoken==0.7.0 torch==2.2.2 +torchaudio==2.2.2 tqdm==4.66.5 typing_extensions==4.12.2 tzdata==2024.2 From 4ab2855f6d68f23a9fba5437c5501efd10e58649 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 23 Jan 2025 15:06:59 -0500 Subject: [PATCH 137/244] Added speech probabilty to adv settings --- src/FreeScribe.client/UI/SettingsWindow.py | 7 +++++-- src/FreeScribe.client/UI/SettingsWindowUI.py | 18 ++++++++++-------- src/FreeScribe.client/client.py | 4 +--- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 3e58cb8c..b77057e9 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -40,6 +40,7 @@ class SettingsKeys(Enum): WHISPER_COMPUTE_TYPE = "Speech2Text (Whisper) Compute Type" WHISPER_BEAM_SIZE = "Speech2Text (Whisper) Beam Size" WHISPER_VAD_FILTER = "Use Speech2Text (Whisper) VAD Filter" + SILERO_SPEECH_THRESHOLD = "Silero Speech Threshold" class Architectures(Enum): @@ -164,12 +165,13 @@ def __init__(self): ] self.adv_whisper_settings = [ - "Real Time Audio Length", - "BlankSpace", # Represents the whisper cuttoff + # "Real Time Audio Length", + # "BlankSpace", # Represents the whisper cuttoff SettingsKeys.WHISPER_BEAM_SIZE.value, SettingsKeys.WHISPER_CPU_COUNT.value, SettingsKeys.WHISPER_VAD_FILTER.value, SettingsKeys.WHISPER_COMPUTE_TYPE.value, + SettingsKeys.SILERO_SPEECH_THRESHOLD.value ] @@ -234,6 +236,7 @@ def __init__(self): "Pre-Processing": "Please break down the conversation into a list of facts. Take the conversation and transform it to a easy to read list:\n\n", "Post-Processing": "\n\nUsing the provided list of facts, review the SOAP note for accuracy. Verify that all details align with the information provided in the list of facts and ensure consistency throughout. Update or adjust the SOAP note as necessary to reflect the listed facts without offering opinions or subjective commentary. Ensure that the revised note excludes a \"Notes\" section and does not include a header for the SOAP note. Provide the revised note after making any necessary corrections.", "Show Scrub PHI": False, + SettingsKeys.SILERO_SPEECH_THRESHOLD.value: 0.5, } self.docker_settings = [ diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 26e11079..b8a0997f 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -497,11 +497,11 @@ def create_processing_section(label_text, setting_key, text_content, row): self.create_editable_settings_col(left_frame, right_frame, 0, 0, self.settings.adv_whisper_settings) - # Audio meter - tk.Label(left_frame, text="Whisper Audio Cutoff").grid(row=1, column=0, padx=0, pady=0, sticky="w") - self.cutoff_slider = AudioMeter(left_frame, width=150, height=50, - threshold=self.settings.editable_settings["Silence cut-off"] * 32768) - self.cutoff_slider.grid(row=1, column=1, padx=0, pady=0, sticky="w") + # # Audio meter + # tk.Label(left_frame, text="Whisper Audio Cutoff").grid(row=1, column=0, padx=0, pady=0, sticky="w") + # self.cutoff_slider = AudioMeter(left_frame, width=150, height=50, + # threshold=self.settings.editable_settings["Silence cut-off"] * 32768) + # self.cutoff_slider.grid(row=1, column=1, padx=0, pady=0, sticky="w") row += 1 # AI Settings @@ -617,7 +617,8 @@ def save_settings(self, close_window=True): self.aiscribe2_text.get("1.0", "end-1c"), # end-1c removes the trailing newline self.settings_window, # self.api_dropdown.get(), - self.cutoff_slider.threshold / 32768, + self.settings.editable_settings["Silence cut-off"], # Save the old one for whisper audio cutoff, will be removed in future, left in incase we go back to old cut off + # self.cutoff_slider.threshold / 32768, # old threshold ) if self.settings.editable_settings["Use Docker Status Bar"] and self.main_window.docker_status_bar is None: @@ -764,7 +765,8 @@ def close_window(self): self.settings_window.unbind_all("") # Unbind mouse wheel event causing errors self.settings_window.unbind_all("") # Unbind the configure event causing errors - if self.cutoff_slider is not None: - self.cutoff_slider.destroy() + if hasattr(self, "cutoff_slider"): + if self.cutoff_slider is not None: + self.cutoff_slider.destroy() self.settings_window.destroy() diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index fc9032a3..f94a6cd1 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -216,7 +216,7 @@ def record_audio(): frames.append(data) # Check for silence audio_buffer = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768 - if is_silent(audio_buffer): + if is_silent(audio_buffer, app_settings.editable_settings[SettingsKeys.SILERO_SPEECH_THRESHOLD.value]): silent_duration += CHUNK / RATE silent_warning_duration += CHUNK / RATE else: @@ -269,7 +269,6 @@ def check_silence_warning(silence_duration): def is_silent(data, threshold=0.65): """Check if audio chunk contains speech using Silero VAD""" - # Convert audio data to tensor and ensure correct format audio_tensor = torch.FloatTensor(data) if audio_tensor.dim() == 2: @@ -277,7 +276,6 @@ def is_silent(data, threshold=0.65): # Get speech probability speech_prob = silero(audio_tensor, 16000).item() - print(f"Speech Probability: {speech_prob}") return speech_prob < threshold def realtime_text(): From c2c2deb3ac7392663ffbda89c2c3cf97f926beaa Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 23 Jan 2025 15:11:53 -0500 Subject: [PATCH 138/244] Updated comment --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f94a6cd1..b49c311d 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -229,7 +229,7 @@ def record_audio(): # Check if we need to warn if silence is long than warn time check_silence_warning(silent_warning_duration) - # If the current_chunk has at least 5 seconds of audio and 1 second of silence at the end + # 1 second of silence at the end so we dont cut off speech if silent_duration >= minimum_silent_duration: if app_settings.editable_settings["Real Time"] and current_chunk: audio_queue.put(b''.join(current_chunk)) From d30e529c836c4e8dfdc13223f3859ba56869602f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 23 Jan 2025 15:23:40 -0500 Subject: [PATCH 139/244] Fixed indentation from merge conflict. Also, added type validation from settings for speech prob threshold --- src/FreeScribe.client/client.py | 109 +++++++++++++++++--------------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 8d93cfd1..33fc0fe4 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -47,7 +47,8 @@ from utils.utils import window_has_running_instance, bring_to_front, close_mutex import gc from pathlib import Path -from decimal import Decimalimport torch +from decimal import Decimal +import torch from WhisperModel import TranscribeError import io @@ -237,7 +238,15 @@ def record_audio(): frames.append(data) # Check for silence audio_buffer = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768 - if is_silent(audio_buffer, app_settings.editable_settings[SettingsKeys.SILERO_SPEECH_THRESHOLD.value]): + + # convert the setting from str to float + try: + speech_prob_threshold = float(app_settings.editable_settings[SettingsKeys.SILERO_SPEECH_THRESHOLD.value]) + except ValueError: + # default it to 0.5 on invalid error + speech_prob_threshold = 0.5 + + if is_silent(audio_buffer, speech_prob_threshold ): silent_duration += CHUNK / RATE silent_warning_duration += CHUNK / RATE else: @@ -288,7 +297,7 @@ def check_silence_warning(silence_duration): silero, _silero = torch.hub.load(repo_or_dir='snakers4/silero-vad', model='silero_vad') -def is_silent(data, threshold=0.65): +def is_silent(data, threshold: float = 0.65): """Check if audio chunk contains speech using Silero VAD""" # Convert audio data to tensor and ensure correct format audio_tensor = torch.FloatTensor(data) @@ -330,55 +339,55 @@ def realtime_text(): except Exception as e: update_gui(f"\nError: {e}\n") - if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): - update_gui(result) + if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): + update_gui(result) + else: + print("Remote Real Time Whisper") + buffer = io.BytesIO() + if frames: + # Buffer to hold the audio data. This is used to send the audio data to the server. + with wave.open(buffer, 'wb') as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(p.get_sample_size(FORMAT)) + wf.setframerate(RATE) + wf.writeframes(b''.join(frames)) + frames = [] else: - print("Remote Real Time Whisper") - buffer = io.BytesIO() - if frames: - # Buffer to hold the audio data. This is used to send the audio data to the server. - with wave.open(buffer, 'wb') as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(p.get_sample_size(FORMAT)) - wf.setframerate(RATE) - wf.writeframes(b''.join(frames)) - frames = [] + # Dont make the network request if frames is empty + buffer.close() + continue + + buffer.seek(0) # Reset buffer position to start + + files = {'audio': buffer} + + headers = { + "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] + } + + try: + verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + + print("Sending audio to server") + print("File informaton") + print("File Size: ", len(buffer.getbuffer()), "bytes") + + response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) + + print("Response from whisper with status code: ", response.status_code) + + if response.status_code == 200: + text = response.json()['text'] + if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): + update_gui(text) else: - # Dont make the network request if frames is empty - buffer.close() - continue - - buffer.seek(0) # Reset buffer position to start - - files = {'audio': buffer} - - headers = { - "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] - } - - try: - verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] - - print("Sending audio to server") - print("File informaton") - print("File Size: ", len(buffer.getbuffer()), "bytes") - - response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) - - print("Response from whisper with status code: ", response.status_code) - - if response.status_code == 200: - text = response.json()['text'] - if not local_cancel_flag and not is_audio_processing_realtime_canceled.is_set(): - update_gui(text) - else: - update_gui(f"Error (HTTP Status {response.status_code}): {response.text}") - except Exception as e: - update_gui(f"Error: {e}") - finally: - #close buffer. we dont need it anymore - buffer.close() - audio_queue.task_done() + update_gui(f"Error (HTTP Status {response.status_code}): {response.text}") + except Exception as e: + update_gui(f"Error: {e}") + finally: + #close buffer. we dont need it anymore + buffer.close() + audio_queue.task_done() else: is_realtimeactive = False From 6f77299a6cb6db2cd7464de9757743f8e3f600ff Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:07:13 -0500 Subject: [PATCH 140/244] ApplicationLock class to handle one instance --- .../utils/ApplicationLock.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/FreeScribe.client/utils/ApplicationLock.py diff --git a/src/FreeScribe.client/utils/ApplicationLock.py b/src/FreeScribe.client/utils/ApplicationLock.py new file mode 100644 index 00000000..1358eeeb --- /dev/null +++ b/src/FreeScribe.client/utils/ApplicationLock.py @@ -0,0 +1,130 @@ +# Application lock class to prevent multiple instances of an app from running +import tkinter as tk +from tkinter import messagebox +import psutil # For process management +import sys +import win32gui # Windows GUI functions +import win32con # Windows GUI constants +import win32process # Windows process management + +class ApplicationLock: + """ + Controls application instances to ensure only one is running. + + Args: + app_name: Window title of the application + app_task_manager_name: Process name as shown in Task Manager + """ + def __init__(self, app_name, app_task_manager_name): + self.app_name = app_name + self.app_task_manager_name = app_task_manager_name + self.root = None + + def get_running_instance_pid(self): + """ + Finds PIDs of any running instances of the application. + + Returns: + list: PIDs of running instances + """ + possible_ids = [] + for proc in psutil.process_iter(['pid', 'name']): + try: + if proc.info['name'] == f"{self.app_task_manager_name}": + possible_ids.append(proc.info['pid']) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + return possible_ids + + def kill_instance(self, pid): + """ + Terminates specified process instance(s). + + Args: + pid: Process ID (int) or list of PIDs to terminate + + Returns: + bool: True if termination successful, False otherwise + """ + try: + if type(pid) == int: + process = psutil.Process(pid) + process.terminate() + return True + elif type(pid) == list: + for pid in pid: + process = psutil.Process(pid) + process.terminate() + return True + except psutil.NoSuchProcess: + return False + return False + + def bring_to_front(self): + """Brings existing application window to foreground""" + hwnd = win32gui.FindWindow(None, self.app_name) + if hwnd: + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + win32gui.SetForegroundWindow(hwnd) + + def show_instance_dialog(self): + """ + Shows dialog when another instance is detected. + Allows user to close existing instance or cancel. + + Returns: + bool: True if existing instance continues, False if terminated + """ + dialog = tk.Tk() + dialog.title("FreeScribe Instance") + dialog.geometry("300x150") + + # Force dialog to front + dialog.attributes("-topmost", True) + dialog.lift() + dialog.focus_force() + + pid = self.get_running_instance_pid() + + return_status = True + + label = tk.Label(dialog, text="Another instance of FreeScribe is already running.\nWhat would you like to do?") + label.pack(pady=20) + + def handle_kill(): + """Handles clicking 'Close Existing Instance' button""" + nonlocal return_status + if self.kill_instance(pid): + dialog.destroy() + return_status = False + else: + messagebox.showerror("Error", "Failed to terminate existing instance") + dialog.destroy() + return_status = True + + def handle_cancel(): + """Handles clicking 'Cancel' button""" + nonlocal return_status + dialog.destroy() + self.bring_to_front() + return_status = True + + + tk.Button(dialog, text="Close Existing Instance", command=handle_kill).pack(padx=5, pady=5) + tk.Button(dialog, text="Cancel", command=handle_cancel).pack(padx=5, pady=2) + + dialog.mainloop() + + return return_status + + def run(self): + """ + Main entry point to check for existing instances. + + Returns: + bool: True if existing instance continues, False if none exists or terminated + """ + if self.get_running_instance_pid(): + return self.show_instance_dialog() + else: + return False \ No newline at end of file From 362e204026c410b13d2b91e28f104b5b70aac008 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:07:54 -0500 Subject: [PATCH 141/244] Renamed ApplicationLock to OneInstance --- .../utils/{ApplicationLock.py => OneInstance.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/FreeScribe.client/utils/{ApplicationLock.py => OneInstance.py} (99%) diff --git a/src/FreeScribe.client/utils/ApplicationLock.py b/src/FreeScribe.client/utils/OneInstance.py similarity index 99% rename from src/FreeScribe.client/utils/ApplicationLock.py rename to src/FreeScribe.client/utils/OneInstance.py index 1358eeeb..915fa8e8 100644 --- a/src/FreeScribe.client/utils/ApplicationLock.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -7,7 +7,7 @@ import win32con # Windows GUI constants import win32process # Windows process management -class ApplicationLock: +class OneInstance: """ Controls application instances to ensure only one is running. From 4a393ad8e14747f69371296081d9c29710cf3b7e Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:15:53 -0500 Subject: [PATCH 142/244] grouped imports and order properly --- src/FreeScribe.client/client.py | 37 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 33fc0fe4..14131577 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -11,26 +11,33 @@ """ +import ctypes +import io +import sys +import gc import os -import tkinter as tk -from tkinter import scrolledtext, ttk, filedialog -import requests -import pyperclip +from pathlib import Path import wave import threading -import numpy as np import base64 import json -import pyaudio -import tkinter.messagebox as messagebox import datetime -from faster_whisper import WhisperModel -import scrubadub import re -import speech_recognition as sr # python package is named speechrecognition import time import queue import atexit +import traceback +import torch +import pyaudio +import requests +import pyperclip +import speech_recognition as sr # python package is named speechrecognition +import scrubadub +import numpy as np +import tkinter as tk +from tkinter import scrolledtext, ttk, filedialog +import tkinter.messagebox as messagebox +from faster_whisper import WhisperModel from UI.MainWindowUI import MainWindowUI from UI.SettingsWindow import SettingsWindow, SettingsKeys, Architectures from UI.Widgets.CustomTextBox import CustomTextBox @@ -39,18 +46,10 @@ from Model import ModelManager from utils.ip_utils import is_private_ip from utils.file_utils import get_file_path, get_resource_path -import ctypes -import sys +from utils.OneInstance import OneInstance from UI.DebugWindow import DualOutput -import traceback -import sys from utils.utils import window_has_running_instance, bring_to_front, close_mutex -import gc -from pathlib import Path -from decimal import Decimal -import torch from WhisperModel import TranscribeError -import io From 76bad50b425c3654d851a744d933106453ff141a Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:16:09 -0500 Subject: [PATCH 143/244] Added OneInstance to main loop --- src/FreeScribe.client/client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 14131577..ff0e2a93 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -58,16 +58,20 @@ sys.stderr = dual APP_NAME = 'AI Medical Scribe' # Application name +APP_TASK_MANAGER_NAME = 'freescribe-client.exe' # check if another instance of the application is already running. # if false, create a new instance of the application # if true, exit the current instance -if not window_has_running_instance(): +app_manager = OneInstance(APP_NAME, APP_TASK_MANAGER_NAME) + +if app_manager.run(): + sys.exit(1) +else: root = tk.Tk() root.title(APP_NAME) -else: - bring_to_front(APP_NAME) - sys.exit(0) + + def delete_temp_file(filename): """ From de7fad1a448e9e0190b4a7998300a29dab6b01f7 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:16:26 -0500 Subject: [PATCH 144/244] removed blank lines --- src/FreeScribe.client/client.py | 2 -- src/FreeScribe.client/test.py | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/FreeScribe.client/test.py diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index ff0e2a93..11e3b343 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -71,8 +71,6 @@ root = tk.Tk() root.title(APP_NAME) - - def delete_temp_file(filename): """ Deletes a temporary file if it exists. diff --git a/src/FreeScribe.client/test.py b/src/FreeScribe.client/test.py new file mode 100644 index 00000000..164125ee --- /dev/null +++ b/src/FreeScribe.client/test.py @@ -0,0 +1,64 @@ +import sounddevice as sd +import numpy as np +from faster_whisper import WhisperModel +import queue + +# Load the Whisper model (choose 'base', 'small', 'medium', or 'large') +model = WhisperModel("medium", device="cpu", compute_type="int8") # Use GPU if available by setting device="cuda" + +# Set parameters +sample_rate = 16000 # Whisper expects 16kHz audio +language = "fr" # Input language: French +target_language = "en" # Translation target language: English + +audio_queue = queue.Queue() + +def select_microphone(): + print("Available audio input devices:") + devices = sd.query_devices() + for i, device in enumerate(devices): + if device['max_input_channels'] > 0: + print(f"{i}: {device['name']}") + + device_id = int(input("Select the input device by entering the corresponding number: ")) + return device_id + +def audio_callback(indata, frames, time, status): + if status: + print(f"Audio status: {status}") + audio_queue.put(indata.copy()) + +def transcribe_audio(): + print("Starting transcription...") + audio_buffer = np.zeros(0, dtype=np.float32) + + while True: + # Retrieve audio data from the queue + try: + data = audio_queue.get() + audio_buffer = np.concatenate((audio_buffer, data[:, 0])) # Use the first channel + except queue.Empty: + continue + + # Process in chunks of 30 seconds (sample_rate * 30 samples) + if len(audio_buffer) >= sample_rate * 30: + audio_chunk = audio_buffer[: sample_rate * 30] + audio_buffer = audio_buffer[sample_rate * 30 :] + + # Transcribe and translate + segments, _ = model.transcribe(audio_chunk, language=language, task="translate", beam_size=5) + for segment in segments: + print(f"[{segment.start:.2f}s - {segment.end:.2f}s]: {segment.text}") + +if __name__ == "__main__": + try: + # Select microphone + device_id = select_microphone() + + # Start audio stream + with sd.InputStream(samplerate=sample_rate, channels=1, dtype=np.float32, callback=audio_callback, device=device_id): + transcribe_audio() + except KeyboardInterrupt: + print("Transcription stopped.") + except Exception as e: + print(f"An error occurred: {e}") From 598d1af32cae198827a0fb11caf33ad610c333af Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:16:53 -0500 Subject: [PATCH 145/244] removed file that was in here for testing --- src/FreeScribe.client/test.py | 64 ----------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/FreeScribe.client/test.py diff --git a/src/FreeScribe.client/test.py b/src/FreeScribe.client/test.py deleted file mode 100644 index 164125ee..00000000 --- a/src/FreeScribe.client/test.py +++ /dev/null @@ -1,64 +0,0 @@ -import sounddevice as sd -import numpy as np -from faster_whisper import WhisperModel -import queue - -# Load the Whisper model (choose 'base', 'small', 'medium', or 'large') -model = WhisperModel("medium", device="cpu", compute_type="int8") # Use GPU if available by setting device="cuda" - -# Set parameters -sample_rate = 16000 # Whisper expects 16kHz audio -language = "fr" # Input language: French -target_language = "en" # Translation target language: English - -audio_queue = queue.Queue() - -def select_microphone(): - print("Available audio input devices:") - devices = sd.query_devices() - for i, device in enumerate(devices): - if device['max_input_channels'] > 0: - print(f"{i}: {device['name']}") - - device_id = int(input("Select the input device by entering the corresponding number: ")) - return device_id - -def audio_callback(indata, frames, time, status): - if status: - print(f"Audio status: {status}") - audio_queue.put(indata.copy()) - -def transcribe_audio(): - print("Starting transcription...") - audio_buffer = np.zeros(0, dtype=np.float32) - - while True: - # Retrieve audio data from the queue - try: - data = audio_queue.get() - audio_buffer = np.concatenate((audio_buffer, data[:, 0])) # Use the first channel - except queue.Empty: - continue - - # Process in chunks of 30 seconds (sample_rate * 30 samples) - if len(audio_buffer) >= sample_rate * 30: - audio_chunk = audio_buffer[: sample_rate * 30] - audio_buffer = audio_buffer[sample_rate * 30 :] - - # Transcribe and translate - segments, _ = model.transcribe(audio_chunk, language=language, task="translate", beam_size=5) - for segment in segments: - print(f"[{segment.start:.2f}s - {segment.end:.2f}s]: {segment.text}") - -if __name__ == "__main__": - try: - # Select microphone - device_id = select_microphone() - - # Start audio stream - with sd.InputStream(samplerate=sample_rate, channels=1, dtype=np.float32, callback=audio_callback, device=device_id): - transcribe_audio() - except KeyboardInterrupt: - print("Transcription stopped.") - except Exception as e: - print(f"An error occurred: {e}") From 532e4b4acfecdf558489eea18a38d295c2d9a819 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:24:32 -0500 Subject: [PATCH 146/244] changed the kill process to psutil for cross compat --- src/FreeScribe.client/utils/OneInstance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index 915fa8e8..d2c1c626 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -58,7 +58,6 @@ def kill_instance(self, pid): return True except psutil.NoSuchProcess: return False - return False def bring_to_front(self): """Brings existing application window to foreground""" From fee04799165e8b67a3836876b74a47bb2d70dada Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:26:39 -0500 Subject: [PATCH 147/244] ran a system compat check on bring to front --- src/FreeScribe.client/utils/OneInstance.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index d2c1c626..d05ebafc 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -60,11 +60,22 @@ def kill_instance(self, pid): return False def bring_to_front(self): - """Brings existing application window to foreground""" + """Brings existing application window to foreground + Returns: + bool: True if window was found and brought to front, False otherwise + """ + if sys.platform != 'win32': + return False + + import win32gui + import win32con + hwnd = win32gui.FindWindow(None, self.app_name) - if hwnd: - win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) - win32gui.SetForegroundWindow(hwnd) + if not hwnd: + return False + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + win32gui.SetForegroundWindow(hwnd) + return True def show_instance_dialog(self): """ From be1bdde52981f5594400ded18215fead8e6c210d Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:27:02 -0500 Subject: [PATCH 148/244] moved imports back to top level --- src/FreeScribe.client/utils/OneInstance.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index d05ebafc..f4a0d7e7 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -3,9 +3,8 @@ from tkinter import messagebox import psutil # For process management import sys -import win32gui # Windows GUI functions -import win32con # Windows GUI constants -import win32process # Windows process management +import win32gui +import win32con class OneInstance: """ @@ -66,9 +65,6 @@ def bring_to_front(self): """ if sys.platform != 'win32': return False - - import win32gui - import win32con hwnd = win32gui.FindWindow(None, self.app_name) if not hwnd: From 2dba455229c09b745bfe724cc910b6f7f5f31939 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:48:33 -0500 Subject: [PATCH 149/244] shwitched bring to front to old code to ensure cross platform --- src/FreeScribe.client/utils/OneInstance.py | 27 +++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index f4a0d7e7..d0527c07 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -3,8 +3,7 @@ from tkinter import messagebox import psutil # For process management import sys -import win32gui -import win32con +import ctypes class OneInstance: """ @@ -58,19 +57,19 @@ def kill_instance(self, pid): except psutil.NoSuchProcess: return False - def bring_to_front(self): - """Brings existing application window to foreground - Returns: - bool: True if window was found and brought to front, False otherwise + def bring_to_front(self, app_name: str): """ - if sys.platform != 'win32': - return False - - hwnd = win32gui.FindWindow(None, self.app_name) - if not hwnd: - return False - win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) - win32gui.SetForegroundWindow(hwnd) + Bring the window with the given handle to the front. + Parameters: + app_name (str): The name of the application window to bring to the front + """ + + # TODO - Check platform and handle for different platform + U32DLL = ctypes.WinDLL('user32') + SW_SHOW = 5 + hwnd = U32DLL.FindWindowW(None, app_name) + U32DLL.ShowWindow(hwnd, SW_SHOW) + U32DLL.SetForegroundWindow(hwnd) return True def show_instance_dialog(self): From 3335d10bf2c1a7985ab2e5ced09f42df061f8b8b Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 13:49:58 -0500 Subject: [PATCH 150/244] Updated function call --- src/FreeScribe.client/utils/OneInstance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index d0527c07..5ddc7676 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -111,7 +111,7 @@ def handle_cancel(): """Handles clicking 'Cancel' button""" nonlocal return_status dialog.destroy() - self.bring_to_front() + self.bring_to_front(self.app_name) return_status = True From 7bf24354db665173555317ac8ee5d80fa5d8068f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 14:36:45 -0500 Subject: [PATCH 151/244] Moved the button handlers to private functions. --- src/FreeScribe.client/utils/OneInstance.py | 49 ++++++++++------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index 5ddc7676..c25483d4 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -71,7 +71,23 @@ def bring_to_front(self, app_name: str): U32DLL.ShowWindow(hwnd, SW_SHOW) U32DLL.SetForegroundWindow(hwnd) return True - + + def _handle_kill(self, dialog, pid): + """Handles clicking 'Close Existing Instance' button""" + if self.kill_instance(pid): + dialog.destroy() + dialog.return_status = False + else: + messagebox.showerror("Error", "Failed to terminate existing instance") + dialog.destroy() + dialog.return_status = True + + def _handle_cancel(self, dialog): + """Handles clicking 'Cancel' button""" + dialog.destroy() + self.bring_to_front(self.app_name) + dialog.return_status = True + def show_instance_dialog(self): """ Shows dialog when another instance is detected. @@ -83,44 +99,21 @@ def show_instance_dialog(self): dialog = tk.Tk() dialog.title("FreeScribe Instance") dialog.geometry("300x150") - - # Force dialog to front dialog.attributes("-topmost", True) dialog.lift() dialog.focus_force() pid = self.get_running_instance_pid() - - return_status = True + dialog.return_status = True label = tk.Label(dialog, text="Another instance of FreeScribe is already running.\nWhat would you like to do?") label.pack(pady=20) - def handle_kill(): - """Handles clicking 'Close Existing Instance' button""" - nonlocal return_status - if self.kill_instance(pid): - dialog.destroy() - return_status = False - else: - messagebox.showerror("Error", "Failed to terminate existing instance") - dialog.destroy() - return_status = True - - def handle_cancel(): - """Handles clicking 'Cancel' button""" - nonlocal return_status - dialog.destroy() - self.bring_to_front(self.app_name) - return_status = True - - - tk.Button(dialog, text="Close Existing Instance", command=handle_kill).pack(padx=5, pady=5) - tk.Button(dialog, text="Cancel", command=handle_cancel).pack(padx=5, pady=2) + tk.Button(dialog, text="Close Existing Instance", command=lambda: self._handle_kill(dialog, pid)).pack(padx=5, pady=5) + tk.Button(dialog, text="Cancel", command=lambda: self._handle_cancel(dialog)).pack(padx=5, pady=2) dialog.mainloop() - - return return_status + return dialog.return_status def run(self): """ From 402b80011ce01fac2c582d20abc03793d4b237a8 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 14:46:58 -0500 Subject: [PATCH 152/244] added guard close --- src/FreeScribe.client/utils/OneInstance.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index c25483d4..56d8eec5 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -96,6 +96,11 @@ def show_instance_dialog(self): Returns: bool: True if existing instance continues, False if terminated """ + pid = self.get_running_instance_pid() + + if not pid: + return False + dialog = tk.Tk() dialog.title("FreeScribe Instance") dialog.geometry("300x150") @@ -103,7 +108,6 @@ def show_instance_dialog(self): dialog.lift() dialog.focus_force() - pid = self.get_running_instance_pid() dialog.return_status = True label = tk.Label(dialog, text="Another instance of FreeScribe is already running.\nWhat would you like to do?") From c5652817eaebfd839d0d2b6203f310cafd101e5f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:00:19 -0500 Subject: [PATCH 153/244] Added blank line at end --- src/FreeScribe.client/utils/OneInstance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index 56d8eec5..a88a08e7 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -129,4 +129,4 @@ def run(self): if self.get_running_instance_pid(): return self.show_instance_dialog() else: - return False \ No newline at end of file + return False From 2568bbeca3b46fc81c2ac24e3473257b8f463e87 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:03:15 -0500 Subject: [PATCH 154/244] Added windows only check --- src/FreeScribe.client/utils/OneInstance.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index a88a08e7..999bb921 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -65,11 +65,13 @@ def bring_to_front(self, app_name: str): """ # TODO - Check platform and handle for different platform - U32DLL = ctypes.WinDLL('user32') - SW_SHOW = 5 - hwnd = U32DLL.FindWindowW(None, app_name) - U32DLL.ShowWindow(hwnd, SW_SHOW) - U32DLL.SetForegroundWindow(hwnd) + # For now, only Windows is supported + if sys.platform == 'win32': + U32DLL = ctypes.WinDLL('user32') + SW_SHOW = 5 + hwnd = U32DLL.FindWindowW(None, app_name) + U32DLL.ShowWindow(hwnd, SW_SHOW) + U32DLL.SetForegroundWindow(hwnd) return True def _handle_kill(self, dialog, pid): From 38aa1cf187e88d0fa994241746de9aef2d223620 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:04:50 -0500 Subject: [PATCH 155/244] Updated correct returns for bring_to_front --- src/FreeScribe.client/utils/OneInstance.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index 999bb921..3d87885c 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -72,7 +72,9 @@ def bring_to_front(self, app_name: str): hwnd = U32DLL.FindWindowW(None, app_name) U32DLL.ShowWindow(hwnd, SW_SHOW) U32DLL.SetForegroundWindow(hwnd) - return True + return True + + return False def _handle_kill(self, dialog, pid): """Handles clicking 'Close Existing Instance' button""" From 9ef9ed54e92120459b9396f78f8515ad18f5efcf Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:43:27 -0500 Subject: [PATCH 156/244] Changed realtime remote to use the audio queue instead of frames --- src/FreeScribe.client/client.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 33fc0fe4..763fb575 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -224,7 +224,6 @@ def record_audio(): return try: - current_chunk = [] silent_duration = 0 silent_warning_duration = 0 @@ -343,20 +342,7 @@ def realtime_text(): update_gui(result) else: print("Remote Real Time Whisper") - buffer = io.BytesIO() - if frames: - # Buffer to hold the audio data. This is used to send the audio data to the server. - with wave.open(buffer, 'wb') as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(p.get_sample_size(FORMAT)) - wf.setframerate(RATE) - wf.writeframes(b''.join(frames)) - frames = [] - else: - # Dont make the network request if frames is empty - buffer.close() - continue - + buffer = io.BytesIO(audio_data) buffer.seek(0) # Reset buffer position to start files = {'audio': buffer} @@ -411,7 +397,7 @@ def save_audio(): threaded_send_audio_to_server() def toggle_recording(): - global is_recording, recording_thread, DEFAULT_BUTTON_COLOUR, audio_queue, current_view, REALTIME_TRANSCRIBE_THREAD_ID + global is_recording, recording_thread, DEFAULT_BUTTON_COLOUR, audio_queue, current_view, REALTIME_TRANSCRIBE_THREAD_ID, frames # Reset the cancel flags going into a fresh recording if not is_recording: @@ -436,6 +422,8 @@ def toggle_recording(): response_display.scrolled_text.configure(state='disabled') is_recording = True + # reset frames before new recording so old data is not used + frames = [] recording_thread = threading.Thread(target=record_audio) recording_thread.start() From bf876468563d9ead446d50864545f97d7c4da3bd Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:52:18 -0500 Subject: [PATCH 157/244] removed unused global reference --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 763fb575..32cd1fb1 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -308,7 +308,7 @@ def is_silent(data, threshold: float = 0.65): return speech_prob < threshold def realtime_text(): - global frames, is_realtimeactive, audio_queue + global is_realtimeactive, audio_queue # Incase the user starts a new recording while this one the older thread is finishing. # This is a local flag to prevent the processing of the current audio chunk # if the global flag is reset on new recording From d68ed40fefa463a72a51bc8908999c43e5fac653 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 17:05:19 -0500 Subject: [PATCH 158/244] write into the audio buffer as wave file format --- src/FreeScribe.client/client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 32cd1fb1..609f26f0 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -342,8 +342,14 @@ def realtime_text(): update_gui(result) else: print("Remote Real Time Whisper") - buffer = io.BytesIO(audio_data) - buffer.seek(0) # Reset buffer position to start + buffer = io.BytesIO() + with wave.open(buffer, 'wb') as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(p.get_sample_size(FORMAT)) + wf.setframerate(RATE) + wf.writeframes(audio_data) + + buffer.seek(0) # Reset buffer position files = {'audio': buffer} From a560c41b3323977648e8f4dcbf3e0f4914b2805b Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Mon, 27 Jan 2025 09:38:52 -0500 Subject: [PATCH 159/244] Microphone test component in main screen --- .../UI/Widgets/MicrophoneTestFrame.py | 201 ++++++++++++++++++ src/FreeScribe.client/client.py | 17 +- 2 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py new file mode 100644 index 00000000..52d443e1 --- /dev/null +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -0,0 +1,201 @@ +import tkinter as tk +from tkinter import ttk +from PIL import Image, ImageTk +import pyaudio +import numpy as np +from utils.file_utils import get_file_path +from UI.Widgets.MicrophoneSelector import MicrophoneState + +class MicrophoneTestFrame: + def __init__(self, history_frame, p): + self.root = history_frame + self.p = p + + # Configure history frame grid + self.root.grid_columnconfigure(0, weight=1) + self.root.grid_rowconfigure(0, weight=4) + self.root.grid_rowconfigure(1, weight=1) + + # Create timestamp listbox + self.timestamp_listbox = tk.Listbox(self.root, height=20) + self.timestamp_listbox.grid(row=0, column=0, sticky='nsew') + self.timestamp_listbox.insert(tk.END, "Temporary Note History") + self.timestamp_listbox.config(fg='grey') + + # Create frame for mic test + self.frame = ttk.Frame(self.root) + self.frame.grid(row=1, column=0, sticky='nsew', padx=0, pady=(5, 0)) + + # Initialize microphone list and settings + self.initialize_microphones() + + # Create mic test UI + self.create_mic_test_ui() + + # Start volume meter updates + self.update_volume_meter() + + def initialize_microphones(self): + self.mic_list = [] + try: + default_input_info = self.p.get_default_input_device_info() + self.default_input_index = default_input_info['index'] + except IOError: + self.default_input_index = None + + device_count = self.p.get_device_count() + for i in range(device_count): + device_info = self.p.get_device_info_by_index(i) + if device_info['maxInputChannels'] > 0: + device_name = device_info['name'] + excluded_names = ["Stereo Mix", "Loopback", "Virtual", "Output", + "Wave Out", "What U Hear", "Aux", "Port", "Mix"] + if not any(excluded_name.lower() in device_name.lower() + for excluded_name in excluded_names): + self.mic_list.append((i, device_name)) + + seen_names = set() + unique_mics = [] + for device_index, device_name in self.mic_list: + if device_name not in seen_names: + unique_mics.append((device_index, device_name)) + seen_names.add(device_name) + self.mic_list = unique_mics + + self.default_selection_index = 0 + for idx, (device_index, device_name) in enumerate(self.mic_list): + if device_index == self.default_input_index: + self.default_selection_index = idx + break + + def create_mic_test_ui(self): + # # Create a title label + # title_label = ttk.Label(self.frame, text="Microphone Test", font=('Helvetica', 9, 'bold')) + # title_label.pack(pady=(0, 5)) + + # Frame for dropdown + dropdown_frame = ttk.Frame(self.frame) + dropdown_frame.pack(fill=tk.X, pady=(0, 5)) + + # Create a container frame for center alignment + center_frame = ttk.Frame(dropdown_frame) + center_frame.pack(expand=True) + + # Create styles for all elements + style = ttk.Style() + style.configure('Mic.TCombobox', padding=(5, 5, 5, 5)) + style.configure('Green.TFrame', background='#2ecc71') + style.configure('Yellow.TFrame', background='#f1c40f') + style.configure('Red.TFrame', background='#e74c3c') + style.configure('Inactive.TFrame', background='#95a5a6') + + # Dropdown for microphone selection + mic_options = [f"{name}" for _, name in self.mic_list] + self.mic_dropdown = ttk.Combobox( + center_frame, + values=mic_options, + state='readonly', + width=30, + style='Mic.TCombobox' + ) + self.mic_dropdown.pack(pady=(0, 5)) + self.mic_dropdown.current(self.default_selection_index) + + # Bind selection change to save immediately + self.mic_dropdown.bind('<>', self.on_mic_change) + + # Volume meter container + meter_frame = ttk.Frame(self.frame) + meter_frame.pack(fill=tk.X, pady=(0, 5)) + + # Try to load mic icon + try: + mic_icon = Image.open(get_file_path('assets', 'mic_icon.png')) + mic_icon = mic_icon.resize((24, 24)) + self.mic_photo = ImageTk.PhotoImage(mic_icon) + mic_icon_label = ttk.Label(meter_frame, image=self.mic_photo) + mic_icon_label.pack(side=tk.LEFT, padx=(0, 10)) + except Exception as e: + print(f"Error loading microphone icon: {e}") + + # Create volume meter segments + self.segments_frame = ttk.Frame(meter_frame) + self.segments_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Create segments + self.SEGMENT_COUNT = 20 + self.segments = [] + for i in range(self.SEGMENT_COUNT): + segment = ttk.Frame(self.segments_frame, width=10, height=20) + segment.pack(side=tk.LEFT, padx=1) + segment.pack_propagate(False) + self.segments.append(segment) + + def on_mic_change(self, event): + # Save the selection immediately + selected_name = self.mic_dropdown.get() + selected_index = None + for device_index, device_name in self.mic_list: + if device_name == selected_name: + selected_index = device_index + break + if selected_index is not None: + MicrophoneState.SELECTED_MICROPHONE_INDEX = selected_index + + # Reset volume meter + for segment in self.segments: + segment.configure(style='Inactive.TFrame') + + def update_volume_meter(self): + selected_name = self.mic_dropdown.get() + selected_index = None + for device_index, device_name in self.mic_list: + if device_name == selected_name: + selected_index = device_index + break + + try: + stream = self.p.open( + format=pyaudio.paInt16, + channels=1, + rate=16000, + input=True, + frames_per_buffer=1024, + input_device_index=selected_index + ) + + data = stream.read(1024, exception_on_overflow=False) + stream.stop_stream() + stream.close() + + audio_data = np.frombuffer(data, dtype=np.int16) + rms = np.sqrt(np.mean(np.square(audio_data.astype(np.float64)))) + + if np.isnan(rms) or rms <= 0: + volume = 0 + else: + scaling_factor = 500 + volume = min(max(int((rms / 32768) * scaling_factor), 0), 100) + + # Update segments + active_segments = int((volume / 100) * self.SEGMENT_COUNT) + for i, segment in enumerate(self.segments): + if i < active_segments: + if i < self.SEGMENT_COUNT * 0.6: + segment.configure(style='Green.TFrame') + elif i < self.SEGMENT_COUNT * 0.8: + segment.configure(style='Yellow.TFrame') + else: + segment.configure(style='Red.TFrame') + else: + segment.configure(style='Inactive.TFrame') + + except Exception as e: + print(f"Error in update_volume_meter: {e}") + for segment in self.segments: + segment.configure(style='Inactive.TFrame') + + self.frame.after(100, self.update_volume_meter) + + def bind_listbox_select(self, callback): + self.timestamp_listbox.bind('<>', callback) \ No newline at end of file diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 54a9064f..1964f817 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1270,12 +1270,25 @@ def _load_stt_model_thread(): if app_settings.editable_settings["Enable Scribe Template"]: window.create_scribe_template() -timestamp_listbox = tk.Listbox(root, height=30) -timestamp_listbox.grid(row=0, column=9, columnspan=2, rowspan=3, padx=5, pady=15, sticky='nsew') +# Create a frame to hold both timestamp listbox and mic test +history_frame = ttk.Frame(root) +history_frame.grid(row=0, column=9, columnspan=2, rowspan=3, padx=5, pady=15, sticky='nsew') + +# Configure the frame's grid +history_frame.grid_columnconfigure(0, weight=1) +history_frame.grid_rowconfigure(0, weight=4) # Timestamp takes more space +history_frame.grid_rowconfigure(1, weight=1) # Mic test takes less space + +timestamp_listbox = tk.Listbox(history_frame, height=30) +timestamp_listbox.grid(row=0, column=0, sticky='nsew') timestamp_listbox.bind('<>', show_response) timestamp_listbox.insert(tk.END, "Temporary Note History") timestamp_listbox.config(fg='grey') +# Add microphone test frame +from UI.Widgets.MicrophoneTestFrame import MicrophoneTestFrame +mic_test = MicrophoneTestFrame(history_frame, p) + window.update_aiscribe_texts(None) # Bind Alt+P to send_and_receive function root.bind('', lambda event: pause_button.invoke()) From 2d940c638f9328969349d74dfb33aca7f7a6c895 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Mon, 27 Jan 2025 09:39:26 -0500 Subject: [PATCH 160/244] mic image --- src/FreeScribe.client/assets/mic_icon.png | Bin 0 -> 7056 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/FreeScribe.client/assets/mic_icon.png diff --git a/src/FreeScribe.client/assets/mic_icon.png b/src/FreeScribe.client/assets/mic_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..776c9ff942c30cb7fb7fbe276c5377414fe76a16 GIT binary patch literal 7056 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 literal 0 HcmV?d00001 From 09dc26f31ac73e4d1ce2afd06968e25fbbd0a38b Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 27 Jan 2025 10:23:42 -0500 Subject: [PATCH 161/244] Added the use_translate to body --- src/FreeScribe.client/client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a009a27b..0d34fed3 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -735,6 +735,10 @@ def cancel_whole_audio_process(thread_id): "Authorization": f"Bearer {app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value]}" } + body = { + "use_translate": app_settings.editable_settings[SettingsKeys.USE_TRANSLATE_TASK.value], + } + try: verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] @@ -744,7 +748,7 @@ def cancel_whole_audio_process(thread_id): print("File Size: ", os.path.getsize(file_to_send)) # Send the request without verifying the SSL certificate - response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers, files=files, verify=verify) + response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers, files=files, verify=verify, data=body) print("Response from whisper with status code: ", response.status_code) From 4ef8ba5d759963a880866229e72aa64e75c9a722 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 27 Jan 2025 10:23:52 -0500 Subject: [PATCH 162/244] added use translate to adv whisper settings --- src/FreeScribe.client/UI/SettingsWindow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index bb55a2c9..72dcc1c7 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -42,6 +42,7 @@ class SettingsKeys(Enum): WHISPER_VAD_FILTER = "Use Whisper VAD Filter (Experimental)" AUDIO_PROCESSING_TIMEOUT_LENGTH = "Audio Processing Timeout (seconds)" SILERO_SPEECH_THRESHOLD = "Silero Speech Threshold" + USE_TRANSLATE_TASK = "Use Translate Task" class Architectures(Enum): @@ -172,7 +173,8 @@ def __init__(self): SettingsKeys.WHISPER_CPU_COUNT.value, SettingsKeys.WHISPER_VAD_FILTER.value, SettingsKeys.WHISPER_COMPUTE_TYPE.value, - SettingsKeys.SILERO_SPEECH_THRESHOLD.value + SettingsKeys.SILERO_SPEECH_THRESHOLD.value, + SettingsKeys.USE_TRANSLATE_TASK.value, ] @@ -240,6 +242,7 @@ def __init__(self): "Show Scrub PHI": False, SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value: 180, SettingsKeys.SILERO_SPEECH_THRESHOLD.value: 0.5, + SettingsKeys.USE_TRANSLATE_TASK.value: False, } self.docker_settings = [ From 26fa551450bf9172f38977010a8c25a6abc40518 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 27 Jan 2025 12:04:26 -0500 Subject: [PATCH 163/244] added psutil for the build process --- client_requirements.txt | Bin 2638 -> 2664 bytes client_requirements_nvidia.txt | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client_requirements.txt b/client_requirements.txt index 7f5b831cec10b71828886904dc501c0b0c1567ba..0a21c336e4bc4c6b8c6a11558d19593a4b61a77e 100644 GIT binary patch delta 34 mcmX>n@TAD2`CLoq`sLkUABLk@#25SlUQF&F}|0RsS{E(Z?) delta 7 OcmaDMa!zD}9~S@(;{xXZ diff --git a/client_requirements_nvidia.txt b/client_requirements_nvidia.txt index 7e2e4c4e..76dbb20d 100644 --- a/client_requirements_nvidia.txt +++ b/client_requirements_nvidia.txt @@ -68,4 +68,5 @@ faster-whisper==1.1.0 nvidia-cudnn-cu12==9.5.0.50 nvidia-cuda-runtime-cu12==12.4.127 nvidia-cuda-nvrtc-cu12==12.4.127 -nvidia-cublas-cu12==12.4.5.8 \ No newline at end of file +nvidia-cublas-cu12==12.4.5.8 +psutil==6.1.0 \ No newline at end of file From cc6b398a1b83f3ecd1048178a84c4e6774193302 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:43:27 -0500 Subject: [PATCH 164/244] Changed realtime remote to use the audio queue instead of frames --- src/FreeScribe.client/client.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 11e3b343..0a8ab770 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -225,7 +225,6 @@ def record_audio(): return try: - current_chunk = [] silent_duration = 0 silent_warning_duration = 0 @@ -344,20 +343,7 @@ def realtime_text(): update_gui(result) else: print("Remote Real Time Whisper") - buffer = io.BytesIO() - if frames: - # Buffer to hold the audio data. This is used to send the audio data to the server. - with wave.open(buffer, 'wb') as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(p.get_sample_size(FORMAT)) - wf.setframerate(RATE) - wf.writeframes(b''.join(frames)) - frames = [] - else: - # Dont make the network request if frames is empty - buffer.close() - continue - + buffer = io.BytesIO(audio_data) buffer.seek(0) # Reset buffer position to start files = {'audio': buffer} @@ -412,7 +398,7 @@ def save_audio(): threaded_send_audio_to_server() def toggle_recording(): - global is_recording, recording_thread, DEFAULT_BUTTON_COLOUR, audio_queue, current_view, REALTIME_TRANSCRIBE_THREAD_ID + global is_recording, recording_thread, DEFAULT_BUTTON_COLOUR, audio_queue, current_view, REALTIME_TRANSCRIBE_THREAD_ID, frames # Reset the cancel flags going into a fresh recording if not is_recording: @@ -437,6 +423,8 @@ def toggle_recording(): response_display.scrolled_text.configure(state='disabled') is_recording = True + # reset frames before new recording so old data is not used + frames = [] recording_thread = threading.Thread(target=record_audio) recording_thread.start() From 9bef4a764470073702f1b1f75addf336f7ba45ea Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 16:52:18 -0500 Subject: [PATCH 165/244] removed unused global reference --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 0a8ab770..ca190400 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -309,7 +309,7 @@ def is_silent(data, threshold: float = 0.65): return speech_prob < threshold def realtime_text(): - global frames, is_realtimeactive, audio_queue + global is_realtimeactive, audio_queue # Incase the user starts a new recording while this one the older thread is finishing. # This is a local flag to prevent the processing of the current audio chunk # if the global flag is reset on new recording From 76aa4058b7bf72516bc694360412753bd8564838 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 24 Jan 2025 17:05:19 -0500 Subject: [PATCH 166/244] write into the audio buffer as wave file format --- src/FreeScribe.client/client.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index ca190400..a009a27b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -343,8 +343,14 @@ def realtime_text(): update_gui(result) else: print("Remote Real Time Whisper") - buffer = io.BytesIO(audio_data) - buffer.seek(0) # Reset buffer position to start + buffer = io.BytesIO() + with wave.open(buffer, 'wb') as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(p.get_sample_size(FORMAT)) + wf.setframerate(RATE) + wf.writeframes(audio_data) + + buffer.seek(0) # Reset buffer position files = {'audio': buffer} From 354a35b5c1115e8cc7017065e0d176a09d30ae1e Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 27 Jan 2025 14:26:35 -0500 Subject: [PATCH 167/244] Add language code to settings --- src/FreeScribe.client/UI/SettingsWindow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 72dcc1c7..9c1d90c7 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -43,6 +43,7 @@ class SettingsKeys(Enum): AUDIO_PROCESSING_TIMEOUT_LENGTH = "Audio Processing Timeout (seconds)" SILERO_SPEECH_THRESHOLD = "Silero Speech Threshold" USE_TRANSLATE_TASK = "Use Translate Task" + WHISPER_LANGUAGE_CODE = "Whisper Language Code" class Architectures(Enum): @@ -175,6 +176,7 @@ def __init__(self): SettingsKeys.WHISPER_COMPUTE_TYPE.value, SettingsKeys.SILERO_SPEECH_THRESHOLD.value, SettingsKeys.USE_TRANSLATE_TASK.value, + SettingsKeys.WHISPER_LANGUAGE_CODE.value, ] @@ -243,6 +245,7 @@ def __init__(self): SettingsKeys.AUDIO_PROCESSING_TIMEOUT_LENGTH.value: 180, SettingsKeys.SILERO_SPEECH_THRESHOLD.value: 0.5, SettingsKeys.USE_TRANSLATE_TASK.value: False, + SettingsKeys.WHISPER_LANGUAGE_CODE.value: "None (Auto Detect)", } self.docker_settings = [ From d9c561d4df566300c814bafcf3d644adc1f68931 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 27 Jan 2025 14:26:51 -0500 Subject: [PATCH 168/244] send language if not auto detect --- src/FreeScribe.client/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 0d34fed3..3d837872 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -739,6 +739,9 @@ def cancel_whole_audio_process(thread_id): "use_translate": app_settings.editable_settings[SettingsKeys.USE_TRANSLATE_TASK.value], } + if app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] not in ["", "auto", "Auto Detect", "None", "None (Auto Detect)"]: + body["language_code"] = app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] + try: verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] From 7dba001f7b7609acff7d3269ed7564bb26185287 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Mon, 27 Jan 2025 16:07:09 -0500 Subject: [PATCH 169/244] Exclude its self from the process search --- src/FreeScribe.client/utils/OneInstance.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/utils/OneInstance.py b/src/FreeScribe.client/utils/OneInstance.py index 3d87885c..1da21d9e 100644 --- a/src/FreeScribe.client/utils/OneInstance.py +++ b/src/FreeScribe.client/utils/OneInstance.py @@ -4,6 +4,7 @@ import psutil # For process management import sys import ctypes +import os class OneInstance: """ @@ -20,15 +21,16 @@ def __init__(self, app_name, app_task_manager_name): def get_running_instance_pid(self): """ - Finds PIDs of any running instances of the application. + Finds PIDs of any running instances of the application, excluding the current process. Returns: - list: PIDs of running instances + list: PIDs of running instances, excluding the current process """ + current_pid = os.getpid() possible_ids = [] for proc in psutil.process_iter(['pid', 'name']): try: - if proc.info['name'] == f"{self.app_task_manager_name}": + if proc.info['name'] == f"{self.app_task_manager_name}" and proc.info['pid'] != current_pid: possible_ids.append(proc.info['pid']) except (psutil.NoSuchProcess, psutil.AccessDenied): continue From 73618cee6ed7913e123c782d7448405071eef850 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 28 Jan 2025 13:13:35 -0500 Subject: [PATCH 170/244] Add translate to realtime --- src/FreeScribe.client/client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3d837872..2aba407e 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -358,6 +358,13 @@ def realtime_text(): "Authorization": "Bearer "+app_settings.editable_settings[SettingsKeys.WHISPER_SERVER_API_KEY.value] } + body = { + "use_translate": app_settings.editable_settings[SettingsKeys.USE_TRANSLATE_TASK.value], + } + + if app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] not in ["", "auto", "Auto Detect", "None", "None (Auto Detect)"]: + body["language_code"] = app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] + try: verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] @@ -365,7 +372,7 @@ def realtime_text(): print("File informaton") print("File Size: ", len(buffer.getbuffer()), "bytes") - response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify) + response = requests.post(app_settings.editable_settings[SettingsKeys.WHISPER_ENDPOINT.value], headers=headers,files=files, verify=verify, data=body) print("Response from whisper with status code: ", response.status_code) From 0f9ee0693c4aebfb888f8dc3f16807dd7bf709c8 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 28 Jan 2025 13:17:38 -0500 Subject: [PATCH 171/244] Moved auto language codes to a constant --- src/FreeScribe.client/UI/SettingsWindow.py | 1 + src/FreeScribe.client/client.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 9c1d90c7..ddc66634 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -105,6 +105,7 @@ class SettingsWindow(): STATE_FILES_DIR = "install_state" DEFAULT_WHISPER_ARCHITECTURE = Architectures.CPU.architecture_value DEFAULT_LLM_ARCHITECTURE = Architectures.CPU.architecture_value + AUTO_DETECT_LANGUAGE_CODES = ["", "auto", "Auto Detect", "None", "None (Auto Detect)"] def __init__(self): """Initializes the ApplicationSettings with default values.""" diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 2aba407e..aa4b32fd 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -362,7 +362,7 @@ def realtime_text(): "use_translate": app_settings.editable_settings[SettingsKeys.USE_TRANSLATE_TASK.value], } - if app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] not in ["", "auto", "Auto Detect", "None", "None (Auto Detect)"]: + if app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] not in SettingsWindow.AUTO_DETECT_LANGUAGE_CODES: body["language_code"] = app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] try: @@ -746,7 +746,7 @@ def cancel_whole_audio_process(thread_id): "use_translate": app_settings.editable_settings[SettingsKeys.USE_TRANSLATE_TASK.value], } - if app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] not in ["", "auto", "Auto Detect", "None", "None (Auto Detect)"]: + if app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] not in SettingsWindow.AUTO_DETECT_LANGUAGE_CODES: body["language_code"] = app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] try: From 518f9149e5f4d7167555d784ef869354b3922746 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Tue, 28 Jan 2025 13:21:01 -0500 Subject: [PATCH 172/244] Settings keys for self signed certs --- src/FreeScribe.client/UI/SettingsWindow.py | 5 +++-- src/FreeScribe.client/client.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index ddc66634..d83507cc 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -44,6 +44,7 @@ class SettingsKeys(Enum): SILERO_SPEECH_THRESHOLD = "Silero Speech Threshold" USE_TRANSLATE_TASK = "Use Translate Task" WHISPER_LANGUAGE_CODE = "Whisper Language Code" + S2T_SELF_SIGNED_CERT = "S2T Server Self-Signed Certificates" class Architectures(Enum): @@ -131,7 +132,7 @@ def __init__(self): SettingsKeys.WHISPER_ENDPOINT.value, SettingsKeys.WHISPER_SERVER_API_KEY.value, "BlankSpace", # Represents the architecture dropdown that is manually placed - "S2T Server Self-Signed Certificates", + SettingsKeys.S2T_SELF_SIGNED_CERT.value, ] self.llm_settings = [ @@ -239,7 +240,7 @@ def __init__(self): "Use Pre-Processing": True, "Use Post-Processing": False, # Disabled for now causes unexcepted behaviour "AI Server Self-Signed Certificates": False, - "S2T Server Self-Signed Certificates": False, + SettingsKeys.S2T_SELF_SIGNED_CERT.value: False, "Pre-Processing": "Please break down the conversation into a list of facts. Take the conversation and transform it to a easy to read list:\n\n", "Post-Processing": "\n\nUsing the provided list of facts, review the SOAP note for accuracy. Verify that all details align with the information provided in the list of facts and ensure consistency throughout. Update or adjust the SOAP note as necessary to reflect the listed facts without offering opinions or subjective commentary. Ensure that the revised note excludes a \"Notes\" section and does not include a header for the SOAP note. Provide the revised note after making any necessary corrections.", "Show Scrub PHI": False, diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index aa4b32fd..abdb0978 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -366,7 +366,7 @@ def realtime_text(): body["language_code"] = app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] try: - verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + verify = not app_settings.editable_settings[SettingsKeys.S2T_SELF_SIGNED_CERT.value] print("Sending audio to server") print("File informaton") @@ -750,7 +750,7 @@ def cancel_whole_audio_process(thread_id): body["language_code"] = app_settings.editable_settings[SettingsKeys.WHISPER_LANGUAGE_CODE.value] try: - verify = not app_settings.editable_settings["S2T Server Self-Signed Certificates"] + verify = not app_settings.editable_settings[SettingsKeys.S2T_SELF_SIGNED_CERT.value] print("Sending audio to server") print("File informaton") From cd10c3f0ff6861dbefe27eb74dc8ab3720d058ac Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Tue, 28 Jan 2025 14:23:42 -0500 Subject: [PATCH 173/244] Microphone test monitor finetuned . Created the microphone test into individual widget . Added error in bottom of audio meter to handle exceptions . Removed microphone from setting page . Modified audio input into single stream --- src/FreeScribe.client/UI/SettingsWindowUI.py | 4 +- .../UI/Widgets/MicrophoneTestFrame.py | 279 ++++++++++++------ src/FreeScribe.client/client.py | 13 +- 3 files changed, 205 insertions(+), 91 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index 0c06ea07..722a79be 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -194,8 +194,8 @@ def create_whisper_settings(self): self.settings.editable_settings_entries["Whisper Model"] = self.whisper_models_drop_down # create the whisper model dropdown slection - microphone_select = MicrophoneSelector(left_frame, left_row, 0, self.settings) - self.settings.editable_settings_entries["Current Mic"] = microphone_select + # microphone_select = MicrophoneSelector(left_frame, left_row, 0, self.settings) + # self.settings.editable_settings_entries["Current Mic"] = microphone_select left_row += 1 diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 52d443e1..0871c0a5 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -1,42 +1,57 @@ import tkinter as tk from tkinter import ttk -from PIL import Image, ImageTk import pyaudio import numpy as np +from PIL import Image, ImageTk from utils.file_utils import get_file_path -from UI.Widgets.MicrophoneSelector import MicrophoneState + +class MicrophoneState: + SELECTED_MICROPHONE_INDEX = None + SELECTED_MICROPHONE_NAME = None class MicrophoneTestFrame: - def __init__(self, history_frame, p): - self.root = history_frame + def __init__(self, parent, p, app_settings): + """ + Initialize the MicrophoneTestFrame. + + Parameters + ---------- + parent : tk.Widget + The parent widget where the frame will be placed. + p : pyaudio.PyAudio + The PyAudio instance for audio operations. + app_settings : dict + Application settings including editable settings. + """ + self.parent = parent self.p = p - - # Configure history frame grid - self.root.grid_columnconfigure(0, weight=1) - self.root.grid_rowconfigure(0, weight=4) - self.root.grid_rowconfigure(1, weight=1) - - # Create timestamp listbox - self.timestamp_listbox = tk.Listbox(self.root, height=20) - self.timestamp_listbox.grid(row=0, column=0, sticky='nsew') - self.timestamp_listbox.insert(tk.END, "Temporary Note History") - self.timestamp_listbox.config(fg='grey') - - # Create frame for mic test - self.frame = ttk.Frame(self.root) - self.frame.grid(row=1, column=0, sticky='nsew', padx=0, pady=(5, 0)) - + self.app_settings = app_settings + self.stream = None # Persistent audio stream + self.is_stream_active = False # Track if the stream is active + + # Create a frame for the microphone test + self.frame = ttk.Frame(self.parent) + self.frame.grid(row=1, column=0, sticky='nsew') + # Initialize microphone list and settings self.initialize_microphones() - + # Create mic test UI self.create_mic_test_ui() - + # Start volume meter updates self.update_volume_meter() + # Initialize the selected microphone + self.initialize_selected_microphone() + def initialize_microphones(self): + """ + Initialize the list of available microphones. + """ self.mic_list = [] + self.mic_mapping = {} # Maps microphone names to their indices + try: default_input_info = self.p.get_default_input_device_info() self.default_input_index = default_input_info['index'] @@ -53,33 +68,30 @@ def initialize_microphones(self): if not any(excluded_name.lower() in device_name.lower() for excluded_name in excluded_names): self.mic_list.append((i, device_name)) + self.mic_mapping[device_name] = i - seen_names = set() - unique_mics = [] - for device_index, device_name in self.mic_list: - if device_name not in seen_names: - unique_mics.append((device_index, device_name)) - seen_names.add(device_name) - self.mic_list = unique_mics - - self.default_selection_index = 0 - for idx, (device_index, device_name) in enumerate(self.mic_list): - if device_index == self.default_input_index: - self.default_selection_index = idx - break + # Load the selected microphone from settings if available + if self.app_settings and "Current Mic" in self.app_settings.editable_settings: + selected_name = self.app_settings.editable_settings["Current Mic"] + if selected_name in self.mic_mapping: + MicrophoneState.SELECTED_MICROPHONE_NAME = selected_name + MicrophoneState.SELECTED_MICROPHONE_INDEX = self.mic_mapping[selected_name] def create_mic_test_ui(self): - # # Create a title label - # title_label = ttk.Label(self.frame, text="Microphone Test", font=('Helvetica', 9, 'bold')) - # title_label.pack(pady=(0, 5)) - + """ + Create the UI elements for microphone testing. + """ # Frame for dropdown dropdown_frame = ttk.Frame(self.frame) - dropdown_frame.pack(fill=tk.X, pady=(0, 5)) + dropdown_frame.grid(row=0, column=0, sticky='nsew', pady=(0, 5)) # Create a container frame for center alignment center_frame = ttk.Frame(dropdown_frame) - center_frame.pack(expand=True) + center_frame.grid(row=0, column=0, sticky='nsew') + + # Configure the center frame to center-align the dropdown + center_frame.grid_rowconfigure(0, weight=1) + center_frame.grid_columnconfigure(0, weight=1) # Create styles for all elements style = ttk.Style() @@ -98,15 +110,20 @@ def create_mic_test_ui(self): width=30, style='Mic.TCombobox' ) - self.mic_dropdown.pack(pady=(0, 5)) - self.mic_dropdown.current(self.default_selection_index) + self.mic_dropdown.grid(row=0, column=0, pady=(0, 5), sticky='nsew') + + # Set the default selection + if MicrophoneState.SELECTED_MICROPHONE_NAME: + self.mic_dropdown.set(MicrophoneState.SELECTED_MICROPHONE_NAME) + elif self.mic_list: + self.mic_dropdown.current(0) # Bind selection change to save immediately self.mic_dropdown.bind('<>', self.on_mic_change) # Volume meter container meter_frame = ttk.Frame(self.frame) - meter_frame.pack(fill=tk.X, pady=(0, 5)) + meter_frame.grid(row=1, column=0, sticky='nsew', pady=(0, 5)) # Try to load mic icon try: @@ -114,60 +131,131 @@ def create_mic_test_ui(self): mic_icon = mic_icon.resize((24, 24)) self.mic_photo = ImageTk.PhotoImage(mic_icon) mic_icon_label = ttk.Label(meter_frame, image=self.mic_photo) - mic_icon_label.pack(side=tk.LEFT, padx=(0, 10)) + mic_icon_label.grid(row=0, column=0, padx=(0, 10), sticky='nsew') except Exception as e: print(f"Error loading microphone icon: {e}") # Create volume meter segments self.segments_frame = ttk.Frame(meter_frame) - self.segments_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.segments_frame.grid(row=0, column=1, sticky='nsew') # Create segments self.SEGMENT_COUNT = 20 self.segments = [] for i in range(self.SEGMENT_COUNT): segment = ttk.Frame(self.segments_frame, width=10, height=20) - segment.pack(side=tk.LEFT, padx=1) - segment.pack_propagate(False) + segment.grid(row=0, column=i, padx=1) + segment.grid_propagate(False) self.segments.append(segment) + # Status label for feedback + self.status_label = ttk.Label(self.frame, text="Microphone: Ready", foreground="green") + self.status_label.grid(row=2, column=0, pady=(5, 0), sticky='nsew') + + def initialize_selected_microphone(self): + """ + Initialize the selected microphone and open the audio stream. + """ + if MicrophoneState.SELECTED_MICROPHONE_INDEX is not None: + self.update_selected_microphone(MicrophoneState.SELECTED_MICROPHONE_INDEX) + elif self.mic_list: + self.update_selected_microphone(self.mic_list[0][0]) + def on_mic_change(self, event): - # Save the selection immediately + """ + Handle the event when a microphone is selected from the dropdown. + """ selected_name = self.mic_dropdown.get() - selected_index = None - for device_index, device_name in self.mic_list: - if device_name == selected_name: - selected_index = device_index - break - if selected_index is not None: - MicrophoneState.SELECTED_MICROPHONE_INDEX = selected_index - - # Reset volume meter - for segment in self.segments: - segment.configure(style='Inactive.TFrame') + if selected_name in self.mic_mapping: + selected_index = self.mic_mapping[selected_name] + self.update_selected_microphone(selected_index) + self.reopen_stream() # Reopen the stream with the new device + + def update_selected_microphone(self, selected_index): + """ + Update the selected microphone index and name. + + Parameters + ---------- + selected_index : int + The index of the selected microphone. + """ + if selected_index >= 0: + try: + selected_mic = self.p.get_device_info_by_index(selected_index) + MicrophoneState.SELECTED_MICROPHONE_INDEX = selected_mic["index"] + MicrophoneState.SELECTED_MICROPHONE_NAME = selected_mic["name"] + self.status_label.config(text="Microphone: Connected", foreground="green") + + # Close existing stream if any + if self.stream: + if self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + self.stream = None + self.is_stream_active = False + + # Open new stream with the selected microphone + self.stream = self.p.open( + format=pyaudio.paInt16, + channels=1, + rate=16000, + input=True, + frames_per_buffer=1024, + input_device_index=selected_index + ) + self.is_stream_active = True + except Exception as e: + self.status_label.config(text="Error: Microphone not found", foreground="red") + # messagebox.showerror("Microphone Error", f"Failed to open microphone: {e}") + else: + MicrophoneState.SELECTED_MICROPHONE_INDEX = None + MicrophoneState.SELECTED_MICROPHONE_NAME = None + self.status_label.config(text="Error: No microphone selected", foreground="red") + + def reopen_stream(self): + """ + Reopen the audio stream with the currently selected microphone. + """ + # Stop and close the existing stream if it is open + if self.stream: + try: + if self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + except Exception as e: + print(f"Error closing stream: {e}") + finally: + self.stream = None + self.is_stream_active = False + + # Open a new stream with the selected microphone + if MicrophoneState.SELECTED_MICROPHONE_INDEX is not None: + try: + self.stream = self.p.open( + format=pyaudio.paInt16, + channels=1, + rate=16000, + input=True, + frames_per_buffer=1024, + input_device_index=MicrophoneState.SELECTED_MICROPHONE_INDEX + ) + self.is_stream_active = True + self.status_label.config(text="Microphone: Connected", foreground="green") + except Exception as e: + self.status_label.config(text="Error: Microphone not found", foreground="red") + # messagebox.showerror("Microphone Error", f"Failed to open microphone: {e}") def update_volume_meter(self): - selected_name = self.mic_dropdown.get() - selected_index = None - for device_index, device_name in self.mic_list: - if device_name == selected_name: - selected_index = device_index - break + """ + Update the volume meter based on the current microphone input. + """ + if not self.is_stream_active: + self.frame.after(100, self.update_volume_meter) + return try: - stream = self.p.open( - format=pyaudio.paInt16, - channels=1, - rate=16000, - input=True, - frames_per_buffer=1024, - input_device_index=selected_index - ) - - data = stream.read(1024, exception_on_overflow=False) - stream.stop_stream() - stream.close() - + data = self.stream.read(1024, exception_on_overflow=False) audio_data = np.frombuffer(data, dtype=np.int16) rms = np.sqrt(np.mean(np.square(audio_data.astype(np.float64)))) @@ -190,12 +278,33 @@ def update_volume_meter(self): else: segment.configure(style='Inactive.TFrame') - except Exception as e: - print(f"Error in update_volume_meter: {e}") - for segment in self.segments: - segment.configure(style='Inactive.TFrame') + except OSError as e: + if e.errno in [-9988, -9999]: # Handle both Stream closed and Unanticipated host error + self.status_label.config(text="Error: Microphone disconnected", foreground="red") + print(f"Error in update_volume_meter: {e}") + self.is_stream_active = False + self.stream = None + for segment in self.segments: + segment.configure(style='Inactive.TFrame') + else: + raise # Re-raise the exception if it's not the expected error self.frame.after(100, self.update_volume_meter) - def bind_listbox_select(self, callback): - self.timestamp_listbox.bind('<>', callback) \ No newline at end of file + def get_selected_microphone_index(self): + """ + Get the selected microphone index. + """ + return MicrophoneState.SELECTED_MICROPHONE_INDEX + + def __del__(self): + """ + Clean up resources when the object is destroyed. + """ + if self.stream: + try: + if self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + except Exception as e: + print(f"Error closing stream in destructor: {e}") \ No newline at end of file diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 1964f817..1a3024e3 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -35,7 +35,6 @@ from UI.SettingsWindow import SettingsWindow, SettingsKeys from UI.Widgets.CustomTextBox import CustomTextBox from UI.LoadingWindow import LoadingWindow -from UI.Widgets.MicrophoneSelector import MicrophoneState from Model import ModelManager from utils.ip_utils import is_private_ip from utils.file_utils import get_file_path, get_resource_path @@ -43,6 +42,9 @@ import sys from UI.DebugWindow import DualOutput import traceback +from UI.Widgets.MicrophoneTestFrame import MicrophoneTestFrame + + dual = DualOutput() sys.stdout = dual @@ -172,13 +174,14 @@ def record_audio(): global is_paused, frames, audio_queue try: + selected_index = MicrophoneTestFrame.get_selected_microphone_index() stream = p.open( format=FORMAT, channels=1, rate=RATE, input=True, frames_per_buffer=CHUNK, - input_device_index=int(MicrophoneState.SELECTED_MICROPHONE_INDEX)) + input_device_index=int(selected_index)) except (OSError, IOError) as e: messagebox.showerror("Audio Error", f"Please check your microphone settings under whisper settings. Error opening audio stream: {e}") return @@ -1279,6 +1282,7 @@ def _load_stt_model_thread(): history_frame.grid_rowconfigure(0, weight=4) # Timestamp takes more space history_frame.grid_rowconfigure(1, weight=1) # Mic test takes less space +# Add the timestamp listbox timestamp_listbox = tk.Listbox(history_frame, height=30) timestamp_listbox.grid(row=0, column=0, sticky='nsew') timestamp_listbox.bind('<>', show_response) @@ -1286,8 +1290,9 @@ def _load_stt_model_thread(): timestamp_listbox.config(fg='grey') # Add microphone test frame -from UI.Widgets.MicrophoneTestFrame import MicrophoneTestFrame -mic_test = MicrophoneTestFrame(history_frame, p) + +mic_test = MicrophoneTestFrame(parent=history_frame, p=p, app_settings=app_settings) +mic_test.frame.grid(row=1, column=0, sticky='nsew') # Use grid to place the frame window.update_aiscribe_texts(None) # Bind Alt+P to send_and_receive function From 0422cd8d836a9e594a08307708de0b1737f03229 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Tue, 28 Jan 2025 14:31:36 -0500 Subject: [PATCH 174/244] removed stero and loopback from filters --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 0871c0a5..d13a3625 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -63,7 +63,7 @@ def initialize_microphones(self): device_info = self.p.get_device_info_by_index(i) if device_info['maxInputChannels'] > 0: device_name = device_info['name'] - excluded_names = ["Stereo Mix", "Loopback", "Virtual", "Output", + excluded_names = ["Virtual", "Output", "Wave Out", "What U Hear", "Aux", "Port", "Mix"] if not any(excluded_name.lower() in device_name.lower() for excluded_name in excluded_names): From 4819ea0e3718abb15279f134db354ae34d3e3df5 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Tue, 28 Jan 2025 14:48:21 -0500 Subject: [PATCH 175/244] Pillow import added in req --- client_requirements.txt | Bin 2638 -> 2670 bytes client_requirements_nvidia.txt | 1 + 2 files changed, 1 insertion(+) diff --git a/client_requirements.txt b/client_requirements.txt index 86b1bf85e38c3f0f8ca615dba2ba61d0dca95aa0..c6cc4b3c07dda2fdd80490770c13df8c3172db78 100644 GIT binary patch delta 40 rcmX>n@=j!fAD2P^LncEG5au(KGuQ&5A%g*f9)l4O8!+%Pa4`S?$>aw# delta 7 OcmaDSa!zD}9~S@)4Fc@| diff --git a/client_requirements_nvidia.txt b/client_requirements_nvidia.txt index 1be57a16..e2661fc6 100644 --- a/client_requirements_nvidia.txt +++ b/client_requirements_nvidia.txt @@ -65,3 +65,4 @@ docker==7.1.0 markdown==3.7 tkhtmlview==0.3.1 llama-cpp-python==v0.2.90 +Pillow==10.2.0 From cca900b3c59dcfc519e36024ff5b422389b790e2 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 10:51:38 -0500 Subject: [PATCH 176/244] try fixing local stt model not loaded when start recording --- src/FreeScribe.client/client.py | 90 ++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index abdb0978..83c1f543 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -13,6 +13,7 @@ import ctypes import io +import logging import sys import gc import os @@ -143,6 +144,8 @@ def on_closing(): # Global instance of whisper model stt_local_model = None +stt_model_loading_thread_lock = threading.Lock() + def get_prompt(formatted_message): @@ -173,6 +176,31 @@ def get_prompt(formatted_message): } def threaded_toggle_recording(): + print(f"*** Toggle Recording...\n {is_recording = } {stt_local_model = }") + # if using local whisper and model is not loaded, when starting recording + if not is_recording and app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and not stt_local_model: + try: + if stt_model_loading_thread_lock.locked(): + stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") + timeout = 180 + time_start = time.monotonic() + # wait until the other loading thread is done + while True: + time.sleep(0.1) + if not stt_model_loading_thread_lock.locked(): + break + if time.monotonic() - time_start > timeout: + messagebox.showerror("Error", f"Timed out while loading local STT model after {timeout} seconds.") + break + stt_loading_window.destroy() + # double check + if stt_local_model is None: + # mandatory loading, synchronous + t = load_stt_model() + t.join() + except Exception as e: + logging.exception(str(e)) + messagebox.showerror("Error", f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") thread = threading.Thread(target=toggle_recording) thread.start() @@ -1336,8 +1364,9 @@ def load_stt_model(event=None): Args: event: Optional event parameter for binding to tkinter events. """ - thread = threading.Thread(target=_load_stt_model_thread, daemon=True) + thread = threading.Thread(target=_load_stt_model_thread) thread.start() + return thread def _load_stt_model_thread(): """ @@ -1350,35 +1379,36 @@ def _load_stt_model_thread(): Exception: Any error that occurs during model loading is caught, logged, and displayed to the user via a message box. """ - global stt_local_model - model = app_settings.editable_settings["Whisper Model"].strip() - stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") - print(f"Loading STT model: {model}") - try: - unload_stt_model() - device_type = get_selected_whisper_architecture() - set_cuda_paths() - - compute_type = app_settings.editable_settings[SettingsKeys.WHISPER_COMPUTE_TYPE.value] - # Change the compute type automatically if using a gpu one. - if device_type == Architectures.CPU.architecture_value and compute_type == "float16": - compute_type = "int8" - + with stt_model_loading_thread_lock: + global stt_local_model + model = app_settings.editable_settings["Whisper Model"].strip() + stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") + print(f"Loading STT model: {model}") + try: + unload_stt_model() + device_type = get_selected_whisper_architecture() + set_cuda_paths() - stt_local_model = WhisperModel( - model, - device=device_type, - cpu_threads=int(app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value]), - compute_type=compute_type) + compute_type = app_settings.editable_settings[SettingsKeys.WHISPER_COMPUTE_TYPE.value] + # Change the compute type automatically if using a gpu one. + if device_type == Architectures.CPU.architecture_value and compute_type == "float16": + compute_type = "int8" - print("STT model loaded successfully.") - except Exception as e: - print(f"An error occurred while loading STT {type(e).__name__}: {e}") - stt_local_model = None - messagebox.showerror("Error", f"An error occurred while loading STT {type(e).__name__}: {e}") - finally: - stt_loading_window.destroy() - print("Closing STT loading window.") + + stt_local_model = WhisperModel( + model, + device=device_type, + cpu_threads=int(app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value]), + compute_type=compute_type) + + print("STT model loaded successfully.") + except Exception as e: + print(f"An error occurred while loading STT {type(e).__name__}: {e}") + stt_local_model = None + messagebox.showerror("Error", f"An error occurred while loading STT {type(e).__name__}: {e}") + finally: + stt_loading_window.destroy() + print("Closing STT loading window.") def unload_stt_model(): """ @@ -1390,9 +1420,9 @@ def unload_stt_model(): global stt_local_model if stt_local_model is not None: print("Unloading STT model from device.") - del stt_local_model - gc.collect() + # no risk of temporary "stt_local_model in globals() is False" with same gc effect stt_local_model = None + gc.collect() print("STT model unloaded successfully.") else: print("STT model is already unloaded.") From 7ba6fee9fab1423f72eb5dccfb49d3143e89d7b1 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 11:05:47 -0500 Subject: [PATCH 177/244] make sure loading window is destroyed --- src/FreeScribe.client/client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 83c1f543..b4e24328 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -179,6 +179,7 @@ def threaded_toggle_recording(): print(f"*** Toggle Recording...\n {is_recording = } {stt_local_model = }") # if using local whisper and model is not loaded, when starting recording if not is_recording and app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and not stt_local_model: + stt_loading_window = None try: if stt_model_loading_thread_lock.locked(): stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") @@ -193,6 +194,7 @@ def threaded_toggle_recording(): messagebox.showerror("Error", f"Timed out while loading local STT model after {timeout} seconds.") break stt_loading_window.destroy() + stt_loading_window = None # double check if stt_local_model is None: # mandatory loading, synchronous @@ -201,6 +203,9 @@ def threaded_toggle_recording(): except Exception as e: logging.exception(str(e)) messagebox.showerror("Error", f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") + finally: + if stt_loading_window: + stt_loading_window.destroy() thread = threading.Thread(target=toggle_recording) thread.start() From 65c93f3fd0a9a0d324aebef70dbe3dda0610ca3c Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 11:07:43 -0500 Subject: [PATCH 178/244] fix logging --- src/FreeScribe.client/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index b4e24328..b427f194 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -52,6 +52,10 @@ from utils.utils import window_has_running_instance, bring_to_front, close_mutex from WhisperModel import TranscribeError +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) dual = DualOutput() @@ -176,7 +180,8 @@ def get_prompt(formatted_message): } def threaded_toggle_recording(): - print(f"*** Toggle Recording...\n {is_recording = } {stt_local_model = }") + logging.debug(f"Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") + # if using local whisper and model is not loaded, when starting recording if not is_recording and app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and not stt_local_model: stt_loading_window = None From 442c45828970061844aa4375a63cf65028b3d1dd Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 11:11:16 -0500 Subject: [PATCH 179/244] extract double_check_stt_model_loading function --- src/FreeScribe.client/client.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index b427f194..2d7f22e9 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -180,8 +180,14 @@ def get_prompt(formatted_message): } def threaded_toggle_recording(): - logging.debug(f"Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") + logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") + double_check_stt_model_loading() + thread = threading.Thread(target=toggle_recording) + thread.start() + + +def double_check_stt_model_loading(): # if using local whisper and model is not loaded, when starting recording if not is_recording and app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and not stt_local_model: stt_loading_window = None @@ -196,7 +202,8 @@ def threaded_toggle_recording(): if not stt_model_loading_thread_lock.locked(): break if time.monotonic() - time_start > timeout: - messagebox.showerror("Error", f"Timed out while loading local STT model after {timeout} seconds.") + messagebox.showerror("Error", + f"Timed out while loading local STT model after {timeout} seconds.") break stt_loading_window.destroy() stt_loading_window = None @@ -211,8 +218,7 @@ def threaded_toggle_recording(): finally: if stt_loading_window: stt_loading_window.destroy() - thread = threading.Thread(target=toggle_recording) - thread.start() + def threaded_realtime_text(): thread = threading.Thread(target=realtime_text) From a0a67c52c293ffe634dbe0abdd0b4d2e9cad898c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 29 Jan 2025 12:17:38 -0500 Subject: [PATCH 180/244] Added exception handler to record_audio on TypeError and ValueError of casting input device --- src/FreeScribe.client/client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index abdb0978..474f9f1e 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -222,6 +222,11 @@ def record_audio(): input_device_index=int(MicrophoneState.SELECTED_MICROPHONE_INDEX)) except (OSError, IOError) as e: messagebox.showerror("Audio Error", f"Please check your microphone settings under whisper settings. Error opening audio stream: {e}") + print(f"Error opening audio stream: {e}") + return + except (ValueError, TypeError) as e: + messagebox.showerror("Audio Error", f"Please check your microphone settings under whisper settings. Error opening audio stream: {e}") + print(f"Error opening audio stream: {e}") return try: From 72bfae6999306ef81a04c134faf7c916a294045d Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 12:45:38 -0500 Subject: [PATCH 181/244] fix UI freeze --- src/FreeScribe.client/client.py | 75 +++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 2d7f22e9..3a3edec5 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -181,43 +181,54 @@ def get_prompt(formatted_message): def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") + task_done_var = tk.BooleanVar(value=False) + stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) + stt_thread.start() + root.wait_variable(task_done_var) - double_check_stt_model_loading() thread = threading.Thread(target=toggle_recording) thread.start() -def double_check_stt_model_loading(): - # if using local whisper and model is not loaded, when starting recording - if not is_recording and app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value] and not stt_local_model: - stt_loading_window = None - try: - if stt_model_loading_thread_lock.locked(): - stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") - timeout = 180 - time_start = time.monotonic() - # wait until the other loading thread is done - while True: - time.sleep(0.1) - if not stt_model_loading_thread_lock.locked(): - break - if time.monotonic() - time_start > timeout: - messagebox.showerror("Error", - f"Timed out while loading local STT model after {timeout} seconds.") - break - stt_loading_window.destroy() - stt_loading_window = None - # double check - if stt_local_model is None: - # mandatory loading, synchronous - t = load_stt_model() - t.join() - except Exception as e: - logging.exception(str(e)) - messagebox.showerror("Error", f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") - finally: - if stt_loading_window: - stt_loading_window.destroy() +def double_check_stt_model_loading(task_done_var): + stt_loading_window = None + try: + if is_recording: + return + if not app_settings.editable_settings[SettingsKeys.LOCAL_WHISPER.value]: + return + if stt_local_model: + return + # if using local whisper and model is not loaded, when starting recording + if stt_model_loading_thread_lock.locked(): + stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") + timeout = 180 + time_start = time.monotonic() + # wait until the other loading thread is done + while True: + time.sleep(0.1) + if not stt_model_loading_thread_lock.locked(): + break + if time.monotonic() - time_start > timeout: + messagebox.showerror("Error", + f"Timed out while loading local STT model after {timeout} seconds.") + break + stt_loading_window.destroy() + stt_loading_window = None + # double check + if stt_local_model is None: + # mandatory loading, synchronous + t = load_stt_model() + t.join() + + except Exception as e: + logging.exception(str(e)) + messagebox.showerror("Error", + f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") + finally: + if stt_loading_window: + stt_loading_window.destroy() + task_done_var.set(True) def threaded_realtime_text(): From ef96d26bc709beba86672b5d3b0524da60b3ee5e Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 12:51:23 -0500 Subject: [PATCH 182/244] refactor loading --- src/FreeScribe.client/client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3a3edec5..dca015dd 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -182,16 +182,17 @@ def get_prompt(formatted_message): def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") task_done_var = tk.BooleanVar(value=False) + stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) stt_thread.start() root.wait_variable(task_done_var) + stt_loading_window.destroy() thread = threading.Thread(target=toggle_recording) thread.start() def double_check_stt_model_loading(task_done_var): - stt_loading_window = None try: if is_recording: return @@ -201,7 +202,7 @@ def double_check_stt_model_loading(task_done_var): return # if using local whisper and model is not loaded, when starting recording if stt_model_loading_thread_lock.locked(): - stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") + timeout = 180 time_start = time.monotonic() # wait until the other loading thread is done @@ -213,8 +214,7 @@ def double_check_stt_model_loading(task_done_var): messagebox.showerror("Error", f"Timed out while loading local STT model after {timeout} seconds.") break - stt_loading_window.destroy() - stt_loading_window = None + # double check if stt_local_model is None: # mandatory loading, synchronous @@ -226,8 +226,6 @@ def double_check_stt_model_loading(task_done_var): messagebox.showerror("Error", f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") finally: - if stt_loading_window: - stt_loading_window.destroy() task_done_var.set(True) From 498ee4db1c4bccc93416ade24326b1b7ba07204b Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 13:04:25 -0500 Subject: [PATCH 183/244] different content in loading window --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index dca015dd..9a8053c3 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -182,7 +182,7 @@ def get_prompt(formatted_message): def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") task_done_var = tk.BooleanVar(value=False) - stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") + stt_loading_window = LoadingWindow(root, "Loading", "Loading models. Please wait.") stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) stt_thread.start() root.wait_variable(task_done_var) From a0358a4d9e594df1c1c66e78f348aadc942f4c0a Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Wed, 29 Jan 2025 14:13:01 -0500 Subject: [PATCH 184/244] Minimize screen fix --- .../UI/Widgets/MicrophoneTestFrame.py | 15 +++++++-------- src/FreeScribe.client/client.py | 13 +++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index d13a3625..b46a43d6 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -63,13 +63,12 @@ def initialize_microphones(self): device_info = self.p.get_device_info_by_index(i) if device_info['maxInputChannels'] > 0: device_name = device_info['name'] - excluded_names = ["Virtual", "Output", - "Wave Out", "What U Hear", "Aux", "Port", "Mix"] - if not any(excluded_name.lower() in device_name.lower() - for excluded_name in excluded_names): - self.mic_list.append((i, device_name)) - self.mic_mapping[device_name] = i - + excluded_names = ["Virtual", "Output", "Wave Out", "What U Hear", "Aux", "Port", "Mix"] + if not any(excluded_name.lower() in device_name.lower() for excluded_name in excluded_names): + if device_name not in [name for _, name in self.mic_list]: + self.mic_list.append((i, device_name)) + self.mic_mapping[device_name] = i + # Load the selected microphone from settings if available if self.app_settings and "Current Mic" in self.app_settings.editable_settings: selected_name = self.app_settings.editable_settings["Current Mic"] @@ -291,7 +290,7 @@ def update_volume_meter(self): self.frame.after(100, self.update_volume_meter) - def get_selected_microphone_index(self): + def get_selected_microphone_index(): """ Get the selected microphone index. """ diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index f5d868b1..25359d20 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -842,17 +842,17 @@ def update_gui_with_response(response_text): global response_history, user_message, IS_FIRST_LOG if IS_FIRST_LOG: - timestamp_listbox.delete(0, tk.END) - timestamp_listbox.config(fg='black') + history_frame.delete(0, tk.END) + history_frame.config(fg='black') IS_FIRST_LOG = False timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") response_history.insert(0, (timestamp, user_message, response_text)) # Update the timestamp listbox - timestamp_listbox.delete(0, tk.END) + history_frame.delete(0, tk.END) for time, _, _ in response_history: - timestamp_listbox.insert(tk.END, time) + history_frame.insert(tk.END, time) display_text(response_text) pyperclip.copy(response_text) @@ -1179,7 +1179,7 @@ def set_full_view(): toggle_button.grid() upload_button.grid() response_display.grid() - timestamp_listbox.grid() + history_frame.grid() mic_button.grid(row=1, column=1, pady=5, padx=0,sticky='nsew') pause_button.grid(row=1, column=2, pady=5, padx=0,sticky='nsew') switch_view_button.grid(row=1, column=7, pady=5, padx=0,sticky='nsew') @@ -1243,9 +1243,10 @@ def set_minimal_view(): toggle_button.grid_remove() upload_button.grid_remove() response_display.grid_remove() - timestamp_listbox.grid_remove() + history_frame.grid_remove() blinking_circle_canvas.grid_remove() + # Configure minimal view button sizes and placements mic_button.config(width=2, height=1) pause_button.config(width=2, height=1) From 5f6dd58fdf12a62e16e1c6901346f9d2b0647a8c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 29 Jan 2025 14:15:17 -0500 Subject: [PATCH 185/244] Screen input function --- src/FreeScribe.client/client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 474f9f1e..82250d28 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -982,9 +982,12 @@ def send_text_to_localmodel(edited_text): ) +def screen_input(conversation): + prompt = "Go over this conversation and ensure its a conversation with more than 50 words. Also, if it is a conversation between a doctor and a patient. Please return one word. Either True or False based. Do not give a explanation and do not format the text. Here is the conversation:\n" + return send_text_to_chatgpt(f"{prompt}{conversation}") -def send_text_to_chatgpt(edited_text): +def send_text_to_chatgpt(edited_text): if app_settings.editable_settings["Use Local LLM"]: return send_text_to_localmodel(edited_text) else: @@ -993,6 +996,9 @@ def send_text_to_chatgpt(edited_text): def generate_note(formatted_message): try: # If note generation is on + prescreen = screen_input(formatted_message) + print("prescreen: ", prescreen) + return if use_aiscribe: # If pre-processing is enabled if app_settings.editable_settings["Use Pre-Processing"]: From ce1aca889f980ee7e9f0306c178a7e16578e8ced Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 29 Jan 2025 14:26:51 -0500 Subject: [PATCH 186/244] prescreen debug --- src/FreeScribe.client/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 82250d28..12692934 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -997,7 +997,12 @@ def generate_note(formatted_message): try: # If note generation is on prescreen = screen_input(formatted_message) - print("prescreen: ", prescreen) + if prescreen is "True": + print("prescreen is true") + return + else: + print("prescreen is false") + return return if use_aiscribe: # If pre-processing is enabled From 8543e66013c4b7eb14e5ecd3d6283fa3c8441ad0 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 14:53:09 -0500 Subject: [PATCH 187/244] control log level --- src/FreeScribe.client/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 54f99356..8c74e2ed 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -52,8 +52,13 @@ from utils.utils import window_has_running_instance, bring_to_front, close_mutex from WhisperModel import TranscribeError +if os.environ.get("DEBUG"): + LOG_LEVEL = logging.DEBUG +else: + LOG_LEVEL = logging.INFO + logging.basicConfig( - level=logging.DEBUG, + level=LOG_LEVEL, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) From 2a24485f13008aa1311f7da2546bb90e31096a38 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 15:00:04 -0500 Subject: [PATCH 188/244] 5min timeout --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 8c74e2ed..609fd5dc 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -208,7 +208,7 @@ def double_check_stt_model_loading(task_done_var): # if using local whisper and model is not loaded, when starting recording if stt_model_loading_thread_lock.locked(): - timeout = 180 + timeout = 300 time_start = time.monotonic() # wait until the other loading thread is done while True: From 1d90195c40bfd9886dccf93e67aae507126ff86d Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Wed, 29 Jan 2025 15:07:57 -0500 Subject: [PATCH 189/244] saves last mic option in settings for next time --- .../UI/Widgets/MicrophoneTestFrame.py | 18 ++++++++++++++---- src/FreeScribe.client/client.py | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index b46a43d6..542baf8e 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -4,13 +4,14 @@ import numpy as np from PIL import Image, ImageTk from utils.file_utils import get_file_path +from UI.SettingsWindowUI import SettingsWindowUI class MicrophoneState: SELECTED_MICROPHONE_INDEX = None SELECTED_MICROPHONE_NAME = None class MicrophoneTestFrame: - def __init__(self, parent, p, app_settings): + def __init__(self, parent, p, app_settings, root): """ Initialize the MicrophoneTestFrame. @@ -23,12 +24,15 @@ def __init__(self, parent, p, app_settings): app_settings : dict Application settings including editable settings. """ + self.root = root self.parent = parent self.p = p self.app_settings = app_settings self.stream = None # Persistent audio stream self.is_stream_active = False # Track if the stream is active + self.setting_window = SettingsWindowUI(self.app_settings, self, self.root) # Settings window + # Create a frame for the microphone test self.frame = ttk.Frame(self.parent) self.frame.grid(row=1, column=0, sticky='nsew') @@ -68,10 +72,12 @@ def initialize_microphones(self): if device_name not in [name for _, name in self.mic_list]: self.mic_list.append((i, device_name)) self.mic_mapping[device_name] = i - + # Load the selected microphone from settings if available + print("yogesh") + print(self.app_settings.editable_settings["Current Mic"]) if self.app_settings and "Current Mic" in self.app_settings.editable_settings: - selected_name = self.app_settings.editable_settings["Current Mic"] + selected_name = self.app_settings.editable_settings["Current Mic"] if selected_name in self.mic_mapping: MicrophoneState.SELECTED_MICROPHONE_NAME = selected_name MicrophoneState.SELECTED_MICROPHONE_INDEX = self.mic_mapping[selected_name] @@ -168,7 +174,10 @@ def on_mic_change(self, event): if selected_name in self.mic_mapping: selected_index = self.mic_mapping[selected_name] self.update_selected_microphone(selected_index) - self.reopen_stream() # Reopen the stream with the new device + # save the settings to the file + self.setting_window.settings.save_settings_to_file() + # Reopen the stream with the new device + self.reopen_stream() def update_selected_microphone(self, selected_index): """ @@ -185,6 +194,7 @@ def update_selected_microphone(self, selected_index): MicrophoneState.SELECTED_MICROPHONE_INDEX = selected_mic["index"] MicrophoneState.SELECTED_MICROPHONE_NAME = selected_mic["name"] self.status_label.config(text="Microphone: Connected", foreground="green") + self.app_settings.editable_settings["Current Mic"] = selected_mic["name"] # Close existing stream if any if self.stream: diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 25359d20..af944b2b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1568,8 +1568,8 @@ def set_cuda_paths(): # Add microphone test frame -mic_test = MicrophoneTestFrame(parent=history_frame, p=p, app_settings=app_settings) -mic_test.frame.grid(row=1, column=0, sticky='nsew') # Use grid to place the frame +mic_test = MicrophoneTestFrame(parent=history_frame, p=p, app_settings=app_settings, root=root) +mic_test.frame.grid(row=1, column=0, pady=15, sticky='nsew') # Use grid to place the frame window.update_aiscribe_texts(None) # Bind Alt+P to send_and_receive function From ac2a9d15fa0a00581af7fea7af392003829e2fb8 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 15:11:05 -0500 Subject: [PATCH 190/244] show model name in loading window --- src/FreeScribe.client/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 609fd5dc..2d18cea2 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -187,7 +187,8 @@ def get_prompt(formatted_message): def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") task_done_var = tk.BooleanVar(value=False) - stt_loading_window = LoadingWindow(root, "Loading", "Loading models. Please wait.") + model_name = app_settings.editable_settings["Whisper Model"].strip() + stt_loading_window = LoadingWindow(root, "Loading STT model", f"Loading {model_name} model. Please wait.") stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) stt_thread.start() root.wait_variable(task_done_var) @@ -1417,7 +1418,7 @@ def _load_stt_model_thread(): with stt_model_loading_thread_lock: global stt_local_model model = app_settings.editable_settings["Whisper Model"].strip() - stt_loading_window = LoadingWindow(root, "Speech to Text", "Loading Speech to Text. Please wait.") + stt_loading_window = LoadingWindow(root, "Speech to Text", f"Loading Speech to Text {model} model. Please wait.") print(f"Loading STT model: {model}") try: unload_stt_model() From 7ecdea2f20413e8de950b20e3071ee5e9e0f4b28 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 15:15:02 -0500 Subject: [PATCH 191/244] increase load window width --- src/FreeScribe.client/UI/LoadingWindow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/LoadingWindow.py b/src/FreeScribe.client/UI/LoadingWindow.py index d0f9bb21..5231e9ee 100644 --- a/src/FreeScribe.client/UI/LoadingWindow.py +++ b/src/FreeScribe.client/UI/LoadingWindow.py @@ -59,7 +59,8 @@ def __init__(self, parent=None, title="Processing", initial_text="Loading", on_c self.popup = tk.Toplevel(parent) self.popup.title(title) - self.popup.geometry("200x105") # Increased height for cancel button + # increased width for whisper model type + self.popup.geometry("240x105") # Increased height for cancel button self.popup.iconbitmap(get_file_path('assets','logo.ico')) if parent: From fad620590cf42ab05577cbca09a91d8883986b95 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 15:16:05 -0500 Subject: [PATCH 192/244] increase width of loading window again --- src/FreeScribe.client/UI/LoadingWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/LoadingWindow.py b/src/FreeScribe.client/UI/LoadingWindow.py index 5231e9ee..0dcd95ec 100644 --- a/src/FreeScribe.client/UI/LoadingWindow.py +++ b/src/FreeScribe.client/UI/LoadingWindow.py @@ -60,7 +60,7 @@ def __init__(self, parent=None, title="Processing", initial_text="Loading", on_c self.popup = tk.Toplevel(parent) self.popup.title(title) # increased width for whisper model type - self.popup.geometry("240x105") # Increased height for cancel button + self.popup.geometry("280x105") # Increased height for cancel button self.popup.iconbitmap(get_file_path('assets','logo.ico')) if parent: From 7348ad4db6242737346c37831a986fd809e202fe Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 15:25:49 -0500 Subject: [PATCH 193/244] adopt log format suggestion --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 2d18cea2..b9eceada 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -59,7 +59,7 @@ logging.basicConfig( level=LOG_LEVEL, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + format='%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s' ) From 7f4bbc6bdb8c1874d3099a3ae728fb4d787e7285 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 15:32:09 -0500 Subject: [PATCH 194/244] env var FREESCRIBE_DEBUG to control log level --- src/FreeScribe.client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index b9eceada..c7629e0b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -52,7 +52,7 @@ from utils.utils import window_has_running_instance, bring_to_front, close_mutex from WhisperModel import TranscribeError -if os.environ.get("DEBUG"): +if os.environ.get("FREESCRIBE_DEBUG"): LOG_LEVEL = logging.DEBUG else: LOG_LEVEL = logging.INFO From 32170c8370973eb2e534cb13298803d2b1363ce7 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Wed, 29 Jan 2025 15:32:13 -0500 Subject: [PATCH 195/244] added padding to make look even size --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 542baf8e..7f02555a 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -74,8 +74,6 @@ def initialize_microphones(self): self.mic_mapping[device_name] = i # Load the selected microphone from settings if available - print("yogesh") - print(self.app_settings.editable_settings["Current Mic"]) if self.app_settings and "Current Mic" in self.app_settings.editable_settings: selected_name = self.app_settings.editable_settings["Current Mic"] if selected_name in self.mic_mapping: @@ -112,10 +110,11 @@ def create_mic_test_ui(self): center_frame, values=mic_options, state='readonly', - width=30, + width=40, style='Mic.TCombobox' ) - self.mic_dropdown.grid(row=0, column=0, pady=(0, 5), sticky='nsew') + self.mic_dropdown.grid(row=0, column=0, pady=(0, 5), padx=(10, 0), sticky='nsew') + # Set the default selection if MicrophoneState.SELECTED_MICROPHONE_NAME: @@ -155,7 +154,7 @@ def create_mic_test_ui(self): # Status label for feedback self.status_label = ttk.Label(self.frame, text="Microphone: Ready", foreground="green") - self.status_label.grid(row=2, column=0, pady=(5, 0), sticky='nsew') + self.status_label.grid(row=2, column=0, pady=(5, 0), padx=(40, 0), sticky='nsew') def initialize_selected_microphone(self): """ From cf9eb02384a2d5f4ff52923e6578226f875c9ba0 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 16:29:18 -0500 Subject: [PATCH 196/244] if user stopped waiting, double_check_stt_model_loading thread should also exit, otherwise it may pop up an error box seemed no reason after timeout --- src/FreeScribe.client/client.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index c7629e0b..b516dc0b 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -188,7 +188,9 @@ def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") task_done_var = tk.BooleanVar(value=False) model_name = app_settings.editable_settings["Whisper Model"].strip() - stt_loading_window = LoadingWindow(root, "Loading STT model", f"Loading {model_name} model. Please wait.") + stt_loading_window = LoadingWindow(root, "Loading STT model", + f"Loading {model_name} model. Please wait.", + on_cancel=lambda: task_done_var.set(True)) stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) stt_thread.start() root.wait_variable(task_done_var) @@ -214,11 +216,15 @@ def double_check_stt_model_loading(task_done_var): # wait until the other loading thread is done while True: time.sleep(0.1) - if not stt_model_loading_thread_lock.locked(): - break + if task_done_var.get(): + # user cancel + logging.debug(f"user canceled after {time.monotonic() - time_start} seconds") + return if time.monotonic() - time_start > timeout: messagebox.showerror("Error", f"Timed out while loading local STT model after {timeout} seconds.") + return + if not stt_model_loading_thread_lock.locked(): break # double check From 9f97ea6db3bc4f361dc768c37b4fc5a90e0f398c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 29 Jan 2025 16:31:05 -0500 Subject: [PATCH 197/244] Custom PopupBox --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/FreeScribe.client/UI/Widgets/PopupBox.py diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py new file mode 100644 index 00000000..5cd11b81 --- /dev/null +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -0,0 +1,75 @@ +import tkinter as tk +from tkinter import Toplevel + +class PopupBox: + def __init__(self, + parent, + title="Message", + message="Message text", + button_text_1="OK", + button_text_2="Cancel", + button_1_callback=None, + button_2_callback=None): + + self.response = None + self.dialog = Toplevel(parent) + self.dialog.title(title) + self.dialog.geometry("300x150") + self.dialog.resizable(False, False) + self.button_1_callback = button_1_callback + self.button_2_callback = button_2_callback + + # Message label + label = tk.Label(self.dialog, text=message, wraplength=250) + label.pack(pady=20) + + # Button frame + button_frame = tk.Frame(self.dialog) + button_frame.pack(pady=10) + + # Buttons + button_1 = tk.Button(button_frame, text=button_text_1, command=self.on_button_1) + button_1.pack(side=tk.LEFT, padx=10) + + button_2 = tk.Button(button_frame, text=button_text_2, command=self.on_button_2) + button_2.pack(side=tk.RIGHT, padx=10) + + # Modal behavior + self.dialog.transient(parent) + self.dialog.grab_set() + parent.wait_window(self.dialog) + + def on_button_1(self): + self.response = "button_1" + self.button_1_callback() + self.dialog.destroy() + + def on_button_2(self): + self.response = "button_2" + self.button_2_callback() + self.dialog.destroy() + + +# Example usage +def main(): + root = tk.Tk() + root.geometry("400x300") + + def show_custom_message_box(): + message_box = CustomMessageBox( + root, + title="Invalid Input", + message="The input does not meet the requirements. Do you want to continue?", + button_text_1="Continue", + button_text_2="Cancel" + ) + print(f"User response: {message_box.response}") + + # Button to show the custom message box + show_message_button = tk.Button(root, text="Show Message Box", command=show_custom_message_box) + show_message_button.pack(pady=20) + + root.mainloop() + +if __name__ == "__main__": + main() From bba6aa341bfcc20fdf850f06b8b24b57823fdbfe Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Wed, 29 Jan 2025 16:31:12 -0500 Subject: [PATCH 198/244] screen input function --- src/FreeScribe.client/client.py | 38 +++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 12692934..29e812b1 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -50,6 +50,7 @@ from UI.DebugWindow import DualOutput from utils.utils import window_has_running_instance, bring_to_front, close_mutex from WhisperModel import TranscribeError +from UI.Widgets.PopupBox import PopupBox @@ -983,9 +984,31 @@ def send_text_to_localmodel(edited_text): def screen_input(conversation): - prompt = "Go over this conversation and ensure its a conversation with more than 50 words. Also, if it is a conversation between a doctor and a patient. Please return one word. Either True or False based. Do not give a explanation and do not format the text. Here is the conversation:\n" + prompt = "Go over this conversation and ensure it's a conversation with more than 50 words. Also, if it is a conversation between a doctor and a patient. Please return one word. Either True or False based. Do not give an explanation and do not format the text. Here is the conversation:\n" - return send_text_to_chatgpt(f"{prompt}{conversation}") + # If note generation is on + prescreen = send_text_to_chatgpt(f"{prompt}{conversation}") + is_valid_input = prescreen.strip().lower() == "true" + + print("Generating Input. AI Prescreen: ", prescreen) + + if not is_valid_input: + # Simulate the popup logic with return values + popup_result = PopupBox( + parent=root, + title="Invalid Input", + message="Input has been flagged as invalid. Please ensure the input is a conversation with more than 50 words between a doctor and a patient. Unexpected results may occur from the AI.", + button_text_1="Cancel", + button_text_2="Process Anyway!", + button_1_callback=cancel, + button_2_callback=process + ) + + # Return based on the user's choice + if popup_result == "button_1": + return False + elif popup_result == "button_2": + return True def send_text_to_chatgpt(edited_text): if app_settings.editable_settings["Use Local LLM"]: @@ -995,15 +1018,6 @@ def send_text_to_chatgpt(edited_text): def generate_note(formatted_message): try: - # If note generation is on - prescreen = screen_input(formatted_message) - if prescreen is "True": - print("prescreen is true") - return - else: - print("prescreen is false") - return - return if use_aiscribe: # If pre-processing is enabled if app_settings.editable_settings["Use Pre-Processing"]: @@ -1082,6 +1096,8 @@ def generate_note_thread(text: str): """ global GENERATION_THREAD_ID + screen_input(text) + thread = threading.Thread(target=generate_note, args=(text,)) thread.start() From ae3aca44d24c4e13e4e4e829153f93fbed17b04c Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 17:43:49 -0500 Subject: [PATCH 199/244] avoid potentially showing duplicate loading windows --- src/FreeScribe.client/client.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index b516dc0b..402e0024 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -187,20 +187,16 @@ def get_prompt(formatted_message): def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") task_done_var = tk.BooleanVar(value=False) - model_name = app_settings.editable_settings["Whisper Model"].strip() - stt_loading_window = LoadingWindow(root, "Loading STT model", - f"Loading {model_name} model. Please wait.", - on_cancel=lambda: task_done_var.set(True)) stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) stt_thread.start() root.wait_variable(task_done_var) - stt_loading_window.destroy() thread = threading.Thread(target=toggle_recording) thread.start() def double_check_stt_model_loading(task_done_var): + stt_loading_window = None try: if is_recording: return @@ -210,7 +206,10 @@ def double_check_stt_model_loading(task_done_var): return # if using local whisper and model is not loaded, when starting recording if stt_model_loading_thread_lock.locked(): - + model_name = app_settings.editable_settings["Whisper Model"].strip() + stt_loading_window = LoadingWindow(root, "Loading STT model", + f"Loading {model_name} model. Please wait.", + on_cancel=lambda: task_done_var.set(True)) timeout = 300 time_start = time.monotonic() # wait until the other loading thread is done @@ -226,7 +225,8 @@ def double_check_stt_model_loading(task_done_var): return if not stt_model_loading_thread_lock.locked(): break - + stt_loading_window.destroy() + stt_loading_window = None # double check if stt_local_model is None: # mandatory loading, synchronous @@ -238,6 +238,8 @@ def double_check_stt_model_loading(task_done_var): messagebox.showerror("Error", f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") finally: + if stt_loading_window: + stt_loading_window.destroy() task_done_var.set(True) From 3a79867f8a4867cfc7860a77e3eacbbe36ec8a85 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 18:36:39 -0500 Subject: [PATCH 200/244] add .idea and __version__ to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 510e476e..bcb6b407 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.idea/ +__version__ + aiscribe.txt aiscribe2.txt settings.txt From 8a2b6fc36a29a786de4041204444c76c7e2e7f2e Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 21:57:58 -0500 Subject: [PATCH 201/244] do not start recording if canceled waiting --- src/FreeScribe.client/client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 402e0024..78082ddb 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -187,15 +187,19 @@ def get_prompt(formatted_message): def threaded_toggle_recording(): logging.debug(f"*** Toggle Recording - Recording status: {is_recording}, STT local model: {stt_local_model}") task_done_var = tk.BooleanVar(value=False) - stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var,)) + task_cancel_var = tk.BooleanVar(value=False) + stt_thread = threading.Thread(target=double_check_stt_model_loading, args=(task_done_var, task_cancel_var)) stt_thread.start() root.wait_variable(task_done_var) + if task_cancel_var.get(): + logging.debug(f"double checking canceled") + return thread = threading.Thread(target=toggle_recording) thread.start() -def double_check_stt_model_loading(task_done_var): +def double_check_stt_model_loading(task_done_var, task_cancel_var): stt_loading_window = None try: if is_recording: @@ -209,13 +213,13 @@ def double_check_stt_model_loading(task_done_var): model_name = app_settings.editable_settings["Whisper Model"].strip() stt_loading_window = LoadingWindow(root, "Loading STT model", f"Loading {model_name} model. Please wait.", - on_cancel=lambda: task_done_var.set(True)) + on_cancel=lambda: task_cancel_var.set(True)) timeout = 300 time_start = time.monotonic() # wait until the other loading thread is done while True: time.sleep(0.1) - if task_done_var.get(): + if task_cancel_var.get(): # user cancel logging.debug(f"user canceled after {time.monotonic() - time_start} seconds") return From 1f1c681d4dddbe937736578b29bcad50091d25ea Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Wed, 29 Jan 2025 22:02:48 -0500 Subject: [PATCH 202/244] timeout be like cancel --- src/FreeScribe.client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 78082ddb..8e41be54 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -226,6 +226,7 @@ def double_check_stt_model_loading(task_done_var, task_cancel_var): if time.monotonic() - time_start > timeout: messagebox.showerror("Error", f"Timed out while loading local STT model after {timeout} seconds.") + task_cancel_var.set(True) return if not stt_model_loading_thread_lock.locked(): break From de9dabc8a56bf9019a6d873abb32bd1568f70827 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 12:28:05 -0500 Subject: [PATCH 203/244] Added some more error logging to debug window on exceptions --- .../UI/Widgets/MicrophoneTestFrame.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 7f02555a..8ad5267d 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -59,7 +59,8 @@ def initialize_microphones(self): try: default_input_info = self.p.get_default_input_device_info() self.default_input_index = default_input_info['index'] - except IOError: + except IOError as e: + print(f"Failed to initialize microphone ({type(e).__name__}): {e}") self.default_input_index = None device_count = self.p.get_device_count() @@ -215,7 +216,7 @@ def update_selected_microphone(self, selected_index): self.is_stream_active = True except Exception as e: self.status_label.config(text="Error: Microphone not found", foreground="red") - # messagebox.showerror("Microphone Error", f"Failed to open microphone: {e}") + print(f"Failed to open microphone ({type(e).__name__}): {e}") else: MicrophoneState.SELECTED_MICROPHONE_INDEX = None MicrophoneState.SELECTED_MICROPHONE_NAME = None @@ -252,7 +253,7 @@ def reopen_stream(self): self.status_label.config(text="Microphone: Connected", foreground="green") except Exception as e: self.status_label.config(text="Error: Microphone not found", foreground="red") - # messagebox.showerror("Microphone Error", f"Failed to open microphone: {e}") + print(f"Failed to open microphone ({type(e).__name__}): {e}") def update_volume_meter(self): """ @@ -289,12 +290,13 @@ def update_volume_meter(self): except OSError as e: if e.errno in [-9988, -9999]: # Handle both Stream closed and Unanticipated host error self.status_label.config(text="Error: Microphone disconnected", foreground="red") - print(f"Error in update_volume_meter: {e}") + print(f"Error in update_volume_meter({type(e).__name__}): {e}") self.is_stream_active = False self.stream = None for segment in self.segments: segment.configure(style='Inactive.TFrame') else: + print(f"Error in update_volume_meter({type(e).__name__}): {e}") raise # Re-raise the exception if it's not the expected error self.frame.after(100, self.update_volume_meter) From 9a57a88c0411f35ae0e79205bfd704401196b4c2 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 12:36:21 -0500 Subject: [PATCH 204/244] Did some element aligning --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 8ad5267d..5e88aa46 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -128,7 +128,7 @@ def create_mic_test_ui(self): # Volume meter container meter_frame = ttk.Frame(self.frame) - meter_frame.grid(row=1, column=0, sticky='nsew', pady=(0, 5)) + meter_frame.grid(row=1, column=0, sticky='nsew', pady=(0, 0)) # Try to load mic icon try: @@ -136,13 +136,13 @@ def create_mic_test_ui(self): mic_icon = mic_icon.resize((24, 24)) self.mic_photo = ImageTk.PhotoImage(mic_icon) mic_icon_label = ttk.Label(meter_frame, image=self.mic_photo) - mic_icon_label.grid(row=0, column=0, padx=(0, 10), sticky='nsew') + mic_icon_label.grid(row=0, column=0, padx=(5, 0), sticky='nsew') except Exception as e: print(f"Error loading microphone icon: {e}") # Create volume meter segments self.segments_frame = ttk.Frame(meter_frame) - self.segments_frame.grid(row=0, column=1, sticky='nsew') + self.segments_frame.grid(row=0, column=1, sticky='nsew', pady=(4, 0)) # Create segments self.SEGMENT_COUNT = 20 @@ -155,7 +155,7 @@ def create_mic_test_ui(self): # Status label for feedback self.status_label = ttk.Label(self.frame, text="Microphone: Ready", foreground="green") - self.status_label.grid(row=2, column=0, pady=(5, 0), padx=(40, 0), sticky='nsew') + self.status_label.grid(row=2, column=0, pady=(0, 0), padx=(10, 0), sticky='nsew') def initialize_selected_microphone(self): """ From 27a95ba617e96c7b26c045753e27f73791ac4372 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 12:42:34 -0500 Subject: [PATCH 205/244] Fixed spacing on full screen --- src/FreeScribe.client/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index af944b2b..a713a40c 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1552,7 +1552,7 @@ def set_cuda_paths(): # Create a frame to hold both timestamp listbox and mic test history_frame = ttk.Frame(root) -history_frame.grid(row=0, column=9, columnspan=2, rowspan=3, padx=5, pady=15, sticky='nsew') +history_frame.grid(row=0, column=9, columnspan=2, rowspan=5, padx=5, pady=10, sticky='nsew') # Configure the frame's grid history_frame.grid_columnconfigure(0, weight=1) @@ -1561,7 +1561,7 @@ def set_cuda_paths(): # Add the timestamp listbox timestamp_listbox = tk.Listbox(history_frame, height=30) -timestamp_listbox.grid(row=0, column=0, sticky='nsew') +timestamp_listbox.grid(row=0, column=0, rowspan=3,sticky='nsew') timestamp_listbox.bind('<>', show_response) timestamp_listbox.insert(tk.END, "Temporary Note History") timestamp_listbox.config(fg='grey') @@ -1569,7 +1569,7 @@ def set_cuda_paths(): # Add microphone test frame mic_test = MicrophoneTestFrame(parent=history_frame, p=p, app_settings=app_settings, root=root) -mic_test.frame.grid(row=1, column=0, pady=15, sticky='nsew') # Use grid to place the frame +mic_test.frame.grid(row=4, column=0, pady=10, sticky='nsew') # Use grid to place the frame window.update_aiscribe_texts(None) # Bind Alt+P to send_and_receive function From 7c8c12fabc7fe0358ed293c6526cc7c3ea676244 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 12:47:48 -0500 Subject: [PATCH 206/244] Update the frequency of meter update for more responsivness --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 5e88aa46..28db30e9 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -260,7 +260,7 @@ def update_volume_meter(self): Update the volume meter based on the current microphone input. """ if not self.is_stream_active: - self.frame.after(100, self.update_volume_meter) + self.frame.after(50, self.update_volume_meter) return try: @@ -299,7 +299,7 @@ def update_volume_meter(self): print(f"Error in update_volume_meter({type(e).__name__}): {e}") raise # Re-raise the exception if it's not the expected error - self.frame.after(100, self.update_volume_meter) + self.frame.after(50, self.update_volume_meter) def get_selected_microphone_index(): """ From e1a73ce5fdcf43dbea985fbf78295db402683ecf Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 13:02:40 -0500 Subject: [PATCH 207/244] Added a error handling for unknown errors in update_volume_meter. Let user know to check debug. --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 28db30e9..0876795f 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -297,7 +297,11 @@ def update_volume_meter(self): segment.configure(style='Inactive.TFrame') else: print(f"Error in update_volume_meter({type(e).__name__}): {e}") - raise # Re-raise the exception if it's not the expected error + self.status_label.config(text="Error: Unknown Error. Check debug log for more.", foreground="red") + self.is_stream_active = False + self.stream = None + for segment in self.segments: + segment.configure(style='Inactive.TFrame') self.frame.after(50, self.update_volume_meter) From eeecc3473bda7684df8d595440d996cd1be7e454 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 13:13:23 -0500 Subject: [PATCH 208/244] Fixed a comment --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 0876795f..0ef29d2c 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -288,7 +288,8 @@ def update_volume_meter(self): segment.configure(style='Inactive.TFrame') except OSError as e: - if e.errno in [-9988, -9999]: # Handle both Stream closed and Unanticipated host error + # Handle both Stream closed and Unanticipated host error + if e.errno in [-9988, -9999]: self.status_label.config(text="Error: Microphone disconnected", foreground="red") print(f"Error in update_volume_meter({type(e).__name__}): {e}") self.is_stream_active = False @@ -296,6 +297,7 @@ def update_volume_meter(self): for segment in self.segments: segment.configure(style='Inactive.TFrame') else: + # Handle any other stream errors print(f"Error in update_volume_meter({type(e).__name__}): {e}") self.status_label.config(text="Error: Unknown Error. Check debug log for more.", foreground="red") self.is_stream_active = False From 6f75f596cde5ea8f910cae09aadf3be1c16963df Mon Sep 17 00:00:00 2001 From: ItsSimko Date: Thu, 30 Jan 2025 13:19:25 -0500 Subject: [PATCH 209/244] Update src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 0ef29d2c..94ab96c8 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -69,10 +69,10 @@ def initialize_microphones(self): if device_info['maxInputChannels'] > 0: device_name = device_info['name'] excluded_names = ["Virtual", "Output", "Wave Out", "What U Hear", "Aux", "Port", "Mix"] - if not any(excluded_name.lower() in device_name.lower() for excluded_name in excluded_names): - if device_name not in [name for _, name in self.mic_list]: - self.mic_list.append((i, device_name)) - self.mic_mapping[device_name] = i + if not any(excluded_name.lower() in device_name.lower() for excluded_name in excluded_names) and device_name not in [name for _, name in self.mic_list]: + self.mic_list.append((i, device_name)) + self.mic_mapping[device_name] = i + # Load the selected microphone from settings if available if self.app_settings and "Current Mic" in self.app_settings.editable_settings: From 7b48da53d1bd9914b19a680c6e332d07ccd9e7c6 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 13:24:05 -0500 Subject: [PATCH 210/244] Fixed repeat code --- .../UI/Widgets/MicrophoneTestFrame.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 0ef29d2c..56e32bbf 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -291,19 +291,15 @@ def update_volume_meter(self): # Handle both Stream closed and Unanticipated host error if e.errno in [-9988, -9999]: self.status_label.config(text="Error: Microphone disconnected", foreground="red") - print(f"Error in update_volume_meter({type(e).__name__}): {e}") - self.is_stream_active = False - self.stream = None - for segment in self.segments: - segment.configure(style='Inactive.TFrame') else: # Handle any other stream errors - print(f"Error in update_volume_meter({type(e).__name__}): {e}") self.status_label.config(text="Error: Unknown Error. Check debug log for more.", foreground="red") - self.is_stream_active = False - self.stream = None - for segment in self.segments: - segment.configure(style='Inactive.TFrame') + + print(f"Error in update_volume_meter({type(e).__name__}): {e}") + self.is_stream_active = False + self.stream = None + for segment in self.segments: + segment.configure(style='Inactive.TFrame') self.frame.after(50, self.update_volume_meter) From 1db32961e1f1c07ba05f1af148c27a321d9ca506 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 13:58:04 -0500 Subject: [PATCH 211/244] fix format --- src/FreeScribe.client/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 8e41be54..eb0bc72e 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1448,7 +1448,8 @@ def _load_stt_model_thread(): model, device=device_type, cpu_threads=int(app_settings.editable_settings[SettingsKeys.WHISPER_CPU_COUNT.value]), - compute_type=compute_type) + compute_type=compute_type + ) print("STT model loaded successfully.") except Exception as e: From a8af692d578e640166169835853ef3a7fbd3b8d1 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 14:34:12 -0500 Subject: [PATCH 212/244] yesnocancel dialog for force stop --- scripts/install.nsi | 47 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index eb3999fb..0cd2e623 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -30,6 +30,32 @@ Var /GLOBAL SELECTED_OPTION Var /GLOBAL REMOVE_CONFIG_CHECKBOX Var /GLOBAL REMOVE_CONFIG +Function KillFreeScribeProcess + nsExec::ExecToStack 'taskkill /F /IM freescribe-client.exe' + Pop $0 ; Return value + + ${If} $0 == 0 + MessageBox MB_OK "FreeScribe process has been terminated." + Return + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION "Failed to terminate FreeScribe process. Please close it manually." + Return + ${EndIf} +FunctionEnd + +Function un.KillFreeScribeProcess + nsExec::ExecToStack 'taskkill /F /IM freescribe-client.exe' + Pop $0 ; Return value + + ${If} $0 == 0 + MessageBox MB_OK "FreeScribe process has been terminated." + Return + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION "Failed to terminate FreeScribe process. Please close it manually." + Return + ${EndIf} +FunctionEnd + Function Check_For_Old_Version_In_App_Data ; Check if the old version exists in AppData IfFileExists "$APPDATA\FreeScribe\freescribe-client.exe" 0 OldVersionDoesNotExist @@ -134,9 +160,13 @@ Function un.onInit ; Check if the process is running ${If} $0 == 0 - MessageBox MB_RETRYCANCEL "FreeScribe is currently running. Please close the application and try again." IDRETRY CheckIfFreeScribeIsRunning IDCANCEL abort - abort: - Abort + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDNO retry + Abort + kill_process: + Call un.KillFreeScribeProcess + Goto CheckIfFreeScribeIsRunning + retry: + Goto CheckIfFreeScribeIsRunning ${EndIf} FunctionEnd ; Checks on installer start @@ -147,9 +177,14 @@ Function .onInit ; Check if the process is running ${If} $0 == 0 - MessageBox MB_RETRYCANCEL "FreeScribe is currently running. Please close the application and try again." IDRETRY CheckIfFreeScribeIsRunning IDCANCEL abort - abort: - Abort + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDNO retry + Abort + kill_process: + Call KillFreeScribeProcess + Goto CheckIfFreeScribeIsRunning + retry: + Goto CheckIfFreeScribeIsRunning + ${EndIf} IfSilent SILENT_MODE NOT_SILENT_MODE From 852b0db819ef99909a71db87f57ef283a953d3b7 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 14:38:11 -0500 Subject: [PATCH 213/244] remove unreachable branch --- scripts/install.nsi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 0cd2e623..04bd6414 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -161,7 +161,7 @@ Function un.onInit ; Check if the process is running ${If} $0 == 0 MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDNO retry - Abort + kill_process: Call un.KillFreeScribeProcess Goto CheckIfFreeScribeIsRunning @@ -178,7 +178,7 @@ Function .onInit ; Check if the process is running ${If} $0 == 0 MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDNO retry - Abort + kill_process: Call KillFreeScribeProcess Goto CheckIfFreeScribeIsRunning From fb2f14da380200c8b11f6fbd07c14991b0a3ede1 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 14:40:08 -0500 Subject: [PATCH 214/244] remove code duplication --- scripts/install.nsi | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 04bd6414..7bf064fa 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -30,7 +30,7 @@ Var /GLOBAL SELECTED_OPTION Var /GLOBAL REMOVE_CONFIG_CHECKBOX Var /GLOBAL REMOVE_CONFIG -Function KillFreeScribeProcess +!macro KillFreeScribeProcessMacro nsExec::ExecToStack 'taskkill /F /IM freescribe-client.exe' Pop $0 ; Return value @@ -41,20 +41,13 @@ Function KillFreeScribeProcess MessageBox MB_OK|MB_ICONEXCLAMATION "Failed to terminate FreeScribe process. Please close it manually." Return ${EndIf} -FunctionEnd +!macroend -Function un.KillFreeScribeProcess - nsExec::ExecToStack 'taskkill /F /IM freescribe-client.exe' - Pop $0 ; Return value +Function KillFreeScribeProcess + !insertmacro KillFreeScribeProcessMacro - ${If} $0 == 0 - MessageBox MB_OK "FreeScribe process has been terminated." - Return - ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "Failed to terminate FreeScribe process. Please close it manually." - Return - ${EndIf} -FunctionEnd +Function un.KillFreeScribeProcess + !insertmacro KillFreeScribeProcessMacro Function Check_For_Old_Version_In_App_Data ; Check if the old version exists in AppData From 450f9aca5f6963510a9827cda9ab460ea23e4b3a Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 14:47:25 -0500 Subject: [PATCH 215/244] Simplify the flow control --- scripts/install.nsi | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 7bf064fa..b94cf2c5 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -153,13 +153,13 @@ Function un.onInit ; Check if the process is running ${If} $0 == 0 - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDNO retry + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process + Goto CheckIfFreeScribeIsRunning kill_process: Call un.KillFreeScribeProcess Goto CheckIfFreeScribeIsRunning - retry: - Goto CheckIfFreeScribeIsRunning + ${EndIf} FunctionEnd ; Checks on installer start @@ -170,13 +170,11 @@ Function .onInit ; Check if the process is running ${If} $0 == 0 - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDNO retry - + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process + Goto CheckIfFreeScribeIsRunning kill_process: Call KillFreeScribeProcess Goto CheckIfFreeScribeIsRunning - retry: - Goto CheckIfFreeScribeIsRunning ${EndIf} From 1fe478f5de90d4154a68202aec35486cba720a39 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 14:54:37 -0500 Subject: [PATCH 216/244] fix message for user: STT -> Voice to Text --- src/FreeScribe.client/client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index eb0bc72e..bd523d66 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -211,7 +211,7 @@ def double_check_stt_model_loading(task_done_var, task_cancel_var): # if using local whisper and model is not loaded, when starting recording if stt_model_loading_thread_lock.locked(): model_name = app_settings.editable_settings["Whisper Model"].strip() - stt_loading_window = LoadingWindow(root, "Loading STT model", + stt_loading_window = LoadingWindow(root, "Loading Voice to Text model", f"Loading {model_name} model. Please wait.", on_cancel=lambda: task_cancel_var.set(True)) timeout = 300 @@ -225,7 +225,7 @@ def double_check_stt_model_loading(task_done_var, task_cancel_var): return if time.monotonic() - time_start > timeout: messagebox.showerror("Error", - f"Timed out while loading local STT model after {timeout} seconds.") + f"Timed out while loading local Voice to Text model after {timeout} seconds.") task_cancel_var.set(True) return if not stt_model_loading_thread_lock.locked(): @@ -241,7 +241,7 @@ def double_check_stt_model_loading(task_done_var, task_cancel_var): except Exception as e: logging.exception(str(e)) messagebox.showerror("Error", - f"An error occurred while loading STT synchronously {type(e).__name__}: {e}") + f"An error occurred while loading Voice to Text model synchronously {type(e).__name__}: {e}") finally: if stt_loading_window: stt_loading_window.destroy() @@ -1431,7 +1431,7 @@ def _load_stt_model_thread(): with stt_model_loading_thread_lock: global stt_local_model model = app_settings.editable_settings["Whisper Model"].strip() - stt_loading_window = LoadingWindow(root, "Speech to Text", f"Loading Speech to Text {model} model. Please wait.") + stt_loading_window = LoadingWindow(root, "Voice to Text", f"Loading Voice to Text {model} model. Please wait.") print(f"Loading STT model: {model}") try: unload_stt_model() @@ -1455,7 +1455,7 @@ def _load_stt_model_thread(): except Exception as e: print(f"An error occurred while loading STT {type(e).__name__}: {e}") stt_local_model = None - messagebox.showerror("Error", f"An error occurred while loading STT {type(e).__name__}: {e}") + messagebox.showerror("Error", f"An error occurred while loading Voice to Text {type(e).__name__}: {e}") finally: stt_loading_window.destroy() print("Closing STT loading window.") From 44fdfb54b473925209d80c007eb23b0e1bcef393 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 15:03:18 -0500 Subject: [PATCH 217/244] fix functionality after refactor --- scripts/install.nsi | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index b94cf2c5..95cf3949 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -45,9 +45,11 @@ Var /GLOBAL REMOVE_CONFIG Function KillFreeScribeProcess !insertmacro KillFreeScribeProcessMacro +FunctionEnd Function un.KillFreeScribeProcess !insertmacro KillFreeScribeProcessMacro +FunctionEnd Function Check_For_Old_Version_In_App_Data ; Check if the old version exists in AppData @@ -153,12 +155,14 @@ Function un.onInit ; Check if the process is running ${If} $0 == 0 - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDCANCEL cancel Goto CheckIfFreeScribeIsRunning kill_process: Call un.KillFreeScribeProcess Goto CheckIfFreeScribeIsRunning + cancel: + Abort ${EndIf} FunctionEnd @@ -170,11 +174,13 @@ Function .onInit ; Check if the process is running ${If} $0 == 0 - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDCANCEL cancel Goto CheckIfFreeScribeIsRunning kill_process: Call KillFreeScribeProcess Goto CheckIfFreeScribeIsRunning + cancel: + Abort ${EndIf} From 50df712094ae0aa38ee88ec387a6a2131e8dbbf3 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 15:08:44 -0500 Subject: [PATCH 218/244] format consistency --- scripts/install.nsi | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/install.nsi b/scripts/install.nsi index 95cf3949..7172842d 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -175,6 +175,7 @@ Function .onInit ; Check if the process is running ${If} $0 == 0 MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDCANCEL cancel + Goto CheckIfFreeScribeIsRunning kill_process: Call KillFreeScribeProcess From dc181a5f90a773bf28902649aac292c44bb25ed4 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 16:14:18 -0500 Subject: [PATCH 219/244] Comments --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 100 ++++++++++--------- 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py index 5cd11b81..abee1bec 100644 --- a/src/FreeScribe.client/UI/Widgets/PopupBox.py +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -2,74 +2,76 @@ from tkinter import Toplevel class PopupBox: + """ + A class to create a popup dialog box with a customizable message and two buttons. + + :param parent: The parent window for the popup dialog. + :param title: The title of the popup window (default: "Message"). + :param message: The message displayed in the popup (default: "Message text"). + :param button_text_1: The text for the first button (default: "OK"). + :param button_text_2: The text for the second button (default: "Cancel"). + :param button_1_callback: Callback function for the first button (default: None). + :param button_2_callback: Callback function for the second button (default: None). + """ + def __init__(self, - parent, - title="Message", - message="Message text", - button_text_1="OK", - button_text_2="Cancel", - button_1_callback=None, - button_2_callback=None): - - self.response = None - self.dialog = Toplevel(parent) - self.dialog.title(title) - self.dialog.geometry("300x150") - self.dialog.resizable(False, False) - self.button_1_callback = button_1_callback - self.button_2_callback = button_2_callback + parent, + title="Message", + message="Message text", + button_text_1="OK", + button_text_2="Cancel", + button_1_callback=None, + button_2_callback=None): + """ + Initialize the PopupBox instance and create the dialog window. + + :param parent: The parent widget for the dialog. + :param title: The title of the dialog window. + :param message: The message to be displayed in the dialog. + :param button_text_1: The text label for the first button. + :param button_text_2: The text label for the second button. + :param button_1_callback: Optional callback function for the first button. + :param button_2_callback: Optional callback function for the second button. + """ + self.response = None # Stores the response indicating which button was clicked + self.dialog = Toplevel(parent) # Create a top-level window for the popup + self.dialog.title(title) # Set the window title + self.dialog.geometry("300x150") # Set the size of the window + self.dialog.resizable(False, False) # Disable window resizing - # Message label + # Create and pack the message label label = tk.Label(self.dialog, text=message, wraplength=250) label.pack(pady=20) - # Button frame + # Create and pack a frame to hold the buttons button_frame = tk.Frame(self.dialog) button_frame.pack(pady=10) - # Buttons + # Create and pack the first button button_1 = tk.Button(button_frame, text=button_text_1, command=self.on_button_1) button_1.pack(side=tk.LEFT, padx=10) + # Create and pack the second button button_2 = tk.Button(button_frame, text=button_text_2, command=self.on_button_2) button_2.pack(side=tk.RIGHT, padx=10) - # Modal behavior - self.dialog.transient(parent) - self.dialog.grab_set() - parent.wait_window(self.dialog) + # Configure the dialog as a modal window + self.dialog.transient(parent) # Make the dialog appear on top of the parent window + self.dialog.grab_set() # Prevent interaction with other windows until this dialog is closed + parent.wait_window(self.dialog) # Wait until the dialog window is closed def on_button_1(self): + """ + Handle the event when the first button is clicked. + Sets the response to 'button_1' and closes the dialog. + """ self.response = "button_1" - self.button_1_callback() self.dialog.destroy() def on_button_2(self): + """ + Handle the event when the second button is clicked. + Sets the response to 'button_2' and closes the dialog. + """ self.response = "button_2" - self.button_2_callback() self.dialog.destroy() - - -# Example usage -def main(): - root = tk.Tk() - root.geometry("400x300") - - def show_custom_message_box(): - message_box = CustomMessageBox( - root, - title="Invalid Input", - message="The input does not meet the requirements. Do you want to continue?", - button_text_1="Continue", - button_text_2="Cancel" - ) - print(f"User response: {message_box.response}") - - # Button to show the custom message box - show_message_button = tk.Button(root, text="Show Message Box", command=show_custom_message_box) - show_message_button.pack(pady=20) - - root.mainloop() - -if __name__ == "__main__": - main() From a371b84eb05b8d53c9a21abb4baf1064e72dd78f Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 16:14:44 -0500 Subject: [PATCH 220/244] Updated function calls, and setting toggle for this feature --- src/FreeScribe.client/UI/SettingsWindow.py | 3 +++ src/FreeScribe.client/client.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index d83507cc..d64a079d 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -45,6 +45,7 @@ class SettingsKeys(Enum): USE_TRANSLATE_TASK = "Use Translate Task" WHISPER_LANGUAGE_CODE = "Whisper Language Code" S2T_SELF_SIGNED_CERT = "S2T Server Self-Signed Certificates" + USE_PRESCREEN_AI_INPUT = "Use Pre-Screen AI Input" class Architectures(Enum): @@ -179,6 +180,7 @@ def __init__(self): SettingsKeys.SILERO_SPEECH_THRESHOLD.value, SettingsKeys.USE_TRANSLATE_TASK.value, SettingsKeys.WHISPER_LANGUAGE_CODE.value, + SettingsKeys.USE_PRESCREEN_AI_INPUT.value, ] @@ -248,6 +250,7 @@ def __init__(self): SettingsKeys.SILERO_SPEECH_THRESHOLD.value: 0.5, SettingsKeys.USE_TRANSLATE_TASK.value: False, SettingsKeys.WHISPER_LANGUAGE_CODE.value: "None (Auto Detect)", + SettingsKeys.USE_PRESCREEN_AI_INPUT.value: True, } self.docker_settings = [ diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 29e812b1..3a421495 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -999,9 +999,7 @@ def screen_input(conversation): title="Invalid Input", message="Input has been flagged as invalid. Please ensure the input is a conversation with more than 50 words between a doctor and a patient. Unexpected results may occur from the AI.", button_text_1="Cancel", - button_text_2="Process Anyway!", - button_1_callback=cancel, - button_2_callback=process + button_text_2="Process Anyway!" ) # Return based on the user's choice @@ -1096,7 +1094,9 @@ def generate_note_thread(text: str): """ global GENERATION_THREAD_ID - screen_input(text) + # Check if we should do the prescreen prompt if enabled in settings + if app_settings.editable_settings[SettingsKeys.USE_PRESCREEN_AI_INPUT.value]: + screen_input(text) thread = threading.Thread(target=generate_note, args=(text,)) thread.start() From 47e06eb35906d93111a9712d1aad2767077bf9c7 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 16:27:35 -0500 Subject: [PATCH 221/244] Made more modular --- src/FreeScribe.client/client.py | 47 ++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 3a421495..52b4d144 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -983,7 +983,7 @@ def send_text_to_localmodel(edited_text): ) -def screen_input(conversation): +def screen_input_with_llm(conversation): prompt = "Go over this conversation and ensure it's a conversation with more than 50 words. Also, if it is a conversation between a doctor and a patient. Please return one word. Either True or False based. Do not give an explanation and do not format the text. Here is the conversation:\n" # If note generation is on @@ -991,22 +991,33 @@ def screen_input(conversation): is_valid_input = prescreen.strip().lower() == "true" print("Generating Input. AI Prescreen: ", prescreen) - - if not is_valid_input: - # Simulate the popup logic with return values - popup_result = PopupBox( - parent=root, - title="Invalid Input", - message="Input has been flagged as invalid. Please ensure the input is a conversation with more than 50 words between a doctor and a patient. Unexpected results may occur from the AI.", - button_text_1="Cancel", - button_text_2="Process Anyway!" - ) + return is_valid_input + +def display_screening_popup(): + # Simulate the popup logic with return values + popup_result = PopupBox( + parent=root, + title="Invalid Input", + message="Input has been flagged as invalid. Please ensure the input is a conversation with more than 50 words between a doctor and a patient. Unexpected results may occur from the AI.", + button_text_1="Cancel", + button_text_2="Process Anyway!" + ) - # Return based on the user's choice - if popup_result == "button_1": - return False - elif popup_result == "button_2": + # Return based on the user's choice + if popup_result.response == "button_1": + return False + elif popup_result.response == "button_2": + return True + +def screen_input(user_message): + if app_settings.editable_settings[SettingsKeys.USE_PRESCREEN_AI_INPUT.value]: + screen_result = screen_input_with_llm(user_message) + if not screen_result: + return display_screening_popup() + else: return True + else: + return True def send_text_to_chatgpt(edited_text): if app_settings.editable_settings["Use Local LLM"]: @@ -1094,9 +1105,9 @@ def generate_note_thread(text: str): """ global GENERATION_THREAD_ID - # Check if we should do the prescreen prompt if enabled in settings - if app_settings.editable_settings[SettingsKeys.USE_PRESCREEN_AI_INPUT.value]: - screen_input(text) + # screen input + if screen_input(text) is False: + return thread = threading.Thread(target=generate_note, args=(text,)) thread.start() From 7e22b7a97a9c799d190b8cbef8137a59fb50396c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 16:27:46 -0500 Subject: [PATCH 222/244] commenting --- src/FreeScribe.client/client.py | 48 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 52b4d144..835ad075 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -984,39 +984,77 @@ def send_text_to_localmodel(edited_text): def screen_input_with_llm(conversation): - prompt = "Go over this conversation and ensure it's a conversation with more than 50 words. Also, if it is a conversation between a doctor and a patient. Please return one word. Either True or False based. Do not give an explanation and do not format the text. Here is the conversation:\n" + """ + Send a conversation to a large language model (LLM) for prescreening. + + :param conversation: A string containing the conversation to be screened. + :return: A boolean indicating whether the conversation is valid. + """ + prompt = ( + "Go over this conversation and ensure it's a conversation with more than 50 words. " + "Also, if it is a conversation between a doctor and a patient. Please return one word. " + "Either True or False based. Do not give an explanation and do not format the text. " + "Here is the conversation:\n" + ) - # If note generation is on + # Send the prompt and conversation to the LLM for evaluation prescreen = send_text_to_chatgpt(f"{prompt}{conversation}") + + # Check if the response from the LLM is 'true' (case-insensitive) is_valid_input = prescreen.strip().lower() == "true" + # Log the AI's response for debugging purposes print("Generating Input. AI Prescreen: ", prescreen) + return is_valid_input + def display_screening_popup(): - # Simulate the popup logic with return values + """ + Display a popup window to inform the user of invalid input and offer options. + + :return: A boolean indicating the user's choice: + - False if the user clicks 'Cancel'. + - True if the user clicks 'Process Anyway!'. + """ + # Create and display the popup window popup_result = PopupBox( parent=root, title="Invalid Input", - message="Input has been flagged as invalid. Please ensure the input is a conversation with more than 50 words between a doctor and a patient. Unexpected results may occur from the AI.", + message=( + "Input has been flagged as invalid. Please ensure the input is a conversation with more than " + "50 words between a doctor and a patient. Unexpected results may occur from the AI." + ), button_text_1="Cancel", button_text_2="Process Anyway!" ) - # Return based on the user's choice + # Return based on the button the user clicks if popup_result.response == "button_1": return False elif popup_result.response == "button_2": return True + def screen_input(user_message): + """ + Screen the user's input message based on the application's settings. + + :param user_message: The message to be screened. + :return: A boolean indicating whether the input is valid and accepted for further processing. + """ + # Check if AI prescreening is enabled in the application settings if app_settings.editable_settings[SettingsKeys.USE_PRESCREEN_AI_INPUT.value]: + # Perform AI-based prescreening screen_result = screen_input_with_llm(user_message) + + # If the input fails prescreening, display a popup for the user if not screen_result: return display_screening_popup() else: return True else: + # If prescreening is disabled, always return True return True def send_text_to_chatgpt(edited_text): From af93ddf31344d113727cf0c5067c40f1ff7741f1 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Thu, 30 Jan 2025 16:28:26 -0500 Subject: [PATCH 223/244] Code clean up --- src/FreeScribe.client/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 835ad075..0bc17322 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1053,9 +1053,9 @@ def screen_input(user_message): return display_screening_popup() else: return True - else: - # If prescreening is disabled, always return True - return True + + #else return true always + return True def send_text_to_chatgpt(edited_text): if app_settings.editable_settings["Use Local LLM"]: From e70ed8b6eb35ea16de1310eaac7d59ab31afeda3 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 19:07:15 -0500 Subject: [PATCH 224/244] test see if a new page works --- scripts/install.nsi | 83 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 7172842d..90cc177d 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -167,29 +167,85 @@ Function un.onInit ${EndIf} FunctionEnd ; Checks on installer start -Function .onInit - CheckIfFreeScribeIsRunning: +Var RunningInstanceDialog +Var ForceStopButton +Var RetryButton +Var CancelButton +Var StatusLabel + +Function CreateRunningInstancePage + !insertmacro MUI_HEADER_TEXT "Running Instance Detected" "FreeScribe is currently running. Please choose an action:" + + nsDialogs::Create 1018 + Pop $RunningInstanceDialog + + ${If} $RunningInstanceDialog == error + Abort + ${EndIf} + + ; Create status label + ${NSD_CreateLabel} 0 10u 100% 24u "FreeScribe is currently running.$\nPlease choose how to proceed:" + Pop $StatusLabel + + ; Create Force Stop button + ${NSD_CreateButton} 10% 50u 30% 12u "Force Stop" + Pop $ForceStopButton + ${NSD_OnClick} $ForceStopButton OnForceStopClick + + ; Create Retry button + ${NSD_CreateButton} 45% 50u 30% 12u "Retry" + Pop $RetryButton + ${NSD_OnClick} $RetryButton OnRetryClick + + ; Create Cancel button + ${NSD_CreateButton} 80% 50u 15% 12u "Cancel" + Pop $CancelButton + ${NSD_OnClick} $CancelButton OnCancelClick + + nsDialogs::Show +FunctionEnd + +Function OnForceStopClick + Call KillFreeScribeProcess nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' - Pop $0 ; Return value + Pop $0 + + ${If} $0 == 0 + ${NSD_SetText} $StatusLabel "Unable to terminate FreeScribe.$\nPlease close it manually and click Retry." + ${Else} + Abort ; Close the dialog and continue installation + ${EndIf} +FunctionEnd - ; Check if the process is running +Function OnRetryClick + nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' + Pop $0 + ${If} $0 == 0 - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDCANCEL cancel + ${NSD_SetText} $StatusLabel "FreeScribe is still running.$\nPlease choose an action." + ${Else} + Abort ; Close the dialog and continue installation + ${EndIf} +FunctionEnd - Goto CheckIfFreeScribeIsRunning - kill_process: - Call KillFreeScribeProcess - Goto CheckIfFreeScribeIsRunning - cancel: - Abort +Function OnCancelClick + Quit +FunctionEnd +Function .onInit + CheckIfFreeScribeIsRunning: + nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' + Pop $0 + + ${If} $0 == 0 + Call CreateRunningInstancePage ${EndIf} - + + ContinueInstallation: IfSilent SILENT_MODE NOT_SILENT_MODE SILENT_MODE: ${GetParameters} $R0 - ; Check for custom parameters ${GetOptions} $R0 "/ARCH=" $R1 ${If} $R1 != "" StrCpy $SELECTED_OPTION $R1 @@ -518,6 +574,7 @@ FunctionEnd ; Define installer pages !insertmacro MUI_PAGE_LICENSE ".\assets\License.txt" +Page Custom CreateRunningInstancePage Page Custom ARCHITECTURE_SELECT ARCHITECTURE_SELECT_LEAVE !insertmacro MUI_PAGE_DIRECTORY !define MUI_PAGE_CUSTOMFUNCTION_LEAVE InsfilesPageLeave From e380b60d9a9ac5383f384616e2a3054e0b1c6a3a Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 21:19:11 -0500 Subject: [PATCH 225/244] refactor, detect running instance in nsDialog custom page --- scripts/install.nsi | 127 +++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 49 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 90cc177d..f615a857 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -29,6 +29,28 @@ Var /GLOBAL NVIDIA_RADIO Var /GLOBAL SELECTED_OPTION Var /GLOBAL REMOVE_CONFIG_CHECKBOX Var /GLOBAL REMOVE_CONFIG +Var /GLOBAL Got_Running_Instance + +Function HideNextButton + GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button + ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button +FunctionEnd + +Function ShowNextButton + GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button + ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button +FunctionEnd + +Function GotoNextPage + ; Programmatically advance to the next page + GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle + SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button +FunctionEnd + +Function HideBackButton + GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button + ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button +FunctionEnd !macro KillFreeScribeProcessMacro nsExec::ExecToStack 'taskkill /F /IM freescribe-client.exe' @@ -47,10 +69,6 @@ Function KillFreeScribeProcess !insertmacro KillFreeScribeProcessMacro FunctionEnd -Function un.KillFreeScribeProcess - !insertmacro KillFreeScribeProcessMacro -FunctionEnd - Function Check_For_Old_Version_In_App_Data ; Check if the old version exists in AppData IfFileExists "$APPDATA\FreeScribe\freescribe-client.exe" 0 OldVersionDoesNotExist @@ -149,70 +167,81 @@ Function .onInstSuccess FunctionEnd Function un.onInit - CheckIfFreeScribeIsRunning: nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' Pop $0 ; Return value - ; Check if the process is running ${If} $0 == 0 - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "FreeScribe is currently running. Would you like to stop it?$\n$\nYes = Force Stop$\nNo = Retry$\nCancel = Exit" IDYES kill_process IDCANCEL cancel - - Goto CheckIfFreeScribeIsRunning - kill_process: - Call un.KillFreeScribeProcess - Goto CheckIfFreeScribeIsRunning - cancel: - Abort - + StrCpy $Got_Running_Instance "1" + ${Else} + StrCpy $Got_Running_Instance "0" ${EndIf} + FunctionEnd ; Checks on installer start Var RunningInstanceDialog Var ForceStopButton Var RetryButton -Var CancelButton + Var StatusLabel +PageEx custom + PageCallbacks CreateRunningInstancePagePre +PageExEnd + Function CreateRunningInstancePage - !insertmacro MUI_HEADER_TEXT "Running Instance Detected" "FreeScribe is currently running. Please choose an action:" - + ${If} $Got_Running_Instance == "0" + Abort + ${EndIf} + !insertmacro MUI_HEADER_TEXT "Running Instance Detected" "" + nsDialogs::Create 1018 Pop $RunningInstanceDialog - + ${If} $RunningInstanceDialog == error Abort ${EndIf} - + ; Create status label - ${NSD_CreateLabel} 0 10u 100% 24u "FreeScribe is currently running.$\nPlease choose how to proceed:" + ${NSD_CreateLabel} 0 10u 100% 24u "FreeScribe is currently running.$\n$\nPlease choose how to proceed: Force Stop or close it manually and Retry" Pop $StatusLabel - + ; Create Force Stop button ${NSD_CreateButton} 10% 50u 30% 12u "Force Stop" Pop $ForceStopButton ${NSD_OnClick} $ForceStopButton OnForceStopClick - + ; Create Retry button ${NSD_CreateButton} 45% 50u 30% 12u "Retry" Pop $RetryButton ${NSD_OnClick} $RetryButton OnRetryClick - - ; Create Cancel button - ${NSD_CreateButton} 80% 50u 15% 12u "Cancel" - Pop $CancelButton - ${NSD_OnClick} $CancelButton OnCancelClick - + + ${If} $Got_Running_Instance == "1" + Call HideNextButton + ${Else} + Call ShowNextButton + ${EndIf} + Call HideBackButton + nsDialogs::Show FunctionEnd +Function CreateRunningInstancePagePre + ${If} $Got_Running_Instance == "0" + Abort + ${EndIf} +FunctionEnd + Function OnForceStopClick Call KillFreeScribeProcess nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' Pop $0 - + ${If} $0 == 0 ${NSD_SetText} $StatusLabel "Unable to terminate FreeScribe.$\nPlease close it manually and click Retry." ${Else} + StrCpy $Got_Running_Instance "0" + Call ShowNextButton + Call GotoNextPage Abort ; Close the dialog and continue installation ${EndIf} FunctionEnd @@ -220,28 +249,27 @@ FunctionEnd Function OnRetryClick nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' Pop $0 - + ${If} $0 == 0 - ${NSD_SetText} $StatusLabel "FreeScribe is still running.$\nPlease choose an action." + ${NSD_SetText} $StatusLabel "FreeScribe is still running.$\n$\nPlease choose how to proceed: Force Stop or close it manually and Retry" ${Else} + StrCpy $Got_Running_Instance "0" + Call ShowNextButton + Call GotoNextPage Abort ; Close the dialog and continue installation ${EndIf} FunctionEnd -Function OnCancelClick - Quit -FunctionEnd - Function .onInit - CheckIfFreeScribeIsRunning: nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' Pop $0 - + ${If} $0 == 0 - Call CreateRunningInstancePage + StrCpy $Got_Running_Instance "1" + ${Else} + StrCpy $Got_Running_Instance "0" ${EndIf} - - ContinueInstallation: + IfSilent SILENT_MODE NOT_SILENT_MODE SILENT_MODE: @@ -493,7 +521,7 @@ FunctionEnd ;------------------------------------------------------------------------------ ; Function: CompareVersions ; Purpose: Compares two version numbers in format "X.Y" (e.g., "1.0", "2.3") -; +; ; Parameters: ; Stack 1 (bottom): First version string to compare ; Stack 0 (top): Second version string to compare @@ -517,22 +545,22 @@ Function CompareVersions Push $R3 Push $R4 Push $R5 - + ; Split version strings into major and minor numbers ${WordFind} $R1 "." "+1" $R2 ; Extract major number from first version ${WordFind} $R1 "." "+2" $R3 ; Extract minor number from first version ${WordFind} $R0 "." "+1" $R4 ; Extract major number from second version ${WordFind} $R0 "." "+2" $R5 ; Extract minor number from second version - + ; Convert to comparable numbers: ; Multiply major version by 1000 to handle minor version properly IntOp $R2 $R2 * 1000 ; Convert first version major number IntOp $R4 $R4 * 1000 ; Convert second version major number - + ; Add minor numbers to create complete comparable values IntOp $R2 $R2 + $R3 ; First version complete number IntOp $R4 $R4 + $R5 ; Second version complete number - + ; Compare versions and set return value ${If} $R2 < $R4 ; If first version is less than second StrCpy $R0 1 @@ -541,7 +569,7 @@ Function CompareVersions ${Else} ; If versions are equal StrCpy $R0 0 ${EndIf} - + ; Restore registers from stack Pop $R5 Pop $R4 @@ -553,7 +581,7 @@ FunctionEnd Function un.CreateRemoveConfigFilesPage !insertmacro MUI_HEADER_TEXT "Remove Configuration Files" "Do you want to remove the configuration files (e.g., settings)?" - + nsDialogs::Create 1018 Pop $0 @@ -573,8 +601,8 @@ Function un.RemoveConfigFilesPageLeave FunctionEnd ; Define installer pages +Page custom CreateRunningInstancePage !insertmacro MUI_PAGE_LICENSE ".\assets\License.txt" -Page Custom CreateRunningInstancePage Page Custom ARCHITECTURE_SELECT ARCHITECTURE_SELECT_LEAVE !insertmacro MUI_PAGE_DIRECTORY !define MUI_PAGE_CUSTOMFUNCTION_LEAVE InsfilesPageLeave @@ -582,6 +610,7 @@ Page Custom ARCHITECTURE_SELECT ARCHITECTURE_SELECT_LEAVE Page Custom CustomizeFinishPage RunApp ; Define the uninstaller pages +Page custom CreateRunningInstancePage !insertmacro MUI_UNPAGE_CONFIRM UninstPage custom un.CreateRemoveConfigFilesPage un.RemoveConfigFilesPageLeave !insertmacro MUI_UNPAGE_INSTFILES From c9a6218e5c53f052ade2720174114bb0e6e4180e Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Thu, 30 Jan 2025 21:57:22 -0500 Subject: [PATCH 226/244] fix for uninstallatioin --- scripts/install.nsi | 104 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 8 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index f615a857..3efddc61 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -69,6 +69,10 @@ Function KillFreeScribeProcess !insertmacro KillFreeScribeProcessMacro FunctionEnd +Function un.KillFreeScribeProcess + !insertmacro KillFreeScribeProcessMacro +FunctionEnd + Function Check_For_Old_Version_In_App_Data ; Check if the old version exists in AppData IfFileExists "$APPDATA\FreeScribe\freescribe-client.exe" 0 OldVersionDoesNotExist @@ -175,8 +179,8 @@ Function un.onInit ${Else} StrCpy $Got_Running_Instance "0" ${EndIf} - FunctionEnd + ; Checks on installer start Var RunningInstanceDialog Var ForceStopButton @@ -184,6 +188,90 @@ Var RetryButton Var StatusLabel +Function un.CreateRunningInstancePage + ${If} $Got_Running_Instance == "0" + Abort + ${EndIf} + !insertmacro MUI_HEADER_TEXT "Running Instance Detected" "" + + nsDialogs::Create 1018 + Pop $RunningInstanceDialog + + ${If} $RunningInstanceDialog == error + Abort + ${EndIf} + + ; Create status label + ${NSD_CreateLabel} 0 10u 100% 24u "FreeScribe is currently running.$\n$\nPlease choose how to proceed: Force Stop or close it manually and Retry" + Pop $StatusLabel + + ; Create Force Stop button + ${NSD_CreateButton} 10% 50u 30% 12u "Force Stop" + Pop $ForceStopButton + ${NSD_OnClick} $ForceStopButton un.OnForceStopClick + + ; Create Retry button + ${NSD_CreateButton} 45% 50u 30% 12u "Retry" + Pop $RetryButton + ${NSD_OnClick} $RetryButton un.OnRetryClick + + Call un.HideNextButton + Call un.HideBackButton + + nsDialogs::Show +FunctionEnd + +Function un.OnForceStopClick + Call un.KillFreeScribeProcess + nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' + Pop $0 + + ${If} $0 == 0 + ${NSD_SetText} $StatusLabel "Unable to terminate FreeScribe.$\nPlease close it manually and click Retry." + ${Else} + StrCpy $Got_Running_Instance "0" + Call un.ShowNextButton + Call un.GotoNextPage + Abort ; Close the dialog and continue uninstallation + ${EndIf} +FunctionEnd + +Function un.OnRetryClick + nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' + Pop $0 + + ${If} $0 == 0 + ${NSD_SetText} $StatusLabel "FreeScribe is still running.$\n$\nPlease choose how to proceed: Force Stop or close it manually and Retry" + ${Else} + StrCpy $Got_Running_Instance "0" + Call un.ShowNextButton + Call un.GotoNextPage + Abort ; Close the dialog and continue uninstallation + ${EndIf} +FunctionEnd + +Function un.HideNextButton + GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button + ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button +FunctionEnd + +Function un.ShowNextButton + GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button + ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button +FunctionEnd + +Function un.HideBackButton + GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button + ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button +FunctionEnd + +Function un.GotoNextPage + ; Programmatically advance to the next page + GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle + SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button +FunctionEnd + + PageEx custom PageCallbacks CreateRunningInstancePagePre PageExEnd @@ -600,6 +688,13 @@ Function un.RemoveConfigFilesPageLeave ${NSD_GetState} $REMOVE_CONFIG_CHECKBOX $REMOVE_CONFIG FunctionEnd +; Define the uninstaller pages first +UninstPage custom un.CreateRunningInstancePage +!insertmacro MUI_UNPAGE_CONFIRM +UninstPage custom un.CreateRemoveConfigFilesPage un.RemoveConfigFilesPageLeave +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_UNPAGE_FINISH + ; Define installer pages Page custom CreateRunningInstancePage !insertmacro MUI_PAGE_LICENSE ".\assets\License.txt" @@ -609,12 +704,5 @@ Page Custom ARCHITECTURE_SELECT ARCHITECTURE_SELECT_LEAVE !insertmacro MUI_PAGE_INSTFILES Page Custom CustomizeFinishPage RunApp -; Define the uninstaller pages -Page custom CreateRunningInstancePage -!insertmacro MUI_UNPAGE_CONFIRM -UninstPage custom un.CreateRemoveConfigFilesPage un.RemoveConfigFilesPageLeave -!insertmacro MUI_UNPAGE_INSTFILES -!insertmacro MUI_UNPAGE_FINISH - ; Define the languages !insertmacro MUI_LANGUAGE English From 12dda6054334fe10e0199adeb7115d13d50c00f4 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Fri, 31 Jan 2025 07:04:13 -0500 Subject: [PATCH 227/244] Code finetuning fix: Removed extra lines, added comments and modified error message. --- .../UI/Widgets/MicrophoneTestFrame.py | 17 ++++++----------- src/FreeScribe.client/client.py | 5 +---- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py index 717a6845..e8294885 100644 --- a/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py +++ b/src/FreeScribe.client/UI/Widgets/MicrophoneTestFrame.py @@ -72,8 +72,6 @@ def initialize_microphones(self): if not any(excluded_name.lower() in device_name.lower() for excluded_name in excluded_names) and device_name not in [name for _, name in self.mic_list]: self.mic_list.append((i, device_name)) self.mic_mapping[device_name] = i - - # Load the selected microphone from settings if available if self.app_settings and "Current Mic" in self.app_settings.editable_settings: selected_name = self.app_settings.editable_settings["Current Mic"] @@ -115,8 +113,6 @@ def create_mic_test_ui(self): style='Mic.TCombobox' ) self.mic_dropdown.grid(row=0, column=0, pady=(0, 5), padx=(10, 0), sticky='nsew') - - # Set the default selection if MicrophoneState.SELECTED_MICROPHONE_NAME: self.mic_dropdown.set(MicrophoneState.SELECTED_MICROPHONE_NAME) @@ -203,15 +199,14 @@ def update_selected_microphone(self, selected_index): self.stream.close() self.stream = None self.is_stream_active = False - # Open new stream with the selected microphone self.stream = self.p.open( - format=pyaudio.paInt16, - channels=1, - rate=16000, - input=True, - frames_per_buffer=1024, - input_device_index=selected_index + format=pyaudio.paInt16, # Specifies the format of the audio data. paInt16 means 16-bit int PCM. + channels=1, # Specifies the number of channels. 1 for mono, 2 for stereo. + rate=16000, # Specifies the sampling rate in Hz. 16000 Hz is a common rate for speech. + input=True, # Indicates that this stream will be used for input (recording). + frames_per_buffer=1024, # Specifies the number of samples (per channel) to read in each buffer. + input_device_index=selected_index # Specifies the index of the input device to use. ) self.is_stream_active = True except Exception as e: diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index a713a40c..4e010e52 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -52,8 +52,6 @@ from WhisperModel import TranscribeError - - dual = DualOutput() sys.stdout = dual sys.stderr = dual @@ -223,7 +221,7 @@ def record_audio(): frames_per_buffer=CHUNK, input_device_index=int(selected_index)) except (OSError, IOError) as e: - messagebox.showerror("Audio Error", f"Please check your microphone settings under whisper settings. Error opening audio stream: {e}") + messagebox.showerror("Audio Error", f"Please check your microphone settings. Error opening audio stream: {e}") return try: @@ -1246,7 +1244,6 @@ def set_minimal_view(): history_frame.grid_remove() blinking_circle_canvas.grid_remove() - # Configure minimal view button sizes and placements mic_button.config(width=2, height=1) pause_button.config(width=2, height=1) From 4a40987e023eef8299eef853ee21ce858039c04d Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Fri, 31 Jan 2025 10:34:08 -0500 Subject: [PATCH 228/244] extract duplicate code to macro --- scripts/install.nsi | 97 +++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 3efddc61..b880afaf 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -31,25 +31,52 @@ Var /GLOBAL REMOVE_CONFIG_CHECKBOX Var /GLOBAL REMOVE_CONFIG Var /GLOBAL Got_Running_Instance +!macro UIButtonMacros + !define HideNextButtonMacro `GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button \ + ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button` + + !define ShowNextButtonMacro `GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button \ + ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button` + + !define GotoNextPageMacro `GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle \ + SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button` + + !define HideBackButtonMacro `GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button \ + ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button` +!macroend + +!insertmacro UIButtonMacros + Function HideNextButton - GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button - ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button + !insertmacro HideNextButtonMacro FunctionEnd Function ShowNextButton - GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button - ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button + !insertmacro ShowNextButtonMacro FunctionEnd Function GotoNextPage - ; Programmatically advance to the next page - GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle - SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button + !insertmacro GotoNextPageMacro FunctionEnd Function HideBackButton - GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button - ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button + !insertmacro HideBackButtonMacro +FunctionEnd + +Function un.HideNextButton + !insertmacro HideNextButtonMacro +FunctionEnd + +Function un.ShowNextButton + !insertmacro ShowNextButtonMacro +FunctionEnd + +Function un.HideBackButton + !insertmacro HideBackButtonMacro +FunctionEnd + +Function un.GotoNextPage + !insertmacro GotoNextPageMacro FunctionEnd !macro KillFreeScribeProcessMacro @@ -171,14 +198,7 @@ Function .onInstSuccess FunctionEnd Function un.onInit - nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' - Pop $0 ; Return value - - ${If} $0 == 0 - StrCpy $Got_Running_Instance "1" - ${Else} - StrCpy $Got_Running_Instance "0" - ${EndIf} + !insertmacro CheckRunningInstanceMacro FunctionEnd ; Checks on installer start @@ -250,28 +270,6 @@ Function un.OnRetryClick ${EndIf} FunctionEnd -Function un.HideNextButton - GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button - ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button -FunctionEnd - -Function un.ShowNextButton - GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button - ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button -FunctionEnd - -Function un.HideBackButton - GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button - ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button -FunctionEnd - -Function un.GotoNextPage - ; Programmatically advance to the next page - GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle - SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button -FunctionEnd - - PageEx custom PageCallbacks CreateRunningInstancePagePre PageExEnd @@ -348,15 +346,20 @@ Function OnRetryClick ${EndIf} FunctionEnd -Function .onInit - nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' - Pop $0 +!macro ProcessManagementMacros + !define CheckRunningInstanceMacro `nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' \ + Pop $0 ; Return value \ + ${If} $0 == 0 \ + StrCpy $Got_Running_Instance "1" \ + ${Else} \ + StrCpy $Got_Running_Instance "0" \ + ${EndIf}` +!macroend - ${If} $0 == 0 - StrCpy $Got_Running_Instance "1" - ${Else} - StrCpy $Got_Running_Instance "0" - ${EndIf} +!insertmacro ProcessManagementMacros + +Function .onInit + !insertmacro CheckRunningInstanceMacro IfSilent SILENT_MODE NOT_SILENT_MODE From 75562df256e20203b23e07ea93ee8c545b2cfe8b Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Fri, 31 Jan 2025 10:45:17 -0500 Subject: [PATCH 229/244] split defines in macros to independent macro --- scripts/install.nsi | 48 +++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index b880afaf..55b142ca 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -31,21 +31,25 @@ Var /GLOBAL REMOVE_CONFIG_CHECKBOX Var /GLOBAL REMOVE_CONFIG Var /GLOBAL Got_Running_Instance -!macro UIButtonMacros - !define HideNextButtonMacro `GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button \ - ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button` - - !define ShowNextButtonMacro `GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button \ - ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button` - - !define GotoNextPageMacro `GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle \ - SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button` - - !define HideBackButtonMacro `GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button \ - ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button` +!macro HideNextButtonMacro + GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button + ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button !macroend -!insertmacro UIButtonMacros +!macro ShowNextButtonMacro + GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button + ShowWindow $R0 ${SW_SHOW} ; Show the "Next" button +!macroend + +!macro GotoNextPageMacro + GetDlgItem $1 $HWNDPARENT 1 ; Get the "Next" button handle + SendMessage $HWNDPARENT ${WM_COMMAND} 1 $1 ; Simulate clicking the "Next" button +!macroend + +!macro HideBackButtonMacro + GetDlgItem $R0 $HWNDPARENT 3 ; Get the handle of the "Back" button + ShowWindow $R0 ${SW_HIDE} ; Hide the "Back" button +!macroend Function HideNextButton !insertmacro HideNextButtonMacro @@ -346,18 +350,16 @@ Function OnRetryClick ${EndIf} FunctionEnd -!macro ProcessManagementMacros - !define CheckRunningInstanceMacro `nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' \ - Pop $0 ; Return value \ - ${If} $0 == 0 \ - StrCpy $Got_Running_Instance "1" \ - ${Else} \ - StrCpy $Got_Running_Instance "0" \ - ${EndIf}` +!macro CheckRunningInstanceMacro + nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' + Pop $0 ; Return value + ${If} $0 == 0 + StrCpy $Got_Running_Instance "1" + ${Else} + StrCpy $Got_Running_Instance "0" + ${EndIf} !macroend -!insertmacro ProcessManagementMacros - Function .onInit !insertmacro CheckRunningInstanceMacro From 2981af6a3c179a28a6ee949eaf05db1540f65ea0 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Fri, 31 Jan 2025 11:23:31 -0500 Subject: [PATCH 230/244] add /K flag to kill running instance; skip running instance page in silent mode --- scripts/install.nsi | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/install.nsi b/scripts/install.nsi index 55b142ca..3fe32890 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -6,6 +6,11 @@ ; Define the name of the installer OutFile "..\dist\FreeScribeInstaller.exe" +; Silent mode flags: +; /S - Silent mode +; /ARCH=[CPU|NVIDIA] - Force architecture selection +; /K - Kill running instance before installation + ; Define the default installation directory to AppData InstallDir "$PROGRAMFILES\FreeScribe" @@ -279,6 +284,10 @@ PageEx custom PageExEnd Function CreateRunningInstancePage + ; Skip this page in silent mode + IfSilent 0 +2 + Abort + ${If} $Got_Running_Instance == "0" Abort ${EndIf} @@ -371,6 +380,17 @@ Function .onInit ${If} $R1 != "" StrCpy $SELECTED_OPTION $R1 ${EndIf} + + ; Check for /K flag to kill running instance + ${GetOptions} $R0 "/K" $R2 + ${IfNot} ${Errors} + Call KillFreeScribeProcess + !insertmacro CheckRunningInstanceMacro ; Re-check after killing + ${EndIf} + + ; Skip running instance page in silent mode + StrCpy $Got_Running_Instance "0" + Return NOT_SILENT_MODE: FunctionEnd From 79412c161f6b430de7f09a2d64c9c35ea296b0b2 Mon Sep 17 00:00:00 2001 From: Xun Zhong Date: Fri, 31 Jan 2025 11:25:06 -0500 Subject: [PATCH 231/244] fix CheckRunningInstanceMacro not found, needs to be defined before any invocation --- scripts/install.nsi | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/install.nsi b/scripts/install.nsi index 3fe32890..34e47238 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -36,6 +36,16 @@ Var /GLOBAL REMOVE_CONFIG_CHECKBOX Var /GLOBAL REMOVE_CONFIG Var /GLOBAL Got_Running_Instance +!macro CheckRunningInstanceMacro + nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' + Pop $0 ; Return value + ${If} $0 == 0 + StrCpy $Got_Running_Instance "1" + ${Else} + StrCpy $Got_Running_Instance "0" + ${EndIf} +!macroend + !macro HideNextButtonMacro GetDlgItem $R0 $HWNDPARENT 1 ; Get the handle of the "Next" button ShowWindow $R0 ${SW_HIDE} ; Hide the "Next" button @@ -359,16 +369,6 @@ Function OnRetryClick ${EndIf} FunctionEnd -!macro CheckRunningInstanceMacro - nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' - Pop $0 ; Return value - ${If} $0 == 0 - StrCpy $Got_Running_Instance "1" - ${Else} - StrCpy $Got_Running_Instance "0" - ${EndIf} -!macroend - Function .onInit !insertmacro CheckRunningInstanceMacro From 41c3ad5eb9b2358428a797620afc2a5278b2b9f6 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 11:58:22 -0500 Subject: [PATCH 232/244] docs: moved right aligned comments to above --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 21 +++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py index abee1bec..7b7a3132 100644 --- a/src/FreeScribe.client/UI/Widgets/PopupBox.py +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -34,10 +34,14 @@ def __init__(self, :param button_2_callback: Optional callback function for the second button. """ self.response = None # Stores the response indicating which button was clicked - self.dialog = Toplevel(parent) # Create a top-level window for the popup - self.dialog.title(title) # Set the window title - self.dialog.geometry("300x150") # Set the size of the window - self.dialog.resizable(False, False) # Disable window resizing + # Create a top-level window for the popup + self.dialog = Toplevel(parent) + # Set the window title + self.dialog.title(title) + # Set the size of the window + self.dialog.geometry("300x150") + # Disable window resizing + self.dialog.resizable(False, False) # Create and pack the message label label = tk.Label(self.dialog, text=message, wraplength=250) @@ -56,9 +60,12 @@ def __init__(self, button_2.pack(side=tk.RIGHT, padx=10) # Configure the dialog as a modal window - self.dialog.transient(parent) # Make the dialog appear on top of the parent window - self.dialog.grab_set() # Prevent interaction with other windows until this dialog is closed - parent.wait_window(self.dialog) # Wait until the dialog window is closed + # Make the dialog appear on top of the parent window + self.dialog.transient(parent) + # Prevent interaction with other windows until this dialog is closed + self.dialog.grab_set() + # Wait until the dialog window is closed + parent.wait_window(self.dialog) def on_button_1(self): """ From 2a2996e35b70974f89a095bd0959fb0651f60619 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 13:17:09 -0500 Subject: [PATCH 233/244] Made popupbox centered to parent --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py index 7b7a3132..0dd8d7b9 100644 --- a/src/FreeScribe.client/UI/Widgets/PopupBox.py +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -39,10 +39,23 @@ def __init__(self, # Set the window title self.dialog.title(title) # Set the size of the window - self.dialog.geometry("300x150") + window_width = 300 + window_height = 150 + self.dialog.geometry(f"{window_width}x{window_height}") # Disable window resizing self.dialog.resizable(False, False) + # Center the dialog relative to the parent window + parent_x = parent.winfo_rootx() + parent_y = parent.winfo_rooty() + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + + center_x = parent_x + (parent_width // 2) - (window_width // 2) + center_y = parent_y + (parent_height // 2) - (window_height // 2) + + self.dialog.geometry(f"+{center_x}+{center_y}") + # Create and pack the message label label = tk.Label(self.dialog, text=message, wraplength=250) label.pack(pady=20) From 7eb00a1ebbbffb11d52222271f06f011714c48ef Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 13:21:09 -0500 Subject: [PATCH 234/244] fix: made the LLM prescreen popup box cancel on 'X' click --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py index 0dd8d7b9..f3a025e0 100644 --- a/src/FreeScribe.client/UI/Widgets/PopupBox.py +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -36,6 +36,8 @@ def __init__(self, self.response = None # Stores the response indicating which button was clicked # Create a top-level window for the popup self.dialog = Toplevel(parent) + # Make the exit button behave like the second button + self.dialog.protocol("WM_DELETE_WINDOW", self.on_button_1) # Set the window title self.dialog.title(title) # Set the size of the window From 6614ce46f69500c7d2e665dfbbbc780a3a4e6724 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 13:17:09 -0500 Subject: [PATCH 235/244] feat: made the LLM prescreen popupbox center to its parent --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py index 7b7a3132..0dd8d7b9 100644 --- a/src/FreeScribe.client/UI/Widgets/PopupBox.py +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -39,10 +39,23 @@ def __init__(self, # Set the window title self.dialog.title(title) # Set the size of the window - self.dialog.geometry("300x150") + window_width = 300 + window_height = 150 + self.dialog.geometry(f"{window_width}x{window_height}") # Disable window resizing self.dialog.resizable(False, False) + # Center the dialog relative to the parent window + parent_x = parent.winfo_rootx() + parent_y = parent.winfo_rooty() + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + + center_x = parent_x + (parent_width // 2) - (window_width // 2) + center_y = parent_y + (parent_height // 2) - (window_height // 2) + + self.dialog.geometry(f"+{center_x}+{center_y}") + # Create and pack the message label label = tk.Label(self.dialog, text=message, wraplength=250) label.pack(pady=20) From 57cbba47e073b2954b517172893c8ba00d2e1ca5 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 13:21:09 -0500 Subject: [PATCH 236/244] fix: made the LLM prescreen popup box cancel on 'X' click --- src/FreeScribe.client/UI/Widgets/PopupBox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/UI/Widgets/PopupBox.py b/src/FreeScribe.client/UI/Widgets/PopupBox.py index 0dd8d7b9..f3a025e0 100644 --- a/src/FreeScribe.client/UI/Widgets/PopupBox.py +++ b/src/FreeScribe.client/UI/Widgets/PopupBox.py @@ -36,6 +36,8 @@ def __init__(self, self.response = None # Stores the response indicating which button was clicked # Create a top-level window for the popup self.dialog = Toplevel(parent) + # Make the exit button behave like the second button + self.dialog.protocol("WM_DELETE_WINDOW", self.on_button_1) # Set the window title self.dialog.title(title) # Set the size of the window From f05422c408ea35a034dadc4fc62cb03710d6dad1 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 13:48:46 -0500 Subject: [PATCH 237/244] fix: added prescreen to load with loading window --- src/FreeScribe.client/client.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index 0bc17322..c4840e23 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1143,14 +1143,7 @@ def generate_note_thread(text: str): """ global GENERATION_THREAD_ID - # screen input - if screen_input(text) is False: - return - - thread = threading.Thread(target=generate_note, args=(text,)) - thread.start() - - GENERATION_THREAD_ID = thread.ident + GENERATION_THREAD_ID = None def cancel_note_generation(thread_id): """Cancels any ongoing note generation. @@ -1160,7 +1153,8 @@ def cancel_note_generation(thread_id): global GENERATION_THREAD_ID try: - kill_thread(thread_id) + if thread_id: + kill_thread(thread_id) except Exception as e: # Log the error message # TODO implment system logger @@ -1170,6 +1164,14 @@ def cancel_note_generation(thread_id): loading_window = LoadingWindow(root, "Generating Note.", "Generating Note. Please wait.", on_cancel=lambda: cancel_note_generation(GENERATION_THREAD_ID)) + # screen input + if screen_input(text) is False: + loading_window.destroy() + return + + thread = threading.Thread(target=generate_note, args=(text,)) + thread.start() + GENERATION_THREAD_ID = thread.ident def check_thread_status(thread, loading_window): if thread.is_alive(): From 303b5e526e8cf967bcccfe7ec03baa1956e1e401 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 14:24:11 -0500 Subject: [PATCH 238/244] chore: changed whisper model to medium --- src/FreeScribe.client/UI/SettingsWindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index d83507cc..a6ed9cd7 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -221,7 +221,7 @@ def __init__(self): SettingsKeys.WHISPER_CPU_COUNT.value: multiprocessing.cpu_count(), SettingsKeys.WHISPER_VAD_FILTER.value: False, SettingsKeys.WHISPER_COMPUTE_TYPE.value: "float16", - "Whisper Model": "small.en", + "Whisper Model": "medium", "Current Mic": "None", "Real Time": True, "Real Time Audio Length": 10, From f58a3efb3e16c7030b4ccdb78c2941e79945ca56 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 14:39:51 -0500 Subject: [PATCH 239/244] feat(presets): remove the preset feature. Unused feature that caused confusion for users. Also unused since settings have been streamlined. --- scripts/install.nsi | 7 --- src/FreeScribe.client/UI/SettingsWindow.py | 42 +------------- src/FreeScribe.client/UI/SettingsWindowUI.py | 14 ----- src/FreeScribe.client/presets/ChatGPT.json | 56 ------------------- .../presets/ClinicianFOCUS Toolbox.json | 56 ------------------- src/FreeScribe.client/presets/Local AI.json | 56 ------------------- 6 files changed, 1 insertion(+), 230 deletions(-) delete mode 100644 src/FreeScribe.client/presets/ChatGPT.json delete mode 100644 src/FreeScribe.client/presets/ClinicianFOCUS Toolbox.json delete mode 100644 src/FreeScribe.client/presets/Local AI.json diff --git a/scripts/install.nsi b/scripts/install.nsi index eb3999fb..c18e1da0 100644 --- a/scripts/install.nsi +++ b/scripts/install.nsi @@ -37,7 +37,6 @@ Function Check_For_Old_Version_In_App_Data MessageBox MB_YESNO|MB_ICONQUESTION "An old version of FreeScribe has been detected. Would you like to uninstall it?" IDYES UninstallOldVersion IDNO OldVersionDoesNotExist UninstallOldVersion: ; Remove the contents/folders of the old version - RMDir /r "$APPDATA\FreeScribe\presets" RMDir /r "$APPDATA\FreeScribe\_internal" RMDir /r "$APPDATA\FreeScribe\models" @@ -167,7 +166,6 @@ FunctionEnd Function CleanUninstall ; Remove the contents/folders of the old version - RMDir /r "$INSTDIR\presets" RMDir /r "$INSTDIR\_internal" ; Remove the old version executable @@ -224,11 +222,6 @@ Section "MainSection" SEC01 SetOutPath "$INSTDIR\_internal" File ".\__version__" - ; add presets - CreateDirectory "$INSTDIR\presets" - SetOutPath "$INSTDIR\presets" - File /r "..\src\FreeScribe.client\presets\*" - SetOutPath "$INSTDIR" ; Create a start menu shortcut diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index d83507cc..a5959394 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -91,7 +91,7 @@ class SettingsWindow(): save_settings_to_file(): Saves the current settings to a JSON file. save_settings(openai_api_key, aiscribe_text, aiscribe2_text, - settings_window, preset): + settings_window): Saves the current settings, including API keys, IP addresses, and user-defined parameters. load_aiscribe_from_file(): Loads the first AI Scribe text from a file. @@ -234,7 +234,6 @@ def __init__(self): "Whisper Caddy Container Name": "caddy", "Auto Shutdown Containers on Exit": True, "Use Docker Status Bar": False, - "Preset": "Custom", "Show Welcome Message": True, "Enable Scribe Template": False, "Use Pre-Processing": True, @@ -512,45 +511,6 @@ def update_models_dropdown(self, dropdown, endpoint=None): else: dropdown.set(models[0]) - - def load_settings_preset(self, preset_name, settings_class): - """ - Load a settings preset from a file. - - This method loads a settings preset from a JSON file with the given name. - The settings are then applied to the application settings. - - Parameters: - preset_name (str): The name of the settings preset to load. - - Returns: - None - """ - self.editable_settings["Preset"] = preset_name - - if preset_name != "Custom": - # load the settigns from the json preset file - self.load_settings_from_file("presets/" + preset_name + ".json") - - self.editable_settings["Preset"] = preset_name - #close the settings window - settings_class.close_window() - - # save the settings to the file - self.save_settings_to_file() - - if preset_name != "Local AI": - messagebox.showinfo("Settings Preset", "Settings preset loaded successfully. Closing settings window. Please re-open and set respective API keys.") - - # Unload ai model if switching - # already has safety check in unload to check if model exist. - ModelManager.unload_model() - else: # if is local ai - # load the models here - ModelManager.start_model_threaded(self, self.main_window.root) - else: - messagebox.showinfo("Custom Settings", "To use custom settings then please fill in the values and save them.") - def set_main_window(self, window): """ Set the main window instance for the settings. diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index b8a0997f..efb777c9 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -652,20 +652,6 @@ def _create_general_settings(self): """ frame, row = self.create_editable_settings(self.general_settings_frame, self.settings.general_settings) - # 1. LLM Preset (Left Column) - tk.Label(frame, text="Settings Presets:").grid(row=row, column=0, padx=0, pady=5, sticky="w") - llm_preset_options = ["Local AI", "ClinicianFocus Toolbox", "Custom"] - self.llm_preset_dropdown = ttk.Combobox(frame, values=llm_preset_options, width=20, state="readonly") - if self.settings.editable_settings["Preset"] in llm_preset_options: - self.llm_preset_dropdown.current(llm_preset_options.index(self.settings.editable_settings["Preset"])) - else: - self.llm_preset_dropdown.set("Custom") - self.llm_preset_dropdown.grid(row=row, column=1, padx=0, pady=5, sticky="w") - - load_preset_btn = ttk.Button(frame, text="Load", width=5, - command=lambda: self.settings.load_settings_preset(self.llm_preset_dropdown.get(), self)) - load_preset_btn.grid(row=row, column=2, padx=0, pady=5, sticky="w") - # Add a note at the bottom of the general settings frame note_text = ( "Note: 'Show Scrub PHI' will only work for local LLM and private network.\n" diff --git a/src/FreeScribe.client/presets/ChatGPT.json b/src/FreeScribe.client/presets/ChatGPT.json deleted file mode 100644 index 2d4c2e57..00000000 --- a/src/FreeScribe.client/presets/ChatGPT.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "openai_api_key": "None", - "editable_settings": { - "Model": "gpt-4o-mini", - "Model Endpoint": "https://api.openai.com/v1/", - "use_story": 0, - "use_memory": 0, - "use_authors_note": 0, - "use_world_info": 0, - "max_context_length": 5000, - "max_length": 400, - "rep_pen": "1.1", - "rep_pen_range": 5000, - "rep_pen_slope": "0.7", - "temperature": "0.1", - "tfs": "0.97", - "top_a": "0.8", - "top_k": 30, - "top_p": "0.4", - "typical": "0.19", - "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", - "singleline": 0, - "frmttriminc": 0, - "frmtrmblln": 0, - "Local Whisper": 1, - "Whisper Endpoint": "https://localhost:2224/whisperaudio", - "Whisper Server API Key": "None", - "Whisper Model": "small.en", - "Real Time": 1, - "Real Time Audio Length": "5", - "Real Time Silence Length": 1, - "Silence cut-off": 0.035003662109375, - "LLM Container Name": "ollama", - "LLM Caddy Container Name": "caddy-ollama", - "Whisper Container Name": "speech-container", - "Whisper Caddy Container Name": "caddy", - "Auto Shutdown Containers on Exit": 1, - "Use Docker Status Bar": 0, - "Preset": "Custom", - "Use Local LLM": 0, - "Architecture": "CPU", - "best_of": "2", - "Use best_of": 0, - "LLM Authentication Container Name": "authentication-ollama", - "Show Welcome Message": 0, - "Enable Scribe Template": 0, - "Use Pre-Processing": 1, - "Use Post-Processing": 0, - "AI Server Self-Signed Certificates": 0, - "S2T Server Self-Signed Certificates": 0, - "Pre-Processing": "Please break down the conversation into a list of facts. Take the conversation and transform it to a easy to read list:\n\n", - "Post-Processing": "\n\nUsing the provided list of facts, review the SOAP note for accuracy. Verify that all details align with the information provided in the list of facts and ensure consistency throughout. Update or adjust the SOAP note as necessary to reflect the listed facts without offering opinions or subjective commentary. Ensure that the revised note excludes a \"Notes\" section and does not include a header for the SOAP note. Provide the revised note after making any necessary corrections.", - "Show Scrub PHI": 1 - }, - "api_style": "OpenAI" -} diff --git a/src/FreeScribe.client/presets/ClinicianFOCUS Toolbox.json b/src/FreeScribe.client/presets/ClinicianFOCUS Toolbox.json deleted file mode 100644 index 7c1b00ab..00000000 --- a/src/FreeScribe.client/presets/ClinicianFOCUS Toolbox.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "openai_api_key": "None", - "editable_settings": { - "Model": "gemma-2-2b-it-Q8_0.gguf", - "Model Endpoint": "http://localhost:3334/v1/", - "use_story": 0, - "use_memory": 0, - "use_authors_note": 0, - "use_world_info": 0, - "max_context_length": 5000, - "max_length": 400, - "rep_pen": "1.1", - "rep_pen_range": 5000, - "rep_pen_slope": "0.7", - "temperature": "0.1", - "tfs": "0.97", - "top_a": "0.8", - "top_k": 30, - "top_p": "0.4", - "typical": "0.19", - "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", - "singleline": 0, - "frmttriminc": 0, - "frmtrmblln": 0, - "Local Whisper": 0, - "Whisper Endpoint": "https://localhost:2224/whisperaudio/", - "Whisper Server API Key": "None", - "Whisper Model": "small.en", - "Real Time": 1, - "Real Time Audio Length": "5", - "Real Time Silence Length": 1, - "Silence cut-off": 0.035003662109375, - "LLM Container Name": "ollama", - "LLM Caddy Container Name": "caddy-ollama", - "Whisper Container Name": "speech-container", - "Whisper Caddy Container Name": "caddy", - "Auto Shutdown Containers on Exit": 1, - "Use Docker Status Bar": 0, - "Preset": "Custom", - "Use Local LLM": 0, - "Architecture": "CPU", - "best_of": "2", - "Use best_of": 0, - "LLM Authentication Container Name": "authentication-ollama", - "Show Welcome Message": 0, - "Enable Scribe Template": 0, - "Use Pre-Processing": 1, - "Use Post-Processing": 0, - "AI Server Self-Signed Certificates": 0, - "S2T Server Self-Signed Certificates": 0, - "Pre-Processing": "Please break down the conversation into a list of facts. Take the conversation and transform it to a easy to read list:\n\n", - "Post-Processing": "\n\nUsing the provided list of facts, review the SOAP note for accuracy. Verify that all details align with the information provided in the list of facts and ensure consistency throughout. Update or adjust the SOAP note as necessary to reflect the listed facts without offering opinions or subjective commentary. Ensure that the revised note excludes a \"Notes\" section and does not include a header for the SOAP note. Provide the revised note after making any necessary corrections.", - "Show Scrub PHI": 0 - }, - "api_style": "OpenAI" -} diff --git a/src/FreeScribe.client/presets/Local AI.json b/src/FreeScribe.client/presets/Local AI.json deleted file mode 100644 index bf9cc27a..00000000 --- a/src/FreeScribe.client/presets/Local AI.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "openai_api_key": "None", - "editable_settings": { - "Model": "gemma2:2b-instruct-q8_0", - "Model Endpoint": "http://localhost:3334/v1/", - "use_story": 0, - "use_memory": 0, - "use_authors_note": 0, - "use_world_info": 0, - "max_context_length": 5000, - "max_length": 400, - "rep_pen": "1.1", - "rep_pen_range": 5000, - "rep_pen_slope": "0.7", - "temperature": "0.1", - "tfs": "0.97", - "top_a": "0.8", - "top_k": 30, - "top_p": "0.4", - "typical": "0.19", - "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", - "singleline": 0, - "frmttriminc": 0, - "frmtrmblln": 0, - "Local Whisper": 1, - "Whisper Endpoint": "https://localhost:2224/whisperaudio/", - "Whisper Server API Key": "None", - "Whisper Model": "small.en", - "Real Time": 1, - "Real Time Audio Length": "5", - "Real Time Silence Length": 1, - "Silence cut-off": 0.035003662109375, - "LLM Container Name": "ollama", - "LLM Caddy Container Name": "caddy-ollama", - "Whisper Container Name": "speech-container", - "Whisper Caddy Container Name": "caddy", - "Auto Shutdown Containers on Exit": 1, - "Use Docker Status Bar": 0, - "Preset": "Custom", - "Use Local LLM": 1, - "Architecture": "CPU", - "best_of": "2", - "Use best_of": 0, - "LLM Authentication Container Name": "authentication-ollama", - "Show Welcome Message": 0, - "Enable Scribe Template": 0, - "Use Pre-Processing": 1, - "Use Post-Processing": 0, - "AI Server Self-Signed Certificates": 0, - "S2T Server Self-Signed Certificates": 0, - "Pre-Processing": "Please break down the conversation into a list of facts. Take the conversation and transform it to a easy to read list:\n\n", - "Post-Processing": "\n\nUsing the provided list of facts, review the SOAP note for accuracy. Verify that all details align with the information provided in the list of facts and ensure consistency throughout. Update or adjust the SOAP note as necessary to reflect the listed facts without offering opinions or subjective commentary. Ensure that the revised note excludes a \"Notes\" section and does not include a header for the SOAP note. Provide the revised note after making any necessary corrections.", - "Show Scrub PHI": 1 - }, - "api_style": "OpenAI" -} From adf56c1740ab4fbe4966805592eed3eb5d93c965 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 15:47:06 -0500 Subject: [PATCH 240/244] fix(settings): On install if CUDA is a available default to it. Before the even if CUDA was available we would default to CPU. Changed the default now to CUDA if it is available. --- src/FreeScribe.client/UI/SettingsWindow.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index d83507cc..65f95df9 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -45,6 +45,7 @@ class SettingsKeys(Enum): USE_TRANSLATE_TASK = "Use Translate Task" WHISPER_LANGUAGE_CODE = "Whisper Language Code" S2T_SELF_SIGNED_CERT = "S2T Server Self-Signed Certificates" + LLM_ARCHITECTURE = "Architecture" class Architectures(Enum): @@ -191,7 +192,7 @@ def __init__(self): "Model": "gemma2:2b-instruct-q8_0", "Model Endpoint": "https://localhost:3334/v1", "Use Local LLM": True, - "Architecture": SettingsWindow.DEFAULT_LLM_ARCHITECTURE, + SettingsKeys.LLM_ARCHITECTURE.value: SettingsWindow.DEFAULT_LLM_ARCHITECTURE, "use_story": False, "use_memory": False, "use_authors_note": False, @@ -582,8 +583,18 @@ def load_or_unload_model(self, old_model, new_model, old_use_local_llm, new_use_ ModelManager.start_model_threaded(self, self.main_window.root) def _create_settings_and_aiscribe_if_not_exist(self): + """ + Create the settings and AI Scribe files if they do not exist. + """ if not os.path.exists(get_resource_path('settings.txt')): - print("Settings file not found. Creating default settings file.") + architectures = self.get_available_architectures() + if Architectures.CUDA.label in architectures: + print("Settings file not found. Creating default settings file with CUDA architecture.") + self.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] = Architectures.CUDA.label + self.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value] = Architectures.CUDA.label + else: + print("Settings file not found. Creating default settings file.") + self.save_settings_to_file() if not os.path.exists(get_resource_path('aiscribe.txt')): print("AIScribe file not found. Creating default AIScribe file.") From d4d1de2b6813aa99afdf5646fb63c49c181a421c Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 16:06:49 -0500 Subject: [PATCH 241/244] refactor: Refactored the "Architecture" settings value too SettingsKeys Moved the "Architecture" to go to SettingsKeys. This way we can change the label in one location if need be and not dig through code. --- src/FreeScribe.client/Model.py | 2 +- src/FreeScribe.client/UI/SettingsWindowUI.py | 8 ++++---- src/FreeScribe.client/client.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/FreeScribe.client/Model.py b/src/FreeScribe.client/Model.py index 252da176..07da459a 100644 --- a/src/FreeScribe.client/Model.py +++ b/src/FreeScribe.client/Model.py @@ -194,7 +194,7 @@ def load_model(): """ gpu_layers = 0 - if app_settings.editable_settings["Architecture"] == "CUDA (Nvidia GPU)": + if app_settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value] == "CUDA (Nvidia GPU)": gpu_layers = -1 model_to_use = "gemma-2-2b-it-Q8_0.gguf" diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index b8a0997f..e5341e7e 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -283,8 +283,8 @@ def create_llm_settings(self): self.local_architecture_label.grid(row=left_row, column=0, padx=0, pady=5, sticky="w") architecture_options = self.settings.get_available_architectures() self.architecture_dropdown = ttk.Combobox(left_frame, values=architecture_options, width=20, state="readonly") - if self.settings.editable_settings["Architecture"] in architecture_options: - self.architecture_dropdown.current(architecture_options.index(self.settings.editable_settings["Architecture"])) + if self.settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value] in architecture_options: + self.architecture_dropdown.current(architecture_options.index(self.settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value])) else: # Default cpu self.architecture_dropdown.set(Architectures.CPU.label) @@ -597,7 +597,7 @@ def save_settings(self, close_window=True): self.get_selected_model(), self.settings.editable_settings["Use Local LLM"], self.settings.editable_settings_entries["Use Local LLM"].get(), - self.settings.editable_settings["Architecture"], + self.settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value], self.architecture_dropdown.get()) if self.get_selected_model() not in ["Loading models...", "Failed to load models"]: @@ -609,7 +609,7 @@ def save_settings(self, close_window=True): self.settings.editable_settings["Post-Processing"] = self.postprocess_text.get("1.0", "end-1c") # end-1c removes the trailing newline # save architecture - self.settings.editable_settings["Architecture"] = self.architecture_dropdown.get() + self.settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value] = self.architecture_dropdown.get() self.settings.save_settings( self.openai_api_key_entry.get(), diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index bd523d66..4c465026 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -1539,7 +1539,7 @@ def set_cuda_paths(): architecture is selected. Updates CUDA_PATH, CUDA_PATH_V12_4, and PATH environment variables with the appropriate NVIDIA driver paths. """ - if (get_selected_whisper_architecture() != Architectures.CUDA.architecture_value) or (app_settings.editable_settings["Architecture"] != Architectures.CUDA.label): + if (get_selected_whisper_architecture() != Architectures.CUDA.architecture_value) or (app_settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value] != Architectures.CUDA.label): return nvidia_base_path = Path(get_file_path('nvidia-drivers')) From e9613d4e2c11748c119345442aa78ee0e8adae2b Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 16:08:07 -0500 Subject: [PATCH 242/244] fix: Potential circular import removed --- src/FreeScribe.client/Model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FreeScribe.client/Model.py b/src/FreeScribe.client/Model.py index 07da459a..252da176 100644 --- a/src/FreeScribe.client/Model.py +++ b/src/FreeScribe.client/Model.py @@ -194,7 +194,7 @@ def load_model(): """ gpu_layers = 0 - if app_settings.editable_settings[SettingsKeys.LLM_ARCHITECTURE.value] == "CUDA (Nvidia GPU)": + if app_settings.editable_settings["Architecture"] == "CUDA (Nvidia GPU)": gpu_layers = -1 model_to_use = "gemma-2-2b-it-Q8_0.gguf" From 7808604ced6caf65554d18009193fd995d30aac7 Mon Sep 17 00:00:00 2001 From: Alex Simko Date: Fri, 31 Jan 2025 16:15:08 -0500 Subject: [PATCH 243/244] docs: Added a comment to the _create_settings_and_aiscribe_if_not_exist --- src/FreeScribe.client/UI/SettingsWindow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index 65f95df9..fde01685 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -588,6 +588,8 @@ def _create_settings_and_aiscribe_if_not_exist(self): """ if not os.path.exists(get_resource_path('settings.txt')): architectures = self.get_available_architectures() + + # If CUDA is available, set it as the default architecture to save in settings if Architectures.CUDA.label in architectures: print("Settings file not found. Creating default settings file with CUDA architecture.") self.editable_settings[SettingsKeys.WHISPER_ARCHITECTURE.value] = Architectures.CUDA.label From 64ac5caf2d3b5d66e8b49b10b220fa4716cf822b Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Sat, 1 Feb 2025 15:46:03 -0500 Subject: [PATCH 244/244] Setting UI finetune and Version display --- src/FreeScribe.client/UI/MarkdownWindow.py | 14 +++++--- src/FreeScribe.client/UI/SettingsWindow.py | 12 +------ src/FreeScribe.client/UI/SettingsWindowUI.py | 36 +++++++++++++++----- src/FreeScribe.client/client.py | 12 ++++++- src/FreeScribe.client/utils/utils.py | 12 ++++++- 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/FreeScribe.client/UI/MarkdownWindow.py b/src/FreeScribe.client/UI/MarkdownWindow.py index 04acb8d1..f069ab6d 100644 --- a/src/FreeScribe.client/UI/MarkdownWindow.py +++ b/src/FreeScribe.client/UI/MarkdownWindow.py @@ -3,6 +3,7 @@ import tkinter as tk from tkhtmlview import HTMLLabel from utils.file_utils import get_file_path +from utils.utils import get_application_version class MarkdownWindow: """ @@ -37,9 +38,14 @@ def __init__(self, parent, title, file_path, callback=None): self.window.iconbitmap(get_file_path('assets', 'logo.ico')) # Footer frame to hold checkbox and close button - footer_frame = tk.Frame(self.window) + footer_frame = tk.Frame(self.window,bg="lightgray") footer_frame.pack(side=tk.BOTTOM, fill="x", padx=10, pady=10) + # Add a version label to the footer + version = get_application_version() + version_label = tk.Label(footer_frame, text=f"FreeScribe Client {version}",bg="lightgray",fg="black").pack(side="left", expand=True, padx=2, pady=5) + + # Create a frame to hold the HTMLLabel and scrollbar frame = tk.Frame(self.window) frame.pack(fill="both", expand=True, padx=10, pady=10) @@ -63,13 +69,13 @@ def __init__(self, parent, title, file_path, callback=None): ).pack(side=tk.BOTTOM, padx=5) close_button = tk.Button( - footer_frame, text="Close", command=lambda: self._on_close(var, callback) + footer_frame, text="Close", command=lambda: self._on_close(var, callback),font=("Arial", 12),width=6,height=1 ) else: - close_button = tk.Button(footer_frame, text="Close", command=self.window.destroy) + close_button = tk.Button(footer_frame, text="Close", command=self.window.destroy,font=("Arial", 12),width=6,height=1) # Add the close button - close_button.pack(side=tk.BOTTOM, padx=5) + close_button.pack(side=tk.BOTTOM, padx=5, pady=5) # Adjust window size based on content with constraints self._adjust_window_size(html_label, scrollbar) diff --git a/src/FreeScribe.client/UI/SettingsWindow.py b/src/FreeScribe.client/UI/SettingsWindow.py index d83507cc..c2cee138 100644 --- a/src/FreeScribe.client/UI/SettingsWindow.py +++ b/src/FreeScribe.client/UI/SettingsWindow.py @@ -629,14 +629,4 @@ def update_whisper_model(self): old_whisper_architecture != self.editable_settings_entries[SettingsKeys.WHISPER_ARCHITECTURE.value].get() or old_cpu_count != self.editable_settings_entries[SettingsKeys.WHISPER_CPU_COUNT.value].get() or old_compute_type != self.editable_settings_entries[SettingsKeys.WHISPER_COMPUTE_TYPE.value].get()): - self.main_window.root.event_generate("<>") - - def get_application_version(self): - version_str = "vx.x.x.alpha" - try: - with open(get_file_path('__version__'), 'r') as file: - version_str = file.read().strip() - except Exception as e: - print(f"Error loading version file ({type(e).__name__}). {e}") - finally: - return version_str \ No newline at end of file + self.main_window.root.event_generate("<>") \ No newline at end of file diff --git a/src/FreeScribe.client/UI/SettingsWindowUI.py b/src/FreeScribe.client/UI/SettingsWindowUI.py index b8a0997f..fe4abb37 100644 --- a/src/FreeScribe.client/UI/SettingsWindowUI.py +++ b/src/FreeScribe.client/UI/SettingsWindowUI.py @@ -26,6 +26,7 @@ import threading from Model import Model, ModelManager from utils.file_utils import get_file_path +from utils.utils import get_application_version from UI.MarkdownWindow import MarkdownWindow from UI.Widgets.MicrophoneSelector import MicrophoneSelector from UI.SettingsWindow import SettingsKeys, FeatureToggle, Architectures, SettingsWindow @@ -102,8 +103,8 @@ def open_settings_window(self): self.docker_settings_frame = ttk.Frame(self.notebook) self.notebook.add(self.general_settings_frame, text="General Settings") - self.notebook.add(self.llm_settings_frame, text="AI Settings") self.notebook.add(self.whisper_settings_frame, text="Speech-to-Text Settings") + self.notebook.add(self.llm_settings_frame, text="AI Settings") self.notebook.add(self.advanced_frame, text="Advanced Settings") self.settings_window.protocol("WM_DELETE_WINDOW", self.close_window) @@ -559,15 +560,15 @@ def create_buttons(self): This method creates and places buttons for saving settings, resetting to default, and closing the settings window. """ - footer_frame = tk.Frame(self.main_frame) + footer_frame = tk.Frame(self.main_frame,bg="lightgray", height=30) footer_frame.pack(side="bottom", fill="x") # Place the "Help" button on the left tk.Button(footer_frame, text="Help", width=10, command=self.create_help_window).pack(side="left", padx=2, pady=5) # Place the label in the center - version = self.settings.get_application_version() - tk.Label(footer_frame, text=f"FreeScribe Client {version}").pack(side="left", expand=True, padx=2, pady=5) + version = get_application_version() + tk.Label(footer_frame, text=f"FreeScribe Client {version}",bg="lightgray",fg="black").pack(side="left", expand=True, padx=2, pady=5) # Create a frame for the right-side elements right_frame = tk.Frame(footer_frame) @@ -668,12 +669,29 @@ def _create_general_settings(self): # Add a note at the bottom of the general settings frame note_text = ( - "Note: 'Show Scrub PHI' will only work for local LLM and private network.\n" - "For internet-facing endpoint, it will be enabled regardless of the 'Show Scrub PHI' value." + "NOTE: To protect personal health information (PHI), we recommend using a local network.\n" + "The 'Show Scrub PHI' feature is only applicable for local LLMs and private networks.\n" + "For internet-facing endpoints, this feature will always be enabled, regardless of the 'Show Scrub PHI' setting." + ) + + # Create a frame to hold the note labels + note_frame = tk.Frame(self.general_settings_frame) + note_frame.grid(padx=10, pady=5, sticky="w") + + # Add the red * label + star_label = tk.Label(note_frame, text="*", fg="red", font=("Arial", 10, "bold")) + star_label.grid(row=0, column=0, sticky="w") + + # Add the rest of the text in black (bold and underlined) + note_label = tk.Label( + note_frame, + text=note_text, + fg="black", # Set text color to black + font=("Arial", 8, "bold underline"), # Set font to bold and underlined + wraplength=400, + justify="left" ) - note_label = tk.Label(self.general_settings_frame, text=note_text, fg="red", wraplength=400, justify="left") - note_label.grid(padx=10, pady=5, sticky="w") - + note_label.grid(row=0, column=1, sticky="w") def _create_checkbox(self, frame, label, setting_name, row_idx, setting_key=None): """ diff --git a/src/FreeScribe.client/client.py b/src/FreeScribe.client/client.py index bd523d66..479cfa54 100644 --- a/src/FreeScribe.client/client.py +++ b/src/FreeScribe.client/client.py @@ -48,6 +48,7 @@ from utils.ip_utils import is_private_ip from utils.file_utils import get_file_path, get_resource_path from utils.OneInstance import OneInstance +from utils.utils import get_application_version from UI.DebugWindow import DualOutput from utils.utils import window_has_running_instance, bring_to_front, close_mutex from WhisperModel import TranscribeError @@ -1259,6 +1260,7 @@ def set_full_view(): pause_button.grid(row=1, column=2, pady=5, padx=0,sticky='nsew') switch_view_button.grid(row=1, column=7, pady=5, padx=0,sticky='nsew') blinking_circle_canvas.grid(row=1, column=8, padx=0,pady=5) + footer_frame.grid() window.toggle_menu_bar(enable=True) @@ -1320,7 +1322,7 @@ def set_minimal_view(): response_display.grid_remove() timestamp_listbox.grid_remove() blinking_circle_canvas.grid_remove() - + footer_frame.grid_remove() # Configure minimal view button sizes and placements mic_button.config(width=2, height=1) pause_button.config(width=2, height=1) @@ -1633,6 +1635,14 @@ def set_cuda_paths(): timestamp_listbox.insert(tk.END, "Temporary Note History") timestamp_listbox.config(fg='grey') +# Add a footer frame at the bottom of the window +footer_frame = tk.Frame(root, bg="lightgray", height=30) +footer_frame.grid(row=100, column=0, columnspan=100, sticky="ew") # Use grid instead of pack + +# Add "Version 2" label in the center of the footer +version = get_application_version() +version_label = tk.Label(footer_frame, text=f"FreeScribe Client {version}",bg="lightgray",fg="black").pack(side="left", expand=True, padx=2, pady=5) + window.update_aiscribe_texts(None) # Bind Alt+P to send_and_receive function root.bind('', lambda event: pause_button.invoke()) diff --git a/src/FreeScribe.client/utils/utils.py b/src/FreeScribe.client/utils/utils.py index 358fb0d3..581ab839 100644 --- a/src/FreeScribe.client/utils/utils.py +++ b/src/FreeScribe.client/utils/utils.py @@ -1,6 +1,6 @@ import ctypes - +from utils.file_utils import get_file_path # Define the mutex name and error code MUTEX_NAME = 'Global\\FreeScribe_Instance' ERROR_ALREADY_EXISTS = 183 @@ -44,3 +44,13 @@ def close_mutex(): ctypes.windll.kernel32.ReleaseMutex(mutex) ctypes.windll.kernel32.CloseHandle(mutex) mutex = None + +def get_application_version(): + version_str = "vx.x.x.alpha" + try: + with open(get_file_path('__version__'), 'r') as file: + version_str = file.read().strip() + except Exception as e: + print(f"Error loading version file ({type(e).__name__}). {e}") + finally: + return version_str \ No newline at end of file