├── scripts ├── CPU_INSTALL.txt ├── NVIDIA_INSTALL.txt ├── assets │ ├── logo.ico │ └── Gemma_License.txt ├── nsis-plugins │ ├── x86-ansi │ │ ├── Banner.dll │ │ ├── Dialer.dll │ │ ├── INetC.dll │ │ ├── Math.dll │ │ ├── NSISdl.dll │ │ ├── Splash.dll │ │ ├── System.dll │ │ ├── VPatch.dll │ │ ├── ZipDLL.dll │ │ ├── nsExec.dll │ │ ├── BgImage.dll │ │ ├── LangDLL.dll │ │ ├── TypeLib.dll │ │ ├── UserInfo.dll │ │ ├── nsUnzip.dll │ │ ├── AdvSplash.dll │ │ ├── StartMenu.dll │ │ ├── nsDialogs.dll │ │ ├── AccessControl.dll │ │ └── InstallOptions.dll │ └── x86-unicode │ │ ├── INetC.dll │ │ ├── Math.dll │ │ ├── Banner.dll │ │ ├── BgImage.dll │ │ ├── Dialer.dll │ │ ├── LangDLL.dll │ │ ├── NSISdl.dll │ │ ├── Splash.dll │ │ ├── System.dll │ │ ├── TypeLib.dll │ │ ├── UserInfo.dll │ │ ├── VPatch.dll │ │ ├── ZipDLL.dll │ │ ├── nsExec.dll │ │ ├── nsUnzip.dll │ │ ├── AdvSplash.dll │ │ ├── StartMenu.dll │ │ ├── nsDialogs.dll │ │ ├── AccessControl.dll │ │ └── InstallOptions.dll ├── hooks │ └── hook-llama_cpp.py └── install.nsi ├── client_requirements.txt ├── src ├── FreeScribe.client │ ├── assets │ │ └── logo.ico │ ├── whisper-assets │ │ └── mel_filters.npz │ ├── markdown │ │ ├── help │ │ │ ├── about.md │ │ │ └── settings.md │ │ └── welcome.md │ ├── utils │ │ ├── ip_utils.py │ │ └── file_utils.py │ ├── presets │ │ ├── ChatGPT.json │ │ ├── Local AI.json │ │ └── ClinicianFOCUS Toolbox.json │ ├── UI │ │ ├── MarkdownWindow.py │ │ ├── Widgets │ │ │ ├── CustomTextBox.py │ │ │ ├── MicrophoneSelector.py │ │ │ └── AudioMeter.py │ │ ├── DebugWindow.py │ │ ├── LoadingWindow.py │ │ ├── MainWindow.py │ │ ├── MainWindowUI.py │ │ └── SettingsWindow.py │ ├── ContainerManager.py │ ├── Model.py │ └── clientfasterwhisper.py └── Freescribe.server │ ├── server.py │ ├── serverwhisperx.py │ └── serverfasterwhisper.py ├── .gitignore ├── mac ├── distribution.xml └── scripts │ └── postinstall ├── .github └── workflows │ ├── README.md │ └── release.yml ├── client_requirements_nvidia.txt └── README.md /scripts/CPU_INSTALL.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/NVIDIA_INSTALL.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client_requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/client_requirements.txt -------------------------------------------------------------------------------- /scripts/assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/assets/logo.ico -------------------------------------------------------------------------------- /src/FreeScribe.client/assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/src/FreeScribe.client/assets/logo.ico -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/Banner.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/Banner.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/Dialer.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/Dialer.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/INetC.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/INetC.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/Math.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/Math.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/NSISdl.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/NSISdl.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/Splash.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/Splash.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/System.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/System.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/VPatch.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/VPatch.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/ZipDLL.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/ZipDLL.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/nsExec.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/nsExec.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/BgImage.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/BgImage.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/LangDLL.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/LangDLL.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/TypeLib.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/TypeLib.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/UserInfo.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/UserInfo.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/nsUnzip.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/nsUnzip.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/INetC.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/INetC.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/Math.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/Math.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/AdvSplash.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/AdvSplash.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/StartMenu.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/StartMenu.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/nsDialogs.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/nsDialogs.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/Banner.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/Banner.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/BgImage.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/BgImage.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/Dialer.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/Dialer.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/LangDLL.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/LangDLL.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/NSISdl.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/NSISdl.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/Splash.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/Splash.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/System.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/System.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/TypeLib.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/TypeLib.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/UserInfo.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/UserInfo.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/VPatch.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/VPatch.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/ZipDLL.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/ZipDLL.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/nsExec.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/nsExec.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/nsUnzip.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/nsUnzip.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/AccessControl.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/AccessControl.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/AdvSplash.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/AdvSplash.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/StartMenu.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/StartMenu.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/nsDialogs.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/nsDialogs.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-ansi/InstallOptions.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-ansi/InstallOptions.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/AccessControl.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/AccessControl.dll -------------------------------------------------------------------------------- /scripts/nsis-plugins/x86-unicode/InstallOptions.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/scripts/nsis-plugins/x86-unicode/InstallOptions.dll -------------------------------------------------------------------------------- /src/FreeScribe.client/whisper-assets/mel_filters.npz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1984Doc/AI-Scribe/HEAD/src/FreeScribe.client/whisper-assets/mel_filters.npz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | aiscribe.txt 2 | aiscribe2.txt 3 | settings.txt 4 | 5 | .venv 6 | __pycache__ 7 | build 8 | dist 9 | recording.wav 10 | output 11 | client.spec 12 | .aider* 13 | .DS_Store 14 | 15 | realtime.wav 16 | src/FreeScribe.client/models 17 | freescribe-client.spec 18 | freescribe-client-cpu.spec 19 | freescribe-client-nvidia.spec 20 | scripts/FreeScribeInstaller.exe 21 | -------------------------------------------------------------------------------- /mac/distribution.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | FreeScribe Installer 4 | 5 | 6 | 7 | 8 | 9 | 10 | #installer.pkg 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/FreeScribe.client/markdown/help/about.md: -------------------------------------------------------------------------------- 1 | # AI-Scribe 2 | 3 | ## Introduction 4 | 5 | > 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. 6 | > 7 | > Regards, 8 | > 9 | > Braedon Hendy 10 | -------------------------------------------------------------------------------- /scripts/hooks/hook-llama_cpp.py: -------------------------------------------------------------------------------- 1 | # How to use this file 2 | # 3 | # 1. create a folder called "hooks" in your repo 4 | # 2. copy this file there 5 | # 3. add the --additional-hooks-dir flag to your pyinstaller command: 6 | # ex: `pyinstaller --name binary-name --additional-hooks-dir=./hooks entry-point.py` 7 | 8 | 9 | from PyInstaller.utils.hooks import collect_data_files, get_package_paths 10 | import os, sys 11 | 12 | # Get the package path 13 | package_path = get_package_paths('llama_cpp')[0] 14 | 15 | # Collect data files 16 | datas = collect_data_files('llama_cpp') 17 | 18 | # Append the additional .dll or .so file 19 | dll_path = os.path.join(package_path, 'llama_cpp', 'lib', 'llama.dll') 20 | datas.append((dll_path, 'llama_cpp')) 21 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # Pushing and Building Release on GitHub Workflow 2 | 3 | ## Steps to Push and Build a Release 4 | 5 | 1. **Checkout the main branch:** 6 | 7 | ```sh 8 | git checkout main 9 | ``` 10 | 11 | 2. **Pull the latest changes from the main branch:** 12 | 13 | ```sh 14 | git pull origin main 15 | ``` 16 | 17 | 3. **Tag the release:** 18 | 19 | ```sh 20 | git tag 21 | ``` 22 | 23 | Example: 24 | 25 | ```sh 26 | git tag v0.0.1 27 | ``` 28 | 29 | 4. **Push the changes and the tag to the remote repository:** 30 | 31 | ```sh 32 | git push origin 33 | ``` 34 | 35 | Example: 36 | 37 | ```sh 38 | git push origin v0.0.1 39 | ``` 40 | 41 | ## Tagging Conventions 42 | 43 | - `vx.x.x` tags latest release. 44 | - `vx.x.x.alpha` tags pre-release. 45 | -------------------------------------------------------------------------------- /mac/scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set Hugging Face model URL and output directory 4 | MODEL_URL="https://huggingface.co/lmstudio-community/gemma-2-2b-it-GGUF/resolve/main/gemma-2-2b-it-Q8_0.gguf?download=true" 5 | OUTPUT_DIR="/Users/Shared/FreeScribe/models" 6 | 7 | # Ensure the output directory exists 8 | mkdir -p "$OUTPUT_DIR" 9 | 10 | echo "Checking if the model already exists..." 11 | if [ -f "$OUTPUT_DIR/gemma-2-2b-it-Q8_0.gguf" ]; then 12 | echo "Model already exists. Skipping download." 13 | exit 0 14 | fi 15 | echo "Downloading model from Hugging Face..." 16 | curl -L "$MODEL_URL" -o "$OUTPUT_DIR/gemma-2-2b-it-Q8_0.gguf" 17 | 18 | # Check if the download succeeded 19 | if [ $? -eq 0 ]; then 20 | echo "Model downloaded successfully." 21 | else 22 | echo "Failed to download the model. Please check your internet connection." 23 | exit 1 24 | fi 25 | -------------------------------------------------------------------------------- /src/FreeScribe.client/utils/ip_utils.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from urllib.parse import urlparse 3 | import re 4 | 5 | 6 | def extract_ip_from_url(url): 7 | """ 8 | Extract the IP address from a URL. 9 | 10 | :param url: The URL to extract the IP address from. 11 | :type url: str 12 | :return: The extracted IP address. 13 | :rtype: str 14 | """ 15 | parsed_url = urlparse(url) 16 | return parsed_url.hostname 17 | 18 | def is_private_ip(ip_or_url): 19 | """ 20 | Check if the given IP address is a private IP address (RFC 1918). 21 | 22 | :param ip: The IP address to check. 23 | :type ip: str 24 | :return: True if the IP address is private, False otherwise. 25 | :rtype: bool 26 | """ 27 | try: 28 | ip = extract_ip_from_url(ip_or_url) if '://' in ip_or_url else ip_or_url 29 | ip_obj = ipaddress.ip_address(ip) 30 | return ip_obj.is_private 31 | except ValueError: 32 | # Handle invalid IP address 33 | return False 34 | 35 | def is_valid_url(url): 36 | """ 37 | Validates if the provided string is a URL. 38 | 39 | A URL is considered valid if it starts with 'http://' or 'https://', 40 | optionally includes 'www.', and contains a combination of alphanumeric 41 | characters, dots, and slashes. 42 | 43 | Args: 44 | url (str): The URL string to validate. 45 | 46 | Returns: 47 | bool: True if the string is a valid URL, False otherwise. 48 | """ 49 | 50 | url_regex = re.compile(r'https?://(?:www\.)?[a-zA-Z0-9./]+') 51 | return re.match(url_regex, url) is not None 52 | -------------------------------------------------------------------------------- /client_requirements_nvidia.txt: -------------------------------------------------------------------------------- 1 | --index-url https://abetlen.github.io/llama-cpp-python/whl/cu121 --extra-index-url https://pypi.org/simple 2 | altgraph==0.17.4 3 | annotated-types==0.7.0 4 | anyio==4.6.0 5 | catalogue==2.0.10 6 | certifi==2024.8.30 7 | charset-normalizer==3.3.2 8 | click==8.1.7 9 | colorama==0.4.6 10 | dateparser==1.2.0 11 | distro==1.9.0 12 | exceptiongroup==1.2.2 13 | Faker==30.0.0 14 | filelock==3.16.1 15 | fsspec==2024.9.0 16 | h11==0.14.0 17 | httpcore==1.0.5 18 | httpx==0.27.2 19 | idna==3.10 20 | Jinja2==3.1.4 21 | jiter==0.5.0 22 | joblib==1.4.2 23 | llvmlite==0.43.0 24 | MarkupSafe==2.1.5 25 | more-itertools==10.5.0 26 | mpmath==1.3.0 27 | networkx==3.3 28 | nltk==3.9.1 29 | numba==0.60.0 30 | numpy==1.26.4 31 | openai==1.50.2 32 | openai-whisper==20240927 33 | packaging==24.1 34 | pefile==2024.8.26 35 | phonenumbers==8.13.46 36 | PyAudio==0.2.14 37 | pydantic==2.9.2 38 | pydantic_core==2.23.4 39 | pyinstaller==6.10.0 40 | pyinstaller-hooks-contrib==2024.8 41 | pyperclip==1.9.0 42 | python-dateutil==2.9.0.post0 43 | python-stdnum==1.20 44 | pytz==2024.2 45 | pywin32-ctypes==0.2.3 46 | regex==2024.9.11 47 | requests==2.32.3 48 | scikit-learn==1.5.2 49 | scipy==1.14.1 50 | scrubadub==2.0.1 51 | six==1.16.0 52 | sniffio==1.3.1 53 | SpeechRecognition==3.10.4 54 | sympy==1.13.3 55 | textblob==0.15.3 56 | threadpoolctl==3.5.0 57 | tiktoken==0.7.0 58 | torch==2.2.2 59 | tqdm==4.66.5 60 | typing_extensions==4.12.2 61 | tzdata==2024.2 62 | tzlocal==5.2 63 | urllib3==2.2.3 64 | docker==7.1.0 65 | markdown==3.7 66 | tkhtmlview==0.3.1 67 | llama-cpp-python==v0.2.90 68 | -------------------------------------------------------------------------------- /src/FreeScribe.client/markdown/welcome.md: -------------------------------------------------------------------------------- 1 | # Welcome to the FreeScribe Project 2 | 3 | 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. 4 | 5 | ## Introduction 6 | 7 | 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. 8 | 9 | ## Features 10 | 11 | - **Real-time Transcription**: Transcribe conversations in real-time using advanced speech recognition models. 12 | - **Medical Note Generation**: Automatically generate structured medical notes from transcriptions. 13 | - **User-Friendly Interface**: Intuitive and easy-to-use interface for healthcare professionals. 14 | - **Customizable Settings**: Customize the application settings to suit your workflow. 15 | - **Docker Integration**: Easily manage the application using Docker containers. 16 | 17 | ## Discord Community 18 | 19 | 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. 20 | 21 | [Join our Discord Community](https://discord.gg/6DnPENSn) 22 | 23 | ## Contributing 24 | 25 | We welcome contributions to the FreeScribe project! To contribute: 26 | 27 | 1. Fork the [repository](https://github.com/ClinicianFOCUS/FreeScribe). 28 | 2. Create a new branch (`git checkout -b feature/your-feature`). 29 | 3. Make your changes and commit them (`git commit -m 'Add some feature'`). 30 | 4. Push to the branch (`git push origin feature/your-feature`). 31 | 5. Open a pull request. 32 | 33 | Please ensure your code adheres to our coding standards and includes appropriate tests. 34 | 35 | ## License 36 | 37 | This project is licensed under the MIT License. See the [LICENSE](https://github.com/ClinicianFOCUS/FreeScribe/blob/main/LICENSE.txt) file for more information. 38 | -------------------------------------------------------------------------------- /src/Freescribe.server/server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Braedon Hendy 2 | # This software is released under the GNU General Public License v3.0 3 | 4 | from http.server import BaseHTTPRequestHandler, HTTPServer 5 | import whisper 6 | import cgi 7 | import json 8 | import os 9 | import tempfile 10 | 11 | # Initialize Whisper model 12 | model = whisper.load_model("medium") 13 | 14 | class RequestHandler(BaseHTTPRequestHandler): 15 | def do_POST(self): 16 | if self.path == '/whisperaudio': 17 | ctype, pdict = cgi.parse_header(self.headers.get('content-type')) 18 | if ctype == 'multipart/form-data': 19 | pdict['boundary'] = bytes(pdict['boundary'], "utf-8") 20 | fields = cgi.parse_multipart(self.rfile, pdict) 21 | audio_data = fields.get('audio')[0] 22 | 23 | # Save the audio file temporarily 24 | with tempfile.NamedTemporaryFile(delete=False) as temp_audio_file: 25 | temp_audio_file.write(audio_data) 26 | temp_file_path = temp_audio_file.name 27 | 28 | try: 29 | # Process the file with Whisper 30 | result = model.transcribe(temp_file_path) 31 | 32 | # Send response 33 | self.send_response(200) 34 | self.send_header('Content-type', 'application/json') 35 | self.end_headers() 36 | response_data = json.dumps({"text": result["text"]}) 37 | self.wfile.write(response_data.encode()) 38 | finally: 39 | # Clean up the temporary file 40 | os.remove(temp_file_path) 41 | else: 42 | self.send_error(400, "Invalid content type") 43 | else: 44 | self.send_error(404, "File not found") 45 | 46 | def run(server_class=HTTPServer, handler_class=RequestHandler, port=8000): 47 | server_address = ('', port) 48 | httpd = server_class(server_address, handler_class) 49 | print(f'Server running at http://localhost:{port}/') 50 | httpd.serve_forever() 51 | 52 | if __name__ == '__main__': 53 | run() 54 | -------------------------------------------------------------------------------- /src/FreeScribe.client/utils/file_utils.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from functools import lru_cache 3 | import os 4 | import sys 5 | 6 | def get_file_path(*file_names: str) -> str: 7 | """ 8 | Get the full path to a files. Use Temporary directory at runtime for bundled apps, otherwise use the current working directory. 9 | 10 | :param file_names: The names of the directories and the file. 11 | :type file_names: str 12 | :return: The full path to the file. 13 | :rtype: str 14 | """ 15 | base = sys._MEIPASS if hasattr(sys, '_MEIPASS') else os.path.abspath('.') 16 | return os.path.join(base, *file_names) 17 | 18 | def get_resource_path(filename: str) -> str: 19 | """ 20 | Get the path to the files. Use User data directory for bundled apps, otherwise use the current working directory. 21 | 22 | :param filename: The name of the file. 23 | :type filename: str 24 | :return: The full path to the file. 25 | :rtype: str 26 | """ 27 | if hasattr(sys, '_MEIPASS'): 28 | base = _get_user_data_dir() 29 | freescribe_dir = os.path.join(base, 'FreeScribe') 30 | 31 | # Check if the FreeScribe directory exists, if not, create it 32 | try: 33 | if not os.path.exists(freescribe_dir): 34 | os.makedirs(freescribe_dir) 35 | except OSError as e: 36 | raise RuntimeError(f"Failed to create FreeScribe directory: {e}") 37 | 38 | return os.path.join(freescribe_dir, filename) 39 | else: 40 | return os.path.abspath(filename) 41 | 42 | def _get_user_data_dir() -> str: 43 | """ 44 | Get the user data directory for the current platform. 45 | 46 | :return: The path to the user data directory. 47 | :rtype: str 48 | """ 49 | if sys.platform == "win32": # Windows 50 | buf = ctypes.create_unicode_buffer(1024) 51 | ctypes.windll.shell32.SHGetFolderPathW(None, 0x001a, None, 0, buf) 52 | return buf.value 53 | elif sys.platform == "darwin": # macOS 54 | return os.path.expanduser("~/Library/Application Support") 55 | else: # Linux 56 | path = os.environ.get("XDG_DATA_HOME", "") 57 | if not path.strip(): 58 | path = os.path.expanduser("~/.local/share") 59 | return self._append_app_name_and_version(path) 60 | 61 | -------------------------------------------------------------------------------- /src/FreeScribe.client/presets/ChatGPT.json: -------------------------------------------------------------------------------- 1 | { 2 | "openai_api_key": "None", 3 | "editable_settings": { 4 | "Model": "gpt-4o-mini", 5 | "Model Endpoint": "https://api.openai.com/v1/", 6 | "use_story": 0, 7 | "use_memory": 0, 8 | "use_authors_note": 0, 9 | "use_world_info": 0, 10 | "max_context_length": 5000, 11 | "max_length": 400, 12 | "rep_pen": "1.1", 13 | "rep_pen_range": 5000, 14 | "rep_pen_slope": "0.7", 15 | "temperature": "0.1", 16 | "tfs": "0.97", 17 | "top_a": "0.8", 18 | "top_k": 30, 19 | "top_p": "0.4", 20 | "typical": "0.19", 21 | "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", 22 | "singleline": 0, 23 | "frmttriminc": 0, 24 | "frmtrmblln": 0, 25 | "Local Whisper": 1, 26 | "Whisper Endpoint": "https://localhost:2224/whisperaudio", 27 | "Whisper Server API Key": "None", 28 | "Whisper Model": "small.en", 29 | "Real Time": 1, 30 | "Real Time Audio Length": "5", 31 | "Real Time Silence Length": 1, 32 | "Silence cut-off": 0.035003662109375, 33 | "LLM Container Name": "ollama", 34 | "LLM Caddy Container Name": "caddy-ollama", 35 | "Whisper Container Name": "speech-container", 36 | "Whisper Caddy Container Name": "caddy", 37 | "Auto Shutdown Containers on Exit": 1, 38 | "Use Docker Status Bar": 0, 39 | "Preset": "Custom", 40 | "Use Local LLM": 0, 41 | "Architecture": "CPU", 42 | "best_of": "2", 43 | "Use best_of": 0, 44 | "LLM Authentication Container Name": "authentication-ollama", 45 | "Show Welcome Message": 0, 46 | "Enable Scribe Template": 0, 47 | "Use Pre-Processing": 1, 48 | "Use Post-Processing": 0, 49 | "AI Server Self-Signed Certificates": 0, 50 | "S2T Server Self-Signed Certificates": 0, 51 | "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", 52 | "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.", 53 | "Show Scrub PHI": 1 54 | }, 55 | "api_style": "OpenAI" 56 | } 57 | -------------------------------------------------------------------------------- /src/FreeScribe.client/presets/Local AI.json: -------------------------------------------------------------------------------- 1 | { 2 | "openai_api_key": "None", 3 | "editable_settings": { 4 | "Model": "gemma2:2b-instruct-q8_0", 5 | "Model Endpoint": "http://localhost:3334/v1/", 6 | "use_story": 0, 7 | "use_memory": 0, 8 | "use_authors_note": 0, 9 | "use_world_info": 0, 10 | "max_context_length": 5000, 11 | "max_length": 400, 12 | "rep_pen": "1.1", 13 | "rep_pen_range": 5000, 14 | "rep_pen_slope": "0.7", 15 | "temperature": "0.1", 16 | "tfs": "0.97", 17 | "top_a": "0.8", 18 | "top_k": 30, 19 | "top_p": "0.4", 20 | "typical": "0.19", 21 | "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", 22 | "singleline": 0, 23 | "frmttriminc": 0, 24 | "frmtrmblln": 0, 25 | "Local Whisper": 1, 26 | "Whisper Endpoint": "https://localhost:2224/whisperaudio/", 27 | "Whisper Server API Key": "None", 28 | "Whisper Model": "small.en", 29 | "Real Time": 1, 30 | "Real Time Audio Length": "5", 31 | "Real Time Silence Length": 1, 32 | "Silence cut-off": 0.035003662109375, 33 | "LLM Container Name": "ollama", 34 | "LLM Caddy Container Name": "caddy-ollama", 35 | "Whisper Container Name": "speech-container", 36 | "Whisper Caddy Container Name": "caddy", 37 | "Auto Shutdown Containers on Exit": 1, 38 | "Use Docker Status Bar": 0, 39 | "Preset": "Custom", 40 | "Use Local LLM": 1, 41 | "Architecture": "CPU", 42 | "best_of": "2", 43 | "Use best_of": 0, 44 | "LLM Authentication Container Name": "authentication-ollama", 45 | "Show Welcome Message": 0, 46 | "Enable Scribe Template": 0, 47 | "Use Pre-Processing": 1, 48 | "Use Post-Processing": 0, 49 | "AI Server Self-Signed Certificates": 0, 50 | "S2T Server Self-Signed Certificates": 0, 51 | "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", 52 | "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.", 53 | "Show Scrub PHI": 1 54 | }, 55 | "api_style": "OpenAI" 56 | } 57 | -------------------------------------------------------------------------------- /src/FreeScribe.client/presets/ClinicianFOCUS Toolbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "openai_api_key": "None", 3 | "editable_settings": { 4 | "Model": "gemma-2-2b-it-Q8_0.gguf", 5 | "Model Endpoint": "http://localhost:3334/v1/", 6 | "use_story": 0, 7 | "use_memory": 0, 8 | "use_authors_note": 0, 9 | "use_world_info": 0, 10 | "max_context_length": 5000, 11 | "max_length": 400, 12 | "rep_pen": "1.1", 13 | "rep_pen_range": 5000, 14 | "rep_pen_slope": "0.7", 15 | "temperature": "0.1", 16 | "tfs": "0.97", 17 | "top_a": "0.8", 18 | "top_k": 30, 19 | "top_p": "0.4", 20 | "typical": "0.19", 21 | "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", 22 | "singleline": 0, 23 | "frmttriminc": 0, 24 | "frmtrmblln": 0, 25 | "Local Whisper": 0, 26 | "Whisper Endpoint": "https://localhost:2224/whisperaudio/", 27 | "Whisper Server API Key": "None", 28 | "Whisper Model": "small.en", 29 | "Real Time": 1, 30 | "Real Time Audio Length": "5", 31 | "Real Time Silence Length": 1, 32 | "Silence cut-off": 0.035003662109375, 33 | "LLM Container Name": "ollama", 34 | "LLM Caddy Container Name": "caddy-ollama", 35 | "Whisper Container Name": "speech-container", 36 | "Whisper Caddy Container Name": "caddy", 37 | "Auto Shutdown Containers on Exit": 1, 38 | "Use Docker Status Bar": 0, 39 | "Preset": "Custom", 40 | "Use Local LLM": 0, 41 | "Architecture": "CPU", 42 | "best_of": "2", 43 | "Use best_of": 0, 44 | "LLM Authentication Container Name": "authentication-ollama", 45 | "Show Welcome Message": 0, 46 | "Enable Scribe Template": 0, 47 | "Use Pre-Processing": 1, 48 | "Use Post-Processing": 0, 49 | "AI Server Self-Signed Certificates": 0, 50 | "S2T Server Self-Signed Certificates": 0, 51 | "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", 52 | "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.", 53 | "Show Scrub PHI": 0 54 | }, 55 | "api_style": "OpenAI" 56 | } 57 | -------------------------------------------------------------------------------- /src/Freescribe.server/serverwhisperx.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Braedon Hendy 2 | # This software is released under the GNU General Public License v3.0 3 | 4 | from http.server import BaseHTTPRequestHandler, HTTPServer 5 | import whisperx 6 | import cgi 7 | import json 8 | import os 9 | import tempfile 10 | 11 | # Initialize Whisper model 12 | model_size = "medium.en" 13 | 14 | class RequestHandler(BaseHTTPRequestHandler): 15 | def do_POST(self): 16 | if self.path == '/whisperaudio': 17 | ctype, pdict = cgi.parse_header(self.headers.get('content-type')) 18 | if ctype == 'multipart/form-data': 19 | pdict['boundary'] = bytes(pdict['boundary'], "utf-8") 20 | fields = cgi.parse_multipart(self.rfile, pdict) 21 | audio_data = fields.get('audio')[0] 22 | 23 | # Save the audio file temporarily 24 | with tempfile.NamedTemporaryFile(delete=False) as temp_audio_file: 25 | temp_audio_file.write(audio_data) 26 | temp_file_path = temp_audio_file.name 27 | 28 | try: 29 | # Process the file with Whisper 30 | model = whisperx.load_model(model_size, device="cuda", compute_type="float16") 31 | audio = whisperx.load_audio(temp_file_path) 32 | result = model.transcribe(audio) 33 | text_segments = [segment['text'] for segment in result['segments']] 34 | transcription = " ".join(text_segments) 35 | 36 | # Send response 37 | self.send_response(200) 38 | self.send_header('Content-type', 'application/json') 39 | self.end_headers() 40 | response_data = json.dumps({"text": transcription}) 41 | self.wfile.write(response_data.encode()) 42 | finally: 43 | # Clean up the temporary file 44 | os.remove(temp_file_path) 45 | else: 46 | self.send_error(400, "Invalid content type") 47 | else: 48 | self.send_error(404, "File not found") 49 | 50 | def run(server_class=HTTPServer, handler_class=RequestHandler, port=8000): 51 | server_address = ('', port) 52 | httpd = server_class(server_address, handler_class) 53 | print(f'Server running at http://localhost:{port}/') 54 | httpd.serve_forever() 55 | 56 | if __name__ == '__main__': 57 | run() 58 | -------------------------------------------------------------------------------- /src/Freescribe.server/serverfasterwhisper.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Braedon Hendy 2 | # This software is released under the GNU General Public License v3.0 3 | 4 | from http.server import BaseHTTPRequestHandler, HTTPServer 5 | from faster_whisper import WhisperModel 6 | import cgi 7 | import json 8 | import os 9 | import tempfile 10 | 11 | # Initialize Whisper model 12 | model_size = "medium.en" 13 | 14 | class RequestHandler(BaseHTTPRequestHandler): 15 | def do_POST(self): 16 | if self.path == '/whisperaudio': 17 | ctype, pdict = cgi.parse_header(self.headers.get('content-type')) 18 | if ctype == 'multipart/form-data': 19 | pdict['boundary'] = bytes(pdict['boundary'], "utf-8") 20 | fields = cgi.parse_multipart(self.rfile, pdict) 21 | audio_data = fields.get('audio')[0] 22 | 23 | # Save the audio file temporarily 24 | with tempfile.NamedTemporaryFile(delete=False) as temp_audio_file: 25 | temp_audio_file.write(audio_data) 26 | temp_file_path = temp_audio_file.name 27 | 28 | try: 29 | # Process the file with Whisper 30 | model = WhisperModel(model_size, device="cuda", compute_type="float16") 31 | # or run on GPU with INT8 32 | # model = WhisperModel(model_size, device="cuda", compute_type="int8_float16") 33 | # or run on CPU with INT8 34 | # model = WhisperModel(model_size, device="cpu", compute_type="int8") 35 | segments, info = model.transcribe(temp_file_path, beam_size=5) 36 | print("Detected language '%s' with probability %f" % (info.language, info.language_probability)) 37 | transcription = "".join(segment.text for segment in segments) 38 | 39 | # Send response 40 | self.send_response(200) 41 | self.send_header('Content-type', 'application/json') 42 | self.end_headers() 43 | response_data = json.dumps({"text": transcription}) 44 | self.wfile.write(response_data.encode()) 45 | finally: 46 | # Clean up the temporary file 47 | os.remove(temp_file_path) 48 | else: 49 | self.send_error(400, "Invalid content type") 50 | else: 51 | self.send_error(404, "File not found") 52 | 53 | def run(server_class=HTTPServer, handler_class=RequestHandler, port=8000): 54 | server_address = ('', port) 55 | httpd = server_class(server_address, handler_class) 56 | print(f'Server running at http://localhost:{port}/') 57 | httpd.serve_forever() 58 | 59 | if __name__ == '__main__': 60 | run() 61 | -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/MarkdownWindow.py: -------------------------------------------------------------------------------- 1 | from tkinter import Toplevel, messagebox 2 | import markdown as md 3 | import tkinter as tk 4 | from tkhtmlview import HTMLLabel 5 | from utils.file_utils import get_file_path 6 | 7 | """ 8 | A class to create a window displaying rendered Markdown content. 9 | Attributes: 10 | ----------- 11 | window : Toplevel 12 | The top-level window for displaying the Markdown content. 13 | Methods: 14 | -------- 15 | __init__(parent, title, file_path, callback=None): 16 | Initializes the MarkdownWindow with the given parent, title, file path, and optional callback. 17 | _on_close(var, callback): 18 | Handles the window close event, invoking the callback with the state of the checkbox. 19 | """ 20 | class MarkdownWindow: 21 | """ 22 | Initializes the MarkdownWindow. 23 | Parameters: 24 | ----------- 25 | parent : widget 26 | The parent widget. 27 | title : str 28 | The title of the window. 29 | file_path : str 30 | The path to the Markdown file to be rendered. 31 | callback : function, optional 32 | A callback function to be called when the window is closed, with the state of the checkbox. 33 | """ 34 | def __init__(self, parent, title, file_path, callback=None): 35 | self.window = Toplevel(parent) 36 | self.window.title(title) 37 | self.window.transient(parent) 38 | self.window.grab_set() 39 | self.window.iconbitmap(get_file_path('assets','logo.ico')) 40 | 41 | try: 42 | with open(file_path, "r") as file: 43 | content = md.markdown(file.read(), extensions=["extra", "smarty"]) 44 | 45 | except FileNotFoundError: 46 | print(f"File not found: {file_path}") 47 | self.window.destroy() 48 | messagebox.showerror("Error", "File not found") 49 | return 50 | 51 | # Create a frame to hold the HTMLLabel and scrollbar 52 | frame = tk.Frame(self.window) 53 | frame.pack(fill="both", expand=True, padx=10, pady=10) 54 | 55 | # Create the HTMLLabel widget 56 | html_label = HTMLLabel(frame, html=content) 57 | html_label.pack(side="left", fill="both", expand=True) 58 | 59 | # Create the scrollbar 60 | scrollbar = tk.Scrollbar(frame, orient="vertical", command=html_label.yview) 61 | scrollbar.pack(side="right", fill="y") 62 | 63 | # Configure the HTMLLabel to use the scrollbar 64 | html_label.config(yscrollcommand=scrollbar.set) 65 | 66 | if callback: 67 | var = tk.BooleanVar() 68 | tk.Checkbutton(self.window, text="Don't show this message again", 69 | variable=var).pack(side=tk.BOTTOM, pady=10) 70 | self.window.protocol("WM_DELETE_WINDOW", 71 | lambda: self._on_close(var, callback)) 72 | 73 | # Add a close button at the bottom center 74 | close_button = tk.Button(self.window, text="Close", command=lambda: self._on_close(var, callback)) 75 | close_button.pack(side=tk.BOTTOM) # Extra padding for separation from the checkbox 76 | else: 77 | # Add a close button at the bottom center 78 | close_button = tk.Button(self.window, text="Close", command= self.window.destroy) 79 | close_button.pack(side=tk.BOTTOM , pady=5) # Extra padding for separation from the checkbox 80 | 81 | self.window.geometry("900x900") 82 | self.window.lift() 83 | 84 | 85 | """ 86 | Handles the window close event. 87 | Parameters: 88 | ----------- 89 | var : BooleanVar 90 | The Tkinter BooleanVar associated with the checkbox. 91 | callback : function 92 | The callback function to be called with the state of the checkbox. 93 | """ 94 | def _on_close(self, var, callback): 95 | callback(var.get()) 96 | self.window.destroy() -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | - "v*.*.*.alpha" 8 | 9 | jobs: 10 | build-windows: 11 | runs-on: windows-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v1 17 | 18 | - name: Install Python 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: "3.10" 22 | architecture: "x64" 23 | 24 | # Create CUDA-enabled executable 25 | - name: Install CUDA-enabled llama_cpp 26 | run: | 27 | 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 28 | 29 | - name: Install requirements 30 | run: | 31 | pip install -r client_requirements.txt 32 | 33 | - name: Run PyInstaller for NVIDIA 34 | run: | 35 | 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 36 | 37 | # Create CPU-only executable 38 | - name: Uninstall CUDA-enabled llama_cpp (if necessary) and install CPU-only llama_cpp 39 | run: | 40 | pip uninstall -y llama-cpp-python 41 | 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 42 | 43 | - name: Run PyInstaller for CPU-only 44 | run: | 45 | 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 46 | 47 | - name: Set up NSIS 48 | uses: joncloud/makensis-action@1c9f4bf2ea0c771147db31a2f3a7f5d8705c0105 49 | with: 50 | script-file: .\scripts\install.nsi 51 | additional-plugin-paths: "./scripts/nsis-plugins" 52 | 53 | - name: Check if alpha release 54 | id: check_alpha 55 | run: | 56 | if ("${{ github.ref }}" -like "*.alpha") { 57 | echo "is_alpha=true" >> $env:GITHUB_OUTPUT 58 | } else { 59 | echo "is_alpha=false" >> $env:GITHUB_OUTPUT 60 | } 61 | shell: pwsh 62 | 63 | - name: Create release 64 | id: create_release 65 | uses: actions/create-release@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | tag_name: ${{ github.ref }} 70 | release_name: Release ${{ github.ref }} 71 | body: | 72 | ## What's Changed 73 | ${{ steps.changelog.outputs.CHANGELOG }} 74 | 75 | For full changelog, see [the commits since last release](${{ github.server_url }}/${{ github.repository }}/compare/${{ steps.changelog.last_tag }}...${{ github.ref }}) 76 | draft: false 77 | prerelease: ${{ steps.check_alpha.outputs.is_alpha == 'true' }} 78 | 79 | - name: Upload Installer 80 | id: upload-installer 81 | uses: actions/upload-release-asset@v1 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | with: 85 | upload_url: ${{steps.create_release.outputs.upload_url}} 86 | asset_path: dist/FreeScribeInstaller.exe 87 | asset_name: FreeScribeInstaller_windows.exe 88 | asset_content_type: application/octet-stream 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI-Scribe 2 | 3 | ## Introduction 4 | 5 | > 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. 6 | > 7 | > Regards, 8 | > 9 | > Braedon Hendy 10 | 11 | ## Demo 12 | [Youtube Demo](https://www.youtube.com/watch?v=w8kUB8Y3A30) 13 | 14 | ## Changelog 15 | 16 | - **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. 17 | - **2024-03-28** - updated `client.py` to allow for `Whisper` to run locally when set to `True` in the settings. 18 | - **2024-03-29** - added `Scrubadub` to be used to remove personal information prior to `OpenAI` token access. 19 | - **2024-04-26** - added alternative server file to use `Faster-Whisper` 20 | - **2024-05-03** - added alternative server file to use `WhisperX` 21 | - **2024-05-06** - added real-time `Whisper` processing 22 | - **2024-05-13** - added `SSL` and OHIP scrubbing 23 | - **2024-05-14** - `GPT` model selection 24 | - **2024-06-01** - template options and further fine-tuning for local and remote real-time `Whisper` 25 | 26 | ## Setup on a Local Machine 27 | 28 | Example instructions for running on a single machine: 29 | 30 | I will preface that this will run slowly if you are not using a GPU but will demonstrate the capability. 31 | 32 | 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`". 33 | 34 | 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: 35 | 36 | ```powershell 37 | Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 38 | Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression 39 | scoop install ffmpeg 40 | ``` 41 | 42 | 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). 43 | 44 | Run the `client.py` (it may prompt for installation of various dependencies via `pip`) 45 | 46 | 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. 47 | 48 | ## Setup on a Server 49 | 50 | Example instructions for running on a server with a GPU: 51 | 52 | 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`". 53 | 54 | Press `Windows key` + `R`, you can run the command line by typing `cmd`. Copy/type the following, running each line by pressing `Enter`: 55 | 56 | ```sh 57 | pip install openai-whisper 58 | ``` 59 | 60 | 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/) 61 | 62 | 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`. 63 | 64 | Once the `Koboldcpp.exe` is opened, click the `Browse` button and select the model downloaded. Now click the `Launch` button. 65 | 66 | You should see a window open and can ask it questions to test! 67 | 68 | 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). 69 | 70 | Run the `server.py` file. This will download the files to help organize the text after converting from audio. 71 | 72 | Run the `client.py` file and edit the IP addresses in the `Settings` menu. 73 | 74 | # How to run with JanAI 75 | 1. Download and install janAI and configure with your LLM of choice. 76 | 2. Start the JanAI server. 77 | 3. Open the python client applications and set the Model Endpoint to your settings in the JanAI (Typically http://localhost:1337/v1/ by default) 78 | 4. Set your model to the one of choice (Gemma 2 2b recommended Model ID: "gemma-2-2b-it") 79 | 5. Save the settings 80 | 6. Click the KoboldCPP button to enable custom endpoint. 81 | -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/Widgets/CustomTextBox.py: -------------------------------------------------------------------------------- 1 | """ 2 | CustomTextBox.py 3 | 4 | This software is released under the AGPL-3.0 license 5 | Copyright (c) 2023-2024 Braedon Hendy 6 | 7 | Further updates and packaging added in 2024 through the ClinicianFOCUS initiative, 8 | a collaboration with Dr. Braedon Hendy and Conestoga College Institute of Applied 9 | Learning and Technology as part of the CNERG+ applied research project, 10 | Unburdening Primary Healthcare: An Open-Source AI Clinician Partner Platform". 11 | Prof. Michael Yingbull (PI), Dr. Braedon Hendy (Partner), 12 | and Research Students - Software Developer Alex Simko, Pemba Sherpa (F24), and Naitik Patel. 13 | 14 | Classes: 15 | CustomTextBox: Custom text box with copy text button overlay. 16 | """ 17 | 18 | import tkinter as tk 19 | from tkinter import messagebox 20 | 21 | class CustomTextBox(tk.Frame): 22 | """ 23 | A custom text box widget with a built-in copy button. 24 | 25 | This widget extends the `tk.Frame` class and contains a `tk.scrolledtext.ScrolledText` widget 26 | with an additional copy button placed in the bottom-right corner. The copy button allows 27 | users to copy the entire content of the text widget to the clipboard. 28 | 29 | :param parent: The parent widget. 30 | :type parent: tk.Widget 31 | :param height: The height of the text widget in lines of text. Defaults to 10. 32 | :type height: int, optional 33 | :param state: The state of the text widget, which can be 'normal' or 'disabled'. Defaults to 'normal'. 34 | :type state: str, optional 35 | :param kwargs: Additional keyword arguments to pass to the `tk.Frame` constructor. 36 | """ 37 | def __init__(self, parent, height=10, state='normal', **kwargs): 38 | tk.Frame.__init__(self, parent, **kwargs) 39 | 40 | # Create scrolled text widget 41 | self.scrolled_text = tk.scrolledtext.ScrolledText(self, wrap="word", height=height, state=state) 42 | self.scrolled_text.pack(side="left", fill="both", expand=True) 43 | 44 | # Create copy button in bottom right corner 45 | self.copy_button = tk.Button( 46 | self.scrolled_text, 47 | text="Copy Text", 48 | command=self.copy_text, 49 | relief="raised", 50 | borderwidth=1 51 | ) 52 | self.copy_button.place(relx=1.0, rely=1.0, x=-2, y=-2, anchor="se") 53 | 54 | def copy_text(self): 55 | """ 56 | Copy all text from the text widget to the clipboard. 57 | 58 | If an error occurs during the copy operation, a message box will display the error message. 59 | """ 60 | try: 61 | # Clear clipboard and append new text 62 | self.clipboard_clear() 63 | text_content = self.scrolled_text.get("1.0", "end-1c") 64 | self.clipboard_append(text_content) 65 | except Exception as e: 66 | messagebox.showerror("Error", f"Failed to copy text: {str(e)}") 67 | 68 | def configure(self, **kwargs): 69 | """ 70 | Configure the text widget with the given keyword arguments. 71 | 72 | :param kwargs: Keyword arguments to pass to the `configure` method of the `ScrolledText` widget. 73 | """ 74 | self.scrolled_text.configure(**kwargs) 75 | 76 | def insert(self, index, text): 77 | """ 78 | Insert text into the widget at the specified index. 79 | 80 | If the widget is in a 'disabled' state, it will be temporarily set to 'normal' to allow insertion. 81 | 82 | :param index: The index at which to insert the text. 83 | :type index: str 84 | :param text: The text to insert. 85 | :type text: str 86 | """ 87 | current_state = self.scrolled_text['state'] 88 | self.scrolled_text.configure(state='normal') 89 | self.scrolled_text.insert(index, text) 90 | self.scrolled_text.configure(state=current_state) 91 | 92 | def delete(self, start, end=None): 93 | """ 94 | Delete text from the widget between the specified start and end indices. 95 | 96 | If the widget is in a 'disabled' state, it will be temporarily set to 'normal' to allow deletion. 97 | 98 | :param start: The start index of the text to delete. 99 | :type start: str 100 | :param end: The end index of the text to delete. If None, deletes from `start` to the end of the text. 101 | :type end: str, optional 102 | """ 103 | current_state = self.scrolled_text['state'] 104 | self.scrolled_text.configure(state='normal') 105 | self.scrolled_text.delete(start, end) 106 | self.scrolled_text.configure(state=current_state) 107 | 108 | def get(self, start, end=None): 109 | """ 110 | Get text from the widget between the specified start and end indices. 111 | 112 | :param start: The start index of the text to retrieve. 113 | :type start: str 114 | :param end: The end index of the text to retrieve. If None, retrieves from `start` to the end of the text. 115 | :type end: str, optional 116 | :return: The text between the specified indices. 117 | :rtype: str 118 | """ 119 | return self.scrolled_text.get(start, end) -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/DebugWindow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input/output utilities for debugging and logging with tkinter GUI support. 3 | 4 | This module provides classes for dual output handling (console and buffer) 5 | and a debug window interface built with tkinter. 6 | """ 7 | 8 | import tkinter as tk 9 | import io 10 | import sys 11 | from datetime import datetime 12 | from collections import deque 13 | 14 | class DualOutput: 15 | buffer = None 16 | MAX_BUFFER_SIZE = 2500 # Maximum number of lines in the buffer 17 | 18 | def __init__(self): 19 | """ 20 | Initialize the dual output handler. 21 | 22 | Creates a deque buffer with a max length and stores references to original stdout/stderr streams. 23 | """ 24 | DualOutput.buffer = deque(maxlen=DualOutput.MAX_BUFFER_SIZE) # Buffer with a fixed size 25 | self.original_stdout = sys.stdout # Save the original stdout 26 | self.original_stderr = sys.stderr # Save the original stderr 27 | 28 | def write(self, message): 29 | """ 30 | Write a message to both the buffer and original stdout. 31 | 32 | :param message: The message to be written 33 | :type message: str 34 | """ 35 | if message.strip(): 36 | timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 37 | if "\n" in message: # Handle multi-line messages 38 | for line in message.splitlines(): 39 | if line.strip(): 40 | formatted_message = f"{timestamp} - {line}\n" 41 | DualOutput.buffer.append(formatted_message) 42 | else: 43 | formatted_message = f"{timestamp} - {message}" 44 | DualOutput.buffer.append(formatted_message) 45 | else: 46 | DualOutput.buffer.append("\n") 47 | if self.original_stdout is not None: 48 | self.original_stdout.write(message) 49 | 50 | def flush(self): 51 | """ 52 | Flush the original stdout to ensure output is written immediately. 53 | """ 54 | if self.original_stdout is not None: 55 | self.original_stdout.flush() 56 | 57 | @staticmethod 58 | def get_buffer_content(): 59 | """ 60 | Retrieve all content stored in the buffer. 61 | 62 | :return: The complete buffer contents as a single string. 63 | :rtype: str 64 | """ 65 | return ''.join(DualOutput.buffer) 66 | 67 | class DebugPrintWindow: 68 | """ 69 | Creates and manages a tkinter window for displaying debug output. 70 | 71 | Provides a GUI interface for viewing buffered output with scroll functionality 72 | and manual refresh capability. 73 | """ 74 | 75 | def __init__(self, parent): 76 | """ 77 | Initialize the debug window interface. 78 | 79 | :param parent: Parent tkinter window 80 | :type parent: tk.Tk or tk.Toplevel 81 | """ 82 | self.window = tk.Toplevel(parent) 83 | self.window.title("Debug Output") 84 | self.window.geometry("650x450") 85 | 86 | # Create a Text widget for displaying captured output 87 | self.text_widget = tk.Text(self.window, wrap="none", width=80, height=20) 88 | self.text_widget.pack(padx=10, pady=(10, 0), fill=tk.BOTH, expand=True) 89 | 90 | # Create vertical scrollbar 91 | scrollbar = tk.Scrollbar(self.text_widget) 92 | scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 93 | 94 | # Create horizontal scrollbar 95 | hscrollbar = tk.Scrollbar(self.text_widget, orient=tk.HORIZONTAL) 96 | hscrollbar.pack(side=tk.BOTTOM, fill=tk.X) 97 | 98 | # Configure scrollbar-text widget interactions 99 | scrollbar.config(command=self.text_widget.yview) 100 | self.text_widget.config(yscrollcommand=scrollbar.set) 101 | hscrollbar.config(command=self.text_widget.xview) 102 | self.text_widget.config(xscrollcommand=hscrollbar.set) 103 | 104 | # Add refresh button 105 | refresh_button = tk.Button(self.window, text="Refresh", command=self.refresh_output) 106 | refresh_button.pack(side=tk.RIGHT,pady=10, padx=10) 107 | 108 | # Add copy to clipboard button 109 | copy_button = tk.Button(self.window, text="Copy to Clipboard", command=self._copy_to_clipboard) 110 | copy_button.pack(side=tk.LEFT, pady=10, padx=10) 111 | 112 | self.refresh_output() 113 | 114 | def _copy_to_clipboard(self): 115 | # Copy the content of the Text widget to the clipboard 116 | content = self.text_widget.get("1.0", tk.END).strip() 117 | self.window.clipboard_clear() 118 | self.window.clipboard_append(content) 119 | self.window.update_idletasks() 120 | 121 | 122 | def refresh_output(self): 123 | """ 124 | Update the debug window with latest buffer contents. 125 | 126 | Preserves scroll position when updating content and only updates 127 | if there are changes in the buffer. 128 | """ 129 | content = DualOutput.get_buffer_content() 130 | current_content = self.text_widget.get("1.0", tk.END).strip() 131 | 132 | if content != current_content: 133 | top_line_index = self.text_widget.index("@0,0") 134 | self.text_widget.delete("1.0", tk.END) 135 | self.text_widget.insert(tk.END, content) 136 | self.text_widget.see(top_line_index) -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/LoadingWindow.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | from utils.file_utils import get_file_path 4 | 5 | class LoadingWindow: 6 | """ 7 | A class to create and manage an animated processing popup window. 8 | 9 | This class creates a popup window with an animated progress bar to indicate 10 | ongoing processing and a cancel button to abort the operation. 11 | 12 | :param parent: The parent window for this popup 13 | :type parent: tk.Tk or tk.Toplevel or None 14 | :param title: The title of the popup window 15 | :type title: str 16 | :param initial_text: The initial text to display in the popup 17 | :type initial_text: str 18 | :param on_cancel: Callback function to execute when cancel is pressed 19 | :type on_cancel: callable or None 20 | 21 | :ivar popup: The main popup window 22 | :type popup: tk.Toplevel 23 | :ivar popup_label: The label widget containing the text 24 | :type popup_label: tk.Label 25 | :ivar cancelled: Flag indicating if the operation was cancelled 26 | :type cancelled: bool 27 | 28 | Example 29 | ------- 30 | >>> root = tk.Tk() 31 | >>> def cancel_callback(): 32 | ... print("Operation cancelled") 33 | >>> processing = LoadingWindow(root, on_cancel=cancel_callback) 34 | >>> # Do some work here 35 | >>> if not processing.cancelled: 36 | ... # Complete the operation 37 | >>> processing.destroy() 38 | """ 39 | 40 | def __init__(self, parent=None, title="Processing", initial_text="Loading", on_cancel=None): 41 | """ 42 | Initialize the processing popup window. 43 | 44 | :param parent: Parent window for this popup 45 | :type parent: tk.Tk or tk.Toplevel or None 46 | :param title: Title of the popup window 47 | :type title: str 48 | :param initial_text: Initial text to display 49 | :type initial_text: str 50 | :param on_cancel: Callback function to execute when cancel is pressed 51 | :type on_cancel: callable or None 52 | """ 53 | try: 54 | self.title = title 55 | self.initial_text = initial_text 56 | self.parent = parent 57 | self.on_cancel = on_cancel 58 | self.cancelled = False 59 | 60 | self.popup = tk.Toplevel(parent) 61 | self.popup.title(title) 62 | self.popup.geometry("200x105") # Increased height for cancel button 63 | self.popup.iconbitmap(get_file_path('assets','logo.ico')) 64 | 65 | if parent: 66 | # Center the popup window on the parent window 67 | parent.update_idletasks() 68 | x = parent.winfo_x() + (parent.winfo_width() - self.popup.winfo_reqwidth()) // 2 69 | y = parent.winfo_y() + (parent.winfo_height() - self.popup.winfo_reqheight()) // 2 70 | self.popup.geometry(f"+{x}+{y}") 71 | self.popup.transient(parent) 72 | 73 | # Disable the parent window 74 | parent.wm_attributes('-disabled', True) 75 | 76 | # Use label and progress bar 77 | self.label = tk.Label(self.popup, text=initial_text) 78 | self.label.pack(pady=(10,5)) 79 | self.progress = ttk.Progressbar(self.popup, mode='indeterminate') 80 | self.progress.pack(padx=20, pady=(0,10), fill='x') 81 | self.progress.start() 82 | 83 | # Add cancel button 84 | self.cancel_button = ttk.Button(self.popup, text="Cancel", command=self._handle_cancel) 85 | self.cancel_button.pack(pady=(4,0)) 86 | 87 | # Not Resizable 88 | self.popup.resizable(False, False) 89 | 90 | # Disable closing of the popup manually 91 | self.popup.protocol("WM_DELETE_WINDOW", lambda: None) 92 | except Exception: 93 | # Enable the window on exception 94 | if parent: 95 | parent.wm_attributes('-disabled', False) 96 | raise 97 | 98 | def _handle_cancel(self): 99 | """ 100 | Internal method to handle cancel button press. 101 | Sets the cancelled flag and calls the user-provided callback if any. 102 | """ 103 | self.cancelled = True 104 | if callable(self.on_cancel): 105 | try: 106 | self.on_cancel() 107 | except Exception: 108 | self.destroy() 109 | 110 | self.destroy() 111 | 112 | def destroy(self): 113 | """ 114 | Clean up and destroy the popup window. 115 | 116 | This method performs the following cleanup operations: 117 | 1. Stops the progress bar animation 118 | 2. Re-enables the parent window 119 | 3. Destroys the popup window 120 | 121 | Note 122 | ---- 123 | This method should be called when you want to close the popup window, 124 | rather than destroying the window directly. 125 | 126 | Example 127 | ------- 128 | >>> popup = LoadingWindow() 129 | >>> # Do some processing 130 | >>> popup.destroy() # Properly clean up and close the window 131 | """ 132 | if self.popup: 133 | # Enable the parent window 134 | if self.parent: 135 | self.parent.wm_attributes('-disabled', False) 136 | 137 | if self.progress.winfo_exists(): 138 | self.progress.stop() 139 | 140 | if self.popup.winfo_exists(): 141 | self.popup.destroy() -------------------------------------------------------------------------------- /src/FreeScribe.client/markdown/help/settings.md: -------------------------------------------------------------------------------- 1 | # Settings Documentation 2 | ## General Settings 3 | - **Show Welcome Message** 4 | - Description: Display welcome message on startup 5 | - Default: `true` 6 | - Type: boolean 7 | - **Show Scrub PHI** 8 | - Description: Enable/Disable Scrub PHI (Only for local llm and private network RFC 18/19) 9 | - Default: `false` 10 | - Type: boolean 11 | ## Whisper Settings 12 | - **Whisper Endpoint** 13 | - Description: API endpoint for Whisper service 14 | - Default: `https://localhost:2224/whisperaudio` 15 | - Type: string 16 | - **Whisper Server API Key** 17 | - Description: API key for Whisper service authentication 18 | - Default: `None` 19 | - Type: string 20 | - **Whisper Model** 21 | - Description: Whisper model to use for speech recognition 22 | - Default: `small.en` 23 | - Type: string 24 | - **Local Whisper** 25 | - Description: Use local Whisper instance instead of cloud service 26 | - Default: `false` 27 | - Type: boolean 28 | - **Real Time** 29 | - Description: Enable real-time processing 30 | - Default: `false` 31 | - Type: boolean 32 | ## LLM Settings 33 | - **Model Endpoint** 34 | - Description: API endpoint URL for the model service 35 | - Default: `https://api.openai.com/v1/` 36 | - Type: string 37 | - **Use Local LLM** 38 | - Description: Toggle to use a locally hosted language model instead of cloud service 39 | - Default: `false` 40 | - Type: boolean 41 | ## Advanced Settings 42 | - **use_story** 43 | - Description: Enable story context for generation 44 | - Default: `false` 45 | - Type: boolean 46 | - **use_memory** 47 | - Description: Enable memory context for generation 48 | - Default: `false` 49 | - Type: boolean 50 | - **use_authors_note** 51 | - Description: Enable author's notes in generation 52 | - Default: `false` 53 | - Type: boolean 54 | - **use_world_info** 55 | - Description: Enable world information in context 56 | - Default: `false` 57 | - Type: boolean 58 | - **Enable Scribe Template** 59 | - Description: Enable Scribe template functionality 60 | - Default: `false` 61 | - Type: boolean 62 | - **max_context_length** 63 | - Description: Maximum number of tokens in the context window 64 | - Default: `5000` 65 | - Type: integer 66 | - **max_length** 67 | - Description: Maximum length of generated text 68 | - Default: `400` 69 | - Type: integer 70 | - **rep_pen** 71 | - Description: Repetition penalty factor 72 | - Default: `1.1` 73 | - Type: float 74 | - **rep_pen_range** 75 | - Description: Token range for repetition penalty 76 | - Default: `5000` 77 | - Type: integer 78 | - **rep_pen_slope** 79 | - Description: Slope of repetition penalty curve 80 | - Default: `0.7` 81 | - Type: float 82 | - **temperature** 83 | - Description: Controls randomness in generation (higher = more random) 84 | - Default: `0.1` 85 | - Type: float 86 | - **tfs** 87 | - Description: Tail free sampling parameter 88 | - Default: `0.97` 89 | - Type: float 90 | - **top_a** 91 | - Description: Top-A sampling parameter 92 | - Default: `0.8` 93 | - Type: float 94 | - **top_k** 95 | - Description: Top-K sampling parameter 96 | - Default: `30` 97 | - Type: integer 98 | - **top_p** 99 | - Description: Top-P (nucleus) sampling parameter 100 | - Default: `0.4` 101 | - Type: float 102 | - **typical** 103 | - Description: Typical sampling parameter 104 | - Default: `0.19` 105 | - Type: float 106 | - **sampler_order** 107 | - Description: Order of sampling methods to apply 108 | - Default: `[6, 0, 1, 3, 4, 2, 5]` 109 | - Type: string (JSON array) 110 | - **singleline** 111 | - Description: Output single line responses only 112 | - Default: `false` 113 | - Type: boolean 114 | - **frmttriminc** 115 | - Description: Trim incomplete sentences from output 116 | - Default: `false` 117 | - Type: boolean 118 | - **frmtrmblln** 119 | - Description: Remove blank lines from output 120 | - Default: `false` 121 | - Type: boolean 122 | - **Use best_of** 123 | - Description: Enable best-of sampling 124 | - Default: `false` 125 | - Type: boolean 126 | - **best_of** 127 | - Description: Number of completions to generate and select from 128 | - Default: `2` 129 | - Type: integer 130 | - **Real Time Audio Length** 131 | - Description: Length of audio segments for real-time processing (seconds) 132 | - Default: `5` 133 | - Type: integer 134 | - **Use Pre-Processing** 135 | - Description: Enable text pre-processing 136 | - Default: `true` 137 | - Type: boolean 138 | - **Use Post-Processing** 139 | - Description: Enable text post-processing 140 | - Default: `false` 141 | - Type: boolean 142 | ## Docker Settings 143 | - **LLM Container Name** 144 | - Description: Docker container name for LLM service 145 | - Default: `ollama` 146 | - Type: string 147 | - **LLM Caddy Container Name** 148 | - Description: Docker container name for Caddy reverse proxy 149 | - Default: `caddy-ollama` 150 | - Type: string 151 | - **LLM Authentication Container Name** 152 | - Description: Docker container name for authentication service 153 | - Default: `authentication-ollama` 154 | - Type: string 155 | - **Whisper Container Name** 156 | - Description: Docker container name for Whisper service 157 | - Default: `speech-container` 158 | - Type: string 159 | - **Whisper Caddy Container Name** 160 | - Description: Docker container name for Whisper Caddy service 161 | - Default: `caddy` 162 | - Type: string 163 | - **Auto Shutdown Containers on Exit** 164 | - Description: Automatically stop Docker containers on application exit 165 | - Default: `true` 166 | - Type: boolean 167 | - **Use Docker Status Bar** 168 | - Description: Show Docker container status in UI 169 | - Default: `false` 170 | - Type: boolean -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/MainWindow.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | MainWindow.py 4 | 5 | Main window module for the FreeScribe client application. 6 | 7 | This module contains the MainWindow class which is responsible for managing the main window of the application. 8 | It includes methods to start and stop Docker containers for LLM and Whisper services. 9 | 10 | This software is released under the AGPL-3.0 license 11 | Copyright (c) 2023-2024 Braedon Hendy 12 | 13 | Further updates and packaging added in 2024 through the ClinicianFOCUS initiative, 14 | a collaboration with Dr. Braedon Hendy and Conestoga College Institute of Applied 15 | Learning and Technology as part of the CNERG+ applied research project, 16 | Unburdening Primary Healthcare: An Open-Source AI Clinician Partner Platform". 17 | Prof. Michael Yingbull (PI), Dr. Braedon Hendy (Partner), 18 | and Research Students - Software Developer Alex Simko, Pemba Sherpa (F24), and Naitik Patel. 19 | 20 | """ 21 | 22 | from ContainerManager import ContainerManager, ContainerState 23 | import tkinter as tk 24 | 25 | class MainWindow: 26 | """ 27 | Main window class for the FreeScribe client application. 28 | 29 | This class initializes the main window and provides methods to manage Docker containers for LLM and Whisper services. 30 | """ 31 | 32 | def __init__(self, settings): 33 | """ 34 | Initialize the main window of the application. 35 | """ 36 | self.container_manager = ContainerManager() 37 | self.settings = settings 38 | 39 | def start_LLM_container(self, widget_name, app_settings): 40 | """ 41 | Start the LLM container. 42 | 43 | :param widget_name: The name of the widget to update with the container status. 44 | :type widget_name: str 45 | :param app_settings: The application settings containing container names. 46 | :type app_settings: ApplicationSettings 47 | """ 48 | try: 49 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.start_container(app_settings.editable_settings["LLM Container Name"])) 50 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.start_container(app_settings.editable_settings["LLM Caddy Container Name"])) 51 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.start_container(app_settings.editable_settings["LLM Authentication Container Name"])) 52 | except Exception as e: 53 | tk.messagebox.showerror("Error", f"An error occurred while starting the LLM container: {e}") 54 | 55 | def stop_LLM_container(self, widget_name, app_settings): 56 | """ 57 | Stop the LLM container. 58 | 59 | :param widget_name: The name of the widget to update with the container status. 60 | :type widget_name: str 61 | :param app_settings: The application settings containing container names. 62 | :type app_settings: ApplicationSettings 63 | """ 64 | try: 65 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.stop_container(app_settings.editable_settings["LLM Container Name"])) 66 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.stop_container(app_settings.editable_settings["LLM Caddy Container Name"])) 67 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.stop_container(app_settings.editable_settings["LLM Authentication Container Name"])) 68 | except Exception as e: 69 | tk.messagebox.showerror("Error", f"An error occurred while stopping the LLM container: {e}") 70 | 71 | def start_whisper_container(self, widget_name, app_settings): 72 | """ 73 | Start the Whisper container. 74 | 75 | :param widget_name: The name of the widget to update with the container status. 76 | :type widget_name: str 77 | :param app_settings: The application settings containing container names. 78 | :type app_settings: ApplicationSettings 79 | """ 80 | try: 81 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.start_container(app_settings.editable_settings["Whisper Container Name"])) 82 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.start_container(app_settings.editable_settings["Whisper Caddy Container Name"])) 83 | except Exception as e: 84 | tk.messagebox.showerror("Error", f"An error occurred while starting the Whisper container: {e}") 85 | 86 | def stop_whisper_container(self, widget_name, app_settings): 87 | """ 88 | Stop the Whisper container. 89 | 90 | :param widget_name: The name of the widget to update with the container status. 91 | :type widget_name: str 92 | :param app_settings: The application settings containing container names. 93 | :type app_settings: ApplicationSettings 94 | """ 95 | try: 96 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.stop_container(app_settings.editable_settings["Whisper Container Name"])) 97 | self.container_manager.set_status_icon_color(widget_name, self.container_manager.stop_container(app_settings.editable_settings["Whisper Caddy Container Name"])) 98 | except Exception as e: 99 | tk.messagebox.showerror("Error", f"An error occurred while stopping the Whisper container: {e}") 100 | 101 | def check_llm_containers(self): 102 | """ 103 | Check the status of the LLM containers. 104 | """ 105 | status_check = all([ 106 | self.container_manager.check_container_status(self.settings.editable_settings["LLM Container Name"]), 107 | self.container_manager.check_container_status(self.settings.editable_settings["LLM Caddy Container Name"]), 108 | self.container_manager.check_container_status(self.settings.editable_settings["LLM Authentication Container Name"]) 109 | ]) 110 | return ContainerState.CONTAINER_STARTED if status_check else ContainerState.CONTAINER_STOPPED 111 | 112 | def check_whisper_containers(self): 113 | """ 114 | Check the status of the Whisper containers. 115 | """ 116 | status_check = all([ 117 | self.container_manager.check_container_status(self.settings.editable_settings["Whisper Container Name"]), 118 | self.container_manager.check_container_status(self.settings.editable_settings["Whisper Caddy Container Name"]) 119 | ]) 120 | 121 | return ContainerState.CONTAINER_STARTED if status_check else ContainerState.CONTAINER_STOPPED -------------------------------------------------------------------------------- /src/FreeScribe.client/ContainerManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | This software is released under the AGPL-3.0 license 3 | Copyright (c) 2023-2024 Braedon Hendy 4 | 5 | Further updates and packaging added in 2024 through the ClinicianFOCUS initiative, 6 | a collaboration with Dr. Braedon Hendy and Conestoga College Institute of Applied 7 | Learning and Technology as part of the CNERG+ applied research project, 8 | Unburdening Primary Healthcare: An Open-Source AI Clinician Partner Platform". 9 | Prof. Michael Yingbull (PI), Dr. Braedon Hendy (Partner), 10 | and Research Students - Software Developer Alex Simko, Pemba Sherpa (F24), and Naitik Patel. 11 | """ 12 | 13 | from enum import Enum 14 | import docker 15 | import asyncio 16 | import time 17 | 18 | class ContainerState(Enum): 19 | CONTAINER_STOPPED = "ContainerStopped" 20 | CONTAINER_STARTED = "ContainerStarted" 21 | 22 | class ContainerManager: 23 | """ 24 | Manages Docker containers by starting and stopping them. 25 | 26 | This class provides methods to interact with Docker containers, 27 | including starting, stopping, and checking their status. 28 | 29 | Attributes: 30 | client (docker.DockerClient): The Docker client used to interact with containers. 31 | """ 32 | 33 | def __init__(self): 34 | """ 35 | Initialize the ContainerManager with a Docker client. 36 | """ 37 | self.client = None 38 | 39 | try: 40 | self.client = docker.from_env() 41 | except docker.errors.DockerException as e: 42 | self.client = None 43 | 44 | def start_container(self, container_name): 45 | """ 46 | Start a Docker container by its name. 47 | 48 | :param container_name: The name of the container to start. 49 | :type container_name: str 50 | :raises docker.errors.NotFound: If the specified container is not found. 51 | :raises docker.errors.APIError: If an error occurs while starting the container. 52 | """ 53 | try: 54 | container = self.client.containers.get(container_name) 55 | container.start() 56 | return ContainerState.CONTAINER_STARTED 57 | except docker.errors.NotFound as e: 58 | raise docker.errors.NotFound(f"Container {container_name} not found.") from e 59 | except docker.errors.APIError as e: 60 | raise docker.errors.APIError(f"An error occurred while starting the container: {e}") from e 61 | 62 | def update_container_status_icon(self, dot, container_name): 63 | """Update the status icon for a Docker container based on its current state. 64 | 65 | This method checks the current status of a specified Docker container and updates 66 | the visual indicator (dot) to reflect that status using appropriate colors. 67 | The method only executes if there is an active Docker client connection. 68 | 69 | Parameters 70 | ---------- 71 | dot : QWidget 72 | The widget representing the status indicator dot that will be updated 73 | with the appropriate color based on container status. 74 | container_name : str 75 | The name of the Docker container to check the status for. 76 | 77 | Notes 78 | ----- 79 | This method requires: 80 | - An active Docker client connection through container_manager 81 | - A valid container name that exists in Docker 82 | 83 | The status colors are managed by the container_manager's set_status_icon_color 84 | method and typically follow conventions like: 85 | - Green for running 86 | - Red for stopped/failed 87 | - Yellow for transitional states 88 | 89 | Examples 90 | -------- 91 | >>> status_dot = QWidget() 92 | >>> self.update_container_status_icon(status_dot, "mysql-container") 93 | """ 94 | if self.client is not None: 95 | status = self.check_container_status(container_name) 96 | self.set_status_icon_color(dot, ContainerState.CONTAINER_STARTED if status else ContainerState.CONTAINER_STOPPED) 97 | 98 | def stop_container(self, container_name): 99 | """ 100 | Stop a Docker container by its name. 101 | 102 | :param container_name: The name of the container to stop. 103 | :type container_name: str 104 | :raises docker.errors.NotFound: If the specified container is not found. 105 | :raises docker.errors.APIError: If an error occurs while stopping the container. 106 | """ 107 | try: 108 | container = self.client.containers.get(container_name) 109 | container.stop() 110 | print(f"Container {container_name} stopped successfully.") 111 | return ContainerState.CONTAINER_STOPPED 112 | except docker.errors.NotFound as e: 113 | raise docker.errors.NotFound(f"Container {container_name} not found.") from e 114 | except docker.errors.APIError as e: 115 | raise docker.errors.APIError(f"An error occurred while stopping the container: {e}") from e 116 | 117 | def check_container_status(self, container_name): 118 | """ 119 | Check the status of a Docker container by its name. 120 | 121 | :param container_name: The name of the container to check. 122 | :type container_name: str 123 | :return: True if the container is running, False otherwise. 124 | :rtype: bool 125 | :raises docker.errors.NotFound: If the specified container is not found. 126 | :raises docker.errors.APIError: If an error occurs while checking the container status. 127 | """ 128 | try: 129 | container = self.client.containers.get(container_name) 130 | status = container.status 131 | 132 | return status == "running" 133 | 134 | except docker.errors.NotFound: 135 | print(f"Container {container_name} not found.") 136 | return False 137 | except docker.errors.APIError as e: 138 | print(f"An error occurred while checking the container status: {e}") 139 | return False 140 | except Exception as e: 141 | print(f"An error occurred while checking the container status: {e}") 142 | return False 143 | 144 | def set_status_icon_color(self, widget, status: ContainerState): 145 | """ 146 | Set the color of the status icon based on the status of the container. 147 | 148 | :param widget: The widget representing the status icon. 149 | :type widget: tkinter.Widget 150 | :param status: The status of the container. 151 | :type status: ContainerState 152 | """ 153 | if status not in ContainerState: 154 | raise ValueError(f"Invalid container state: {status}") 155 | 156 | if status == ContainerState.CONTAINER_STARTED: 157 | widget.config(fg='green') 158 | elif status == ContainerState.CONTAINER_STOPPED: 159 | widget.config(fg='red') 160 | 161 | def check_docker_availability(self): 162 | """ 163 | Check if the Docker client is available. 164 | 165 | :return: True if the Docker client is available, False otherwise. 166 | :rtype: bool 167 | """ 168 | try: 169 | self.client = docker.from_env() 170 | except docker.errors.DockerException as e: 171 | self.client = None 172 | 173 | return self.client is not None -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/Widgets/MicrophoneSelector.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | import pyaudio 4 | 5 | class MicrophoneState: 6 | SELECTED_MICROPHONE_INDEX = None 7 | SELECTED_MICROPHONE_NAME = None 8 | 9 | @staticmethod 10 | def load_microphone_from_settings(app_settings): 11 | """ 12 | Load the microphone settings from the application settings. 13 | 14 | Parameters 15 | ---------- 16 | app_settings : dict 17 | Application settings including editable settings. 18 | 19 | Returns 20 | ------- 21 | str 22 | The name of the currently selected microphone. 23 | """ 24 | p = pyaudio.PyAudio() 25 | 26 | if "Current Mic" in app_settings.editable_settings: 27 | MicrophoneState.SELECTED_MICROPHONE_NAME = app_settings.editable_settings["Current Mic"] 28 | for i in range(p.get_device_count()): 29 | device_info = p.get_device_info_by_index(i) 30 | if device_info['name'] == MicrophoneState.SELECTED_MICROPHONE_NAME: 31 | MicrophoneState.SELECTED_MICROPHONE_INDEX = device_info["index"] 32 | break 33 | else: 34 | MicrophoneState.SELECTED_MICROPHONE_INDEX = 0 35 | MicrophoneState.SELECTED_MICROPHONE_NAME = p.get_device_info_by_index(0)['name'] 36 | 37 | class MicrophoneSelector: 38 | """ 39 | A class to manage microphone selection using a Tkinter UI. 40 | 41 | Attributes 42 | ---------- 43 | PYAUDIO : pyaudio.PyAudio 44 | A PyAudio instance used to interact with audio devices. 45 | SELECTED_MICROPHONE_INDEX : int 46 | The index of the currently selected microphone. 47 | SELECTED_MICROPHONE_NAME : str 48 | The name of the currently selected microphone. 49 | """ 50 | 51 | def __init__(self, root, row, column, app_settings): 52 | """ 53 | Initialize the MicrophoneSelector class. 54 | 55 | Parameters 56 | ---------- 57 | root : tk.Tk 58 | The root Tkinter instance. 59 | row : int 60 | The row position in the Tkinter grid for UI elements. 61 | column : int 62 | The column position in the Tkinter grid for UI elements. 63 | app_settings : dict 64 | Application settings including editable settings. 65 | """ 66 | self.root = root 67 | self.settings = app_settings 68 | 69 | self.pyaudio = pyaudio.PyAudio() 70 | 71 | self.selected_index = None 72 | self.selected_name = None 73 | 74 | # Create UI elements 75 | self.label = tk.Label(root, text="Select a Microphone:") 76 | self.label.grid(row=row, column=0, pady=5, sticky="w") 77 | 78 | self.dropdown = ttk.Combobox(root, state="readonly", width=15) 79 | self.dropdown.grid(row=row, pady=5, column=1) 80 | 81 | # Populate microphones in the dropdown 82 | self.update_microphones() 83 | 84 | # Bind selection event 85 | self.dropdown.bind("<>", self.on_mic_selected) 86 | 87 | def update_microphones(self): 88 | """ 89 | Update the microphone dropdown with available devices and manage selection. 90 | """ 91 | # Initialize microphone mapping and list of names 92 | self.mic_mapping = {} 93 | selected_index = -1 94 | 95 | # Populate the microphone mapping with devices having input channels 96 | for i in range(self.pyaudio.get_device_count()): 97 | device_info = self.pyaudio.get_device_info_by_index(i) 98 | 99 | if device_info['maxInputChannels'] > 0: 100 | name = device_info['name'] 101 | 102 | if name not in self.mic_mapping: # Avoid duplicates 103 | self.mic_mapping[name] = { 104 | "index": device_info['index'], 105 | "channels": device_info['maxInputChannels'], 106 | "defaultSampleRate": device_info['defaultSampleRate'] 107 | } 108 | 109 | # Match the current mic setting, if applicable 110 | if name == self.settings.editable_settings.get("Current Mic") and selected_index == -1: 111 | selected_index = device_info['index'] 112 | 113 | # Update the dropdown menu with the microphone names 114 | mic_names = list(self.mic_mapping.keys()) 115 | self.dropdown['values'] = mic_names 116 | 117 | if selected_index != -1: 118 | # Set the dropdown and selected microphone to the current mic 119 | self.dropdown.set(self.settings.editable_settings["Current Mic"]) 120 | self.update_selected_microphone(selected_index) 121 | elif mic_names: 122 | # Default to the first available microphone if none is selected 123 | first_mic_name = mic_names[0] 124 | self.dropdown.set(first_mic_name) 125 | self.update_selected_microphone(self.mic_mapping[first_mic_name]['index']) 126 | else: 127 | # Handle the case where no microphones are available 128 | self.dropdown.set("No microphones available") 129 | self.update_selected_microphone(-1) 130 | 131 | def on_mic_selected(self, event): 132 | """ 133 | Handle the event when a microphone is selected from the dropdown. 134 | 135 | Parameters 136 | ---------- 137 | event : tk.Event 138 | The event object containing information about the selection. 139 | """ 140 | selected_name = self.dropdown.get() 141 | if selected_name in self.mic_mapping: 142 | selected_index = self.mic_mapping[selected_name]['index'] 143 | self.update_selected_microphone(selected_index) 144 | 145 | def update_selected_microphone(self, selected_index): 146 | """ 147 | Update the selected microphone index and name. 148 | 149 | Parameters 150 | ---------- 151 | selected_index : int 152 | The index of the selected microphone. 153 | """ 154 | if selected_index >= 0: 155 | try: 156 | selected_mic = self.pyaudio.get_device_info_by_index(selected_index) 157 | MicrophoneState.SELECTED_MICROPHONE_INDEX = selected_mic["index"] 158 | MicrophoneState.SELECTED_MICROPHONE_NAME = selected_mic["name"] 159 | self.selected_index = selected_mic["index"] 160 | self.selected_name = selected_mic["name"] 161 | except OSError: 162 | # Handle cases where the selected index is invalid 163 | MicrophoneState.SELECTED_MICROPHONE_INDEX = None 164 | MicrophoneState.SELECTED_MICROPHONE_NAME = None 165 | self.selected_index = None 166 | self.selected_name = None 167 | else: 168 | MicrophoneState.SELECTED_MICROPHONE_INDEX = None 169 | MicrophoneState.SELECTED_MICROPHONE_NAME = None 170 | self.selected_index = None 171 | self.selected_name = None 172 | 173 | def close(self): 174 | """ 175 | Close the Tkinter root window. 176 | """ 177 | self.root.destroy() 178 | 179 | def get(self): 180 | """ 181 | Get the name of the currently selected microphone. 182 | 183 | Returns 184 | ------- 185 | str 186 | The name of the currently selected microphone. 187 | """ 188 | return MicrophoneState.SELECTED_MICROPHONE_NAME 189 | -------------------------------------------------------------------------------- /scripts/assets/Gemma_License.txt: -------------------------------------------------------------------------------- 1 | Section 1: DEFINITIONS 2 | 1.1 Definitions 3 | (a) "Agreement" or "Gemma Terms of Use" means these terms and conditions that govern the use, reproduction, Distribution or modification of the Gemma Services and any terms and conditions incorporated by reference. 4 | 5 | (b) "Distribution" or "Distribute" means any transmission, publication, or other sharing of Gemma or Model Derivatives to a third party, including by providing or making Gemma or its functionality available as a hosted service via API, web access, or any other electronic or remote means ("Hosted Service"). 6 | 7 | (c) "Gemma" means the set of machine learning language models, trained model weights and parameters identified at ai.google.dev/gemma, regardless of the source that you obtained it from. 8 | 9 | (d) "Google" means Google LLC. 10 | 11 | (e) "Model Derivatives" means all (i) modifications to Gemma, (ii) works based on Gemma, or (iii) any other machine learning model which is created by transfer of patterns of the weights, parameters, operations, or Output of Gemma, to that model in order to cause that model to perform similarly to Gemma, including distillation methods that use intermediate data representations or methods based on the generation of synthetic data Outputs by Gemma for training that model. For clarity, Outputs are not deemed Model Derivatives. 12 | 13 | (f) "Output" means the information content output of Gemma or a Model Derivative that results from operating or otherwise using Gemma or the Model Derivative, including via a Hosted Service. 14 | 15 | 1.2 16 | As used in this Agreement, "including" means "including without limitation". 17 | 18 | Section 2: ELIGIBILITY AND USAGE 19 | 2.1 Eligibility 20 | You represent and warrant that you have the legal capacity to enter into this Agreement (including being of sufficient age of consent). If you are accessing or using any of the Gemma Services for or on behalf of a legal entity, (a) you are entering into this Agreement on behalf of yourself and that legal entity, (b) you represent and warrant that you have the authority to act on behalf of and bind that entity to this Agreement and (c) references to "you" or "your" in the remainder of this Agreement refers to both you (as an individual) and that entity. 21 | 22 | 2.2 Use 23 | You may use, reproduce, modify, Distribute, perform or display any of the Gemma Services only in accordance with the terms of this Agreement, and must not violate (or encourage or permit anyone else to violate) any term of this Agreement. 24 | 25 | Section 3: DISTRIBUTION AND RESTRICTIONS 26 | 3.1 Distribution and Redistribution 27 | You may reproduce or Distribute copies of Gemma or Model Derivatives if you meet all of the following conditions: 28 | 29 | You must include the use restrictions referenced in Section 3.2 as an enforceable provision in any agreement (e.g., license agreement, terms of use, etc.) governing the use and/or distribution of Gemma or Model Derivatives and you must provide notice to subsequent users you Distribute to that Gemma or Model Derivatives are subject to the use restrictions in Section 3.2. 30 | You must provide all third party recipients of Gemma or Model Derivatives a copy of this Agreement. 31 | You must cause any modified files to carry prominent notices stating that you modified the files. 32 | All Distributions (other than through a Hosted Service) must be accompanied by a "Notice" text file that contains the following notice: "Gemma is provided under and subject to the Gemma Terms of Use found at ai.google.dev/gemma/terms". 33 | You may add your own intellectual property statement to your modifications and, except as set forth in this Section, may provide additional or different terms and conditions for use, reproduction, or Distribution of your modifications, or for any such Model Derivatives as a whole, provided your use, reproduction, modification, Distribution, performance, and display of Gemma otherwise complies with the terms and conditions of this Agreement. Any additional or different terms and conditions you impose must not conflict with the terms of this Agreement. 34 | 35 | 3.2 Use Restrictions 36 | You must not use any of the Gemma Services: 37 | 38 | for the restricted uses set forth in the Gemma Prohibited Use Policy at ai.google.dev/gemma/prohibited_use_policy ("Prohibited Use Policy"), which is hereby incorporated by reference into this Agreement; or 39 | in violation of applicable laws and regulations. 40 | To the maximum extent permitted by law, Google reserves the right to restrict (remotely or otherwise) usage of any of the Gemma Services that Google reasonably believes are in violation of this Agreement. 41 | 42 | 3.3 Generated Output 43 | Google claims no rights in Outputs you generate using Gemma. You and your users are solely responsible for Outputs and their subsequent uses. 44 | 45 | Section 4: ADDITIONAL PROVISIONS 46 | 4.1 Updates 47 | Google may update Gemma from time to time. 48 | 49 | 4.2 Trademarks 50 | Nothing in this Agreement grants you any rights to use Google's trademarks, trade names, logos or to otherwise suggest endorsement or misrepresent the relationship between you and Google. Google reserves any rights not expressly granted herein. 51 | 52 | 4.3 DISCLAIMER OF WARRANTY 53 | UNLESS REQUIRED BY APPLICABLE LAW, THE GEMMA SERVICES, AND OUTPUTS, ARE PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OF USING, REPRODUCING, MODIFYING, PERFORMING, DISPLAYING OR DISTRIBUTING ANY OF THE GEMMA SERVICES OR OUTPUTS AND ASSUME ANY AND ALL RISKS ASSOCIATED WITH YOUR USE OR DISTRIBUTION OF ANY OF THE GEMMA SERVICES OR OUTPUTS AND YOUR EXERCISE OF RIGHTS AND PERMISSIONS UNDER THIS AGREEMENT. 54 | 55 | 4.4 LIMITATION OF LIABILITY 56 | TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT AND UNDER NO LEGAL THEORY, WHETHER IN TORT (INCLUDING NEGLIGENCE), PRODUCT LIABILITY, CONTRACT, OR OTHERWISE, UNLESS REQUIRED BY APPLICABLE LAW, SHALL GOOGLE OR ITS AFFILIATES BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY DIRECT, INDIRECT, SPECIAL, INCIDENTAL, EXEMPLARY, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR LOST PROFITS OF ANY KIND ARISING FROM THIS AGREEMENT OR RELATED TO, ANY OF THE GEMMA SERVICES OR OUTPUTS EVEN IF GOOGLE OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 57 | 58 | 4.5 Term, Termination, and Survival 59 | The term of this Agreement will commence upon your acceptance of this Agreement (including acceptance by your use, modification, or Distribution, reproduction, performance or display of any portion or element of the Gemma Services) and will continue in full force and effect until terminated in accordance with the terms of this Agreement. Google may terminate this Agreement if you are in breach of any term of this Agreement. Upon termination of this Agreement, you must delete and cease use and Distribution of all copies of Gemma and Model Derivatives in your possession or control. Sections 1, 2.1, 3.3, 4.2 to 4.9 shall survive the termination of this Agreement. 60 | 61 | 4.6 Governing Law and Jurisdiction 62 | This Agreement will be governed by the laws of the State of California without regard to choice of law principles. The UN Convention on Contracts for the International Sale of Goods does not apply to this Agreement. The state and federal courts of Santa Clara County, California shall have exclusive jurisdiction of any dispute arising out of this Agreement. 63 | 64 | 4.7 Severability 65 | If any provision of this Agreement is held to be invalid, illegal or unenforceable, the remaining provisions shall be unaffected thereby and remain valid as if such provision had not been set forth herein. 66 | 67 | 4.8 Entire Agreement 68 | This Agreement states all the terms agreed between the parties and supersedes all other agreements between the parties as of the date of acceptance relating to its subject matter. 69 | 70 | 4.9 No Waiver 71 | Google will not be treated as having waived any rights by not exercising (or delaying the exercise of) any rights under this Agreement. -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/Widgets/AudioMeter.py: -------------------------------------------------------------------------------- 1 | """ 2 | src/FreeScribe.client/UI/Widgets/AudioMeter.py 3 | 4 | This software is released under the AGPL-3.0 license 5 | Copyright (c) 2023-2024 Braedon Hendy 6 | 7 | Further updates and packaging added in 2024 through the ClinicianFOCUS initiative, 8 | a collaboration with Dr. Braedon Hendy and Conestoga College Institute of Applied 9 | Learning and Technology as part of the CNERG+ applied research project, 10 | Unburdening Primary Healthcare: An Open-Source AI Clinician Partner Platform". 11 | Prof. Michael Yingbull (PI), Dr. Braedon Hendy (Partner), 12 | and Research Students - Software Developer Alex Simko, Pemba Sherpa (F24), and Naitik Patel. 13 | 14 | """ 15 | 16 | import struct 17 | import tkinter as tk 18 | from tkinter import ttk 19 | import pyaudio 20 | import numpy as np 21 | from threading import Thread 22 | from UI.Widgets.MicrophoneSelector import MicrophoneState 23 | 24 | class AudioMeter(tk.Frame): 25 | """ 26 | A Tkinter widget that displays an audio level meter. 27 | 28 | This widget captures audio input and displays the audio level in real-time. 29 | It includes a threshold slider to adjust the sensitivity of the meter. 30 | 31 | :param master: The parent widget. 32 | :type master: tkinter.Widget 33 | :param width: The width of the widget. 34 | :type width: int 35 | :param height: The height of the widget. 36 | :type height: int 37 | :param threshold: The initial threshold value for the audio meter. 38 | :type threshold: int 39 | """ 40 | def __init__(self, master=None, width=400, height=100, threshold=750): 41 | """ 42 | Initialize the AudioMeter widget. 43 | 44 | :param master: The parent widget. 45 | :type master: tkinter.Widget 46 | :param width: The width of the widget. 47 | :type width: int 48 | :param height: The height of the widget. 49 | :type height: int 50 | :param threshold: The initial threshold value for the audio meter. 51 | :type threshold: int 52 | """ 53 | super().__init__(master) 54 | self.master = master 55 | self.width = width 56 | self.height = height 57 | self.running = False 58 | self.threshold = threshold 59 | self.destroyed = False # Add flag to track widget destruction 60 | self.setup_audio() 61 | self.create_widgets() 62 | 63 | # Bind the cleanup method to widget destruction 64 | self.bind('', self.cleanup) 65 | 66 | def cleanup(self, event=None): 67 | """ 68 | Clean up resources when the widget is destroyed. 69 | 70 | This method stops the audio stream and terminates the PyAudio instance. 71 | 72 | :param event: The event that triggered the cleanup (default is None). 73 | :type event: tkinter.Event 74 | """ 75 | 76 | if self.destroyed: 77 | return 78 | 79 | self.destroyed = True 80 | self.running = False 81 | 82 | # Stop audio first 83 | if hasattr(self, 'stream') and self.stream: 84 | self.stream.stop_stream() 85 | self.stream.close() 86 | if hasattr(self, 'p') and self.p: 87 | self.p.terminate() 88 | 89 | # Then wait for thread 90 | if hasattr(self, 'monitoring_thread') and self.monitoring_thread: 91 | self.monitoring_thread.join(timeout=1.0) 92 | 93 | def destroy(self): 94 | """ 95 | Override the destroy method to ensure cleanup. 96 | 97 | This method calls the cleanup method before destroying the widget. 98 | """ 99 | self.cleanup() 100 | super().destroy() 101 | 102 | def setup_audio(self): 103 | """ 104 | Set up the audio parameters for capturing audio input. 105 | 106 | This method initializes the PyAudio instance and sets the audio format, 107 | number of channels, sample rate, and chunk size. 108 | """ 109 | # Adjusted CHUNK size for 16kHz to maintain similar update rate 110 | self.CHUNK = 512 # Reduced from 1024 to maintain similar time resolution 111 | self.FORMAT = pyaudio.paInt16 112 | self.CHANNELS = 1 113 | self.RATE = 16000 # Changed from 44100 to 16000 114 | self.p = pyaudio.PyAudio() 115 | 116 | def create_widgets(self): 117 | """ 118 | Create the UI elements for the audio meter. 119 | 120 | This method creates the slider for adjusting the threshold, the canvas 121 | for displaying the audio level, and the threshold line indicator. 122 | """ 123 | # Create frame for slider 124 | self.slider_frame = tk.Frame(self) 125 | self.slider_frame.pack(fill='x') 126 | 127 | 128 | # Add threshold slider - adjusted range for int16 audio values 129 | self.threshold_slider = tk.Scale( 130 | self.slider_frame, 131 | from_=100, 132 | to=32767, # Max value for 16-bit audio 133 | orient='horizontal', 134 | command=self.update_threshold, 135 | length=self.width 136 | ) 137 | 138 | self.threshold_slider.set(self.threshold) # Set default threshold 139 | self.threshold_slider.pack(side='left', fill='x', expand=True, padx=0) 140 | 141 | # Make the canvas shorter since we only need enough height for the bar 142 | self.canvas = tk.Canvas( 143 | self, 144 | width=self.width, 145 | height=30, 146 | borderwidth=0, 147 | highlightthickness=0 148 | ) 149 | self.canvas.pack(expand=True, fill='both', padx=0, pady=0) 150 | 151 | # Create horizontal level meter rectangle 152 | self.level_meter = self.canvas.create_rectangle( 153 | 0, 5, 154 | 0, 25, 155 | fill='green' 156 | ) 157 | 158 | # Create threshold line indicator 159 | self.threshold_line = self.canvas.create_line( 160 | 0, 0, 161 | 0, 30, 162 | fill='red', 163 | width=2 164 | ) 165 | 166 | self.toggle_monitoring() 167 | 168 | def update_threshold(self, value): 169 | """ 170 | Update the threshold value and the visual indicator. 171 | 172 | This method updates the threshold value based on the slider position 173 | and adjusts the position of the threshold line on the canvas. 174 | 175 | :param value: The new threshold value from the slider. 176 | :type value: str 177 | """ 178 | self.threshold = float(value) 179 | 180 | # Update threshold line position 181 | # Scale the threshold value to canvas width 182 | scaled_position = (float(value) / 32767) * self.width 183 | self.canvas.coords( 184 | self.threshold_line, 185 | scaled_position, 0, 186 | scaled_position, 30 187 | ) 188 | 189 | def toggle_monitoring(self): 190 | """ 191 | Start or stop the audio monitoring. 192 | 193 | This method starts or stops the audio stream and the monitoring thread 194 | based on the current state of the widget. 195 | """ 196 | if not self.running: 197 | self.running = True 198 | 199 | try: 200 | self.stream = self.p.open( 201 | format=self.FORMAT, 202 | channels=1, 203 | rate=self.RATE, 204 | input=True, 205 | input_device_index=MicrophoneState.SELECTED_MICROPHONE_INDEX, 206 | frames_per_buffer=self.CHUNK, 207 | ) 208 | except (OSError, IOError) as e: 209 | tk.messagebox.showerror("Error", f"Please check your microphone settings under the speech2text settings tab. Error opening audio stream: {e}") 210 | 211 | self.monitoring_thread = Thread(target=self.update_meter) 212 | self.monitoring_thread.start() 213 | else: 214 | self.running = False 215 | self.stream.stop_stream() 216 | self.stream.close() 217 | 218 | def update_meter(self): 219 | """ 220 | Continuously update the audio meter. 221 | 222 | This method reads audio data from the stream, calculates the maximum 223 | audio level, and updates the meter display on the main thread. 224 | """ 225 | while self.running and not self.destroyed: # Check destroyed flag 226 | try: 227 | data = self.stream.read(self.CHUNK, exception_on_overflow=False) 228 | audio_data = struct.unpack(f'{self.CHUNK}h', data) 229 | max_value = max(abs(np.array(audio_data))) 230 | level = min(self.width, int((max_value / 32767) * self.width)) 231 | 232 | # Only schedule update if not destroyed 233 | if not self.destroyed: 234 | self.master.after(0, self.update_meter_display, level) 235 | except Exception as e: 236 | print(f"Error in audio monitoring: {e}") 237 | break 238 | 239 | def update_meter_display(self, level): 240 | """ 241 | Update the meter display on the canvas. 242 | 243 | This method updates the position and color of the audio level meter 244 | based on the current audio level. 245 | 246 | :param level: The current audio level to display. 247 | :type level: int 248 | """ 249 | if not self.destroyed and self.winfo_exists(): 250 | try: 251 | self.canvas.coords( 252 | self.level_meter, 253 | 0, 5, 254 | level, 25 255 | ) 256 | 257 | # Color logic 258 | if level < 120: 259 | color = 'green' 260 | elif level < 250: 261 | color = 'yellow' 262 | else: 263 | color = 'red' 264 | self.canvas.itemconfig(self.level_meter, fill=color) 265 | except tk.TclError: 266 | # Widget was destroyed during update 267 | self.cleanup() 268 | -------------------------------------------------------------------------------- /src/FreeScribe.client/Model.py: -------------------------------------------------------------------------------- 1 | from llama_cpp import Llama 2 | import os 3 | from typing import Optional, Dict, Any 4 | import threading 5 | from UI.LoadingWindow import LoadingWindow 6 | import tkinter.messagebox as messagebox 7 | 8 | class Model: 9 | """ 10 | Model class for handling GPU-accelerated text generation using the Llama library. 11 | 12 | This class provides an interface to initialize a language model with specific configurations 13 | for GPU acceleration, generate responses based on a text prompt, and retrieve GPU settings. 14 | The class is configured to support multi-GPU setups and custom configurations for batch size, 15 | context window, and sampling settings. 16 | 17 | Attributes: 18 | model: Instance of the Llama model configured with specified GPU and context parameters. 19 | config: Dictionary containing the GPU and model configuration. 20 | 21 | Methods: 22 | generate_response: Generates a text response based on an input prompt using 23 | the specified sampling parameters. 24 | get_gpu_info: Returns the current GPU configuration and batch size details. 25 | """ 26 | def __init__( 27 | self, 28 | model_path: str, 29 | chat_template: str = None, 30 | context_size: int = 4096, 31 | gpu_layers: int = -1, # -1 means load all layers to GPU 32 | main_gpu: int = 0, # Primary GPU device index 33 | tensor_split: Optional[list] = None, # For multi-GPU setup 34 | n_batch: int = 512, # Batch size for inference 35 | n_threads: Optional[int] = None, # CPU threads when needed 36 | seed: int = 1337 37 | ): 38 | """ 39 | Initializes the GGUF model with GPU acceleration. 40 | 41 | Args: 42 | model_path: Path to the model file 43 | context_size: Size of the context window 44 | gpu_layers: Number of layers to offload to GPU (-1 for all) 45 | main_gpu: Main GPU device index 46 | tensor_split: List of GPU memory splits for multi-GPU setup 47 | n_batch: Batch size for inference 48 | n_threads: Number of CPU threads 49 | seed: Random seed for reproducibility 50 | """ 51 | try: 52 | # Set environment variables for GPU 53 | os.environ["CUDA_VISIBLE_DEVICES"] = str(main_gpu) 54 | 55 | # Initialize model with GPU settings 56 | self.model = Llama( 57 | model_path=model_path, 58 | n_ctx=context_size, 59 | n_gpu_layers=gpu_layers, 60 | n_batch=n_batch, 61 | n_threads=n_threads or os.cpu_count(), 62 | seed=seed, 63 | tensor_split=tensor_split, 64 | chat_format=chat_template, 65 | ) 66 | 67 | # Store configuration 68 | self.config = { 69 | "gpu_layers": gpu_layers, 70 | "main_gpu": main_gpu, 71 | "context_size": context_size, 72 | "n_batch": n_batch 73 | } 74 | except Exception as e: 75 | self.model = None 76 | raise e 77 | 78 | def generate_response( 79 | self, 80 | prompt: str, 81 | max_tokens: int = 50, 82 | temperature: float = 0.1, 83 | top_p: float = 0.95, 84 | repeat_penalty: float = 1.1 85 | ) -> str: 86 | """ 87 | Generates a response using GPU-accelerated inference. 88 | 89 | Args: 90 | prompt: Input text prompt 91 | max_tokens: Maximum number of tokens to generate 92 | temperature: Sampling temperature (higher = more random) 93 | top_p: Top-p sampling threshold 94 | repeat_penalty: Penalty for repeating tokens 95 | 96 | Returns: 97 | Generated text response 98 | """ 99 | try: 100 | # Generate response using the model 101 | 102 | # Message template for chat completion 103 | messages = [ 104 | {"role": "user", 105 | "content": prompt} 106 | ] 107 | 108 | response = self.model.create_chat_completion( 109 | messages, 110 | max_tokens=max_tokens, 111 | temperature=temperature, 112 | top_p=top_p, 113 | repeat_penalty=repeat_penalty, 114 | ) 115 | 116 | # reset the model tokens 117 | self.model.reset() 118 | return response["choices"][0]["message"]["content"] 119 | 120 | except Exception as e: 121 | print(f"GPU inference error ({e.__class__.__name__}): {str(e)}") 122 | return f"({e.__class__.__name__}): {str(e)}" 123 | 124 | 125 | def get_gpu_info(self) -> Dict[str, Any]: 126 | """ 127 | Returns information about the current GPU configuration. 128 | """ 129 | return { 130 | "gpu_layers": self.config["gpu_layers"], 131 | "main_gpu": self.config["main_gpu"], 132 | "batch_size": self.config["n_batch"], 133 | "context_size": self.config["context_size"] 134 | } 135 | 136 | def close(self): 137 | """ 138 | Unloads the model from GPU memory. 139 | """ 140 | self.model.close() 141 | self.model = None 142 | 143 | def __del__(self): 144 | """Cleanup GPU memory on deletion""" 145 | if self.model is not None: 146 | self.model.close() 147 | self.model = None 148 | 149 | class ModelManager: 150 | """ 151 | Manages the lifecycle of a local LLM model including setup and unloading operations. 152 | 153 | This class provides static methods to handle model initialization, loading, and cleanup 154 | using the llama.cpp Python bindings. It supports different model architectures and 155 | quantization levels. 156 | 157 | Attributes: 158 | local_model (Llama): Static reference to the loaded model instance. None if no model is loaded. 159 | """ 160 | local_model = None 161 | 162 | @staticmethod 163 | def setup_model(app_settings, root): 164 | """ 165 | Initialize and load the LLM model based on application settings. 166 | 167 | Creates a loading window and starts model loading in a separate thread to prevent 168 | UI freezing. Automatically checks thread status and closes the loading window 169 | when complete. 170 | 171 | Args: 172 | app_settings: Application settings object containing model preferences 173 | root: Tkinter root window for creating the loading dialog 174 | 175 | Raises: 176 | ValueError: If the specified model file cannot be loaded 177 | 178 | Note: 179 | The method uses threading to avoid blocking the UI while loading the model. 180 | GPU layers are set to -1 for CUDA architecture and 0 for CPU. 181 | """ 182 | loading_window = LoadingWindow(root, "Loading Model", "Loading Model. Please wait") 183 | 184 | # unload before loading new model 185 | if ModelManager.local_model is not None: 186 | ModelManager.unload_model() 187 | 188 | def load_model(): 189 | """ 190 | Internal function to handle the actual model loading process. 191 | 192 | Determines the model file based on settings and initializes the Llama instance 193 | with appropriate parameters. 194 | """ 195 | gpu_layers = 0 196 | 197 | if app_settings.editable_settings["Architecture"] == "CUDA (Nvidia GPU)": 198 | gpu_layers = -1 199 | 200 | model_to_use = "gemma-2-2b-it-Q8_0.gguf" 201 | 202 | model_path = f"./models/{model_to_use}" 203 | try: 204 | ModelManager.local_model = Model(model_path, 205 | context_size=4096, 206 | gpu_layers=gpu_layers, 207 | main_gpu=0, 208 | n_batch=512, 209 | n_threads=None, 210 | seed=1337) 211 | except Exception as e: 212 | # model doesnt exist 213 | #TODO: Logo to system log 214 | messagebox.showerror("Model Error", f"Model failed to load. Please ensure you have a valid model selected in the settings. Currently trying to load: {os.path.abspath(model_path)}. Error received ({e.__class__.__name__}): {str(e)}") 215 | ModelManager.local_model = None 216 | 217 | thread = threading.Thread(target=load_model) 218 | thread.start() 219 | 220 | def check_thread_status(thread, loading_window, root): 221 | """ 222 | Recursive function to check the status of the model loading thread. 223 | 224 | Args: 225 | thread: The thread to monitor 226 | loading_window: LoadingWindow instance to close when complete 227 | root: Tkinter root window for scheduling checks 228 | """ 229 | if thread.is_alive(): 230 | root.after(500, lambda: check_thread_status(thread, loading_window, root)) 231 | else: 232 | loading_window.destroy() 233 | 234 | root.after(500, lambda: check_thread_status(thread, loading_window, root)) 235 | 236 | 237 | @staticmethod 238 | def start_model_threaded(settings, root_window): 239 | """ 240 | Start the model in a separate thread. 241 | 242 | :param settings: Configuration settings for the model 243 | :type settings: dict 244 | :param root_window: The main application window reference 245 | :type root_window: tkinter.Tk 246 | :return: The created thread instance 247 | :rtype: threading.Thread 248 | 249 | This method creates and starts a new thread that runs the model's start 250 | function with the provided settings and root window reference. The model 251 | is accessed through ModelManager's local_model attribute. 252 | """ 253 | thread = threading.Thread(target=ModelManager.setup_model, args=(settings, root_window)) 254 | thread.start() 255 | return thread 256 | 257 | @staticmethod 258 | def unload_model(): 259 | """ 260 | Safely unload and cleanup the currently loaded model. 261 | 262 | Closes the model if it exists and sets the local_model reference to None. 263 | This method should be called before loading a new model or shutting down 264 | the application. 265 | """ 266 | if ModelManager.local_model is not None: 267 | ModelManager.local_model.model.close() 268 | del ModelManager.local_model 269 | ModelManager.local_model = None 270 | -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/MainWindowUI.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | import UI.MainWindow as mw 4 | from UI.SettingsWindow import FeatureToggle 5 | from UI.SettingsWindowUI import SettingsWindowUI 6 | from UI.MarkdownWindow import MarkdownWindow 7 | from utils.file_utils import get_file_path 8 | from UI.DebugWindow import DebugPrintWindow 9 | 10 | DOCKER_CONTAINER_CHECK_INTERVAL = 10000 # Interval in milliseconds to check the Docker container status 11 | DOCKER_DESKTOP_CHECK_INTERVAL = 10000 # Interval in milliseconds to check the Docker Desktop status 12 | 13 | class MainWindowUI: 14 | """ 15 | This class handles the user interface (UI) for the main application window, 16 | including the Docker status bar for managing the LLM and Whisper containers. 17 | 18 | :param root: The root Tkinter window. 19 | :param settings: The application settings passed to control the containers' behavior. 20 | """ 21 | 22 | def __init__(self, root, settings): 23 | """ 24 | Initialize the MainWindowUI class. 25 | 26 | :param root: The Tkinter root window. 27 | :param settings: The application settings used to control container states. 28 | """ 29 | self.root = root # Tkinter root window 30 | self.docker_status_bar = None # Docker status bar frame 31 | self.is_status_bar_enabled = False # Flag to indicate if the Docker status bar is enabled 32 | self.app_settings = settings # Application settings 33 | self.logic = mw.MainWindow(self.app_settings) # Logic to control the container behavior 34 | self.scribe_template = None 35 | self.setting_window = SettingsWindowUI(self.app_settings, self, self.root) # Settings window 36 | self.root.iconbitmap(get_file_path('assets','logo.ico')) 37 | 38 | self.current_docker_status_check_id = None # ID for the current Docker status check 39 | self.current_container_status_check_id = None # ID for the current container status check 40 | 41 | def load_main_window(self): 42 | """ 43 | Load the main window of the application. 44 | This method initializes the main window components, including the menu bar. 45 | """ 46 | self._bring_to_focus() 47 | self._create_menu_bar() 48 | if (self.setting_window.settings.editable_settings['Show Welcome Message']): 49 | self._show_welcome_message() 50 | 51 | def _bring_to_focus(self): 52 | """ 53 | Bring the main window to focus. 54 | """ 55 | self.root.lift() # Lift the window to the top 56 | self.root.attributes('-topmost', True) # Set the window to be always on top 57 | self.root.focus_force() # Force focus on the window 58 | self.root.after_idle(self.root.attributes, '-topmost', False) # Reset the always on top attribute after idle 59 | 60 | 61 | def update_aiscribe_texts(self, event): 62 | if self.scribe_template is not None: 63 | selected_option = self.scribe_template.get() 64 | if selected_option in self.app_settings.scribe_template_mapping: 65 | self.app_settings.AISCRIBE, self.app_settings.AISCRIBE2 = self.app_settings.scribe_template_mapping[selected_option] 66 | 67 | def create_docker_status_bar(self): 68 | """ 69 | Create a Docker status bar to display the status of the LLM and Whisper containers. 70 | Adds start and stop buttons for each container. 71 | """ 72 | # if not feature do not do this 73 | if FeatureToggle.DOCKER_STATUS_BAR is not True: 74 | return 75 | 76 | if self.docker_status_bar is not None: 77 | return 78 | 79 | # Create the frame for the Docker status bar, placed at the bottom of the window 80 | self.docker_status_bar = tk.Frame(self.root, bd=1, relief=tk.SUNKEN) 81 | self.docker_status_bar.grid(row=4, column=0, columnspan=14, sticky='nsew') 82 | 83 | # Add a label to indicate Docker container status section 84 | self.docker_status = tk.Label(self.docker_status_bar, text="Docker Container Status:", padx=10) 85 | self.docker_status.pack(side=tk.LEFT) 86 | 87 | # Add LLM container status label 88 | llm_status = tk.Label(self.docker_status_bar, text="LLM Container Status:", padx=10) 89 | llm_status.pack(side=tk.LEFT) 90 | 91 | # Add status dot for LLM (default: red) 92 | llm_dot = tk.Label(self.docker_status_bar, text='●', fg='red') 93 | self.logic.container_manager.update_container_status_icon(llm_dot, self.app_settings.editable_settings["LLM Container Name"]) 94 | llm_dot.pack(side=tk.LEFT) 95 | 96 | # Add Whisper server status label 97 | whisper_status = tk.Label(self.docker_status_bar, text="Whisper Server Status:", padx=10) 98 | whisper_status.pack(side=tk.LEFT) 99 | 100 | # Add status dot for Whisper (default: red) 101 | whisper_dot = tk.Label(self.docker_status_bar, text='●', fg='red') 102 | self.logic.container_manager.update_container_status_icon(whisper_dot, self.app_settings.editable_settings["Whisper Container Name"]) 103 | whisper_dot.pack(side=tk.LEFT) 104 | 105 | # Start button for Whisper container with a command to invoke the start method from logic 106 | start_whisper_button = tk.Button(self.docker_status_bar, text="Start Whisper", command=lambda: self.logic.start_whisper_container(whisper_dot, self.app_settings)) 107 | start_whisper_button.pack(side=tk.RIGHT) 108 | 109 | # Start button for LLM container with a command to invoke the start method from logic 110 | start_llm_button = tk.Button(self.docker_status_bar, text="Start LLM", command=lambda: self.logic.start_LLM_container(llm_dot, self.app_settings)) 111 | start_llm_button.pack(side=tk.RIGHT) 112 | 113 | # Stop button for Whisper container with a command to invoke the stop method from logic 114 | stop_whisper_button = tk.Button(self.docker_status_bar, text="Stop Whisper", command=lambda: self.logic.stop_whisper_container(whisper_dot, self.app_settings)) 115 | stop_whisper_button.pack(side=tk.RIGHT) 116 | 117 | # Stop button for LLM container with a command to invoke the stop method from logic 118 | stop_llm_button = tk.Button(self.docker_status_bar, text="Stop LLM", command=lambda: self.logic.stop_LLM_container(llm_dot, self.app_settings)) 119 | stop_llm_button.pack(side=tk.RIGHT) 120 | 121 | self.is_status_bar_enabled = True 122 | self._background_availbility_docker_check() 123 | self._background_check_container_status(llm_dot, whisper_dot) 124 | 125 | def disable_docker_ui(self): 126 | """ 127 | Disable the Docker status bar UI elements. 128 | """ 129 | 130 | if FeatureToggle.DOCKER_STATUS_BAR is not True: 131 | return 132 | 133 | 134 | self.is_status_bar_enabled = False 135 | self.docker_status.config(text="(Docker not found)") 136 | if self.docker_status_bar is not None: 137 | for child in self.docker_status_bar.winfo_children(): 138 | child.configure(state='disabled') 139 | 140 | def enable_docker_ui(self): 141 | """ 142 | Enable the Docker status bar UI elements. 143 | """ 144 | 145 | if FeatureToggle.DOCKER_STATUS_BAR is not True: 146 | return 147 | 148 | self.is_status_bar_enabled = True 149 | self.docker_status.config(text="Docker Container Status: ") 150 | if self.docker_status_bar is not None: 151 | for child in self.docker_status_bar.winfo_children(): 152 | child.configure(state='normal') 153 | 154 | def destroy_docker_status_bar(self): 155 | """ 156 | Destroy the Docker status bar if it exists. 157 | """ 158 | if FeatureToggle.DOCKER_STATUS_BAR is not True: 159 | return 160 | 161 | if self.docker_status_bar is not None: 162 | self.docker_status_bar.destroy() 163 | self.docker_status_bar = None 164 | 165 | # cancel the check loop as the bar no longer exists and it is waster resources. 166 | if self.current_docker_status_check_id is not None: 167 | self.root.after_cancel(self.current_docker_status_check_id) 168 | self.current_docker_status_check_id = None 169 | 170 | if self.current_container_status_check_id is not None: 171 | self.root.after_cancel(self.current_container_status_check_id) 172 | self.current_container_status_check_id = None 173 | 174 | def toggle_menu_bar(self, enable: bool): 175 | """ 176 | Enable or disable the menu bar. 177 | 178 | :param enable: True to enable the menu bar, False to disable it. 179 | :type enable: bool 180 | """ 181 | if enable: 182 | self._create_menu_bar() 183 | else: 184 | self._destroy_menu_bar() 185 | 186 | def _create_menu_bar(self): 187 | """ 188 | Private method to create menu bar. 189 | Create a menu bar with a Help menu. 190 | This method sets up the menu bar at the top of the main window and adds a Help menu with an About option. 191 | """ 192 | self.menu_bar = tk.Menu(self.root) 193 | self.root.config(menu=self.menu_bar) 194 | self._create_settings_menu() 195 | self._create_help_menu() 196 | 197 | def _destroy_menu_bar(self): 198 | """ 199 | Private method to destroy the menu bar. 200 | Destroy the menu bar if it exists. 201 | """ 202 | if self.menu_bar is not None: 203 | self.menu_bar.destroy() 204 | self.menu_bar = None 205 | self._destroy_settings_menu() 206 | self._destroy_help_menu() 207 | 208 | def _create_settings_menu(self): 209 | # Add Settings menu 210 | setting_menu = tk.Menu(self.menu_bar, tearoff=0) 211 | self.menu_bar.add_cascade(label="Settings", menu=setting_menu) 212 | setting_menu.add_command(label="Settings", command=self.setting_window.open_settings_window) 213 | 214 | def _destroy_settings_menu(self): 215 | """ 216 | Private method to destroy the Settings menu. 217 | Destroy the Settings menu if it exists. 218 | """ 219 | if self.menu_bar is not None: 220 | setting_menu = self.menu_bar.nametowidget('Settings') 221 | if setting_menu is not None: 222 | setting_menu.destroy() 223 | 224 | def _create_help_menu(self): 225 | # Add Help menu 226 | help_menu = tk.Menu(self.menu_bar, tearoff=0) 227 | self.menu_bar.add_cascade(label="Help", menu=help_menu) 228 | help_menu.add_command(label="Debug Window", command=lambda: DebugPrintWindow(self.root)) 229 | help_menu.add_command(label="About", command=lambda: self._show_md_content(get_file_path('markdown','help','about.md'), 'About')) 230 | 231 | def _destroy_help_menu(self): 232 | """ 233 | Private method to destroy the Help menu. 234 | Destroy the Help menu if it exists. 235 | """ 236 | if self.menu_bar is not None: 237 | help_menu = self.menu_bar.nametowidget('Help') 238 | if help_menu is not None: 239 | help_menu.destroy() 240 | 241 | def disable_settings_menu(self): 242 | """ 243 | Disable the Settings menu. 244 | """ 245 | if self.menu_bar is not None: 246 | self.menu_bar.entryconfig("Settings", state="disabled") # Disables the entire Settings menu 247 | 248 | def enable_settings_menu(self): 249 | """ 250 | Enable the Settings menu. 251 | """ 252 | if self.menu_bar is not None: 253 | self.menu_bar.entryconfig("Settings", state="normal") 254 | 255 | def _show_md_content(self, file_path: str, title: str, show_checkbox: bool = False): 256 | """ 257 | Private method to display help/about information. 258 | Display help information in a message box. 259 | This method shows a message box with information about the application when the About option is selected from the Help menu. 260 | """ 261 | 262 | # Callback function called when the window is closed 263 | def on_close(checkbox_state): 264 | self.setting_window.settings.editable_settings['Show Welcome Message'] = not checkbox_state 265 | self.setting_window.settings.save_settings_to_file() 266 | 267 | # Create a MarkdownWindow to display the content 268 | MarkdownWindow(self.root, title, file_path, 269 | callback=on_close if show_checkbox else None) 270 | 271 | 272 | def _on_help_window_close(self, help_window, dont_show_again: tk.BooleanVar): 273 | """ 274 | Private method to handle the closing of the help window. 275 | Updates the 'Show Welcome Message' setting based on the checkbox state. 276 | """ 277 | self.setting_window.settings.editable_settings['Show Welcome Message'] = not dont_show_again.get() 278 | self.setting_window.settings.save_settings_to_file() 279 | help_window.destroy() 280 | 281 | def _show_welcome_message(self): 282 | """ 283 | Private method to display a welcome message. 284 | Display a welcome message when the application is launched. 285 | This method shows a welcome message in a message box when the application is launched. 286 | """ 287 | self._show_md_content(get_file_path('markdown','welcome.md'), 'Welcome', True) 288 | 289 | def create_scribe_template(self, row=3, column=4, columnspan=3, pady=10, padx=10, sticky='nsew'): 290 | """ 291 | Create a template for the Scribe application. 292 | """ 293 | self.scribe_template = ttk.Combobox(self.root, values=self.app_settings.scribe_template_values, width=35, state="readonly") 294 | self.scribe_template.current(0) 295 | self.scribe_template.bind("<>", self.app_settings.scribe_template_values) 296 | self.scribe_template.grid(row=row, column=column, columnspan=columnspan, pady=pady, padx=padx, sticky=sticky) 297 | 298 | def destroy_scribe_template(self): 299 | """ 300 | Destroy the Scribe template if it exists. 301 | """ 302 | if self.scribe_template is not None: 303 | self.scribe_template.destroy() 304 | self.scribe_template = None 305 | 306 | def _background_availbility_docker_check(self): 307 | """ 308 | Check if the Docker client is available in the background. 309 | 310 | This method is intended to be run in a separate thread to periodically 311 | check the availability of the Docker client. 312 | """ 313 | print("Checking Docker availability in the background...") 314 | if self.logic.container_manager.check_docker_availability(): 315 | # Enable the Docker status bar UI elements if not enabled 316 | if not self.is_status_bar_enabled: 317 | self.enable_docker_ui() 318 | print("Docker client is available.") 319 | else: 320 | # Disable the Docker status bar UI elements if not disabled 321 | if self.is_status_bar_enabled: 322 | self.disable_docker_ui() 323 | 324 | print("Docker client is not available.") 325 | 326 | self.current_docker_status_check_id = self.root.after(DOCKER_DESKTOP_CHECK_INTERVAL, self._background_availbility_docker_check) 327 | 328 | def _background_check_container_status(self, llm_dot, whisper_dot): 329 | """ 330 | Check the status of Docker containers in the background. 331 | 332 | This method is intended to be run in a separate thread to periodically 333 | check the status of the LLM and Whisper containers. 334 | """ 335 | if self.is_status_bar_enabled: 336 | self.logic.container_manager.set_status_icon_color(llm_dot, self.logic.check_llm_containers()) 337 | self.logic.container_manager.set_status_icon_color(whisper_dot, self.logic.check_whisper_containers()) 338 | self.current_container_status_check_id = self.root.after(DOCKER_CONTAINER_CHECK_INTERVAL, self._background_check_container_status, llm_dot, whisper_dot) 339 | 340 | 341 | 342 | -------------------------------------------------------------------------------- /scripts/install.nsi: -------------------------------------------------------------------------------- 1 | !include "MUI2.nsh" 2 | !include "LogicLib.nsh" 3 | !include "FileFunc.nsh" 4 | !include "WordFunc.nsh" 5 | 6 | ; Define the name of the installer 7 | OutFile "..\dist\FreeScribeInstaller.exe" 8 | 9 | ; Define the default installation directory to AppData 10 | InstallDir "$PROGRAMFILES\FreeScribe" 11 | 12 | ; Define the name of the installer 13 | Name "FreeScribe" 14 | 15 | ; Define the version of the installer 16 | VIProductVersion "0.0.0.1" 17 | VIAddVersionKey "ProductName" "FreeScribe" 18 | VIAddVersionKey "FileVersion" "0.0.0.1" 19 | VIAddVersionKey "LegalCopyright" "Copyright (c) 2023-2024 Braedon Hendy" 20 | VIAddVersionKey "FileDescription" "FreeScribe Installer" 21 | 22 | ; Define the logo image 23 | !define MUI_ICON ./assets/logo.ico 24 | !define MIN_CUDA_DRIVER_VERSION 527.41 ; The nvidia graphic driver that is compatiable with Cuda 12.1 25 | 26 | ; Variables for checkboxes 27 | Var /GLOBAL CPU_RADIO 28 | Var /GLOBAL NVIDIA_RADIO 29 | Var /GLOBAL SELECTED_OPTION 30 | Var /GLOBAL REMOVE_CONFIG_CHECKBOX 31 | Var /GLOBAL REMOVE_CONFIG 32 | 33 | Function Check_For_Old_Version_In_App_Data 34 | ; Check if the old version exists in AppData 35 | IfFileExists "$APPDATA\FreeScribe\freescribe-client.exe" 0 OldVersionDoesNotExist 36 | ; Open Dialog to ask user if they want to uninstall the old version 37 | MessageBox MB_YESNO|MB_ICONQUESTION "An old version of FreeScribe has been detected. Would you like to uninstall it?" IDYES UninstallOldVersion IDNO OldVersionDoesNotExist 38 | UninstallOldVersion: 39 | ; Remove the contents/folders of the old version 40 | RMDir /r "$APPDATA\FreeScribe\presets" 41 | RMDir /r "$APPDATA\FreeScribe\_internal" 42 | RMDir /r "$APPDATA\FreeScribe\models" 43 | 44 | ; Remove the old version executable 45 | Delete "$APPDATA\FreeScribe\freescribe-client.exe" 46 | 47 | ; Remove the uninstaller entry from the Control Panel 48 | Delete "$APPDATA\FreeScribe\uninstall.exe" 49 | 50 | ; Remove the start menu shortcut 51 | Delete "$SMPROGRAMS\FreeScribe\FreeScribe.lnk" 52 | RMDir "$SMPROGRAMS\FreeScribe" 53 | 54 | ; Show message when uninstallation is complete 55 | MessageBox MB_OK "FreeScribe has been successfully uninstalled." 56 | OldVersionDoesNotExist: 57 | FunctionEnd 58 | 59 | 60 | ; Function to create a custom page with CPU/NVIDIA options 61 | Function ARCHITECTURE_SELECT 62 | Call Check_For_Old_Version_In_App_Data 63 | !insertmacro MUI_HEADER_TEXT "Architecture Selection" "Choose your preferred installation architecture based on your hardware" 64 | 65 | nsDialogs::Create 1018 66 | Pop $0 67 | 68 | ${If} $0 == error 69 | Abort 70 | ${EndIf} 71 | 72 | ; Main instruction text for architecture selection 73 | ${NSD_CreateLabel} 0 0 100% 12u "Choose your preferred installation architecture based on your hardware:" 74 | Pop $0 75 | 76 | ; Radio button for CPU 77 | ${NSD_CreateRadioButton} 10 15u 100% 10u "CPU" 78 | Pop $CPU_RADIO 79 | ${NSD_Check} $CPU_RADIO 80 | StrCpy $SELECTED_OPTION "CPU" 81 | 82 | ; CPU explanation text (grey with padding) 83 | ${NSD_CreateLabel} 20 25u 100% 20u "Recommended for most users. Runs on any modern processor and provides good performance for general use." 84 | Pop $0 85 | SetCtlColors $0 808080 transparent 86 | 87 | ; Radio button for NVIDIA 88 | ${NSD_CreateRadioButton} 10 55u 100% 10u "NVIDIA" 89 | Pop $NVIDIA_RADIO 90 | 91 | ; NVIDIA explanation text (grey with padding) 92 | ${NSD_CreateLabel} 20 65u 100% 30u "Choose this option if you have an NVIDIA GPU. Provides accelerated performance. Only select if you have a Nvidia GPU installed." 93 | Pop $0 94 | SetCtlColors $0 808080 transparent 95 | 96 | ; Bottom padding (10u of space) 97 | ${NSD_CreateLabel} 0 95u 100% 10u "" 98 | Pop $0 99 | 100 | ${NSD_OnClick} $CPU_RADIO OnRadioClick 101 | ${NSD_OnClick} $NVIDIA_RADIO OnRadioClick 102 | 103 | nsDialogs::Show 104 | FunctionEnd 105 | 106 | Function ARCHITECTURE_SELECT_LEAVE 107 | ${If} $SELECTED_OPTION == "NVIDIA" 108 | Call CheckNvidiaDrivers 109 | ${EndIf} 110 | FunctionEnd 111 | 112 | ; Callback function for radio button clicks 113 | Function OnRadioClick 114 | Pop $0 ; Get the handle of the clicked control 115 | 116 | ${If} $0 == $CPU_RADIO 117 | StrCpy $SELECTED_OPTION "CPU" 118 | ${ElseIf} $0 == $NVIDIA_RADIO 119 | StrCpy $SELECTED_OPTION "NVIDIA" 120 | ${EndIf} 121 | FunctionEnd 122 | 123 | ; Function to show message box on finish 124 | Function .onInstSuccess 125 | ; Check if silent, if is silent skip message box prompt 126 | IfSilent +2 127 | MessageBox MB_OK "Installation completed successfully! Please note upon first launch start time may be slow. Please wait for the program to open!" 128 | FunctionEnd 129 | 130 | Function un.onInit 131 | CheckIfFreeScribeIsRunning: 132 | nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' 133 | Pop $0 ; Return value 134 | 135 | ; Check if the process is running 136 | ${If} $0 == 0 137 | MessageBox MB_RETRYCANCEL "FreeScribe is currently running. Please close the application and try again." IDRETRY CheckIfFreeScribeIsRunning IDCANCEL abort 138 | abort: 139 | Abort 140 | ${EndIf} 141 | FunctionEnd 142 | ; Checks on installer start 143 | Function .onInit 144 | CheckIfFreeScribeIsRunning: 145 | nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq freescribe-client.exe" /NH | find /I "freescribe-client.exe" > nul' 146 | Pop $0 ; Return value 147 | 148 | ; Check if the process is running 149 | ${If} $0 == 0 150 | MessageBox MB_RETRYCANCEL "FreeScribe is currently running. Please close the application and try again." IDRETRY CheckIfFreeScribeIsRunning IDCANCEL abort 151 | abort: 152 | Abort 153 | ${EndIf} 154 | 155 | IfSilent SILENT_MODE NOT_SILENT_MODE 156 | 157 | SILENT_MODE: 158 | ${GetParameters} $R0 159 | ; Check for custom parameters 160 | ${GetOptions} $R0 "/ARCH=" $R1 161 | ${If} $R1 != "" 162 | StrCpy $SELECTED_OPTION $R1 163 | ${EndIf} 164 | 165 | NOT_SILENT_MODE: 166 | FunctionEnd 167 | 168 | Function CleanUninstall 169 | ; Remove the contents/folders of the old version 170 | RMDir /r "$INSTDIR\presets" 171 | RMDir /r "$INSTDIR\_internal" 172 | 173 | ; Remove the old version executable 174 | Delete "$INSTDIR\freescribe-client.exe" 175 | 176 | ; Remove the uninstaller entry from the Control Panel 177 | Delete "$INSTDIR\uninstall.exe" 178 | 179 | ; Remove the start menu shortcut 180 | Delete "$SMPROGRAMS\FreeScribe\FreeScribe.lnk" 181 | RMDir "$SMPROGRAMS\FreeScribe" 182 | FunctionEnd 183 | 184 | Function CheckForOldConfig 185 | ; Check if the old version exists in AppData 186 | IfFileExists "$APPDATA\FreeScribe\settings.txt" 0 End 187 | ; Open Dialog to ask user if they want to uninstall the old version 188 | MessageBox MB_YESNO|MB_ICONQUESTION "An old configuration file has been detected. We recommend removing it to prevent conflict with new versions. Would you like to remove it?" IDYES RemoveOldConfig IDNO End 189 | RemoveOldConfig: 190 | ClearErrors 191 | ; Remove the old version executable 192 | RMDir /r "$APPDATA\FreeScribe" 193 | ${If} ${Errors} 194 | MessageBox MB_RETRYCANCEL "Unable to remove old configuration. Please close any applications using these files and try again." IDRETRY RemoveOldConfig IDCANCEL ConfigFilesFailed 195 | ${EndIf} 196 | Goto End 197 | ConfigFilesFailed: 198 | MessageBox MB_OK|MB_ICONEXCLAMATION "Old configuration files could not be removed. Proceeding with installation." 199 | End: 200 | FunctionEnd 201 | 202 | ; Define the section of the installer 203 | Section "MainSection" SEC01 204 | Call CleanUninstall 205 | Call CheckForOldConfig 206 | ; Set output path to the installation directory 207 | SetOutPath "$INSTDIR" 208 | 209 | ${If} $SELECTED_OPTION == "CPU" 210 | ; Add files to the installer 211 | File /r "..\dist\freescribe-client-cpu\freescribe-client-cpu.exe" 212 | Rename "$INSTDIR\freescribe-client-cpu.exe" "$INSTDIR\freescribe-client.exe" 213 | File /r "..\dist\freescribe-client-cpu\_internal" 214 | ${EndIf} 215 | 216 | ${If} $SELECTED_OPTION == "NVIDIA" 217 | ; Add files to the installer 218 | File /r "..\dist\freescribe-client-nvidia\freescribe-client-nvidia.exe" 219 | Rename "$INSTDIR\freescribe-client-nvidia.exe" "$INSTDIR\freescribe-client.exe" 220 | File /r "..\dist\freescribe-client-nvidia\_internal" 221 | ${EndIf} 222 | 223 | 224 | ; add presets 225 | CreateDirectory "$INSTDIR\presets" 226 | SetOutPath "$INSTDIR\presets" 227 | File /r "..\src\FreeScribe.client\presets\*" 228 | 229 | SetOutPath "$INSTDIR" 230 | 231 | ; Create a start menu shortcut 232 | CreateDirectory "$SMPROGRAMS\FreeScribe" 233 | CreateShortcut "$SMPROGRAMS\FreeScribe\FreeScribe.lnk" "$INSTDIR\freescribe-client.exe" 234 | 235 | ; Create an uninstaller 236 | WriteUninstaller "$INSTDIR\Uninstall.exe" 237 | SectionEnd 238 | 239 | Section "GGUF Installs" GGUF_INSTALLS 240 | AddSize 2800000 ; Add the size in kilobytes for the models 241 | 242 | CreateDirectory "$INSTDIR\models" 243 | SetOutPath "$INSTDIR\models" 244 | 245 | ; Copy the license 246 | File ".\assets\gemma_license.txt" 247 | 248 | ; Check if the file already exists 249 | IfFileExists "$INSTDIR\models\gemma-2-2b-it-Q8_0.gguf" 0 +2 250 | Goto SkipDownload 251 | 252 | ; Install the gemma 2 q8 253 | inetc::get /TIMEOUT=30000 "https://huggingface.co/lmstudio-community/gemma-2-2b-it-GGUF/resolve/main/gemma-2-2b-it-Q8_0.gguf?download=true" "$INSTDIR\models\gemma-2-2b-it-Q8_0.gguf" /END 254 | 255 | SkipDownload: 256 | SetOutPath "$INSTDIR" 257 | 258 | SectionEnd 259 | 260 | ; Define the uninstaller section 261 | Section "Uninstall" 262 | ; Remove the installation directory and all its contents 263 | RMDir /r "$INSTDIR" 264 | 265 | ; Remove the start menu shortcut 266 | Delete "$SMPROGRAMS\FreeScribe\FreeScribe.lnk" 267 | RMDir "$SMPROGRAMS\FreeScribe" 268 | 269 | ; Remove the uninstaller entry from the Control Panel 270 | Delete "$INSTDIR\Uninstall.exe" 271 | 272 | RemoveConfigFiles: 273 | ; Remove configuration files if the checkbox is selected 274 | ${If} $REMOVE_CONFIG == ${BST_CHECKED} 275 | ClearErrors 276 | RMDir /r "$APPDATA\FreeScribe" 277 | ${If} ${Errors} 278 | MessageBox MB_RETRYCANCEL "Unable to remove old configuration. Please close any applications using these files and try again." IDRETRY RemoveConfigFiles IDCANCEL ConfigFilesFailed 279 | ${EndIf} 280 | ${EndIf} 281 | 282 | ; Show message when uninstallation is complete 283 | MessageBox MB_OK "FreeScribe has been successfully uninstalled." 284 | Goto EndUninstall 285 | 286 | ConfigFilesFailed: 287 | MessageBox MB_OK|MB_ICONEXCLAMATION "FreeScribe has been successfully uninstalled, but the configuration files could not be removed. Please close any applications using these files and try again." 288 | EndUninstall: 289 | SectionEnd 290 | 291 | # Variables for checkboxes 292 | Var DesktopShortcutCheckbox 293 | Var StartMenuCheckbox 294 | Var RunAppCheckbox 295 | 296 | Function CustomizeFinishPage 297 | !insertmacro MUI_HEADER_TEXT "Installation Complete" "Please select your preferences and close the installer." 298 | 299 | nsDialogs::Create 1018 300 | Pop $0 301 | 302 | ${If} $0 == error 303 | Abort 304 | ${EndIf} 305 | 306 | # Run App Checkbox 307 | ${NSD_CreateCheckbox} 10u 10u 100% 12u "Run FreeScribe after installation" 308 | Pop $RunAppCheckbox 309 | ${NSD_SetState} $RunAppCheckbox ${BST_CHECKED} 310 | 311 | # Desktop Shortcut Checkbox 312 | ${NSD_CreateCheckbox} 10u 30u 100% 12u "Create Desktop Shortcut" 313 | Pop $DesktopShortcutCheckbox 314 | ${NSD_SetState} $DesktopShortcutCheckbox ${BST_CHECKED} 315 | 316 | # Start Menu Checkbox 317 | ${NSD_CreateCheckbox} 10u 50u 100% 12u "Add to Start Menu" 318 | Pop $StartMenuCheckbox 319 | ${NSD_SetState} $StartMenuCheckbox ${BST_CHECKED} 320 | 321 | nsDialogs::Show 322 | FunctionEnd 323 | 324 | Function RunApp 325 | ${NSD_GetState} $RunAppCheckbox $0 326 | ${If} $0 == ${BST_CHECKED} 327 | Exec '"$INSTDIR\freescribe-client.exe"' 328 | ${EndIf} 329 | 330 | # Check Desktop Shortcut 331 | ${NSD_GetState} $DesktopShortcutCheckbox $0 332 | StrCmp $0 ${BST_CHECKED} +2 333 | Goto SkipDesktopShortcut 334 | CreateShortcut "$DESKTOP\FreeScribe.lnk" "$INSTDIR\freescribe-client.exe" 335 | SkipDesktopShortcut: 336 | 337 | # Check Start Menu 338 | ${NSD_GetState} $StartMenuCheckbox $0 339 | StrCmp $0 ${BST_CHECKED} +2 340 | Goto SkipStartMenu 341 | CreateDirectory "$SMPROGRAMS\FreeScribe" 342 | CreateShortcut "$SMPROGRAMS\FreeScribe\FreeScribe.lnk" "$INSTDIR\freescribe-client.exe" 343 | SkipStartMenu: 344 | FunctionEnd 345 | 346 | ; Function to execute when leaving the InstallFiles page 347 | ; Goes to the next page after the installation is complete 348 | Function InsfilesPageLeave 349 | SetAutoClose true 350 | FunctionEnd 351 | 352 | Function CheckNvidiaDrivers 353 | Var /GLOBAL DriverVersion 354 | 355 | ; Try to read from the registry 356 | SetRegView 64 357 | ReadRegStr $DriverVersion HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{B2FE1952-0186-46C3-BAEC-A80AA35AC5B8}_Display.Driver" "DisplayVersion" 358 | 359 | ${If} $DriverVersion == "" 360 | ; Fallback to 32-bit registry view 361 | SetRegView 32 362 | ReadRegStr $DriverVersion HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{B2FE1952-0186-46C3-BAEC-A80AA35AC5B8}_Display.Driver" "DisplayVersion" 363 | ${EndIf} 364 | 365 | ; No nvidia drivers detected - show error message 366 | ${If} $DriverVersion == "" 367 | 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." 368 | Goto driver_check_end 369 | ${EndIf} 370 | ; Push the version number to the stack 371 | Push $DriverVersion 372 | ; Push min driver version 373 | Push ${MIN_CUDA_DRIVER_VERSION} 374 | 375 | Call CompareVersions 376 | 377 | Pop $0 ; Get the return value 378 | 379 | ${If} $0 == 1 380 | 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." 381 | Abort 382 | ${EndIf} 383 | driver_check_end: 384 | FunctionEnd 385 | 386 | ;------------------------------------------------------------------------------ 387 | ; Function: CompareVersions 388 | ; Purpose: Compares two version numbers in format "X.Y" (e.g., "1.0", "2.3") 389 | ; 390 | ; Parameters: 391 | ; Stack 1 (bottom): First version string to compare 392 | ; Stack 0 (top): Second version string to compare 393 | ; 394 | ; Returns: 395 | ; 0: Versions are equal 396 | ; 1: First version is less than second version 397 | ; 2: First version is greater than second version 398 | ; 399 | ; Example: 400 | ; Push "1.0" ; First version 401 | ; Push "2.0" ; Second version 402 | ; Call CompareVersions 403 | ; Pop $R0 ; $R0 will contain 1 (1.0 < 2.0) 404 | ;------------------------------------------------------------------------------ 405 | Function CompareVersions 406 | Exch $R0 ; Get second version from stack into $R0 407 | Exch 408 | Exch $R1 ; Get first version from stack into $R1 409 | Push $R2 410 | Push $R3 411 | Push $R4 412 | Push $R5 413 | 414 | ; Split version strings into major and minor numbers 415 | ${WordFind} $R1 "." "+1" $R2 ; Extract major number from first version 416 | ${WordFind} $R1 "." "+2" $R3 ; Extract minor number from first version 417 | ${WordFind} $R0 "." "+1" $R4 ; Extract major number from second version 418 | ${WordFind} $R0 "." "+2" $R5 ; Extract minor number from second version 419 | 420 | ; Convert to comparable numbers: 421 | ; Multiply major version by 1000 to handle minor version properly 422 | IntOp $R2 $R2 * 1000 ; Convert first version major number 423 | IntOp $R4 $R4 * 1000 ; Convert second version major number 424 | 425 | ; Add minor numbers to create complete comparable values 426 | IntOp $R2 $R2 + $R3 ; First version complete number 427 | IntOp $R4 $R4 + $R5 ; Second version complete number 428 | 429 | ; Compare versions and set return value 430 | ${If} $R2 < $R4 ; If first version is less than second 431 | StrCpy $R0 1 432 | ${ElseIf} $R2 > $R4 ; If first version is greater than second 433 | StrCpy $R0 2 434 | ${Else} ; If versions are equal 435 | StrCpy $R0 0 436 | ${EndIf} 437 | 438 | ; Restore registers from stack 439 | Pop $R5 440 | Pop $R4 441 | Pop $R3 442 | Pop $R2 443 | Pop $R1 444 | Exch $R0 ; Put return value on stack 445 | FunctionEnd 446 | 447 | Function un.CreateRemoveConfigFilesPage 448 | !insertmacro MUI_HEADER_TEXT "Remove Configuration Files" "Do you want to remove the configuration files (e.g., settings)?" 449 | 450 | nsDialogs::Create 1018 451 | Pop $0 452 | 453 | ${If} $0 == error 454 | Abort 455 | ${EndIf} 456 | 457 | ${NSD_CreateCheckbox} 0 20u 100% 12u "Remove configuration files" 458 | Pop $REMOVE_CONFIG_CHECKBOX 459 | ${NSD_SetState} $REMOVE_CONFIG_CHECKBOX ${BST_CHECKED} 460 | 461 | nsDialogs::Show 462 | FunctionEnd 463 | 464 | Function un.RemoveConfigFilesPageLeave 465 | ${NSD_GetState} $REMOVE_CONFIG_CHECKBOX $REMOVE_CONFIG 466 | FunctionEnd 467 | 468 | ; Define installer pages 469 | !insertmacro MUI_PAGE_LICENSE ".\assets\License.txt" 470 | Page Custom ARCHITECTURE_SELECT ARCHITECTURE_SELECT_LEAVE 471 | !insertmacro MUI_PAGE_DIRECTORY 472 | !define MUI_PAGE_CUSTOMFUNCTION_LEAVE InsfilesPageLeave 473 | !insertmacro MUI_PAGE_INSTFILES 474 | Page Custom CustomizeFinishPage RunApp 475 | 476 | ; Define the uninstaller pages 477 | !insertmacro MUI_UNPAGE_CONFIRM 478 | UninstPage custom un.CreateRemoveConfigFilesPage un.RemoveConfigFilesPageLeave 479 | !insertmacro MUI_UNPAGE_INSTFILES 480 | !insertmacro MUI_UNPAGE_FINISH 481 | 482 | ; Define the languages 483 | !insertmacro MUI_LANGUAGE English 484 | -------------------------------------------------------------------------------- /src/FreeScribe.client/clientfasterwhisper.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Braedon Hendy 2 | # This software is released under the GNU General Public License v3.0 3 | 4 | import tkinter as tk 5 | from tkinter import scrolledtext, ttk, filedialog 6 | import requests 7 | import pyperclip 8 | import wave 9 | import threading 10 | import numpy as np 11 | import base64 12 | import json 13 | import pyaudio 14 | import tkinter.messagebox as messagebox 15 | import datetime 16 | import functools 17 | import os 18 | from faster_whisper import WhisperModel 19 | from openai import OpenAI 20 | import scrubadub 21 | 22 | # Add these near the top of your script 23 | editable_settings = { 24 | "use_story": False, 25 | "use_memory": False, 26 | "use_authors_note": False, 27 | "use_world_info": False, 28 | "max_context_length": 2048, 29 | "max_length": 360, 30 | "rep_pen": 1.1, 31 | "rep_pen_range": 2048, 32 | "rep_pen_slope": 0.7, 33 | "temperature": 0, 34 | "tfs": 0.97, 35 | "top_a": 0.8, 36 | "top_k": 30, 37 | "top_p": 0.4, 38 | "typical": 0.19, 39 | "sampler_order": [6, 0, 1, 3, 4, 2, 5], 40 | "singleline": False, 41 | "frmttriminc": False, 42 | "frmtrmblln": False, 43 | "Local Whisper": False, 44 | "Whisper Model": "medium.en", 45 | "GPT Model": "gpt-4" 46 | } 47 | 48 | # Function to build the full URL from IP 49 | def build_url(ip, port): 50 | return f"http://{ip}:{port}" 51 | 52 | # Function to save settings to a file 53 | def save_settings_to_file(koboldcpp_ip, whisperaudio_ip, openai_api_key): 54 | settings = { 55 | "koboldcpp_ip": koboldcpp_ip, 56 | "whisperaudio_ip": whisperaudio_ip, 57 | "openai_api_key": openai_api_key, 58 | "editable_settings": editable_settings 59 | } 60 | with open('settings.txt', 'w') as file: 61 | json.dump(settings, file) 62 | 63 | def load_settings_from_file(): 64 | try: 65 | with open('settings.txt', 'r') as file: 66 | try: 67 | settings = json.load(file) 68 | except json.JSONDecodeError: 69 | return "192.168.1.195", "192.168.1.195", "None" 70 | 71 | koboldcpp_ip = settings.get("koboldcpp_ip", "192.168.1.195") 72 | whisperaudio_ip = settings.get("whisperaudio_ip", "192.168.1.195") 73 | openai_api_key = settings.get("openai_api_key", "NONE") 74 | loaded_editable_settings = settings.get("editable_settings", {}) 75 | for key, value in loaded_editable_settings.items(): 76 | if key in editable_settings: 77 | editable_settings[key] = value 78 | return koboldcpp_ip, whisperaudio_ip, openai_api_key 79 | except FileNotFoundError: 80 | # Return default values if file not found 81 | return "192.168.1.195", "192.168.1.195", "None" 82 | 83 | def load_aiscribe_from_file(): 84 | try: 85 | with open('aiscribe.txt', 'r') as f: 86 | content = f.read().strip() 87 | return content if content else None 88 | except FileNotFoundError: 89 | return None 90 | 91 | def load_aiscribe2_from_file(): 92 | try: 93 | with open('aiscribe2.txt', 'r') as f: 94 | content = f.read().strip() 95 | return content if content else None 96 | except FileNotFoundError: 97 | return None 98 | 99 | # Load settings at the start 100 | KOBOLDCPP_IP, WHISPERAUDIO_IP, OPENAI_API_KEY = load_settings_from_file() 101 | KOBOLDCPP = build_url(KOBOLDCPP_IP, "5001") 102 | WHISPERAUDIO = build_url(WHISPERAUDIO_IP, "8000/whisperaudio") 103 | response_history = [] 104 | current_view = "full" 105 | 106 | 107 | # Other constants and global variables 108 | username = "user" 109 | botname = "Assistant" 110 | num_lines_to_keep = 20 111 | DEFAULT_AISCRIBE = "AI, please transform the following conversation into a concise SOAP note. Do not invent or assume any medical data, vital signs, or lab values. Base the note strictly on the information provided in the conversation. Ensure that the SOAP note is structured appropriately with Subjective, Objective, Assessment, and Plan sections. Here's the conversation:" 112 | DEFAULT_AISCRIBE2 = "Remember, the Subjective section should reflect the patient's perspective and complaints as mentioned in the conversation. The Objective section should only include observable or measurable data from the conversation. The Assessment should be a summary of your understanding and potential diagnoses, considering the conversation's content. The Plan should outline the proposed management or follow-up required, strictly based on the dialogue provided" 113 | AISCRIBE = load_aiscribe_from_file() or DEFAULT_AISCRIBE 114 | AISCRIBE2 = load_aiscribe2_from_file() or DEFAULT_AISCRIBE2 115 | uploaded_file_path = None 116 | 117 | # Function to get prompt for KoboldAI Generation 118 | def get_prompt(formatted_message): 119 | # Check and parse 'sampler_order' if it's a string 120 | sampler_order = editable_settings["sampler_order"] 121 | if isinstance(sampler_order, str): 122 | sampler_order = json.loads(sampler_order) 123 | return { 124 | "prompt": f"{formatted_message}\n", 125 | "use_story": editable_settings["use_story"], 126 | "use_memory": editable_settings["use_memory"], 127 | "use_authors_note": editable_settings["use_authors_note"], 128 | "use_world_info": editable_settings["use_world_info"], 129 | "max_context_length": int(editable_settings["max_context_length"]), 130 | "max_length": int(editable_settings["max_length"]), 131 | "rep_pen": float(editable_settings["rep_pen"]), 132 | "rep_pen_range": int(editable_settings["rep_pen_range"]), 133 | "rep_pen_slope": float(editable_settings["rep_pen_slope"]), 134 | "temperature": float(editable_settings["temperature"]), 135 | "tfs": float(editable_settings["tfs"]), 136 | "top_a": float(editable_settings["top_a"]), 137 | "top_k": int(editable_settings["top_k"]), 138 | "top_p": float(editable_settings["top_p"]), 139 | "typical": float(editable_settings["typical"]), 140 | "sampler_order": sampler_order, 141 | "singleline": editable_settings["singleline"], 142 | "frmttriminc": editable_settings["frmttriminc"], 143 | "frmtrmblln": editable_settings["frmtrmblln"] 144 | } 145 | 146 | def threaded_handle_message(formatted_message): 147 | thread = threading.Thread(target=handle_message, args=(formatted_message,)) 148 | thread.start() 149 | 150 | def threaded_send_audio_to_server(): 151 | thread = threading.Thread(target=send_audio_to_server) 152 | thread.start() 153 | 154 | def handle_message(formatted_message): 155 | if gpt_button.cget("bg") == "red": 156 | show_edit_transcription_popup(formatted_message) 157 | else: 158 | prompt = get_prompt(formatted_message) 159 | response = requests.post(f"{KOBOLDCPP}/api/v1/generate", json=prompt) 160 | if response.status_code == 200: 161 | results = response.json()['results'] 162 | response_text = results[0]['text'] 163 | response_text = response_text.replace(" ", " ").strip() 164 | update_gui_with_response(response_text) 165 | 166 | def send_and_receive(): 167 | global use_aiscribe 168 | user_message = user_input.get("1.0", tk.END).strip() 169 | clear_response_display() 170 | if use_aiscribe: 171 | formatted_message = f'{AISCRIBE} [{user_message}] {AISCRIBE2}' 172 | else: 173 | formatted_message = user_message 174 | threaded_handle_message(formatted_message) 175 | 176 | def clear_response_display(): 177 | response_display.configure(state='normal') 178 | response_display.delete("1.0", tk.END) 179 | response_display.configure(state='disabled') 180 | 181 | def update_gui_with_response(response_text): 182 | global response_history 183 | timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 184 | response_history.insert(0, (timestamp, response_text)) 185 | 186 | # Update the timestamp listbox 187 | timestamp_listbox.delete(0, tk.END) 188 | for time, _ in response_history: 189 | timestamp_listbox.insert(tk.END, time) 190 | 191 | response_display.configure(state='normal') 192 | response_display.insert(tk.END, f"{response_text}\n") 193 | response_display.configure(state='disabled') 194 | pyperclip.copy(response_text) 195 | stop_flashing() 196 | 197 | def show_response(event): 198 | selection = event.widget.curselection() 199 | if selection: 200 | index = selection[0] 201 | response_text = response_history[index][1] 202 | response_display.configure(state='normal') 203 | response_display.delete('1.0', tk.END) 204 | response_display.insert('1.0', response_text) 205 | response_display.configure(state='disabled') 206 | pyperclip.copy(response_text) 207 | 208 | def send_text_to_chatgpt(edited_text): 209 | api_key = OPENAI_API_KEY 210 | headers = { 211 | "Authorization": f"Bearer {api_key}", 212 | "Content-Type": "application/json", 213 | } 214 | payload = { 215 | "model": editable_settings["GPT Model"].strip(), 216 | "messages": [ 217 | {"role": "user", "content": edited_text} 218 | ], 219 | } 220 | 221 | response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, data=json.dumps(payload)) 222 | 223 | if response.status_code == 200: 224 | response_data = response.json() 225 | response_text = (response_data['choices'][0]['message']['content']) 226 | update_gui_with_response(response_text) 227 | 228 | 229 | def show_edit_transcription_popup(formatted_message): 230 | popup = tk.Toplevel(root) 231 | popup.title("Scrub PHI Prior to GPT") 232 | text_area = scrolledtext.ScrolledText(popup, height=20, width=80) 233 | text_area.pack(padx=10, pady=10) 234 | cleaned_message = scrubadub.clean(formatted_message) 235 | text_area.insert(tk.END, cleaned_message) 236 | 237 | def on_proceed(): 238 | edited_text = text_area.get("1.0", tk.END).strip() 239 | popup.destroy() 240 | send_text_to_chatgpt(edited_text) 241 | 242 | proceed_button = tk.Button(popup, text="Proceed", command=on_proceed) 243 | proceed_button.pack(side=tk.RIGHT, padx=10, pady=10) 244 | 245 | # Cancel button 246 | cancel_button = tk.Button(popup, text="Cancel", command=popup.destroy) 247 | cancel_button.pack(side=tk.LEFT, padx=10, pady=10) 248 | 249 | # Global variable to control recording state 250 | is_recording = False 251 | audio_data = [] 252 | frames = [] 253 | is_paused = False 254 | use_aiscribe = True 255 | is_gpt_button_active = False 256 | 257 | # Global variables for PyAudio 258 | CHUNK = 1024 259 | FORMAT = pyaudio.paInt16 260 | CHANNELS = 1 261 | RATE = 16000 262 | RECORD_SECONDS = 5 263 | 264 | p = pyaudio.PyAudio() # Creating an instance of PyAudio 265 | 266 | def toggle_pause(): 267 | global is_paused 268 | is_paused = not is_paused 269 | 270 | if is_paused: 271 | pause_button.config(text="Resume", bg="red") 272 | else: 273 | pause_button.config(text="Pause", bg="SystemButtonFace") 274 | 275 | def record_audio(): 276 | global is_paused, frames 277 | stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK) 278 | 279 | while is_recording: 280 | if not is_paused: 281 | data = stream.read(CHUNK) 282 | frames.append(data) 283 | 284 | stream.stop_stream() 285 | stream.close() 286 | 287 | # Function to save audio and send to server 288 | def save_audio(): 289 | global frames 290 | if frames: 291 | with wave.open('recording.wav', 'wb') as wf: 292 | wf.setnchannels(CHANNELS) 293 | wf.setsampwidth(p.get_sample_size(FORMAT)) 294 | wf.setframerate(RATE) 295 | wf.writeframes(b''.join(frames)) 296 | frames = [] # Clear recorded data 297 | threaded_send_audio_to_server() 298 | 299 | def toggle_recording(): 300 | global is_recording, recording_thread 301 | if not is_recording: 302 | # Clear the user_input and response_display textboxes 303 | # This code runs only when the recording is about to start 304 | user_input.delete("1.0", tk.END) 305 | response_display.configure(state='normal') 306 | response_display.delete("1.0", tk.END) 307 | response_display.configure(state='disabled') 308 | is_recording = True 309 | recording_thread = threading.Thread(target=record_audio) 310 | recording_thread.start() 311 | mic_button.config(bg="red", text="Microphone ON") 312 | start_flashing() 313 | else: 314 | is_recording = False 315 | if recording_thread.is_alive(): 316 | recording_thread.join() # Ensure the recording thread is terminated 317 | save_audio() 318 | mic_button.config(bg="SystemButtonFace", text="Microphone OFF") 319 | 320 | def clear_all_text_fields(): 321 | user_input.configure(state='normal') 322 | user_input.delete("1.0", tk.END) 323 | stop_flashing() 324 | response_display.configure(state='normal') 325 | response_display.delete("1.0", tk.END) 326 | response_display.configure(state='disabled') 327 | 328 | def toggle_gpt_button(): 329 | global is_gpt_button_active 330 | if is_gpt_button_active: 331 | gpt_button.config(bg="SystemButtonFace", text="GPT OFF") 332 | is_gpt_button_active = False 333 | else: 334 | gpt_button.config(bg="red", text="GPT ON") 335 | is_gpt_button_active = True 336 | 337 | def toggle_aiscribe(): 338 | global use_aiscribe 339 | use_aiscribe = not use_aiscribe 340 | toggle_button.config(text="AISCRIBE ON" if use_aiscribe else "AISCRIBE OFF") 341 | 342 | def save_settings(koboldcpp_ip, whisperaudio_ip, openai_api_key, aiscribe_text, aiscribe2_text, settings_window): 343 | global KOBOLDCPP, WHISPERAUDIO, KOBOLDCPP_IP, WHISPERAUDIO_IP, OPENAI_API_KEY, editable_settings, AISCRIBE, AISCRIBE2 344 | KOBOLDCPP_IP = koboldcpp_ip 345 | WHISPERAUDIO_IP = whisperaudio_ip 346 | OPENAI_API_KEY = openai_api_key 347 | KOBOLDCPP = build_url(KOBOLDCPP_IP, "5001") 348 | WHISPERAUDIO = build_url(WHISPERAUDIO_IP, "8000/whisperaudio") 349 | for setting, entry in editable_settings_entries.items(): 350 | value = entry.get() 351 | if setting in ["max_context_length", "max_length", "rep_pen_range", "top_k"]: 352 | value = int(value) 353 | # Add similar conditions for other data types 354 | editable_settings[setting] = value 355 | save_settings_to_file(KOBOLDCPP_IP, WHISPERAUDIO_IP, OPENAI_API_KEY) # Save to file 356 | AISCRIBE = aiscribe_text 357 | AISCRIBE2 = aiscribe2_text 358 | with open('aiscribe.txt', 'w') as f: 359 | f.write(AISCRIBE) 360 | with open('aiscribe2.txt', 'w') as f: 361 | f.write(AISCRIBE2) 362 | 363 | settings_window.destroy() 364 | 365 | # New dictionary for entry widgets 366 | editable_settings_entries = {} 367 | 368 | def open_settings_window(): 369 | settings_window = tk.Toplevel(root) 370 | settings_window.title("Settings") 371 | 372 | # KOBOLDCPP IP input 373 | tk.Label(settings_window, text="KOBOLDCPP IP:").grid(row=0, column=0) 374 | koboldcpp_ip_entry = tk.Entry(settings_window, width=50) 375 | koboldcpp_ip_entry.insert(0, KOBOLDCPP_IP) 376 | koboldcpp_ip_entry.grid(row=0, column=1) 377 | 378 | # WHISPERAUDIO IP input 379 | tk.Label(settings_window, text="WHISPERAUDIO IP:").grid(row=1, column=0) 380 | whisperaudio_ip_entry = tk.Entry(settings_window, width=50) 381 | whisperaudio_ip_entry.insert(0, WHISPERAUDIO_IP) 382 | whisperaudio_ip_entry.grid(row=1, column=1) 383 | 384 | tk.Label(settings_window, text="OpenAI API Key:").grid(row=2, column=0) 385 | openai_api_key_entry = tk.Entry(settings_window, width=50) 386 | openai_api_key_entry.insert(0, OPENAI_API_KEY) 387 | openai_api_key_entry.grid(row=2, column=1) 388 | 389 | row_index = 3 390 | for setting, value in editable_settings.items(): 391 | tk.Label(settings_window, text=f"{setting}:").grid(row=row_index, column=0, sticky='nw') 392 | entry = tk.Entry(settings_window, width=50) 393 | entry.insert(0, str(value)) 394 | entry.grid(row=row_index, column=1, sticky='nw') 395 | editable_settings_entries[setting] = entry 396 | row_index += 1 397 | 398 | # AISCRIBE text box 399 | tk.Label(settings_window, text="Context Before Conversation").grid(row=0, column=2, sticky='nw', padx=(10,0)) 400 | aiscribe_textbox = tk.Text(settings_window, width=50, height=15) 401 | aiscribe_textbox.insert('1.0', AISCRIBE) 402 | aiscribe_textbox.grid(row=1, column=2, rowspan=10, sticky='nw', padx=(10,0)) 403 | 404 | # AISCRIBE2 text box 405 | tk.Label(settings_window, text="Context After Conversation").grid(row=11, column=2, sticky='nw', padx=(10,0)) 406 | aiscribe2_textbox = tk.Text(settings_window, width=50, height=15) 407 | aiscribe2_textbox.insert('1.0', AISCRIBE2) 408 | aiscribe2_textbox.grid(row=12, column=2, rowspan=10, sticky='nw', padx=(10,0)) 409 | 410 | # Save, Close, and Default buttons under the left column 411 | save_button = tk.Button(settings_window, text="Save", width=15, command=lambda: save_settings(koboldcpp_ip_entry.get(), whisperaudio_ip_entry.get(), openai_api_key_entry.get(), aiscribe_textbox.get("1.0", tk.END), aiscribe2_textbox.get("1.0", tk.END), settings_window)) 412 | save_button.grid(row=row_index, column=0, padx=5, pady=5) 413 | 414 | close_button = tk.Button(settings_window, text="Close", width=15, command=settings_window.destroy) 415 | close_button.grid(row=row_index + 1, column=0, padx=5, pady=5) 416 | 417 | default_button = tk.Button(settings_window, text="Default", width=15, command=clear_settings_file) 418 | default_button.grid(row=row_index + 2, column=0, padx=5, pady=5) 419 | 420 | def upload_file(): 421 | global uploaded_file_path 422 | file_path = filedialog.askopenfilename(filetypes=(("Audio files", "*.wav *.mp3"),)) 423 | if file_path: 424 | uploaded_file_path = file_path 425 | threaded_send_audio_to_server() # Add this line to process the file immediately 426 | start_flashing() 427 | 428 | def send_audio_to_server(): 429 | global uploaded_file_path 430 | if editable_settings["Local Whisper"] == "True": 431 | print("Using Local Whisper for transcription.") 432 | # model = WhisperModel(model_size, device="cuda", compute_type="float16") 433 | # model = WhisperModel(model_size, device="cuda", compute_type="int8_float16") 434 | model = WhisperModel(editable_settings["Whisper Model"].strip(), device="cpu", compute_type="int8") 435 | file_to_send = uploaded_file_path if uploaded_file_path else 'recording.wav' 436 | uploaded_file_path = None 437 | segments, info = model.transcribe(file_to_send, beam_size=5) 438 | print("Detected language '%s' with probability %f" % (info.language, info.language_probability)) 439 | transcribed_text = "".join(segment.text for segment in segments) 440 | user_input.delete("1.0", tk.END) 441 | user_input.insert(tk.END, transcribed_text) 442 | send_and_receive() 443 | else: 444 | print("Using Remote Whisper for transcription.") 445 | if uploaded_file_path: 446 | file_to_send = uploaded_file_path 447 | uploaded_file_path = None 448 | else: 449 | file_to_send = 'recording.wav' 450 | with open(file_to_send, 'rb') as f: 451 | files = {'audio': f} 452 | response = requests.post(WHISPERAUDIO, files=files) 453 | if response.status_code == 200: 454 | transcribed_text = response.json()['text'] 455 | user_input.delete("1.0", tk.END) 456 | user_input.insert(tk.END, transcribed_text) 457 | send_and_receive() 458 | 459 | is_flashing = False 460 | 461 | def start_flashing(): 462 | global is_flashing 463 | is_flashing = True 464 | flash_circle() 465 | 466 | def stop_flashing(): 467 | global is_flashing 468 | is_flashing = False 469 | blinking_circle_canvas.itemconfig(circle, fill='white') # Reset to default color 470 | 471 | def flash_circle(): 472 | if is_flashing: 473 | current_color = blinking_circle_canvas.itemcget(circle, 'fill') 474 | new_color = 'blue' if current_color != 'blue' else 'black' 475 | blinking_circle_canvas.itemconfig(circle, fill=new_color) 476 | root.after(1000, flash_circle) # Adjust the flashing speed as needed 477 | 478 | def send_and_flash(): 479 | start_flashing() 480 | send_and_receive() 481 | 482 | def clear_settings_file(): 483 | try: 484 | open('settings.txt', 'w').close() # This opens the files and immediately closes it, clearing its contents. 485 | open('aiscribe.txt', 'w').close() 486 | open('aiscribe2.txt', 'w').close() 487 | messagebox.showinfo("Settings Reset", "Settings have been reset. Please restart.") 488 | print("Settings file cleared.") 489 | except Exception as e: 490 | print(f"Error clearing settings files: {e}") 491 | 492 | def toggle_view(): 493 | global current_view 494 | if current_view == "full": 495 | user_input.grid_remove() 496 | send_button.grid_remove() 497 | clear_button.grid_remove() 498 | toggle_button.grid_remove() 499 | gpt_button.grid_remove() 500 | settings_button.grid_remove() 501 | upload_button.grid_remove() 502 | response_display.grid_remove() 503 | timestamp_listbox.grid_remove() 504 | mic_button.config(width=10, height=1) 505 | pause_button.config(width=10, height=1) 506 | switch_view_button.config(width=10, height=1) 507 | mic_button.grid(row=0, column=0, pady=5) 508 | pause_button.grid(row=0, column=1, pady=5) 509 | switch_view_button.grid(row=0, column=2, pady=5) 510 | blinking_circle_canvas.grid(row=0, column=3, pady=5) 511 | root.attributes('-topmost', True) 512 | current_view = "minimal" 513 | else: 514 | mic_button.config(width=15, height=2) 515 | pause_button.config(width=15, height=2) 516 | switch_view_button.config(width=15, height=2) 517 | user_input.grid() 518 | send_button.grid() 519 | clear_button.grid() 520 | toggle_button.grid() 521 | gpt_button.grid() 522 | settings_button.grid() 523 | upload_button.grid() 524 | response_display.grid() 525 | timestamp_listbox.grid() 526 | mic_button.grid(row=1, column=0, pady=5) 527 | pause_button.grid(row=1, column=2, pady=5) 528 | switch_view_button.grid(row=1, column=8, pady=5) 529 | blinking_circle_canvas.grid(row=1, column=9, pady=5) 530 | root.attributes('-topmost', False) 531 | current_view = "full" 532 | 533 | # GUI Setup 534 | root = tk.Tk() 535 | root.title("AI Medical Scribe") 536 | 537 | user_input = scrolledtext.ScrolledText(root, height=15) 538 | user_input.grid(row=0, column=0, columnspan=10, padx=5, pady=5) 539 | 540 | mic_button = tk.Button(root, text="Microphone OFF", command=toggle_recording, height=2, width=15) 541 | mic_button.grid(row=1, column=0, pady=5) 542 | 543 | send_button = tk.Button(root, text="Send", command=send_and_flash, height=2, width=15) 544 | send_button.grid(row=1, column=1, pady=5) 545 | 546 | pause_button = tk.Button(root, text="Pause", command=toggle_pause, height=2, width=15) 547 | pause_button.grid(row=1, column=2, pady=5) 548 | 549 | clear_button = tk.Button(root, text="Clear", command=clear_all_text_fields, height=2, width=15) 550 | clear_button.grid(row=1, column=3, pady=5) 551 | 552 | toggle_button = tk.Button(root, text="AISCRIBE ON", command=toggle_aiscribe, height=2, width=15) 553 | toggle_button.grid(row=1, column=4, pady=5) 554 | 555 | gpt_button = tk.Button(root, text="GPT OFF", command=toggle_gpt_button, height=2, width=15) 556 | gpt_button.grid(row=1, column=5, pady=5) 557 | 558 | settings_button = tk.Button(root, text="Settings", command=open_settings_window, height=2, width=15) 559 | settings_button.grid(row=1, column=6, pady=5) 560 | 561 | upload_button = tk.Button(root, text="Upload File", command=upload_file, height=2, width=15) 562 | upload_button.grid(row=1, column=7, pady=5) 563 | 564 | switch_view_button = tk.Button(root, text="Switch View", command=toggle_view, height=2, width=15) 565 | switch_view_button.grid(row=1, column=8, pady=5) 566 | 567 | blinking_circle_canvas = tk.Canvas(root, width=20, height=20) 568 | blinking_circle_canvas.grid(row=1, column=9, pady=5) 569 | circle = blinking_circle_canvas.create_oval(5, 5, 15, 15, fill='white') 570 | 571 | response_display = scrolledtext.ScrolledText(root, height=15, state='disabled') 572 | response_display.grid(row=2, column=0, columnspan=10, padx=5, pady=5) 573 | 574 | timestamp_listbox = tk.Listbox(root, height=30) 575 | timestamp_listbox.grid(row=0, column=10, rowspan=3, padx=5, pady=5) 576 | timestamp_listbox.bind('<>', show_response) 577 | 578 | # Bind Alt+P to send_and_receive function 579 | root.bind('', lambda event: pause_button.invoke()) 580 | 581 | # Bind Alt+R to toggle_recording function 582 | root.bind('', lambda event: mic_button.invoke()) 583 | 584 | root.mainloop() 585 | 586 | p.terminate() 587 | -------------------------------------------------------------------------------- /src/FreeScribe.client/UI/SettingsWindow.py: -------------------------------------------------------------------------------- 1 | """ 2 | application_settings.py 3 | 4 | This software is released under the AGPL-3.0 license 5 | Copyright (c) 2023-2024 Braedon Hendy 6 | 7 | Further updates and packaging added in 2024 through the ClinicianFOCUS initiative, 8 | a collaboration with Dr. Braedon Hendy and Conestoga College Institute of Applied 9 | Learning and Technology as part of the CNERG+ applied research project, 10 | Unburdening Primary Healthcare: An Open-Source AI Clinician Partner Platform". 11 | Prof. Michael Yingbull (PI), Dr. Braedon Hendy (Partner), 12 | and Research Students - Software Developer Alex Simko, Pemba Sherpa (F24), and Naitik Patel. 13 | 14 | This module contains the ApplicationSettings class, which manages the settings for an 15 | application that involves audio processing and external API interactions, including 16 | WhisperAudio, and OpenAI services. 17 | 18 | """ 19 | 20 | import json 21 | import os 22 | import tkinter as tk 23 | from tkinter import ttk, messagebox 24 | import requests 25 | import numpy as np 26 | from utils.file_utils import get_resource_path, get_file_path 27 | from Model import ModelManager 28 | import threading 29 | from UI.Widgets.MicrophoneSelector import MicrophoneState 30 | from utils.ip_utils import is_valid_url 31 | from enum import Enum 32 | 33 | class SettingsKeys(Enum): 34 | LOCAL_WHISPER = "Built-in Speech2Text" 35 | WHISPER_ENDPOINT = "Speech2Text (Whisper) Endpoint" 36 | WHISPER_SERVER_API_KEY = "Speech2Text (Whisper) API Key" 37 | 38 | 39 | class FeatureToggle: 40 | DOCKER_SETTINGS_TAB = False 41 | DOCKER_STATUS_BAR = False 42 | 43 | class SettingsWindow(): 44 | """ 45 | Manages application settings related to audio processing and external API services. 46 | 47 | Attributes 48 | ---------- 49 | OPENAI_API_KEY : str 50 | The API key for OpenAI integration. 51 | AISCRIBE : str 52 | Placeholder for the first AI Scribe settings. 53 | AISCRIBE2 : str 54 | Placeholder for the second AI Scribe settings. 55 | # API_STYLE : str FUTURE FEATURE REVISION 56 | # The API style to be used (default is 'OpenAI'). FUTURE FEATURE 57 | 58 | editable_settings : dict 59 | A dictionary containing user-editable settings such as model parameters, audio 60 | settings, and real-time processing configurations. 61 | 62 | Methods 63 | ------- 64 | load_settings_from_file(): 65 | Loads settings from a JSON file and updates the internal state. 66 | save_settings_to_file(): 67 | Saves the current settings to a JSON file. 68 | save_settings(openai_api_key, aiscribe_text, aiscribe2_text, 69 | settings_window, preset): 70 | Saves the current settings, including API keys, IP addresses, and user-defined parameters. 71 | load_aiscribe_from_file(): 72 | Loads the first AI Scribe text from a file. 73 | load_aiscribe2_from_file(): 74 | Loads the second AI Scribe text from a file. 75 | clear_settings_file(settings_window): 76 | Clears the content of settings files and closes the settings window. 77 | """ 78 | 79 | CPU_INSTALL_FILE = "CPU_INSTALL.txt" 80 | NVIDIA_INSTALL_FILE = "NVIDIA_INSTALL.txt" 81 | STATE_FILES_DIR = "install_state" 82 | 83 | def __init__(self): 84 | """Initializes the ApplicationSettings with default values.""" 85 | 86 | 87 | self.OPENAI_API_KEY = "None" 88 | # self.API_STYLE = "OpenAI" # FUTURE FEATURE REVISION 89 | self.main_window = None 90 | self.scribe_template_values = [] 91 | self.scribe_template_mapping = {} 92 | 93 | 94 | self.general_settings = [ 95 | "Show Welcome Message", 96 | "Show Scrub PHI" 97 | ] 98 | 99 | self.whisper_settings = [ 100 | "BlankSpace", # Represents the SettingsKeys.LOCAL_WHISPER.value checkbox that is manually placed 101 | "Real Time", 102 | "BlankSpace", # Represents the model dropdown that is manually placed 103 | SettingsKeys.WHISPER_ENDPOINT.value, 104 | SettingsKeys.WHISPER_SERVER_API_KEY.value, 105 | "S2T Server Self-Signed Certificates", 106 | ] 107 | 108 | self.llm_settings = [ 109 | "Model Endpoint", 110 | "AI Server Self-Signed Certificates", 111 | ] 112 | 113 | self.adv_ai_settings = [ 114 | "use_story", 115 | "use_memory", 116 | "use_authors_note", 117 | "use_world_info", 118 | "Use best_of", 119 | "best_of", 120 | "max_context_length", 121 | "max_length", 122 | "rep_pen", 123 | "rep_pen_range", 124 | "rep_pen_slope", 125 | "temperature", 126 | "tfs", 127 | "top_a", 128 | "top_k", 129 | "top_p", 130 | "typical", 131 | "sampler_order", 132 | "singleline", 133 | "frmttriminc", 134 | "frmtrmblln", 135 | ] 136 | 137 | self.adv_whisper_settings = [ 138 | "Real Time Audio Length", 139 | ] 140 | 141 | 142 | self.adv_general_settings = [ 143 | "Enable Scribe Template", 144 | ] 145 | 146 | self.editable_settings = { 147 | "Model": "gpt-4", 148 | "Model Endpoint": "https://api.openai.com/v1/", 149 | "Use Local LLM": True, 150 | "Architecture": "CPU", 151 | "use_story": False, 152 | "use_memory": False, 153 | "use_authors_note": False, 154 | "use_world_info": False, 155 | "max_context_length": 5000, 156 | "max_length": 400, 157 | "rep_pen": 1.1, 158 | "rep_pen_range": 5000, 159 | "rep_pen_slope": 0.7, 160 | "temperature": 0.1, 161 | "tfs": 0.97, 162 | "top_a": 0.8, 163 | "top_k": 30, 164 | "top_p": 0.4, 165 | "typical": 0.19, 166 | "sampler_order": "[6, 0, 1, 3, 4, 2, 5]", 167 | "singleline": False, 168 | "frmttriminc": False, 169 | "frmtrmblln": False, 170 | "best_of": 2, 171 | "Use best_of": False, 172 | SettingsKeys.LOCAL_WHISPER.value: True, 173 | SettingsKeys.WHISPER_ENDPOINT.value: "https://localhost:2224/whisperaudio", 174 | SettingsKeys.WHISPER_SERVER_API_KEY.value: "", 175 | "Whisper Model": "small.en", 176 | "Current Mic": "None", 177 | "Real Time": True, 178 | "Real Time Audio Length": 5, 179 | "Real Time Silence Length": 1, 180 | "Silence cut-off": 0.035, 181 | "LLM Container Name": "ollama", 182 | "LLM Caddy Container Name": "caddy-ollama", 183 | "LLM Authentication Container Name": "authentication-ollama", 184 | "Whisper Container Name": "speech-container", 185 | "Whisper Caddy Container Name": "caddy", 186 | "Auto Shutdown Containers on Exit": True, 187 | "Use Docker Status Bar": False, 188 | "Preset": "Custom", 189 | "Show Welcome Message": True, 190 | "Enable Scribe Template": False, 191 | "Use Pre-Processing": True, 192 | "Use Post-Processing": False, # Disabled for now causes unexcepted behaviour 193 | "AI Server Self-Signed Certificates": False, 194 | "S2T Server Self-Signed Certificates": False, 195 | "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", 196 | "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.", 197 | "Show Scrub PHI": False, 198 | } 199 | 200 | self.docker_settings = [ 201 | "LLM Container Name", 202 | "LLM Caddy Container Name", 203 | "LLM Authentication Container Name", 204 | "Whisper Container Name", 205 | "Whisper Caddy Container Name", 206 | "Auto Shutdown Containers on Exit", 207 | "Use Docker Status Bar", 208 | ] 209 | 210 | self.editable_settings_entries = {} 211 | 212 | self.load_settings_from_file() 213 | self.AISCRIBE = self.load_aiscribe_from_file() or "AI, please transform the following conversation into a concise SOAP note. Do not assume any medical data, vital signs, or lab values. Base the note strictly on the information provided in the conversation. Ensure that the SOAP note is structured appropriately with Subjective, Objective, Assessment, and Plan sections. Strictly extract facts from the conversation. Here's the conversation:" 214 | self.AISCRIBE2 = self.load_aiscribe2_from_file() or "Remember, the Subjective section should reflect the patient's perspective and complaints as mentioned in the conversation. The Objective section should only include observable or measurable data from the conversation. The Assessment should be a summary of your understanding and potential diagnoses, considering the conversation's content. The Plan should outline the proposed management, strictly based on the dialogue provided. Do not add any information that did not occur and do not make assumptions. Strictly extract facts from the conversation." 215 | 216 | self.get_dropdown_values_and_mapping() 217 | self._create_settings_and_aiscribe_if_not_exist() 218 | 219 | MicrophoneState.load_microphone_from_settings(self) 220 | 221 | def get_dropdown_values_and_mapping(self): 222 | """ 223 | Reads the 'options.txt' file to populate dropdown values and their mappings. 224 | 225 | This function attempts to read a file named 'options.txt' to extract templates 226 | that consist of three lines: a title, aiscribe, and aiscribe2. These templates 227 | are then used to populate the dropdown values and their corresponding mappings. 228 | If the file is not found, default values are used instead. 229 | 230 | :raises FileNotFoundError: If 'options.txt' is not found, a message is printed 231 | and default values are used. 232 | """ 233 | self.scribe_template_values = [] 234 | self.scribe_template_mapping = {} 235 | try: 236 | with open('options.txt', 'r') as file: 237 | content = file.read().strip() 238 | templates = content.split('\n\n') 239 | for template in templates: 240 | lines = template.split('\n') 241 | if len(lines) == 3: 242 | title, aiscribe, aiscribe2 = lines 243 | self.scribe_template_values.append(title) 244 | self.scribe_template_mapping[title] = (aiscribe, aiscribe2) 245 | except FileNotFoundError: 246 | print("options.txt not found, using default values.") 247 | # Fallback default options if file not found 248 | self.scribe_template_values = ["Settings Template"] 249 | self.scribe_template_mapping["Settings Template"] = (self.AISCRIBE, self.AISCRIBE2) 250 | 251 | def load_settings_from_file(self, filename='settings.txt'): 252 | """ 253 | Loads settings from a JSON file. 254 | 255 | The settings are read from 'settings.txt'. If the file does not exist or cannot be parsed, 256 | default settings will be used. The method updates the instance attributes with loaded values. 257 | 258 | Returns: 259 | tuple: A tuple containing the IPs, ports, SSL settings, and API key. 260 | """ 261 | try: 262 | with open(get_resource_path(filename), 'r') as file: 263 | try: 264 | settings = json.load(file) 265 | except json.JSONDecodeError: 266 | print("Error loading settings file. Using default settings.") 267 | return self.OPENAI_API_KEY 268 | 269 | self.OPENAI_API_KEY = settings.get("openai_api_key", self.OPENAI_API_KEY) 270 | # self.API_STYLE = settings.get("api_style", self.API_STYLE) # FUTURE FEATURE REVISION 271 | loaded_editable_settings = settings.get("editable_settings", {}) 272 | for key, value in loaded_editable_settings.items(): 273 | if key in self.editable_settings: 274 | self.editable_settings[key] = value 275 | 276 | if self.editable_settings["Use Docker Status Bar"] and self.main_window is not None: 277 | self.main_window.create_docker_status_bar() 278 | 279 | if self.editable_settings["Enable Scribe Template"] and self.main_window is not None: 280 | self.main_window.create_scribe_template() 281 | 282 | 283 | return self.OPENAI_API_KEY 284 | except FileNotFoundError: 285 | print("Settings file not found. Using default settings.") 286 | return self.OPENAI_API_KEY 287 | 288 | def save_settings_to_file(self): 289 | """ 290 | Saves the current settings to a JSON file. 291 | 292 | The settings are written to 'settings.txt'. This includes all application settings 293 | such as IP addresses, ports, SSL settings, and editable settings. 294 | 295 | Returns: 296 | None 297 | """ 298 | settings = { 299 | "openai_api_key": self.OPENAI_API_KEY, 300 | "editable_settings": self.editable_settings 301 | # "api_style": self.API_STYLE # FUTURE FEATURE REVISION 302 | } 303 | with open(get_resource_path('settings.txt'), 'w') as file: 304 | json.dump(settings, file) 305 | 306 | def save_settings(self, openai_api_key, aiscribe_text, aiscribe2_text, settings_window, 307 | silence_cutoff): 308 | """ 309 | Save the current settings, including IP addresses, API keys, and user-defined parameters. 310 | 311 | This method writes the AI Scribe text to separate text files and updates the internal state 312 | of the Settings instance. 313 | 314 | :param str openai_api_key: The OpenAI API key for authentication. 315 | :param str aiscribe_text: The text for the first AI Scribe. 316 | :param str aiscribe2_text: The text for the second AI Scribe. 317 | :param tk.Toplevel settings_window: The settings window instance to be destroyed after saving. 318 | """ 319 | self.OPENAI_API_KEY = openai_api_key 320 | # self.API_STYLE = api_style 321 | 322 | self.editable_settings["Silence cut-off"] = silence_cutoff 323 | 324 | for setting, entry in self.editable_settings_entries.items(): 325 | value = entry.get() 326 | if setting in ["max_context_length", "max_length", "rep_pen_range", "top_k"]: 327 | value = int(value) 328 | self.editable_settings[setting] = value 329 | 330 | self.save_settings_to_file() 331 | 332 | self.AISCRIBE = aiscribe_text 333 | self.AISCRIBE2 = aiscribe2_text 334 | 335 | with open(get_resource_path('aiscribe.txt'), 'w') as f: 336 | f.write(self.AISCRIBE) 337 | with open(get_resource_path('aiscribe2.txt'), 'w') as f: 338 | f.write(self.AISCRIBE2) 339 | 340 | def load_aiscribe_from_file(self): 341 | """ 342 | Load the AI Scribe text from a file. 343 | 344 | :returns: The AI Scribe text, or None if the file does not exist or is empty. 345 | :rtype: str or None 346 | """ 347 | try: 348 | with open(get_resource_path('aiscribe.txt'), 'r') as f: 349 | return f.read() 350 | except FileNotFoundError: 351 | return None 352 | 353 | def load_aiscribe2_from_file(self): 354 | """ 355 | Load the second AI Scribe text from a file. 356 | 357 | :returns: The second AI Scribe text, or None if the file does not exist or is empty. 358 | :rtype: str or None 359 | """ 360 | try: 361 | with open(get_resource_path('aiscribe2.txt'), 'r') as f: 362 | return f.read() 363 | except FileNotFoundError: 364 | return None 365 | 366 | 367 | def clear_settings_file(self, settings_window): 368 | """ 369 | Clears the content of settings files and closes the settings window. 370 | 371 | This method attempts to open and clear the contents of three text files: 372 | `settings.txt`, `aiscribe.txt`, and `aiscribe2.txt`. After clearing the 373 | files, it displays a message box to notify the user that the settings 374 | have been reset and closes the `settings_window`. If an error occurs 375 | during this process, the exception will be caught and printed. 376 | 377 | :param settings_window: The settings window object to be closed after resetting. 378 | :type settings_window: tkinter.Toplevel or similar 379 | :raises Exception: If there is an issue with file handling or window destruction. 380 | 381 | Example usage: 382 | 383 | """ 384 | try: 385 | # Open the files and immediately close them to clear their contents. 386 | open(get_resource_path('settings.txt'), 'w').close() 387 | open(get_resource_path('aiscribe.txt'), 'w').close() 388 | open(get_resource_path('aiscribe2.txt'), 'w').close() 389 | 390 | # Display a message box informing the user of successful reset. 391 | messagebox.showinfo("Settings Reset", "Settings have been reset. Please restart.") 392 | print("Settings file cleared.") 393 | 394 | # Close the settings window. 395 | settings_window.destroy() 396 | except Exception as e: 397 | # Print any exception that occurs during file handling or window destruction. 398 | print(f"Error clearing settings files: {e}") 399 | 400 | def get_available_models(self,endpoint=None): 401 | """ 402 | Returns a list of available models for the user to choose from. 403 | 404 | This method returns a list of available models that can be used with the AI Scribe 405 | service. The list includes the default model, `gpt-4`, as well as any other models 406 | that may be added in the future. 407 | 408 | Returns: 409 | list: A list of available models for the user to choose from. 410 | """ 411 | 412 | headers = { 413 | "Authorization": f"Bearer {self.OPENAI_API_KEY}", 414 | "X-API-Key": self.OPENAI_API_KEY 415 | } 416 | 417 | endpoint = endpoint or self.editable_settings_entries["Model Endpoint"].get() 418 | 419 | # url validate the endpoint 420 | if not is_valid_url(endpoint): 421 | print("Invalid LLM Endpoint") 422 | return ["Invalid LLM Endpoint", "Custom"] 423 | 424 | try: 425 | verify = not self.editable_settings["AI Server Self-Signed Certificates"] 426 | response = requests.get(endpoint + "/models", headers=headers, timeout=1.0, verify=verify) 427 | response.raise_for_status() # Raise an error for bad responses 428 | models = response.json().get("data", []) # Extract the 'data' field 429 | 430 | if not models: 431 | return ["No models available", "Custom"] 432 | 433 | available_models = [model["id"] for model in models] 434 | available_models.append("Custom") 435 | return available_models 436 | except requests.RequestException as e: 437 | # messagebox.showerror("Error", f"Failed to fetch models: {e}. Please ensure your OpenAI API key is correct.") 438 | print(e) 439 | return ["Failed to load models", "Custom"] 440 | 441 | def update_models_dropdown(self, dropdown, endpoint=None): 442 | """ 443 | Updates the models dropdown with the available models. 444 | 445 | This method fetches the available models from the AI Scribe service and updates 446 | the dropdown widget in the settings window with the new list of models. 447 | """ 448 | if self.editable_settings_entries["Use Local LLM"].get(): 449 | dropdown["values"] = ["gemma-2-2b-it-Q8_0.gguf"] 450 | dropdown.set("gemma-2-2b-it-Q8_0.gguf") 451 | else: 452 | dropdown["values"] = ["Loading models...", "Custom"] 453 | dropdown.set("Loading models...") 454 | models = self.get_available_models(endpoint=endpoint) 455 | dropdown["values"] = models 456 | if self.editable_settings["Model"] in models: 457 | dropdown.set(self.editable_settings["Model"]) 458 | else: 459 | dropdown.set(models[0]) 460 | 461 | 462 | def load_settings_preset(self, preset_name, settings_class): 463 | """ 464 | Load a settings preset from a file. 465 | 466 | This method loads a settings preset from a JSON file with the given name. 467 | The settings are then applied to the application settings. 468 | 469 | Parameters: 470 | preset_name (str): The name of the settings preset to load. 471 | 472 | Returns: 473 | None 474 | """ 475 | self.editable_settings["Preset"] = preset_name 476 | 477 | if preset_name != "Custom": 478 | # load the settigns from the json preset file 479 | self.load_settings_from_file("presets/" + preset_name + ".json") 480 | 481 | self.editable_settings["Preset"] = preset_name 482 | #close the settings window 483 | settings_class.close_window() 484 | 485 | # save the settings to the file 486 | self.save_settings_to_file() 487 | 488 | if preset_name != "Local AI": 489 | messagebox.showinfo("Settings Preset", "Settings preset loaded successfully. Closing settings window. Please re-open and set respective API keys.") 490 | 491 | # Unload ai model if switching 492 | # already has safety check in unload to check if model exist. 493 | ModelManager.unload_model() 494 | else: # if is local ai 495 | # load the models here 496 | ModelManager.start_model_threaded(self, self.main_window.root) 497 | else: 498 | messagebox.showinfo("Custom Settings", "To use custom settings then please fill in the values and save them.") 499 | 500 | def set_main_window(self, window): 501 | """ 502 | Set the main window instance for the settings. 503 | 504 | This method sets the main window instance for the settings class, allowing 505 | the settings to interact with the main window when necessary. 506 | 507 | Parameters: 508 | window (MainWindow): The main window instance to set. 509 | """ 510 | self.main_window = window 511 | 512 | def load_or_unload_model(self, old_model, new_model, old_use_local_llm, new_use_local_llm, old_architecture, new_architecture): 513 | # Check if old model and new model are different if they are reload and make sure new model is checked. 514 | if old_model != new_model and new_use_local_llm == 1: 515 | ModelManager.unload_model() 516 | ModelManager.start_model_threaded(self, self.main_window.root) 517 | 518 | # Load the model if check box is now selected 519 | if old_use_local_llm == 0 and new_use_local_llm == 1: 520 | ModelManager.start_model_threaded(self, self.main_window.root) 521 | 522 | # Check if Local LLM was on and if turned off unload model.abs 523 | if old_use_local_llm == 1 and new_use_local_llm == 0: 524 | ModelManager.unload_model() 525 | 526 | if old_architecture != new_architecture and new_use_local_llm == 1: 527 | ModelManager.unload_model() 528 | ModelManager.start_model_threaded(self, self.main_window.root) 529 | 530 | def _create_settings_and_aiscribe_if_not_exist(self): 531 | if not os.path.exists(get_resource_path('settings.txt')): 532 | print("Settings file not found. Creating default settings file.") 533 | self.save_settings_to_file() 534 | if not os.path.exists(get_resource_path('aiscribe.txt')): 535 | print("AIScribe file not found. Creating default AIScribe file.") 536 | with open(get_resource_path('aiscribe.txt'), 'w') as f: 537 | f.write(self.AISCRIBE) 538 | if not os.path.exists(get_resource_path('aiscribe2.txt')): 539 | print("AIScribe2 file not found. Creating default AIScribe2 file.") 540 | with open(get_resource_path('aiscribe2.txt'), 'w') as f: 541 | f.write(self.AISCRIBE2) 542 | 543 | def get_available_architectures(self): 544 | """ 545 | Returns a list of available architectures for the user to choose from. 546 | 547 | Based on the install state files in _internal folder 548 | 549 | Files must be named CPU_INSTALL or NVIDIA_INSTALL 550 | 551 | Returns: 552 | list: A list of available architectures for the user to choose from. 553 | """ 554 | architectures = ["CPU"] # CPU is always available as fallback 555 | 556 | # Check for NVIDIA support 557 | if os.path.isfile(get_file_path(self.STATE_FILES_DIR, self.NVIDIA_INSTALL_FILE)): 558 | architectures.append("CUDA (Nvidia GPU)") 559 | 560 | return architectures 561 | --------------------------------------------------------------------------------