├── 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 | [](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 |
--------------------------------------------------------------------------------