├── requirements.txt ├── setup.py ├── src ├── utils │ ├── resource_path.py │ ├── helpers.py │ ├── defaults.py │ └── storage.py ├── main.py ├── main.tcss ├── audio │ ├── Transcriber.py │ ├── textual_transcription_textarea.py │ └── AudioCapture.py ├── template_select_modal.py ├── llm │ └── model.py ├── notes_editor_components.py ├── app.py ├── notes │ └── manager.py └── settings_screen.py ├── .github └── workflows │ ├── check-format.yaml │ ├── release.yaml │ └── build.yaml ├── note-taker.iss ├── entitlements.plist ├── data └── templates │ ├── standup.md │ ├── retrospectives.md │ ├── default.md │ ├── 1-1.md │ └── discovery-meeting.md ├── LICENSE ├── .gitignore ├── note-taker.spec └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | huggingface_hub 2 | llama-cpp-python 3 | numpy==1.26.4 4 | platformdirs 5 | pyinstaller==6.10.0 6 | pyperclip 7 | python-dotenv 8 | resampy 9 | rich 10 | simpler-whisper 11 | sounddevice 12 | textual[syntax] 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='note-taking-app', 5 | version='1.0', 6 | packages=find_packages(), 7 | install_requires=[ 8 | 'pywhispercpp', 9 | 'sounddevice', 10 | ], 11 | entry_points={ 12 | 'console_scripts': [ 13 | 'note-taking-app = src.main:main', 14 | ], 15 | }, 16 | ) -------------------------------------------------------------------------------- /src/utils/resource_path.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def resource_path(*relative_paths): 6 | """Get absolute path to resource, works for dev and for PyInstaller""" 7 | try: 8 | # PyInstaller creates a temp folder and stores path in _MEIPASS 9 | base_path = sys._MEIPASS 10 | except Exception: 11 | base_path = os.path.abspath(".") 12 | 13 | return os.path.join(base_path, *relative_paths) 14 | -------------------------------------------------------------------------------- /.github/workflows/check-format.yaml: -------------------------------------------------------------------------------- 1 | name: Check Python Formatting 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | check-format: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.x 18 | 19 | - name: Install dependencies 20 | run: pip install black 21 | 22 | - name: Check formatting 23 | run: black --check ./src --exclude '/ui_.*\.py' 24 | -------------------------------------------------------------------------------- /note-taker.iss: -------------------------------------------------------------------------------- 1 | [Setup] 2 | AppName=LocaalNoteTaker 3 | AppVersion=@note-taker_VERSION@ 4 | DefaultDirName={pf}\LocaalNoteTaker 5 | DefaultGroupName=LocaalNoteTaker 6 | OutputDir=.\dist 7 | OutputBaseFilename=note-taker-setup 8 | Compression=lzma 9 | SolidCompression=yes 10 | ArchitecturesInstallIn64BitMode=x64 11 | 12 | [Files] 13 | Source: "dist\note-taker\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs 14 | 15 | [Icons] 16 | Name: "{group}\LocaalNoteTaker"; Filename: "{app}\note-taker.exe" 17 | 18 | [Run] 19 | Filename: "{app}\note-taker.exe"; Description: "Launch Locaal Note Taker"; Flags: nowait postinstall skipifsilent 20 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from audio.Transcriber import Transcriber 4 | from app import RichNoteTakingApp 5 | from llm.model import LanguageModel 6 | from utils import resource_path 7 | 8 | 9 | if __name__ == "__main__": 10 | print("Starting Note Taker") 11 | 12 | load_dotenv(resource_path.resource_path(".env")) 13 | 14 | Transcriber() # Warm up the Transcriber 15 | LanguageModel() # Warm up the LLM 16 | 17 | app = RichNoteTakingApp() 18 | app.title = "Locaal AI Note Taker" 19 | # get the version from the .env file 20 | version = os.getenv("LOCAL_RELEASE_TAG") 21 | app.sub_title = f"Version: {version}" 22 | app.run() 23 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.disable-library-validation 11 | 12 | 13 | com.apple.security.device.audio-input 14 | 15 | com.apple.security.device.camera 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main.tcss: -------------------------------------------------------------------------------- 1 | 2 | .box { 3 | height: 100%; 4 | width: 1fr; 5 | border: solid green; 6 | } 7 | 8 | DeleteScreen, TemplateSelectModal { 9 | align: center middle; 10 | } 11 | 12 | #dialog { 13 | grid-size: 2; 14 | grid-gutter: 1 2; 15 | grid-rows: 1fr 3; 16 | padding: 0 1; 17 | width: 60; 18 | height: 11; 19 | border: thick $background 80%; 20 | background: $surface; 21 | } 22 | 23 | #question { 24 | column-span: 2; 25 | height: 1fr; 26 | width: 1fr; 27 | content-align: center middle; 28 | } 29 | 30 | Button { 31 | width: 100%; 32 | } 33 | 34 | .template-modal { 35 | grid-size: 2; 36 | grid-gutter: 1 2; 37 | grid-rows: 1fr 3; 38 | padding: 0 1; 39 | width: 40; 40 | height: 11; 41 | border: thick $background 80%; 42 | background: $surface;} 43 | -------------------------------------------------------------------------------- /data/templates/standup.md: -------------------------------------------------------------------------------- 1 | ## TLDR 2 | - What team got done yesterday 3 | - What is trying to get done today 4 | - Blockers & dependencies 5 | - Milestones achieved 6 | - Discussion topics 7 | - Action items 8 | 9 | ## Metrics 10 | 11 | - Sprint progress: \[Points completed / total points\] 12 | - Sprint Burndown: \[On track/Behind/Ahead\] 13 | - Days remaining in sprint: \[Number\] 14 | 15 | ## Updates 16 | 17 | - Team member 1 18 | - Got done yesterday 19 | - Item 1 20 | - Item 2 21 | - … 22 | - Tackling today 23 | - Item 3 24 | - Item 4 25 | - … 26 | - Blockers 27 | - Item 5 28 | - Item 6 29 | - … 30 | - Team member 2 31 | - Got done yesterday 32 | - Item 1 33 | - Item 2 34 | - … 35 | - Tackling today 36 | - Item 3 37 | - Item 4 38 | - … 39 | - Blockers 40 | - Item 5 41 | - Item 6 42 | - … 43 | - Other team members 44 | 45 | ## Housekeeping items discussed 46 | - List administrative items discussed here 47 | 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Locaal, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /data/templates/retrospectives.md: -------------------------------------------------------------------------------- 1 | # Meeting Notes Template 2 | 3 | ## TL;DR 4 | [Summarize high-level insights or lessons learned relevant to the team or project] 5 | 6 | ## 1. Meeting Details 7 | - **Date:** [Insert Date] 8 | - **Time:** [Insert Time] 9 | - **Participants:** [List of Participants] 10 | - **Meeting Purpose:** [Brief description of the meeting purpose or goal] 11 | 12 | ## 2. Discussion Summary 13 | - **What Went Well:** 14 | [Summarize successful or positive aspects covered in the meeting] 15 | 16 | - **Areas for Improvement:** 17 | [Summarize challenges, issues, or areas needing improvement] 18 | 19 | - **Decisions Made:** 20 | [List any key decisions, agreements, or commitments made by the team] 21 | 22 | ## 3. Action Items 23 | - **Start:** 24 | [New initiatives or actions to begin as a result of the meeting] 25 | 26 | - **Stop:** 27 | [Practices, activities, or behaviors identified for discontinuation] 28 | 29 | - **Continue:** 30 | [Ongoing actions or successful practices to maintain] 31 | 32 | ## 4. Next Steps / Follow-Up 33 | - **Assigned Tasks:** 34 | [List tasks assigned, with the responsible person(s) and deadlines] 35 | 36 | - **Next Meeting Date:** 37 | [Optional, if a follow-up meeting is scheduled] 38 | -------------------------------------------------------------------------------- /src/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import subprocess 4 | 5 | 6 | def create_directory(directory): 7 | """ 8 | Creates a directory if it doesn't exist. 9 | """ 10 | if not os.path.exists(directory): 11 | os.makedirs(directory) 12 | 13 | 14 | def read_file(file_path): 15 | """ 16 | Reads the contents of a file and returns them as a string. 17 | """ 18 | with open(file_path, "r") as file: 19 | return file.read() 20 | 21 | 22 | def write_file(file_path, content): 23 | """ 24 | Writes the given content to a file. 25 | """ 26 | with open(file_path, "w") as file: 27 | file.write(content) 28 | 29 | 30 | def delete_file(file_path): 31 | """ 32 | Deletes a file if it exists. 33 | """ 34 | if os.path.exists(file_path): 35 | os.remove(file_path) 36 | 37 | 38 | def open_folder_with_finder(path): 39 | """ 40 | Opens the given folder in Finder (macOS specific). 41 | """ 42 | system = platform.system().lower() 43 | 44 | if system == "windows": 45 | # Windows 46 | os.startfile(path) 47 | elif system == "darwin": 48 | # macOS 49 | subprocess.Popen(["open", path]) 50 | else: 51 | # Linux/Unix 52 | subprocess.Popen(["xdg-open", path]) 53 | -------------------------------------------------------------------------------- /data/templates/default.md: -------------------------------------------------------------------------------- 1 | # Meeting Summary 2 | 3 | **Date:** 4 | **Attendees:** 5 | **Facilitator:** 6 | **Meeting Purpose/Goal:** 7 | 8 | --- 9 | 10 | ### Summary Highlights 11 | - **Discussion Topics:** [Summarize main discussion points or key insights.] 12 | - **Decisions:** [Summarize the key decisions made.] 13 | - **Action Items:** [Summarize main actions and responsibilities.] 14 | 15 | --- 16 | 17 | ### 1. Agenda Overview 18 | **Primary Topics Discussed:** 19 | - [List each main topic covered during the meeting.] 20 | 21 | --- 22 | 23 | ### 2. Discussion Highlights 24 | **Key Points by Topic:** 25 | - **Topic 1:** [Summarize main points or insights discussed.] 26 | - **Topic 2:** [Summarize main points or insights discussed.] 27 | - **...Continue for each topic on the agenda...] 28 | 29 | --- 30 | 31 | ### 3. Decisions Made 32 | **Summary of Key Decisions:** 33 | - **Decision 1:** [Describe the decision made and any relevant context.] 34 | - **Decision 2:** [Describe the decision made and any relevant context.] 35 | - **...Continue for each decision made during the meeting...] 36 | 37 | --- 38 | 39 | ### 4. Action Items 40 | **Assigned Tasks and Responsibilities:** 41 | - **Action Item 1:** [Describe the task, assignee, and deadline.] 42 | - **Action Item 2:** [Describe the task, assignee, and deadline.] 43 | - **...Continue for each action item identified during the meeting...] 44 | 45 | --- 46 | 47 | ### 5. Next Steps and Follow-Up 48 | **Follow-Up Items for the Next Meeting:** 49 | - [List any items or issues that need to be revisited or reviewed in the next meeting.] 50 | 51 | **Date of Next Meeting:** [Date or timeframe] 52 | 53 | --- 54 | 55 | -------------------------------------------------------------------------------- /src/audio/Transcriber.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from simpler_whisper.whisper import ( 3 | ThreadedWhisperModel, 4 | set_log_callback, 5 | WhisperSegment, 6 | ) 7 | from utils import resource_path 8 | import platform 9 | from typing import Callable, List 10 | 11 | 12 | def my_log_callback(level, message): 13 | # prevent debug messages from being printed 14 | pass 15 | 16 | 17 | class Transcriber: 18 | _instance = None 19 | _lock = threading.Lock() 20 | 21 | def __new__(cls, *args, **kwargs): 22 | if not cls._instance: 23 | with cls._lock: 24 | if not cls._instance: 25 | cls._instance = super(Transcriber, cls).__new__(cls) 26 | cls._instance._initialize(*args, **kwargs) 27 | return cls._instance 28 | 29 | def _initialize(self): 30 | model_path = resource_path.resource_path("data/ggml-small.en-q5_1.bin") 31 | use_gpu = not (platform.system() == "Darwin") 32 | self.model = ThreadedWhisperModel( 33 | model_path=model_path, 34 | callback=self.handle_result, 35 | use_gpu=use_gpu, 36 | max_duration_sec=10.0, 37 | ) 38 | set_log_callback(my_log_callback) 39 | self.callback = None 40 | 41 | def handle_result( 42 | self, chunk_id: int, text: List[WhisperSegment], is_partial: bool 43 | ): 44 | if self.callback: 45 | self.callback(chunk_id, text, is_partial) 46 | 47 | def start(self, callback: Callable[[int, List[WhisperSegment], bool], None] = None): 48 | if callback: 49 | self.callback = callback 50 | self.model.start() 51 | 52 | def stop(self): 53 | self.model.stop() 54 | 55 | def queue_audio(self, audio_chunk): 56 | self.model.queue_audio(audio_chunk) 57 | -------------------------------------------------------------------------------- /data/templates/1-1.md: -------------------------------------------------------------------------------- 1 | # 1-1 Meeting Summary 2 | 3 | 4 | ### Summary Highlights 5 | - **Wins and Accomplishments:** [Summarize the key wins.] 6 | - **Key Challenges and Solutions:** [Summarize primary challenges and potential solutions discussed.] 7 | - **Next Steps and Priorities:** [Summarize main action items and priorities for the upcoming period.] 8 | 9 | 10 | --- 11 | 12 | ### 1. Employee Agenda and Discussion Points 13 | **Summary of Topics Raised by Employee:** 14 | - [Briefly summarize each topic the employee brought up, e.g., personal development goals, project updates, etc.] 15 | 16 | --- 17 | 18 | ### 2. Recent Progress and Wins 19 | **Highlights:** 20 | - [List any notable accomplishments or milestones achieved by the employee since the last 1-1.] 21 | 22 | --- 23 | 24 | ### 3. Challenges and Roadblocks 25 | **Key Challenges Discussed:** 26 | - [Outline any roadblocks the employee is facing, including context and possible solutions discussed.] 27 | 28 | --- 29 | 30 | ### 4. Goals and Priorities Alignment 31 | **Current Goals and Priorities:** 32 | - [Summarize the employee’s key goals and any changes or clarifications on priorities discussed during the meeting.] 33 | 34 | --- 35 | 36 | ### 5. Feedback and Growth Opportunities 37 | **Feedback Provided:** 38 | - [Describe any feedback given to the employee, including both strengths and areas for improvement.] 39 | 40 | **Feedback Received from Employee:** 41 | - [Summarize any feedback the employee offered to the manager or the organization.] 42 | 43 | --- 44 | 45 | ### 6. Career Development and Long-term Planning 46 | **Career and Development Focus:** 47 | - [Include any discussion around career aspirations, long-term goals, or growth opportunities mentioned.] 48 | 49 | --- 50 | 51 | ### 7. Action Items and Next Steps 52 | **Actionable Takeaways:** 53 | - [List specific next steps, responsibilities, and deadlines, if any, as agreed upon during the meeting.] 54 | 55 | --- 56 | -------------------------------------------------------------------------------- /src/utils/defaults.py: -------------------------------------------------------------------------------- 1 | import platformdirs 2 | 3 | 4 | default_system_prompt = """ 5 | You are a note processing assistant. Your task is to create a coherent document by combining: 6 | 7 | User typed notes 8 | Transcribed content 9 | A predefined template 10 | 11 | INPUT FORMAT: 12 | [USER_NOTES] 13 | {User's typed notes will appear here} 14 | [END_USER_NOTES] 15 | [TRANSCRIPTION] 16 | {Transcribed content if available will appear here} 17 | [END_TRANSCRIPTION] 18 | [TEMPLATE] 19 | {Template structure will appear here} 20 | [END_TEMPLATE] 21 | 22 | INSTRUCTIONS: 23 | 24 | Analyze both the user notes and transcription to identify key information 25 | Look for matching sections between the template fields and available content 26 | For each template section: 27 | 28 | - First prioritize explicit matches from user notes. 29 | - Then look for relevant information from the transcription. 30 | - Maintain the original meaning while fitting the template structure. 31 | 32 | If multiple pieces of information could fill a template field, prioritize: 33 | 34 | - User typed content over transcribed content. 35 | - More specific information over general information. 36 | - Complete sentences over fragments. 37 | 38 | If a template section cannot be filled - remove the section and its title. 39 | 40 | OUTPUT FORMAT: 41 | Provide the completed template with all sections filled as appropriate. 42 | NOTES: 43 | - Preserve any specific formatting requirements from the template. 44 | - Do not add information that isn't present in either source. 45 | - Maintain the original tone and style of the user's content. 46 | - Maintain original template formatting only if they can be filled. 47 | - **Do not include or repeat the original notes or transcription verbatim in the final output.** 48 | """ 49 | default_query_template = "{user_notes}" 50 | default_model = "bartowski/Llama-3.2-1B-Instruct-GGUF" 51 | default_model_file = "Llama-3.2-1B-Instruct-Q4_K_M.gguf" 52 | default_context_size = 8192 53 | default_storage_folder = platformdirs.user_data_dir("notes", "NoteTakingApp") 54 | -------------------------------------------------------------------------------- /src/template_select_modal.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Select, Button, Static 2 | from textual.screen import ModalScreen 3 | from textual.containers import Container 4 | 5 | from pathlib import Path 6 | 7 | from utils.resource_path import resource_path 8 | 9 | 10 | class TemplateSelectModal(ModalScreen): 11 | def __init__(self): 12 | super().__init__() 13 | self.templates_dir = Path(resource_path("data/templates")) 14 | self.templates = self._load_template_names() 15 | 16 | def _load_template_names(self): 17 | """Load template names from the templates directory""" 18 | template_files = list(self.templates_dir.glob("*.md")) 19 | return [(f.stem.replace("-", " ").title(), f.name) for f in template_files] 20 | 21 | def compose(self): 22 | yield Container( 23 | Static("Select a template:"), 24 | Select( 25 | options=self.templates, value=self.templates[1][1], id="template_select" 26 | ), 27 | Button("Confirm", variant="primary", id="confirm"), 28 | Button("Cancel", variant="default", id="cancel"), 29 | classes="template-modal", 30 | ) 31 | 32 | def _on_mount(self, event): 33 | self.focus_next("#confirm") 34 | return super()._on_mount(event) 35 | 36 | def on_button_pressed(self, event: Button.Pressed) -> None: 37 | if event.button.id == "confirm": 38 | selected = self.query_one("#template_select").value 39 | if selected: 40 | template_path = self.templates_dir / selected 41 | with open(template_path, "r") as f: 42 | template_content = f.read() 43 | self.dismiss((True, template_content)) 44 | else: 45 | self.dismiss((False, None)) 46 | else: 47 | self.dismiss((False, None)) 48 | 49 | def _on_key(self, event): 50 | if event.key == "escape": 51 | self.dismiss((False, None)) 52 | # stop the bubbling of the event 53 | event.stop() 54 | return 55 | return super()._on_key(event) 56 | -------------------------------------------------------------------------------- /src/llm/model.py: -------------------------------------------------------------------------------- 1 | from llama_cpp import Llama, llama_log_set 2 | import ctypes 3 | from utils.storage import fetch_data, subscribe_to_data 4 | from utils.defaults import ( 5 | default_system_prompt, 6 | default_model, 7 | default_model_file, 8 | default_context_size, 9 | default_query_template, 10 | ) 11 | 12 | 13 | def my_log_callback(level, message, user_data): 14 | pass 15 | 16 | 17 | class LanguageModel: 18 | _instance = None 19 | 20 | def __new__(cls, *args, **kwargs): 21 | if cls._instance is None: 22 | cls._instance = super(LanguageModel, cls).__new__(cls, *args, **kwargs) 23 | return cls._instance 24 | 25 | def __init__(self): 26 | if not hasattr(self, "llm"): 27 | # log_callback = ctypes.CFUNCTYPE( 28 | # None, ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p 29 | # )(my_log_callback) 30 | # llama_log_set(log_callback, ctypes.c_void_p()) 31 | self.set_model(None) 32 | subscribe_to_data("settings.json", "model", self.set_model) 33 | subscribe_to_data("settings.json", "context_size", self.set_model) 34 | 35 | def set_model(self, _): 36 | self.context_size = fetch_data( 37 | "settings.json", "context_size", default_context_size 38 | ) 39 | self.llm = Llama.from_pretrained( 40 | repo_id=fetch_data("settings.json", "model", default_model), 41 | filename=fetch_data("settings.json", "model_file", default_model_file), 42 | verbose=False, 43 | n_ctx=self.context_size, 44 | ) 45 | 46 | def generate_response( 47 | self, 48 | note_text: str, 49 | transcription: str | None = None, 50 | template: str | None = None, 51 | ): 52 | query_template = fetch_data("settings.json", "query", default_query_template) 53 | query = query_template.format( 54 | user_notes=note_text, 55 | transcription=transcription or "", 56 | template=template or "", 57 | ) 58 | # make sure query fits in context size 59 | query = query[: self.context_size - 2] 60 | # write to a file for debugging 61 | with open("query.txt", "w") as f: 62 | f.write(query) 63 | 64 | for chunk in self.llm.create_chat_completion( 65 | messages=[ 66 | { 67 | "role": "system", 68 | "content": fetch_data( 69 | "settings.json", "prompt", default_system_prompt 70 | ), 71 | }, 72 | {"role": "user", "content": query}, 73 | ], 74 | stream=True, 75 | ): 76 | if "content" not in chunk["choices"][0]["delta"]: 77 | continue 78 | yield chunk["choices"][0]["delta"]["content"] 79 | -------------------------------------------------------------------------------- /src/utils/storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from platformdirs import user_data_dir 4 | 5 | 6 | data_subscribers = {} 7 | 8 | 9 | def get_data_dir(): 10 | # Get the user data directory 11 | return user_data_dir("notes", "NoteTakingApp", ensure_exists=True) 12 | 13 | 14 | def subscribe_to_data(file_path: str, document_name: str, callback: callable): 15 | # Subscribe to data changes in a JSON file 16 | # prepend the user data directory 17 | file_path = os.path.join(get_data_dir(), file_path) 18 | 19 | if file_path not in data_subscribers: 20 | data_subscribers[file_path] = {} 21 | if document_name not in data_subscribers[file_path]: 22 | data_subscribers[file_path][document_name] = [] 23 | data_subscribers[file_path][document_name].append(callback) 24 | 25 | 26 | def store_data(file_path, document_name, data): 27 | # Store data into a JSON file 28 | # get the user data directory 29 | data_dir = get_data_dir() 30 | 31 | # prepend the user data directory 32 | file_path = os.path.join(data_dir, file_path) 33 | 34 | if os.path.exists(file_path): 35 | with open(file_path, "r") as f: 36 | try: 37 | documents = json.load(f) 38 | except json.JSONDecodeError: 39 | documents = {} 40 | else: 41 | documents = {} 42 | 43 | documents[document_name] = data 44 | 45 | with open(file_path, "w") as f: 46 | json.dump(documents, f, indent=2) 47 | 48 | # notify subscribers 49 | if file_path in data_subscribers and document_name in data_subscribers[file_path]: 50 | for callback in data_subscribers[file_path][document_name]: 51 | callback(data) 52 | 53 | 54 | def remove_data(file_path, document_name): 55 | # Remove data from a JSON file 56 | # prepend the user data directory 57 | file_path = os.path.join(get_data_dir(), file_path) 58 | 59 | if not os.path.exists(file_path): 60 | return 61 | 62 | with open(file_path, "r") as f: 63 | documents = json.load(f) 64 | 65 | if document_name in documents: 66 | del documents[document_name] 67 | 68 | # notify subscribers 69 | if file_path in data_subscribers and document_name in data_subscribers[file_path]: 70 | for callback in data_subscribers[file_path][document_name]: 71 | callback(None) 72 | 73 | with open(file_path, "w") as f: 74 | json.dump(documents, f, indent=2) 75 | 76 | 77 | def fetch_data(file_path, document_name, default=None): 78 | # Fetch data from a JSON file 79 | # prepend the user data directory 80 | file_path = os.path.join(get_data_dir(), file_path) 81 | 82 | if not os.path.exists(file_path): 83 | return default 84 | 85 | with open(file_path, "r") as f: 86 | try: 87 | documents = json.load(f) 88 | except json.JSONDecodeError: 89 | return default 90 | 91 | if document_name in documents: 92 | return documents[document_name] 93 | else: 94 | return default 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/*.bin 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyderworkspace 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # pytype static type analyzer 132 | .pytype/ 133 | 134 | # Cython debug symbols 135 | cython_debug/ 136 | 137 | # macOS 138 | .DS_Store 139 | .AppleDouble 140 | .LSOverride 141 | 142 | # Icon must end with two \r 143 | Icon 144 | 145 | # Thumbnails 146 | ._* 147 | 148 | # Files that might appear in the root of a volume 149 | .DocumentRevisions-V100 150 | .fseventsd 151 | .Spotlight-V100 152 | .TemporaryItems 153 | .Trashes 154 | .VolumeIcon.icns 155 | .com.apple.timemachine.donotpresent 156 | 157 | # Directories potentially created on remote AFP share 158 | .AppleDB 159 | .AppleDesktop 160 | Network Trash Folder 161 | Temporary Items 162 | .apdisk 163 | 164 | *.wav 165 | 166 | # VS Code 167 | .vscode/ 168 | .history/ 169 | -------------------------------------------------------------------------------- /data/templates/discovery-meeting.md: -------------------------------------------------------------------------------- 1 | 2 | ### JTBD Interview Notes 3 | 4 | ## TLDR 5 | - Push Forces: Summarize the main push forces identified 6 | - Pull forces: Summarize the main push forces identified 7 | - Anxieties: Summarize anxieties identified 8 | - Inertia: Summarize inertia identified 9 | 10 | **1. Identify Push Forces** 11 | - **Instructions to LLM:** Look for statements where the customer expresses dissatisfaction or frustration with their current solution. Extract the specific reasons or circumstances that push them toward finding a new solution. 12 | - **Prompts to Identify Push Forces:** 13 | - “What made you start looking for a new solution?” 14 | - “What specific problems or frustrations were you experiencing?” 15 | - **Output Format for Push Forces:** 16 | - **Push Force #1:** `[Extracted statement summarizing dissatisfaction or problem]` 17 | - **Push Force #2:** `[Extracted statement summarizing dissatisfaction or problem]` 18 | - **Additional Push Forces (if applicable):** `[Extract additional push forces here]` 19 | 20 | **2. Identify Pull Forces** 21 | - **Instructions to LLM:** Capture parts of the conversation where the customer talks about desirable aspects of the new solution or specific benefits they are looking for. 22 | - **Prompts to Identify Pull Forces:** 23 | - “What attracted you to this new solution?” 24 | - “What benefits or improvements were you hoping for with this change?” 25 | - **Output Format for Pull Forces:** 26 | - **Pull Force #1:** `[Extracted statement summarizing desirable quality or benefit]` 27 | - **Pull Force #2:** `[Extracted statement summarizing desirable quality or benefit]` 28 | - **Additional Pull Forces (if applicable):** `[Extract additional pull forces here]` 29 | 30 | **3. Identify Anxieties** 31 | - **Instructions to LLM:** Focus on parts of the transcription where the customer expresses concerns, doubts, or potential risks about switching to a new solution. 32 | - **Prompts to Identify Anxieties:** 33 | - “Were there any concerns or worries when considering this switch?” 34 | - “What hesitations did you have about changing to this new solution?” 35 | - **Output Format for Anxieties:** 36 | - **Anxiety #1:** `[Extracted statement summarizing concern or hesitation]` 37 | - **Anxiety #2:** `[Extracted statement summarizing concern or hesitation]` 38 | - **Additional Anxieties (if applicable):** `[Extract additional anxieties here]` 39 | 40 | **4. Identify Inertia (Habits)** 41 | - **Instructions to LLM:** Look for mentions of familiarity, routine, or comfort with the current solution that might contribute to resistance to change. 42 | - **Prompts to Identify Inertia:** 43 | - “What aspects of your current solution do you like?” 44 | - “What made it hard to consider switching to something new?” 45 | - **Output Format for Inertia:** 46 | - **Inertia #1:** `[Extracted statement summarizing comfort with current solution]` 47 | - **Inertia #2:** `[Extracted statement summarizing comfort with current solution]` 48 | - **Additional Inertia Factors (if applicable):** `[Extract additional inertia factors here]` 49 | 50 | --- 51 | 52 | ## Sample Parsing Output 53 | 54 | Based on this template, an LLM parsing an interview might output structured insights like: 55 | 56 | ## TLDR: 57 | - Push Forces: Current tool crashes and makes it hard to share 58 | - Pull forces: Real-time feature and integrations 59 | - Anxieties: Learning curb of new software and compatibility with systems 60 | - Inertia: User is already familiar with existing tool 61 | 62 | **Push Forces:** 63 | - Push Force #1: “The current tool often crashes during busy hours, causing delays.” 64 | - Push Force #2: “I can’t easily share files with team members using my current system.” 65 | 66 | **Pull Forces:** 67 | - Pull Force #1: “I liked the real-time tracking feature, which could improve my productivity.” 68 | - Pull Force #2: “The integration with other software I use daily was a big draw.” 69 | 70 | **Anxieties:** 71 | - Anxiety #1: “I was worried that the new system might require a lot of training for my team.” 72 | - Anxiety #2: “There’s a chance the new tool might not be compatible with my existing software.” 73 | 74 | **Inertia:** 75 | - Inertia #1: “I’ve been using this software for years, and I know all the shortcuts.” 76 | - Inertia #2: “I’m used to the layout and functionality, even if it’s outdated.” 77 | 78 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # only run this workflow on the main branch. 2 | # when a tag is pushed this workflow will create a release draft and upload the build artifacts 3 | # to the release draft 4 | name: Release 5 | run-name: ${{ github.ref_name }} release run 🚀 6 | on: 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - '*' 12 | permissions: 13 | contents: write 14 | concurrency: 15 | group: '${{ github.workflow }} @ ${{ github.ref }}' 16 | cancel-in-progress: ${{ github.ref_type == 'tag' }} 17 | jobs: 18 | build-project: 19 | name: Build Project 🧱 20 | uses: ./.github/workflows/build.yaml 21 | secrets: inherit 22 | permissions: 23 | contents: read 24 | 25 | create-release: 26 | name: Create Release 🛫 27 | if: github.ref_type == 'tag' 28 | runs-on: ubuntu-22.04 29 | needs: build-project 30 | defaults: 31 | run: 32 | shell: bash 33 | steps: 34 | - name: Check Release Tag ☑️ 35 | id: check 36 | run: | 37 | : Check Release Tag ☑️ 38 | if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi 39 | shopt -s extglob 40 | 41 | case "${GITHUB_REF_NAME}" in 42 | +([0-9]).+([0-9]).+([0-9]) ) 43 | echo 'validTag=true' >> $GITHUB_OUTPUT 44 | echo 'prerelease=false' >> $GITHUB_OUTPUT 45 | echo "version=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT 46 | ;; 47 | +([0-9]).+([0-9]).+([0-9])-@(beta|rc)*([0-9]) ) 48 | echo 'validTag=true' >> $GITHUB_OUTPUT 49 | echo 'prerelease=true' >> $GITHUB_OUTPUT 50 | echo "version=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT 51 | ;; 52 | *) echo 'validTag=false' >> $GITHUB_OUTPUT ;; 53 | esac 54 | 55 | - name: Download Build Artifacts 📥 56 | uses: actions/download-artifact@v4 57 | if: fromJSON(steps.check.outputs.validTag) 58 | id: download 59 | 60 | - name: Print downloaded artifacts 📥 61 | if: fromJSON(steps.check.outputs.validTag) 62 | run: | 63 | : Print downloaded artifacts 📥 64 | if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi 65 | shopt -s extglob 66 | 67 | ls -laR ${{ steps.download.outputs.artifacts }} 68 | 69 | - name: Rename Files 🏷️ 70 | if: fromJSON(steps.check.outputs.validTag) 71 | run: | 72 | : Rename Files 🏷️ 73 | if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi 74 | shopt -s extglob 75 | shopt -s nullglob 76 | 77 | commit_hash="${GITHUB_SHA:0:9}" 78 | 79 | variants=( 80 | 'linux' 81 | 'macos-x86' 82 | 'macos-arm64' 83 | 'windows-cpu' 84 | 'windows-cuda' 85 | ) 86 | 87 | mkdir -p "${{ github.workspace }}/uploads" 88 | 89 | for variant in "${variants[@]}"; do 90 | 91 | candidates=(*-${variant}/@(*)) 92 | 93 | for candidate in "${candidates[@]}"; do 94 | cp "${candidate}" "${{ github.workspace }}/uploads/note-taker-${variant}-${GITHUB_REF_NAME}.${candidate##*.}" 95 | done 96 | done 97 | 98 | - name: Upload Release Artifacts 📤 99 | if: fromJSON(steps.check.outputs.validTag) 100 | uses: actions/upload-artifact@v4 101 | with: 102 | name: note-taker-${GITHUB_REF_NAME} 103 | path: uploads 104 | 105 | - name: Generate Checksums 🪪 106 | if: fromJSON(steps.check.outputs.validTag) 107 | run: | 108 | : Generate Checksums 🪪 109 | if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi 110 | shopt -s extglob 111 | 112 | echo "### Checksums" > ${{ github.workspace }}/CHECKSUMS.txt 113 | # find the files from the above step and generate checksums 114 | for file in ${{ github.workspace }}/uploads/note-taker-*; do 115 | echo " ${file##*/}: $(sha256sum "${file}" | cut -d " " -f 1)" >> ${{ github.workspace }}/CHECKSUMS.txt 116 | done 117 | 118 | - name: Create Release 🛫 119 | if: fromJSON(steps.check.outputs.validTag) 120 | id: create_release 121 | uses: softprops/action-gh-release@v2 122 | with: 123 | draft: false 124 | name: Locaal Note Taker v${{ steps.check.outputs.version }} 125 | generate_release_notes: true 126 | body_path: ${{ github.workspace }}/CHECKSUMS.txt 127 | files: | 128 | ${{ github.workspace }}/uploads/note-taker-*.zip 129 | ${{ github.workspace }}/uploads/note-taker-*.exe 130 | ${{ github.workspace }}/uploads/note-taker-*.dmg 131 | ${{ github.workspace }}/uploads/note-taker-*.tar 132 | -------------------------------------------------------------------------------- /src/notes_editor_components.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pyperclip 3 | 4 | from textual.widgets import Static 5 | from textual.widgets import Static, Footer, TextArea 6 | from textual.screen import Screen 7 | from textual.binding import Binding 8 | from textual import work 9 | from textual.reactive import reactive 10 | 11 | from llm.model import LanguageModel 12 | from notes.manager import NoteManager 13 | 14 | from audio.textual_transcription_textarea import TranscriptionTextArea 15 | from template_select_modal import TemplateSelectModal 16 | 17 | 18 | class NoteTextArea(TextArea): 19 | BINDINGS = [] 20 | 21 | def __init__(self, uuid: str, content: str, *args, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | self.language = "markdown" 24 | self.note_manager = NoteManager() 25 | self.content = content 26 | self.uuid = uuid 27 | self.text = self.content 28 | 29 | def on_mount(self): 30 | if self.text and len(self.text) > 0: 31 | lines = self.text.split("\n") 32 | self.cursor_location = (len(lines) - 1, len(lines[-1])) 33 | 34 | def on_text_area_changed(self, changed: TextArea.Changed): 35 | self.note_manager.update_note_content(self.uuid, changed.text_area.text) 36 | 37 | 38 | class NoteEditScreen(Screen): 39 | BINDINGS = [ 40 | ("escape", "quit", "Quit"), 41 | Binding("ctrl+l", "run_llm", "Run LLM", show=True, priority=True), 42 | Binding("ctrl+o", "clipboard", "Copy to Clipboard", show=True, priority=True), 43 | ] 44 | 45 | content = reactive("") 46 | 47 | def __init__(self, *args, **kwargs): 48 | super().__init__(*args, **kwargs) 49 | self.note_manager = NoteManager() 50 | if self.note_manager.selected_note is None: 51 | today = datetime.datetime.now().strftime("%Y-%m-%d") 52 | self.title = today 53 | # add todays date to the title 54 | self.content = f"# Notes {today}\n\n" 55 | self.uuid = self.note_manager.create_note(self.title, self.content) 56 | else: 57 | note = self.note_manager.selected_note 58 | self.title = note["title"] 59 | self.content = self.note_manager.read_note(note["uuid"]) 60 | self.uuid = note["uuid"] 61 | 62 | def compose(self): 63 | yield Static(f"Title: {self.title}") 64 | yield NoteTextArea(self.uuid, self.content, id="note_text") 65 | yield Footer() 66 | 67 | def action_quit(self): 68 | self.app.notify("Note Saved", timeout=1) 69 | new_content = self.query_one("#note_text").text 70 | self.note_manager.update_note_content(self.uuid, new_content) 71 | self.dismiss() 72 | 73 | @work 74 | async def action_run_llm(self): 75 | template_modal = TemplateSelectModal() 76 | result = await self.app.push_screen_wait(template_modal) 77 | # read the template content 78 | 79 | if result and result[0]: 80 | self.app.notify("Running LLM") 81 | textArea = self.query_one("#note_text") 82 | note_content = textArea.text 83 | textArea.text += f"\n\n# AI Summary\n\n" 84 | for response in LanguageModel().generate_response( 85 | note_content, template=result[1] 86 | ): 87 | self.log.info(response) 88 | textArea.text += response 89 | else: 90 | self.app.notify("Cancelled LLM") 91 | 92 | def action_clipboard(self): 93 | textArea = self.query_one("#note_text") 94 | text = textArea.text 95 | # add "watermark" to the copied text 96 | text += "\n\n---\n\nMade by 🤖 with https://locaal.ai" 97 | pyperclip.copy(text) 98 | self.app.notify("Text copied to clipboard") 99 | 100 | 101 | class LiveNoteEditScreen(Screen): 102 | BINDINGS = [ 103 | ("escape", "quit", "Quit"), 104 | ] 105 | 106 | def __init__(self, *args, **kwargs): 107 | super().__init__(*args, **kwargs) 108 | self.note_manager = NoteManager() 109 | today = datetime.datetime.now().strftime("%Y-%m-%d") 110 | self.title = today 111 | # add todays date to the title 112 | self.content = f"# Notes {today}\n\n" 113 | self.uuid = self.note_manager.create_note(self.title, self.content) 114 | 115 | def compose(self): 116 | yield Static(f"Title: {self.title}") 117 | yield TranscriptionTextArea(id="Transcription", uuid=self.uuid) 118 | text_area = NoteTextArea(self.uuid, self.content, id="note_text") 119 | text_area.focus() 120 | yield text_area 121 | 122 | def action_quit(self): 123 | self.app.notify("Note Saved") 124 | note_content = self.query_one("#note_text").text 125 | transcription = self.query_one("#Transcription").text 126 | combined = f"{note_content}\n\n# Transcription\n\n{transcription}" 127 | self.note_manager.update_note_content(self.uuid, combined) 128 | self.dismiss() 129 | -------------------------------------------------------------------------------- /src/audio/textual_transcription_textarea.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from queue import Queue 3 | import time 4 | from typing import List 5 | import numpy as np 6 | from textual.widgets import TextArea 7 | from textual import on, work 8 | from textual.message import Message 9 | from textual.worker import get_current_worker 10 | import wave 11 | 12 | from audio.Transcriber import Transcriber 13 | from audio.AudioCapture import AudioCapture 14 | from notes.manager import NoteManager 15 | 16 | from simpler_whisper.whisper import WhisperSegment 17 | 18 | 19 | class TranscriptionTextArea(TextArea): 20 | def __init__(self, uuid: str, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.transcriptions: List[str] = [] 23 | self.partial_transcription = "" 24 | self.update_queue = Queue() 25 | self.is_transcribing = False 26 | self.wav_file = None 27 | self.uuid = uuid 28 | self.transcriber = Transcriber() 29 | self.audio_capture = None 30 | self.read_only = True 31 | self.text = "Transcription will appear here." 32 | self.note_manager = NoteManager() 33 | self.start_transcription() 34 | 35 | def on_unmount(self): 36 | self.stop_transcription() 37 | 38 | def generate_transcription_content(self): 39 | # Combine full transcriptions and partial transcription 40 | all_content = self.transcriptions + [f"\n\n(p) {self.partial_transcription}\n"] 41 | 42 | # Join the visible content into a single string 43 | content = "\n".join(all_content) 44 | 45 | return content 46 | 47 | def process_transcription( 48 | self, chunk_id: int, transcription: List[WhisperSegment], is_partial: bool 49 | ): 50 | if not transcription or len(transcription) == 0: 51 | return 52 | transcription_str = " ".join([segment.text for segment in transcription]) 53 | if is_partial: 54 | self.partial_transcription = transcription_str 55 | else: 56 | self.partial_transcription = "" 57 | self.transcriptions.append(transcription_str) 58 | self.update_queue.put(True) # Signal that an update is available 59 | 60 | def update_transcriptions(self): 61 | updated = False 62 | while not self.update_queue.empty(): 63 | self.update_queue.get() 64 | updated = True 65 | return updated 66 | 67 | def send_audio_to_transcriber(self, audio_data: np.ndarray): 68 | self.transcriber.queue_audio(audio_data) 69 | if self.wav_file is not None: 70 | audio_data_int = (audio_data * 32767).astype(np.int16) 71 | self.wav_file.writeframes(audio_data_int.tobytes()) 72 | 73 | class Update(Message): 74 | def __init__(self, transcription: str): 75 | self.transcription = transcription 76 | super().__init__() 77 | 78 | @on(Update) 79 | def update_ui(self, update: Update): 80 | self.text = update.transcription 81 | # write the transcription to the note 82 | self.note_manager.update_note_transcription(self.uuid, self.text) 83 | # set the cursor to the last line 84 | self.cursor_location = (len(self.text.split("\n")) + 1, 0) 85 | 86 | @work(thread=True) 87 | def start_transcription(self): 88 | self.app.notify("Starting transcription.") 89 | self.wav_file = wave.open( 90 | path.join( 91 | self.note_manager.get_notes_directory(), "streaming_recording.wav" 92 | ), 93 | "wb", 94 | ) 95 | self.wav_file.setnchannels(1) 96 | self.wav_file.setsampwidth(2) 97 | self.wav_file.setframerate(16000) 98 | 99 | self.audio_capture = AudioCapture(self.send_audio_to_transcriber) 100 | 101 | self.transcriber.start(self.process_transcription) 102 | self.audio_capture.start_recording() 103 | self.is_transcribing = True 104 | 105 | worker = get_current_worker() 106 | 107 | while self.is_transcribing and not worker.is_cancelled: 108 | if self.update_transcriptions(): 109 | self.post_message(self.Update(self.generate_transcription_content())) 110 | # sleep for a short time to avoid busy loop 111 | time.sleep(0.1) 112 | 113 | self.log.info("Transcription thread end.") 114 | self.app.notify("Transcription thread end.") 115 | 116 | def stop_transcription(self): 117 | self.app.notify("Stopping transcription.") 118 | self.is_transcribing = False 119 | if self.transcriber: 120 | self.transcriber.stop() 121 | self.transcriber = None 122 | if self.audio_capture: 123 | self.audio_capture.stop_recording() 124 | self.audio_capture = None 125 | if self.wav_file: 126 | self.wav_file.close() 127 | self.wav_file = None 128 | self.app.notify("Transcription stopped.") 129 | self.app.workers.cancel_all() 130 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import subprocess 4 | from typing import Optional 5 | 6 | from rich.panel import Panel 7 | from rich import box 8 | 9 | from textual import work 10 | from textual.app import App, ComposeResult 11 | from textual.widgets import Static, ListView, ListItem 12 | from textual.containers import Grid 13 | from textual.widgets import Header, Static, Footer, Button, Label 14 | from textual.screen import Screen, ModalScreen 15 | from textual.binding import Binding 16 | 17 | from notes.manager import NoteManager 18 | from notes_editor_components import NoteEditScreen, LiveNoteEditScreen 19 | from settings_screen import SettingsScreen 20 | from utils.helpers import open_folder_with_finder 21 | 22 | 23 | class NotePanel(Static): 24 | def __init__(self, title: str, content: str, note_id: str, *args, **kwargs): 25 | super().__init__(*args, **kwargs) 26 | self.title = title 27 | self.content = content 28 | self.note_id = note_id 29 | 30 | def render(self): 31 | return Panel( 32 | self.content, 33 | title=self.title, 34 | border_style="cyan", 35 | box=box.ROUNDED, 36 | expand=True, 37 | ) 38 | 39 | 40 | class NoteListView(ListView): 41 | def __init__(self, node_select_callback: callable, *args, **kwargs): 42 | super().__init__(*args, **kwargs) 43 | self.note_manager = NoteManager() 44 | self.node_select_callback = node_select_callback 45 | 46 | def on_list_view_selected(self, selected: ListView.Selected): 47 | self.note_manager.select_note_by_uuid(selected.item.children[0].note_id) 48 | self.node_select_callback() 49 | 50 | def compose(self): 51 | notes = self.note_manager.list_notes(True) 52 | for note in notes: 53 | content = self.note_manager.read_note(note["uuid"]) 54 | yield ListItem( 55 | NotePanel( 56 | note["title"], 57 | (content[:50] + "..." if len(content) > 50 else content), 58 | note["uuid"], 59 | ) 60 | ) 61 | 62 | 63 | class DeleteScreen(ModalScreen): 64 | def compose(self) -> ComposeResult: 65 | yield Grid( 66 | Label("Delete Note?", id="question"), 67 | Button("Delete", variant="error", id="delete"), 68 | Button("Cancel", variant="primary", id="cancel"), 69 | id="dialog", 70 | ) 71 | 72 | def on_button_pressed(self, event: Button.Pressed) -> None: 73 | if event.button.id == "delete": 74 | self.dismiss(True) 75 | else: 76 | self.dismiss(False) 77 | 78 | 79 | class RichNoteTakingScreen(Screen): 80 | BINDINGS = [ 81 | ("q", "quit", "Quit"), 82 | ("escape", "quit", "Quit"), 83 | ("n", "new_note", "New"), 84 | ("l", "live_note", "New Live"), 85 | ("d", "delete_note", "Delete"), 86 | ("s", "settings", "Settings"), 87 | Binding("ctrl+g", "github", "GitHub", show=True, priority=True), 88 | Binding("ctrl+f", "folder", "Notes Folder", show=True, priority=True), 89 | ] 90 | CSS_PATH = "main.tcss" 91 | 92 | current_selected_index: Optional[int] = None 93 | 94 | def __init__(self): 95 | super().__init__() 96 | self.note_manager = NoteManager() 97 | 98 | def compose(self): 99 | yield Header() 100 | yield NoteListView(self.action_edit_note, id="note_list") 101 | yield Footer() 102 | 103 | def action_new_note(self): 104 | self.note_manager.selected_note = None 105 | self.action_edit_note() 106 | 107 | @work 108 | async def action_edit_note(self): 109 | await self.app.push_screen_wait(NoteEditScreen()) 110 | self.app.pop_screen() 111 | self.app.push_screen(RichNoteTakingScreen()) 112 | 113 | @work 114 | async def action_live_note(self): 115 | await self.app.push_screen_wait(LiveNoteEditScreen()) 116 | self.app.pop_screen() 117 | self.app.push_screen(RichNoteTakingScreen()) 118 | 119 | @work 120 | async def action_settings(self): 121 | await self.app.push_screen_wait(SettingsScreen()) 122 | self.app.pop_screen() 123 | self.app.push_screen(RichNoteTakingScreen()) 124 | 125 | def action_github(self): 126 | # use the system open command to open the browser 127 | url = "https://github.com/locaal-ai/note-taker?tab=readme-ov-file#local-ai-note-taking-app" 128 | import webbrowser 129 | 130 | webbrowser.open(url) 131 | 132 | def action_folder(self): 133 | path = self.note_manager.get_notes_directory() 134 | open_folder_with_finder(path) 135 | 136 | def action_quit(self): 137 | self.app.exit() 138 | 139 | def action_delete_note(self): 140 | def check_delete(delete: bool | None) -> None: 141 | """Called when the delete screen is dismissed""" 142 | if delete: 143 | highlighted_item = self.query_one("#note_list").highlighted_child 144 | note_id = highlighted_item.children[0].note_id 145 | self.note_manager.delete_note(note_id) 146 | self.app.pop_screen() 147 | self.app.push_screen(RichNoteTakingScreen()) 148 | 149 | self.app.push_screen(DeleteScreen(), check_delete) 150 | 151 | 152 | class RichNoteTakingApp(App): 153 | def on_mount(self): 154 | self.push_screen(RichNoteTakingScreen()) 155 | -------------------------------------------------------------------------------- /note-taker.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import os 3 | from PyInstaller.utils.hooks import collect_all 4 | 5 | # parse command line arguments 6 | import argparse 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('--mac_osx', action='store_true') 10 | parser.add_argument('--win', action='store_true') 11 | parser.add_argument('--debug', action='store_true') 12 | parser.add_argument('--arch', type=str, choices=['x86_64', 'arm64', 'universal2'], help='Mac OSX: Specify the target architecture') 13 | 14 | args = parser.parse_args() 15 | 16 | note_taker_sources = [ 17 | 'src/app.py', 18 | 'src/main.py', 19 | 'src/settings_screen.py', 20 | 'src/notes_editor_components.py', 21 | 'src/template_select_modal.py', 22 | 'src/audio/AudioCapture.py', 23 | 'src/audio/textual_transcription_textarea.py', 24 | 'src/audio/Transcriber.py', 25 | 'src/llm/model.py', 26 | 'src/notes/manager.py', 27 | 'src/utils/defaults.py', 28 | 'src/utils/helpers.py', 29 | 'src/utils/resource_path.py', 30 | 'src/utils/storage.py' 31 | ] 32 | 33 | datas = [ 34 | ('src/main.tcss', '.'), 35 | ('.env', '.'), 36 | ('data/ggml-small.en-q5_1.bin', 'data/'), 37 | ('data/templates/1-1.md', 'data/templates/'), 38 | ('data/templates/default.md', 'data/templates/'), 39 | ('data/templates/discovery-meeting.md', 'data/templates/'), 40 | ('data/templates/retrospectives.md', 'data/templates/'), 41 | ('data/templates/standup.md', 'data/templates/'), 42 | ('data/models_directory.json', 'data/') 43 | ] 44 | 45 | if args.mac_osx: 46 | datas += [ 47 | ('data/ggml-small.en-encoder.mlmodelc/weights/weight.bin', 'data/ggml-small.en-encoder.mlmodelc/weights/'), 48 | ('data/ggml-small.en-encoder.mlmodelc/metadata.json', 'data/ggml-small.en-encoder.mlmodelc/'), 49 | ('data/ggml-small.en-encoder.mlmodelc/model.mil', 'data/ggml-small.en-encoder.mlmodelc/'), 50 | ('data/ggml-small.en-encoder.mlmodelc/coremldata.bin', 'data/ggml-small.en-encoder.mlmodelc/'), 51 | ('data/ggml-small.en-encoder.mlmodelc/analytics/coremldata.bin', 'data/ggml-small.en-encoder.mlmodelc/analytics/'), 52 | ] 53 | 54 | numpy_datas, numpy_binaries, numpy_hiddenimports = collect_all('numpy') 55 | llama_cpp_datas, llama_cpp_binaries, llama_cpp_hiddenimports = collect_all('llama_cpp') 56 | whisper_datas, whisper_binaries, whisper_hiddenimports = collect_all('simpler_whisper') 57 | ws_hiddenimports=['websockets', 'websockets.legacy'] 58 | 59 | a = Analysis( 60 | note_taker_sources, 61 | pathex=[], 62 | binaries=numpy_binaries + llama_cpp_binaries + whisper_binaries, 63 | datas=datas + numpy_datas + llama_cpp_datas + whisper_datas, 64 | hiddenimports=numpy_hiddenimports + ws_hiddenimports + llama_cpp_hiddenimports + whisper_hiddenimports, 65 | hookspath=[], 66 | hooksconfig={}, 67 | runtime_hooks=[], 68 | excludes=[ 69 | 'tensorflow', 70 | 'torch', 71 | 'torchvision', 72 | 'torchaudio', 73 | 'transformers', 74 | 'diffusers', 75 | 'PyQt6', 76 | 'PySide6', 77 | 'matplotlib', 78 | 'tkinter', 79 | 'nltk', 80 | 'django', 81 | 'av', 82 | 'numpy.tests', 83 | 'numpy.testing', 84 | 'numpy.lib.tests', 85 | 'numpy.doc' 86 | ], 87 | noarchive=False, 88 | ) 89 | pyz = PYZ(a.pure) 90 | 91 | if args.win: 92 | exe = EXE( 93 | pyz, 94 | a.scripts, 95 | [], 96 | exclude_binaries=True, 97 | name='note-taker', 98 | debug=args.debug is not None and args.debug, 99 | bootloader_ignore_signals=False, 100 | strip=False, 101 | upx=True, 102 | console=True, 103 | windowed=False, 104 | disable_windowed_traceback=False, 105 | argv_emulation=False, 106 | target_arch=None, 107 | ) 108 | coll = COLLECT( 109 | exe, 110 | a.binaries, 111 | a.datas, 112 | strip=False, 113 | upx=True, 114 | upx_exclude=[], 115 | name='note-taker', 116 | ) 117 | elif args.mac_osx: 118 | exe = EXE( 119 | pyz, 120 | a.binaries, 121 | a.datas, 122 | a.scripts, 123 | name='note-taker', 124 | debug=args.debug is not None and args.debug, 125 | bootloader_ignore_signals=False, 126 | strip=False, 127 | upx=True, 128 | console=True, 129 | windowed=False, 130 | disable_windowed_traceback=False, 131 | argv_emulation=False, 132 | target_arch=args.arch if args.arch is not None else 'universal2', 133 | codesign_identity=os.environ.get('APPLE_APP_DEVELOPER_ID', ''), 134 | entitlements_file='./entitlements.plist', 135 | ) 136 | # app = BUNDLE( 137 | # exe, 138 | # name='note-taker.app', 139 | # bundle_identifier='ai.locaal.note-taker', 140 | # version='0.0.1', 141 | # info_plist={ 142 | # 'NSPrincipalClass': 'NSApplication', 143 | # 'NSAppleScriptEnabled': False, 144 | # 'NSCameraUsageDescription': 'Getting images from the camera to perform OCR', 145 | # 'NSMicrophoneUsageDescription': 'Record the microphone for speech recognition', 146 | # } 147 | # ) 148 | else: 149 | exe = EXE( 150 | pyz, 151 | a.binaries, 152 | a.datas, 153 | a.scripts, 154 | name='note-taker', 155 | debug=args.debug is not None and args.debug, 156 | bootloader_ignore_signals=False, 157 | strip=False, 158 | upx=True, 159 | console=True, 160 | disable_windowed_traceback=False, 161 | argv_emulation=False, 162 | target_arch=None, 163 | ) 164 | -------------------------------------------------------------------------------- /src/notes/manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import uuid 4 | import platformdirs 5 | from utils.storage import fetch_data, subscribe_to_data 6 | from utils.defaults import default_storage_folder 7 | 8 | 9 | class NoteManager: 10 | _instance = None 11 | 12 | def __new__(cls, *args, **kwargs): 13 | if cls._instance is None: 14 | cls._instance = super(NoteManager, cls).__new__(cls) 15 | return cls._instance 16 | 17 | def __init__(self): 18 | if hasattr(self, "_initialized") and self._initialized: 19 | return 20 | self.notes_directory = None 21 | self.notes = {} 22 | self.get_notes_directory() 23 | self.selected_note: dict | None = None 24 | self._initialized = True 25 | self._load_json_store() 26 | 27 | def get_notes_directory(self): 28 | """ 29 | Retrieves the directory path where notes are stored. If the directory does not exist, it is created. 30 | 31 | Returns: 32 | str: The path to the notes directory. 33 | """ 34 | if self.notes_directory is None: 35 | self.notes_directory = fetch_data( 36 | "settings.json", "storage_folder", default_storage_folder 37 | ) 38 | subscribe_to_data( 39 | "settings.json", "storage_folder", self.set_notes_directory 40 | ) 41 | try: 42 | if not os.path.exists(self.notes_directory): 43 | os.makedirs(self.notes_directory) 44 | except Exception as e: 45 | print(f"Error creating notes directory: {e}") 46 | return 47 | self.json_file = os.path.join(self.notes_directory, "notes.json") 48 | return self.notes_directory 49 | 50 | def set_notes_directory(self, new_notes_directory): 51 | self.notes_directory = new_notes_directory 52 | try: 53 | if not os.path.exists(self.notes_directory): 54 | os.makedirs(self.notes_directory) 55 | except Exception as e: 56 | print(f"Error creating notes directory: {e}") 57 | self.notes_directory = None 58 | return 59 | self.json_file = os.path.join(self.notes_directory, "notes.json") 60 | self._load_json_store() 61 | 62 | def _load_json_store(self): 63 | if not hasattr(self, "json_file"): 64 | print("Notes directory not set") 65 | return 66 | if os.path.exists(self.json_file): 67 | with open(self.json_file, "r") as f: 68 | self.notes = json.load(f) 69 | else: 70 | self.notes = {} 71 | 72 | def _save_json_store(self): 73 | if not hasattr(self, "json_file"): 74 | print("Notes directory not set") 75 | return 76 | with open(self.json_file, "w") as f: 77 | json.dump(self.notes, f, indent=2) 78 | 79 | def select_note_by_uuid(self, uuid): 80 | if uuid in self.notes: 81 | self.selected_note = self.notes[uuid] 82 | else: 83 | raise KeyError("Note not found") 84 | 85 | def select_note_by_index(self, index: int): 86 | note_uuids = list(self.notes.keys()) 87 | if 0 <= index < len(note_uuids): 88 | self.selected_note = self.notes[note_uuids[index]] 89 | else: 90 | raise IndexError("Index out of range") 91 | 92 | def get_note_path_from_uuid(self, uuid): 93 | if not hasattr(self, "notes_directory"): 94 | print("Notes directory not set") 95 | return None 96 | return os.path.join(self.notes_directory, f"{uuid}.txt") 97 | 98 | def create_note(self, title, content) -> str: 99 | note_uuid = str(uuid.uuid4()) 100 | note_path = self.get_note_path_from_uuid(note_uuid) 101 | if note_path: 102 | with open(note_path, "w") as file: 103 | file.write(content) 104 | else: 105 | print("Error creating note") 106 | self.notes[note_uuid] = { 107 | "uuid": note_uuid, 108 | "title": title, 109 | "path": note_path, 110 | "created_at": os.path.getmtime(note_path), 111 | "updated_at": os.path.getmtime(note_path), 112 | } 113 | self._save_json_store() 114 | return note_uuid 115 | 116 | def read_note(self, uuid): 117 | if uuid not in self.notes: 118 | raise KeyError("Note not found") 119 | note_path = self.notes[uuid]["path"] 120 | with open(note_path, "r") as file: 121 | return file.read() 122 | 123 | def update_note_title(self, uuid, new_title): 124 | if uuid not in self.notes: 125 | raise KeyError("Note not found") 126 | self.notes[uuid]["title"] = new_title 127 | self._save_json_store() 128 | self.notes[uuid]["updated_at"] = os.path.getmtime(self.notes[uuid]["path"]) 129 | 130 | def update_note_content(self, uuid, new_content): 131 | if uuid not in self.notes: 132 | raise KeyError("Note not found") 133 | note_path = self.notes[uuid]["path"] 134 | with open(note_path, "w") as file: 135 | file.write(new_content) 136 | self.notes[uuid]["updated_at"] = os.path.getmtime(note_path) 137 | 138 | def update_note_transcription(self, uuid: str, new_transcription: str): 139 | if not hasattr(self, "notes_directory") or self.notes_directory is None: 140 | print("Notes directory not set") 141 | return 142 | if uuid not in self.notes: 143 | raise KeyError("Note not found") 144 | # create a new file with the transcription with "_transcription" appended to the uuid 145 | transcription_path = os.path.join( 146 | self.notes_directory, f"{uuid}_transcription.txt" 147 | ) 148 | with open(transcription_path, "w") as file: 149 | file.write(new_transcription) 150 | self.notes[uuid]["updated_at"] = os.path.getmtime(transcription_path) 151 | 152 | def delete_note(self, uuid: str): 153 | if not hasattr(self, "notes_directory") or self.notes_directory is None: 154 | print("Notes directory not set") 155 | return 156 | if uuid not in self.notes: 157 | raise KeyError("Note not found") 158 | note_path = self.notes[uuid]["path"] 159 | if os.path.exists(note_path): 160 | os.remove(note_path) 161 | transcription_path = os.path.join( 162 | self.notes_directory, f"{uuid}_transcription.txt" 163 | ) 164 | if os.path.exists(transcription_path): 165 | os.remove(transcription_path) 166 | del self.notes[uuid] 167 | self._save_json_store() 168 | 169 | def list_notes(self, sort_by_date=False): 170 | notes_list = list(self.notes.values()) 171 | if sort_by_date: 172 | notes_list.sort( 173 | key=lambda x: ( 174 | x["updated_at"] 175 | if "updated_at" in x 176 | else x["created_at"] if "created_at" in x else 0 177 | ), 178 | reverse=True, 179 | ) 180 | return notes_list 181 | -------------------------------------------------------------------------------- /src/settings_screen.py: -------------------------------------------------------------------------------- 1 | import json 2 | from textual.containers import Grid, Vertical 3 | from textual.widgets import Select, TextArea, Button, Label, Header, Footer, Input 4 | from textual.screen import ModalScreen 5 | from textual.message import Message 6 | from textual.binding import Binding 7 | from notes.manager import default_storage_folder 8 | from utils.helpers import open_folder_with_finder 9 | from utils.storage import get_data_dir, store_data, fetch_data 10 | from utils.defaults import default_system_prompt, default_model, default_query_template 11 | from utils.resource_path import resource_path 12 | from huggingface_hub import list_repo_files 13 | import re 14 | 15 | 16 | # Load models from the JSON file 17 | def load_transcription_models(): 18 | with open(resource_path("data/models_directory.json"), "r") as file: 19 | models = json.load(file)["models"] 20 | # keep models which don't have a "extra" field 21 | models = [model for model in models if "extra" not in model] 22 | # keep models that are transcription type 23 | models = [model for model in models if model["type"] == "MODEL_TYPE_TRANSCRIPTION"] 24 | # sort models by friendly name 25 | models = sorted(models, key=lambda x: x["friendly_name"]) 26 | # turn into Select options format (tuple of model name) 27 | models = [(model["friendly_name"], model["local_folder_name"]) for model in models] 28 | return models 29 | 30 | 31 | def find_q4_model_file(repo_id): 32 | """ 33 | Find a Q4 quantized model file in a Hugging Face repository. 34 | 35 | Args: 36 | repo_id (str): Repository ID in format "username/repository" 37 | 38 | Returns: 39 | str: Filename of the Q4 model if found, None otherwise 40 | """ 41 | try: 42 | # List all files in the repository 43 | files = list_repo_files(repo_id) 44 | 45 | # Look for files containing 'q4' in their name (case insensitive) 46 | q4_files = [f for f in files if re.search(r"q4", f, re.IGNORECASE)] 47 | 48 | # Filter for common model extensions 49 | model_extensions = (".bin", ".gguf") 50 | q4_model_files = [f for f in q4_files if f.lower().endswith(model_extensions)] 51 | 52 | if q4_model_files: 53 | return q4_model_files[0] # Return the first matching file 54 | 55 | return None 56 | 57 | except Exception as e: 58 | print(f"Error accessing repository: {e}") 59 | return None 60 | 61 | 62 | class SettingsScreen(ModalScreen): 63 | """A modal screen for managing the settings.""" 64 | 65 | BINDINGS = [ 66 | ("escape", "exit", "Exit"), 67 | Binding("ctrl+f", "folder", "Open Settings Folder", show=True, priority=True), 68 | ] 69 | 70 | def __init__(self): 71 | super().__init__() 72 | self.current_prompt = fetch_data( 73 | "settings.json", "prompt", default_system_prompt 74 | ) 75 | self.current_model = fetch_data("settings.json", "model", default_model) 76 | self.current_whisper_model = fetch_data( 77 | "settings.json", "whisper_model", "ggml-model-whisper-small-en-q5_1" 78 | ) 79 | self.storage_folder = fetch_data( 80 | "settings.json", "storage_folder", default_storage_folder 81 | ) 82 | self.current_query = fetch_data( 83 | "settings.json", "query", default_query_template 84 | ) 85 | 86 | def compose(self): 87 | yield Header("Settings") 88 | yield Grid( 89 | Vertical( 90 | Label("LLM Settings", id="settings-title", classes="settings-header"), 91 | Label("System Prompt:"), 92 | TextArea( 93 | id="prompt-input", 94 | text=self.current_prompt, 95 | classes="settings-prompt", 96 | ), 97 | Label("Query format:"), 98 | TextArea( 99 | id="query-input", 100 | text=self.current_query, 101 | classes="settings-query", 102 | ), 103 | Label("LLM Model:"), 104 | Select( 105 | [ 106 | (model, model) 107 | for model in [ 108 | "bartowski/Llama-3.2-1B-Instruct-GGUF", 109 | "bartowski/SmolLM2-1.7B-Instruct-GGUF", 110 | "bartowski/Phi-3.5-mini-instruct-GGUF", 111 | "MaziyarPanahi/Llama-3.2-1B-Instruct-GGUF", 112 | "MaziyarPanahi/Qwen2-1.5B-Instruct-GGUF", 113 | "unsloth/Llama-3.2-1B-Instruct-GGUF", 114 | "lmstudio-community/Llama-3.2-1B-Instruct-GGUF", 115 | "lmstudio-community/Qwen2.5-1.5B-Instruct-GGUF", 116 | "lmstudio-community/SmolLM2-360M-Instruct-GGUF", 117 | "HuggingFaceTB/SmolLM2-1.7B-Instruct-GGUF", 118 | ] 119 | ], 120 | id="model-select", 121 | value=self.current_model, 122 | classes="settings-select", 123 | ), 124 | Label("Transcription Model:"), 125 | Select( 126 | load_transcription_models(), 127 | id="whisper-model-select", 128 | value=self.current_whisper_model, 129 | classes="settings-select", 130 | name="whisper-model", 131 | ), 132 | Label( 133 | "Storage Folder:", 134 | classes="settings-storage-folder", 135 | ), 136 | Input(self.storage_folder, id="storage-folder-input"), 137 | id="settings-container", 138 | ), 139 | id="settings-dialog", 140 | ) 141 | yield Footer() 142 | 143 | def on_select_changed(self, changed: Select.Changed): 144 | if changed.select.id == "model-select": 145 | self.current_model = changed.select.value 146 | model_file = find_q4_model_file(self.current_model) 147 | if model_file is None: 148 | self.notify("Q4 model file not found.") 149 | return 150 | store_data("settings.json", "model_file", model_file) 151 | store_data("settings.json", "model", self.current_model) 152 | self.notify("Model updated.") 153 | elif changed.select.id == "whisper-model-select": 154 | self.current_whisper_model = changed.select.value 155 | store_data("settings.json", "whisper_model", self.current_whisper_model) 156 | self.notify("Whisper model updated.") 157 | 158 | def on_text_area_changed(self, changed: TextArea.Changed): 159 | if changed.text_area.id == "prompt-input": 160 | self.current_prompt = changed.text_area.text 161 | store_data("settings.json", "prompt", self.current_prompt) 162 | elif changed.text_area.id == "query-input": 163 | self.current_query = changed.text_area.text 164 | store_data("settings.json", "query", self.current_query) 165 | 166 | def on_input_changed(self, changed: Input.Changed): 167 | if changed.input.id == "storage-folder-input": 168 | self.storage_folder = changed.value 169 | store_data("settings.json", "storage_folder", self.storage_folder) 170 | 171 | def action_exit(self): 172 | self.notify("Settings updated.") 173 | self.dismiss(False) 174 | 175 | def action_folder(self): 176 | path = get_data_dir() 177 | open_folder_with_finder(path) 178 | -------------------------------------------------------------------------------- /src/audio/AudioCapture.py: -------------------------------------------------------------------------------- 1 | import sounddevice as sd 2 | import numpy as np 3 | import threading 4 | import queue 5 | import resampy 6 | from textual import log 7 | from typing import Callable 8 | 9 | 10 | def print_audio_devices(): 11 | log.info("Available audio devices:") 12 | devices = sd.query_devices() 13 | for i, device in enumerate(devices): 14 | log.info( 15 | f"{i}: {device['name']} (Max channels: In={device['max_input_channels']}, Out={device['max_output_channels']})" 16 | ) 17 | 18 | 19 | class AudioCapture: 20 | def __init__( 21 | self, 22 | audio_data_callback: Callable[[np.ndarray], None], 23 | target_sample_rate=16000, 24 | block_duration=0.05, 25 | ): 26 | self.target_sample_rate = target_sample_rate 27 | self.block_duration = block_duration 28 | self.recording = False 29 | self.audio_queue = queue.Queue() 30 | self.audio_data_callback = audio_data_callback 31 | self.last_mic_data = None 32 | self.last_desktop_data = None 33 | self.new_mic_data = True 34 | self.new_desktop_data = True 35 | 36 | def get_desktop_device(self): 37 | devices = sd.query_devices() 38 | for i, device in enumerate(devices): 39 | if device["max_input_channels"] > 0: # Check if it's an input device 40 | if "Stereo Mix" in device["name"] or "What U Hear" in device["name"]: 41 | return i 42 | return sd.default.device[0] # Default input device if no specific one found 43 | 44 | def start_recording(self): 45 | self.recording = True 46 | 47 | try: 48 | # Get default device sample rates 49 | default_input_device = sd.query_devices(sd.default.device[0], "input") 50 | 51 | self.mic_sample_rate = int(default_input_device["default_samplerate"]) 52 | mic_block_size = int(self.mic_sample_rate * self.block_duration) 53 | 54 | # Start microphone stream 55 | self.mic_stream = sd.InputStream( 56 | samplerate=self.mic_sample_rate, 57 | blocksize=mic_block_size, 58 | channels=1, 59 | device=sd.default.device[0], 60 | callback=self.mic_callback, 61 | ) 62 | 63 | # Find and configure desktop audio stream 64 | desktop_device = self.get_desktop_device() 65 | self.desktop_sample_rate = sd.query_devices(desktop_device, "input")[ 66 | "default_samplerate" 67 | ] 68 | device_info = sd.query_devices(desktop_device, "input") 69 | desktop_channels = min( 70 | device_info["max_input_channels"], 2 71 | ) # Use max 2 channels 72 | desktop_block_size = int(self.desktop_sample_rate * self.block_duration) 73 | 74 | self.desktop_stream = sd.InputStream( 75 | samplerate=self.desktop_sample_rate, 76 | blocksize=desktop_block_size, 77 | channels=desktop_channels, 78 | device=desktop_device, 79 | callback=self.desktop_callback, 80 | ) 81 | 82 | self.mic_stream.start() 83 | self.desktop_stream.start() 84 | 85 | # Start processing thread 86 | self.processing_thread = threading.Thread(target=self.process_audio) 87 | self.processing_thread.start() 88 | 89 | log.info( 90 | f"Recording started. Mic: {self.mic_stream.channels} channel(s) at {self.mic_sample_rate}Hz, Desktop: {self.desktop_stream.channels} channel(s) at {self.desktop_sample_rate}Hz" 91 | ) 92 | except sd.PortAudioError as e: 93 | log.info(f"Error starting audio streams: {e}") 94 | log.info("Available devices:") 95 | print_audio_devices() 96 | raise 97 | 98 | def stop_recording(self): 99 | self.recording = False 100 | if self.mic_stream: 101 | self.mic_stream.stop() 102 | self.mic_stream.close() 103 | if self.desktop_stream: 104 | self.desktop_stream.stop() 105 | self.desktop_stream.close() 106 | if hasattr(self, "processing_thread"): 107 | self.processing_thread.join() 108 | log.info("Recording stopped.") 109 | 110 | def mic_callback(self, indata, frames, time, status): 111 | if status: 112 | log.info(f"Microphone stream error: {status}") 113 | if self.recording: 114 | self.audio_queue.put(("mic", indata.copy())) 115 | 116 | def desktop_callback(self, indata, frames, time, status): 117 | if status: 118 | log.info(f"Desktop stream error: {status}") 119 | if self.recording: 120 | self.audio_queue.put(("desktop", indata.copy())) 121 | 122 | def normalize_audio(self, audio, target_level=-3): 123 | """ 124 | Normalize the audio to a target RMS level in dB. 125 | """ 126 | rms = np.sqrt(np.mean(audio**2)) 127 | target_rms = 10 ** (target_level / 20) 128 | gain = target_rms / (rms + 1e-9) # Add small value to avoid division by zero 129 | return audio * gain 130 | 131 | def resample(self, audio, original_sample_rate): 132 | # make sure audio is 2D array with shape (channels, samples) 133 | if audio.ndim == 1: 134 | audio = audio.reshape(1, -1) 135 | if audio.shape[0] > audio.shape[1]: 136 | audio = audio.T 137 | 138 | # If stereo, downmix to mono 139 | if audio.shape[1] >= 2: 140 | audio = np.mean(audio, axis=0) 141 | 142 | resampled_audio = resampy.resample( 143 | audio, original_sample_rate, self.target_sample_rate 144 | ) 145 | 146 | # Normalize audio shape to (samples, 1) 147 | return resampled_audio.reshape(-1, 1) 148 | 149 | def process_audio(self): 150 | while self.recording or not self.audio_queue.empty(): 151 | try: 152 | source, data = self.audio_queue.get(timeout=1) 153 | 154 | # Resample to target sample rate 155 | if source == "mic": 156 | data = self.resample(data, self.mic_sample_rate) 157 | self.last_mic_data = data 158 | self.new_mic_data = True 159 | else: 160 | data = self.resample(data, self.desktop_sample_rate) 161 | self.last_desktop_data = data 162 | self.new_desktop_data = True 163 | 164 | # Blend mic and desktop audio if both are available 165 | if ( 166 | self.last_mic_data is not None 167 | and self.last_desktop_data is not None 168 | and self.new_mic_data 169 | and self.new_desktop_data 170 | ): 171 | # Normalize audio levels 172 | normalized_mic = self.last_mic_data 173 | normalized_desktop = self.last_desktop_data 174 | 175 | # Ensure both arrays have the same shape 176 | max_length = max( 177 | normalized_mic.shape[0], self.last_desktop_data.shape[0] 178 | ) 179 | mic_padded = np.pad( 180 | normalized_mic, 181 | ((0, max_length - normalized_mic.shape[0]), (0, 0)), 182 | ) 183 | desktop_padded = np.pad( 184 | normalized_desktop, 185 | ((0, max_length - normalized_desktop.shape[0]), (0, 0)), 186 | ) 187 | 188 | # Blend the audio (you can adjust the weights as needed) 189 | blended_audio = 0.5 * mic_padded + 0.5 * desktop_padded 190 | 191 | if self.audio_data_callback: 192 | self.audio_data_callback(blended_audio) 193 | 194 | self.new_mic_data = False 195 | self.new_desktop_data = False 196 | 197 | except queue.Empty: 198 | continue 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Note Taker - Privacy focused AI note taking app, that runs on-device 2 | 3 |
4 | 5 | [![Build Status](https://github.com/locaal-ai/note-taker/actions/workflows/build.yaml/badge.svg)](https://github.com/locaal-ai/note-taker/actions) 6 | 7 |
8 | 9 | Privacy-focused, command-line note-taking app that uses Locaal's on-device AI SDK for transcription and summarization. Transcribe meetings and generate concise summaries, all locally. Free, open-source, and built for developers.  10 | 11 |
12 | 13 |
14 | 15 | ## Features 16 | 17 | - **Private**: Data stays on your device. No servers or third-party access, ensuring complete privacy. 18 | - **Real-Time**: Transcribe in 99+ languages. 19 | - **Smart**: AI-generated summaries with customizable templates. 20 | - **Portable**: No data lock-in. Your notes are saved as markdown files either locally or on your favorite cloud storage provider. 21 | - **Works Offline**: No internet connection required. 22 | - **Non-Intrusive**: Runs in the background. No bots joining your calls. 23 | - **Customizable**: Choose AI models and summary templates. 24 | - **Cross-Platform**: Supports Windows, macOS, Linux. 25 | - **Cost-Effective**: No server or usage fees. 26 | 27 | ## How It Works 28 | 29 | - Built leveraging Locaal's on-device AI SDK: 30 | - **Transcription**: Uses Locaal's [simpler-whisper](https://github.com/locaal-ai/simpler-whisper/), a multi-threaded local implementation of OpenAI's Whisper model. 31 | - **Summarization**: Integrates [llama-cpp-python](https://github.com/abetlen/llama-cpp-python) for local language model capabilities. 32 | - **Audio Capture**: Uses [sounddevice](https://python-sounddevice.readthedocs.io/) for microphone and desktop audio capture. 33 | 34 | ## Prerequisites 35 | 36 | - Python 3.11 or higher 37 | - Git 38 | 39 | ## Installation 40 | 41 | 1. Install a pre-built version from the [Releases](https://github.com/locaal-ai/note-taker/releases) page, including an installer for windows or a simple standalone app for Mac. 42 | 43 | 1. Clone the repository: 44 | 45 | ```bash 46 | git clone https://github.com/locaal-ai/note-taker.git 47 | cd note-taker 48 | ``` 49 | 50 | 1. Install the dependencies: 51 | 52 | ```bash 53 | pip install -r requirements.txt 54 | ``` 55 | 56 | ## Usage 57 | 58 | 1. Navigate to the `src` directory: 59 | 60 | ```bash 61 | cd src 62 | ``` 63 | 64 | 2. Run the main script: 65 | 66 | ```bash 67 | python main.py 68 | ``` 69 | 70 | 3. Follow the on-screen prompts to: 71 | - Transcribe audio from your microphone or desktop 72 | - Create, read, update, and delete notes 73 | - Use AI-powered features for text analysis and summarization 74 | 75 | ## Privacy and Security 76 | 77 | - All AI processing occurs on your local device, ensuring your data never leaves your control. 78 | - No internet connection is required for core functionalities. 79 | - Your notes and transcriptions are stored locally in an encrypted format. 80 | 81 | ## Building from Source 82 | 83 | This project uses PyInstaller to create standalone executables for Windows, macOS, and Linux. The build process is automated using GitHub Actions, but you can also build the app locally. 84 | 85 | ### Prerequisites 86 | 87 | - Python 3.11 88 | - PyInstaller 6.10.0 89 | - Platform-specific dependencies (see below) 90 | 91 | ### Windows 92 | 93 | 1. Install `simpler-whisper` (https://github.com/locaal-ai/simpler-whisper) prebuilt wheel and `llama-cpp-python` 94 | 95 | ```powershell 96 | Invoke-WebRequest -Uri https://github.com/locaal-ai/simpler-whisper/releases/download/0.1.0/simpler_whisper-0.1.0-cp311-cp311-cuda-win64-win_amd64.whl -OutFile simpler_whisper-0.1.0-cp311-cp311-win_amd64.whl 97 | pip install simpler_whisper-0.1.0-cp311-cp311-win_amd64.whl 98 | rm simpler_whisper-0.1.0-cp311-cp311-win_amd64.whl 99 | pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu125 100 | ``` 101 | 102 | 1. Install other dependencies: 103 | ```powershell 104 | pip install -r requirements.txt 105 | ``` 106 | 107 | 1. Download the whisper model: 108 | 109 | ```powershell 110 | mkdir data 111 | Invoke-WebRequest -Uri https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-q5_1.bin?download=true -OutFile data/ggml-small.en-q5_1.bin 112 | ``` 113 | 114 | 1. For CPU version: 115 | ```powershell 116 | pyinstaller --clean --noconfirm note-taker.spec -- --win 117 | ``` 118 | 119 | For CUDA version: 120 | ```powershell 121 | pyinstaller --clean --noconfirm note-taker.spec -- --win --cuda 122 | ``` 123 | 124 | 1. The executable will be in the `dist` folder. 125 | 126 | 1. To create an installer: 127 | - Ensure Inno Setup is installed 128 | - Run: `iscc note-taker.iss` 129 | 130 | ### macOS 131 | 132 | 1. Install dependencies: 133 | ``` 134 | pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal 135 | pip install -r requirements.txt 136 | ``` 137 | 138 | 1. Download the models: 139 | 140 | ```bash 141 | mkdir -p data/ 142 | wget -P data/ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-q5_1.bin?download=true" -O data/ggml-small.en-q5_1.bin 143 | curl -L https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-encoder.mlmodelc.zip?download=true -o data/ggml-small.en-encoder.mlmodelc.zip 144 | unzip data/ggml-small.en-encoder.mlmodelc.zip -d data 145 | rm data/ggml-small.en-encoder.mlmodelc.zip 146 | ``` 147 | 148 | 1. Build the app: 149 | ``` 150 | pyinstaller --clean --noconfirm note-taker.spec -- --mac_osx 151 | ``` 152 | 153 | 1. The app bundle will be in the `dist` folder. 154 | 155 | 1. To create a DMG: 156 | ``` 157 | hdiutil create -volname "note-taker" -srcfolder dist/note-taker.app -ov -format UDRO note-taker-macos.dmg 158 | ``` 159 | 160 | ### Linux 161 | 162 | 1. Install dependencies: 163 | ``` 164 | pip install -r requirements.txt 165 | ``` 166 | 167 | 1. Download the model: 168 | 169 | ```bash 170 | wget -P data/ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-q5_1.bin?download=true" -O data/ggml-small.en-q5_1.bin 171 | ``` 172 | 173 | 1. Build the app: 174 | ``` 175 | pyinstaller --clean --noconfirm note-taker.spec 176 | ``` 177 | 178 | 1. The executable will be in the `dist` folder. 179 | 180 | 1. To create a tarball: 181 | ``` 182 | tar -cvf note-taker.tar -C dist note-taker 183 | ``` 184 | 185 | ### CI/CD Builds 186 | 187 | Our GitHub Actions workflow (`build.yaml`) automates builds for multiple platforms: 188 | 189 | - Windows: CPU and CUDA versions 190 | - macOS: x86 and ARM64 versions 191 | - Linux: x86_64 version 192 | 193 | The workflow handles dependency installation, building, and packaging for each platform. For macOS, it also includes code signing and notarization steps. 194 | 195 | ## Running the Built Application 196 | 197 | ### Windows 198 | Run the `note-taker.exe` file in the `dist` folder, or use the installer created by Inno Setup. 199 | 200 | ### macOS 201 | Open the `note-taker.app` bundle in the `dist` folder, or mount the created DMG and drag the app to your Applications folder. 202 | 203 | ### Linux 204 | Run the `note-taker` executable in the `dist` folder. 205 | 206 | ### CI/CD 207 | 208 | Our GitHub Actions workflow (`build.yaml`) automates builds for multiple platforms, ensuring the app is always ready for distribution with the latest local AI capabilities. 209 | 210 | ## Project Structure 211 | 212 | ``` 213 | src/ 214 | ├── app.py 215 | ├── audio 216 | │ ├── AudioCapture.py 217 | │ ├── Transcriber.py 218 | │ └── textual_transcription_textarea.py 219 | ├── llm 220 | │ └── model.py 221 | ├── main.py 222 | ├── main.tcss 223 | ├── notes 224 | │ └── manager.py 225 | ├── notes_editor_components.py 226 | ├── settings_screen.py 227 | ├── template_select_modal.py 228 | └── utils 229 | ├── defaults.py 230 | ├── helpers.py 231 | ├── resource_path.py 232 | └── storage.py 233 | ``` 234 | 235 | ## Key Dependencies 236 | 237 | - [`simpler-whisper`](https://github.com/locaal-ai/simpler-whisper): Local implementation of Whisper for speech recognition 238 | - [`llama-cpp-python`](https://github.com/abetlen/llama-cpp-python): Python bindings for the llama.cpp library 239 | - [`sounddevice`](https://github.com/spatialaudio/python-sounddevice): For audio capture 240 | - [`textual`](https://github.com/Textualize/textual): TUI (Text User Interface) framework 241 | 242 | For a complete list, see `requirements.txt`. 243 | 244 | ## Contributing 245 | 246 | We welcome contributions that enhance the app's local AI capabilities, improve privacy features, or optimize performance. Please feel free to submit a Pull Request. 247 | 248 | ## License 249 | 250 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 251 | 252 | ## Disclaimer 253 | 254 | While this app processes all data locally, users are responsible for ensuring compliance with local laws and regulations regarding data privacy and AI usage. 255 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | workflow_call: 7 | 8 | jobs: 9 | check-format: 10 | name: Check Formatting 🔍 11 | uses: ./.github/workflows/check-format.yaml 12 | permissions: 13 | contents: read 14 | 15 | build: 16 | needs: check-format 17 | strategy: 18 | matrix: 19 | include: 20 | # - os: macos-14-large 21 | # target: macos-x86 22 | - os: macos-latest 23 | target: macos-arm64 24 | - os: ubuntu-latest 25 | target: linux 26 | - os: windows-latest 27 | target: windows-cpu 28 | - os: windows-latest 29 | target: windows-cuda 30 | 31 | runs-on: ${{ matrix.os }} 32 | 33 | env: 34 | simpler-whisper-version: 0.2.2 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up Python 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: '3.11' 43 | 44 | - name: Install pyinstaller for Windows 45 | if: startsWith(matrix.os, 'windows') 46 | run: | 47 | Invoke-WebRequest -Uri https://github.com/pyinstaller/pyinstaller/archive/refs/tags/v6.10.0.zip -OutFile pyinstaller-6.10.0.zip 48 | Expand-Archive -Path pyinstaller-6.10.0.zip -DestinationPath . 49 | cd pyinstaller-6.10.0 50 | python -m pip install . 51 | cd .. 52 | Remove-Item -Recurse -Force pyinstaller-6.10.0 53 | Remove-Item -Force pyinstaller-6.10.0.zip 54 | 55 | - name: Install Linux simpler-whisper 56 | if: matrix.target == 'linux' 57 | run: | 58 | curl -L https://github.com/locaal-ai/simpler-whisper/releases/download/${{ env.simpler-whisper-version }}/simpler_whisper-${{ env.simpler-whisper-version }}+cpu-cp311-cp311-linux_x86_64.whl -o simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-linux_x86_64.whl 59 | python -m pip install simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-linux_x86_64.whl 60 | 61 | - name: Install MacOS dependencies 62 | if: startsWith(matrix.os, 'macos') 63 | run: | 64 | python -m pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/metal 65 | curl -L https://github.com/locaal-ai/simpler-whisper/releases/download/${{ env.simpler-whisper-version }}/simpler_whisper-${{ env.simpler-whisper-version }}+cpu-cp311-cp311-macosx_14_0_universal2.whl -o simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-macosx_14_0_universal2.whl 66 | python -m pip install simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-macosx_14_0_universal2.whl 67 | 68 | - name: Install Windows CPU dependencies 69 | if: matrix.target == 'windows-cpu' 70 | run: | 71 | Invoke-WebRequest -Uri https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.1/llama_cpp_python-0.3.1-cp311-cp311-win_amd64.whl -OutFile llama_cpp_python-0.3.1-cp311-cp311-win_amd64.whl 72 | python -m pip install llama_cpp_python-0.3.1-cp311-cp311-win_amd64.whl 73 | 74 | Invoke-WebRequest -Uri https://github.com/locaal-ai/simpler-whisper/releases/download/${{ env.simpler-whisper-version }}/simpler_whisper-${{ env.simpler-whisper-version }}+cpu-cp311-cp311-win_amd64.whl -OutFile simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-win_amd64.whl 75 | python -m pip install simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-win_amd64.whl 76 | 77 | - name: Install Windows CUDA dependencies 78 | if: matrix.target == 'windows-cuda' 79 | run: | 80 | python -m pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu125 81 | 82 | Invoke-WebRequest -Uri https://github.com/locaal-ai/simpler-whisper/releases/download/${{ env.simpler-whisper-version }}/simpler_whisper-${{ env.simpler-whisper-version }}+cuda-cp311-cp311-win_amd64.whl -OutFile simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-win_amd64.whl 83 | python -m pip install simpler_whisper-${{ env.simpler-whisper-version }}-cp311-cp311-win_amd64.whl 84 | 85 | - name: Install dependencies 86 | run: | 87 | python -m pip install -r requirements.txt 88 | 89 | - name: Download Whisper Model Windows 90 | if: startsWith(matrix.target, 'windows') 91 | run: | 92 | if (-Not (Test-Path -Path data)) { 93 | mkdir data 94 | } 95 | Invoke-WebRequest -Uri https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-q5_1.bin?download=true -OutFile data/ggml-small.en-q5_1.bin 96 | 97 | - name: Download Whisper Model MacOS and Linux 98 | if: startsWith(matrix.os, 'macos') || matrix.os == 'ubuntu-latest' 99 | run: | 100 | mkdir -p data 101 | curl -L https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-q5_1.bin?download=true -o data/ggml-small.en-q5_1.bin 102 | 103 | - name: Download MacOS CoreML Model 104 | if: startsWith(matrix.os, 'macos') 105 | run: | 106 | mkdir -p data 107 | curl -L https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-encoder.mlmodelc.zip?download=true -o data/ggml-small.en-encoder.mlmodelc.zip 108 | unzip data/ggml-small.en-encoder.mlmodelc.zip -d data 109 | rm data/ggml-small.en-encoder.mlmodelc.zip 110 | 111 | - name: Import Apple Certificate 112 | if: startsWith(matrix.os, 'macos') 113 | run: | 114 | if security list-keychains | grep -q "github_build.keychain"; then 115 | security delete-keychain github_build.keychain 116 | fi 117 | security create-keychain -p "" github_build.keychain 118 | security default-keychain -s github_build.keychain 119 | security set-keychain-settings -lut 21600 github_build.keychain 120 | echo "${{ secrets.APPLE_CERTIFICATE }}" | base64 --decode > apple_certificate.p12 121 | security import apple_certificate.p12 -k github_build.keychain -P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \ 122 | -t cert -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun 123 | security unlock-keychain -p "" github_build.keychain 124 | security set-key-partition-list -S 'apple-tool:,apple:' -s -k "" github_build.keychain 125 | security list-keychain -d user -s github_build.keychain 'login-keychain' 126 | env: 127 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 128 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 129 | 130 | - name: Unlock keychain on Mac 131 | if: startsWith(matrix.os, 'macos') 132 | run: | 133 | security unlock-keychain -p "" github_build.keychain 134 | security set-key-partition-list -S apple-tool:,apple: -k "" -D "Developer" -t private github_build.keychain 135 | 136 | - name: List available signing identities 137 | if: startsWith(matrix.os, 'macos') 138 | run: | 139 | security find-identity -v -p codesigning 140 | 141 | - name: Write .env file for Windows CUDA 142 | if: matrix.target == 'windows-cuda' 143 | run: | 144 | @" 145 | WHISPER_EXEC_BACKEND=cuda 146 | WHISPER_COMPUTE_TYPE=float32 147 | LOCAL_RELEASE_TAG=$env:GITHUB_REF_NAME 148 | LOCAL_RELEASE_DATE=$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') 149 | "@ | Out-File -FilePath .env -Encoding ASCII 150 | shell: pwsh 151 | 152 | - name: Write .env file for other platforms 153 | if: matrix.target != 'windows-cuda' 154 | run: | 155 | echo "WHISPER_EXEC_BACKEND=auto" >> .env 156 | echo "WHISPER_COMPUTE_TYPE=float16" >> .env 157 | echo "LOCAL_RELEASE_TAG=${GITHUB_REF_NAME}" >> .env 158 | echo "LOCAL_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .env 159 | 160 | - name: Build with PyInstaller (MacOS) 161 | if: startsWith(matrix.target, 'macos-') 162 | run: | 163 | ARCH=$(echo ${{ matrix.target }} | sed 's/macos-//') 164 | if [ "$ARCH" = "x86" ]; then 165 | ARCH="x86_64" 166 | fi 167 | pyinstaller --clean --noconfirm note-taker.spec -- --mac_osx --arch $ARCH 168 | env: 169 | APPLE_APP_DEVELOPER_ID: ${{ secrets.APPLE_APP_DEVELOPER_ID }} 170 | 171 | - name: Build with PyInstaller (Windows) 172 | if: startsWith(matrix.target, 'windows-') 173 | run: | 174 | if ("${{ github.event_name }}" -eq "pull_request") { 175 | pyinstaller --clean --noconfirm note-taker.spec -- --win --debug 176 | } else { 177 | pyinstaller --clean --noconfirm note-taker.spec -- --win 178 | } 179 | 180 | - name: Build with PyInstaller (Linux) 181 | if: matrix.os == 'ubuntu-latest' 182 | run: | 183 | pyinstaller --clean --noconfirm note-taker.spec 184 | 185 | - name: Zip Application for Notarization 186 | if: startsWith(matrix.os, 'macos') 187 | run: | 188 | ditto -c -k --keepParent dist/note-taker note-taker.zip 189 | 190 | - name: Notarize 191 | if: startsWith(matrix.os, 'macos') 192 | run: | 193 | xcrun notarytool submit note-taker.zip --apple-id \ 194 | "${{ secrets.APPLE_DEVELOPER_ID_USER }}" --password \ 195 | "${{ secrets.APPLE_DEVELOPER_ID_PASSWORD }}" --team-id \ 196 | "${{ secrets.APPLE_DEVELOPER_ID_TEAM }}" --wait --verbose 197 | 198 | - name: Verify Code Signing 199 | if: startsWith(matrix.os, 'macos') 200 | run: | 201 | codesign -vv --deep dist/note-taker 202 | rm note-taker.zip 203 | 204 | - name: Add version to .iss file 205 | if: matrix.os == 'windows-latest' 206 | run: | 207 | $version = (Get-Content -Path note-taker.iss -Raw) -replace '@note-taker_VERSION@', $env:GITHUB_REF_NAME 208 | $version | Out-File -FilePath note-taker.iss -Encoding ASCII 209 | shell: pwsh 210 | 211 | - name: Compile .ISS to .EXE Installer 212 | if: matrix.os == 'windows-latest' 213 | uses: Minionguyjpro/Inno-Setup-Action@v1.2.4 214 | with: 215 | path: note-taker.iss 216 | options: /O+ 217 | 218 | - name: Create tar Linux 219 | if: matrix.os == 'ubuntu-latest' 220 | # strip the folder name from the tar 221 | run: | 222 | chmod a+x dist/note-taker 223 | tar -cvf note-taker.tar -C dist note-taker 224 | 225 | - name: Create dmg MacOS 226 | if: startsWith(matrix.os, 'macos') 227 | run: | 228 | chmod a+x dist/note-taker 229 | hdiutil create -volname "note-taker" -srcfolder dist/note-taker -ov -format UDRO note-taker-${{ matrix.target }}.dmg 230 | 231 | - name: Create zip on Windows 232 | if: matrix.os == 'windows-latest' 233 | run: | 234 | Compress-Archive -Path "dist/note-taker-setup.exe" -DestinationPath "./note-taker-${{ matrix.target }}.zip" 235 | shell: pwsh 236 | 237 | - name: Upload artifact 238 | uses: actions/upload-artifact@v4 239 | with: 240 | name: note-taker-${{ matrix.target }} 241 | path: | 242 | note-taker-macos-arm64.dmg 243 | note-taker-macos-x86.dmg 244 | note-taker.tar 245 | note-taker-windows-cpu.zip 246 | note-taker-windows-cuda.zip 247 | --------------------------------------------------------------------------------