1264 lines
44 KiB
Python
1264 lines
44 KiB
Python
|
|
"""
|
||
|
|
Voice Recorder for Obsidian
|
||
|
|
A simple GUI to record voice memos and transcribe them to Obsidian notes.
|
||
|
|
With Claude CLI integration for automatic note processing.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import tkinter as tk
|
||
|
|
from tkinter import ttk, messagebox, scrolledtext
|
||
|
|
import threading
|
||
|
|
import subprocess
|
||
|
|
import tempfile
|
||
|
|
import wave
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import ssl
|
||
|
|
import traceback
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
# Fix SSL certificate issues on some Windows/conda setups
|
||
|
|
try:
|
||
|
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||
|
|
except AttributeError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# ============================================
|
||
|
|
# CONFIGURATION
|
||
|
|
# ============================================
|
||
|
|
BASE_OUTPUT_DIR = Path(r"C:\Users\antoi\antoine\My Libraries\Antoine Brain Extension\0-Inbox\Transcripts")
|
||
|
|
MODEL = "base"
|
||
|
|
SAMPLE_RATE = 44100
|
||
|
|
CHANNELS = 1
|
||
|
|
|
||
|
|
# Obsidian Vault Path (for PKM context)
|
||
|
|
OBSIDIAN_VAULT = Path(r"C:\Users\antoi\antoine\My Libraries\Antoine Brain Extension")
|
||
|
|
|
||
|
|
# PKM Context Configuration
|
||
|
|
PKM_CONTEXT = {
|
||
|
|
"enabled": True,
|
||
|
|
# Recent daily notes to include
|
||
|
|
"daily_notes": {
|
||
|
|
"enabled": True,
|
||
|
|
"folder": "0-Inbox/Transcripts/daily", # Relative to vault
|
||
|
|
"count": 3, # Number of recent notes
|
||
|
|
},
|
||
|
|
# Project/context files to always include
|
||
|
|
"project_files": {
|
||
|
|
"enabled": True,
|
||
|
|
"files": [
|
||
|
|
# Add paths relative to vault, e.g.:
|
||
|
|
# "Projects/Active Project.md",
|
||
|
|
# "Areas/Work/Current Focus.md",
|
||
|
|
],
|
||
|
|
},
|
||
|
|
# Tag-based context (searches vault for matching tags)
|
||
|
|
"tag_context": {
|
||
|
|
"enabled": True,
|
||
|
|
"max_files": 5, # Max files per tag
|
||
|
|
# Map note types to relevant tags to search
|
||
|
|
"type_tags": {
|
||
|
|
"instructions": ["bug", "fix", "issue", "project"],
|
||
|
|
"capture": ["notes", "capture", "thoughts"],
|
||
|
|
"meeting": ["meeting", "meetings", "work"],
|
||
|
|
"daily": ["todo", "tasks", "daily"],
|
||
|
|
"idea": ["idea", "ideas", "brainstorm"],
|
||
|
|
"review": ["review", "reflection", "weekly"],
|
||
|
|
"journal": ["journal", "personal", "thoughts"],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
# Folder structure by type
|
||
|
|
FOLDERS = {
|
||
|
|
"instructions": "instructions",
|
||
|
|
"capture": "captures",
|
||
|
|
"daily": "daily",
|
||
|
|
"meeting": "meetings",
|
||
|
|
"idea": "ideas",
|
||
|
|
"review": "reviews",
|
||
|
|
"journal": "journal",
|
||
|
|
"default": "notes"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Note type configurations
|
||
|
|
# CUSTOMIZE YOUR PROMPTS HERE - Edit the "prompt" field for each type
|
||
|
|
# Note: Prompts are bilingual - Claude will respond in the transcript's language
|
||
|
|
NOTE_TYPES = {
|
||
|
|
"instructions": {
|
||
|
|
"emoji": "X",
|
||
|
|
"label": "Instructions",
|
||
|
|
"color": "#f97316",
|
||
|
|
"prompt": """This is a PROBLEM/FIX REQUEST to convert into Claude Code instructions.
|
||
|
|
|
||
|
|
Your task: Transform this voice description into a READY-TO-USE prompt for Claude Code.
|
||
|
|
|
||
|
|
OUTPUT FORMAT:
|
||
|
|
## Context
|
||
|
|
[Brief explanation of the project/codebase context mentioned]
|
||
|
|
|
||
|
|
## Problem Statement
|
||
|
|
[Clear description of what's broken or needs improvement]
|
||
|
|
|
||
|
|
## Requirements
|
||
|
|
- [Bullet list of what needs to be achieved]
|
||
|
|
- [Be specific and actionable]
|
||
|
|
|
||
|
|
## Instructions for Claude
|
||
|
|
```
|
||
|
|
[Write the actual prompt to give to Claude Code in another session]
|
||
|
|
[Be direct and imperative: "Fix X", "Update Y", "Refactor Z"]
|
||
|
|
[Include specific file paths if mentioned]
|
||
|
|
[Include technical details and constraints]
|
||
|
|
[Make it copy-paste ready]
|
||
|
|
```
|
||
|
|
|
||
|
|
## Success Criteria
|
||
|
|
- [ ] [How to verify the fix works]
|
||
|
|
- [ ] [What to test]
|
||
|
|
|
||
|
|
IMPORTANT:
|
||
|
|
- Extract ALL technical details mentioned (file names, function names, error messages)
|
||
|
|
- If context is vague, note what information is missing
|
||
|
|
- Make instructions detailed enough for Claude to execute without asking questions
|
||
|
|
- Prioritize issues if multiple are mentioned"""
|
||
|
|
},
|
||
|
|
"capture": {
|
||
|
|
"emoji": "C",
|
||
|
|
"label": "Capture",
|
||
|
|
"color": "#6e7681",
|
||
|
|
"prompt": """This is a FREEFORM CAPTURE - organize naturally without forcing a specific structure.
|
||
|
|
|
||
|
|
Instructions:
|
||
|
|
- Capture EVERYTHING said, don't skip details
|
||
|
|
- Group related thoughts together
|
||
|
|
- Use bullet points for clarity
|
||
|
|
- Add headers (##) only when topic changes significantly
|
||
|
|
- Preserve the natural flow of ideas
|
||
|
|
- If there are action items, list them at the end
|
||
|
|
- Don't force categories - let the content guide the structure
|
||
|
|
|
||
|
|
Goal: Clean, readable text that preserves all information."""
|
||
|
|
},
|
||
|
|
"meeting": {
|
||
|
|
"emoji": "M",
|
||
|
|
"label": "Meeting",
|
||
|
|
"color": "#e91e63",
|
||
|
|
"prompt": """This is a MEETING summary. / C'est un compte-rendu de MEETING.
|
||
|
|
|
||
|
|
Structure:
|
||
|
|
- **Participants** (if mentioned)
|
||
|
|
- **Context**
|
||
|
|
- **Points discussed**
|
||
|
|
- **Decisions**
|
||
|
|
- **Actions** (format: - [ ] @person: task)
|
||
|
|
|
||
|
|
Professional and concise style."""
|
||
|
|
},
|
||
|
|
"idea": {
|
||
|
|
"emoji": "I",
|
||
|
|
"label": "Idea",
|
||
|
|
"color": "#ff9800",
|
||
|
|
"prompt": """This is an IDEA/BRAINSTORM note. / C'est une NOTE D'IDÉES.
|
||
|
|
|
||
|
|
- Organize ideas by theme
|
||
|
|
- Identify connections between ideas
|
||
|
|
- Highlight promising ideas
|
||
|
|
- List concrete next steps
|
||
|
|
|
||
|
|
Clear and actionable style."""
|
||
|
|
},
|
||
|
|
"daily": {
|
||
|
|
"emoji": "D",
|
||
|
|
"label": "Daily",
|
||
|
|
"color": "#4caf50",
|
||
|
|
"prompt": """This is a DAILY note - compile today's tasks and carry over incomplete ones.
|
||
|
|
|
||
|
|
You receive:
|
||
|
|
1. ALL transcripts from TODAY
|
||
|
|
2. YESTERDAY'S incomplete tasks (if provided in context)
|
||
|
|
|
||
|
|
Instructions:
|
||
|
|
- Extract ALL tasks from today's transcripts
|
||
|
|
- Look for incomplete tasks (- [ ]) from yesterday's daily note in the PKM context
|
||
|
|
- CARRY OVER any uncompleted tasks from yesterday (mark with "carried over" if useful)
|
||
|
|
- Merge duplicates
|
||
|
|
- Group by category (Meetings, Work, Personal, etc.)
|
||
|
|
- Format: - [ ] Task (context if relevant)
|
||
|
|
- Meetings with time: - [ ] 1:00pm - Meeting with X
|
||
|
|
- Order: time-sensitive first, then by category
|
||
|
|
- Professional, minimalist style, no emojis
|
||
|
|
|
||
|
|
If yesterday had incomplete tasks, include them at the top under "## Carried Over"."""
|
||
|
|
},
|
||
|
|
"review": {
|
||
|
|
"emoji": "R",
|
||
|
|
"label": "Review",
|
||
|
|
"color": "#2196f3",
|
||
|
|
"prompt": """This is a REVIEW/REFLECTION. / C'est une REVUE/RÉFLEXION.
|
||
|
|
|
||
|
|
Structure:
|
||
|
|
- **Accomplishments**
|
||
|
|
- **Challenges encountered**
|
||
|
|
- **Learnings**
|
||
|
|
- **Actions** (checkbox format)
|
||
|
|
|
||
|
|
Reflective but concise style."""
|
||
|
|
},
|
||
|
|
"journal": {
|
||
|
|
"emoji": "J",
|
||
|
|
"label": "Journal",
|
||
|
|
"color": "#9c27b0",
|
||
|
|
"prompt": """This is a PERSONAL JOURNAL entry. / C'est une entrée de JOURNAL.
|
||
|
|
|
||
|
|
- Preserve the personal tone
|
||
|
|
- Organize by theme or chronology
|
||
|
|
- Note important reflections
|
||
|
|
- Identify patterns or insights
|
||
|
|
|
||
|
|
Authentic style."""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Auto-process with Claude after saving
|
||
|
|
AUTO_PROCESS = True
|
||
|
|
|
||
|
|
# Daily note settings - TODOs will be appended to daily note instead of creating new files
|
||
|
|
DAILY_NOTE_ENABLED = True
|
||
|
|
DAILY_NOTE_PATTERN = "Daily {date}.md" # {date} will be replaced with YYYY-MM-DD
|
||
|
|
|
||
|
|
# Base Claude prompt - adapts to transcript language automatically
|
||
|
|
CLAUDE_BASE_PROMPT = """You are processing a voice transcript. IMPORTANT: Respond in the SAME LANGUAGE as the transcript.
|
||
|
|
If the transcript is in French, respond in French. If in English, respond in English. Match the language.
|
||
|
|
|
||
|
|
{type_instruction}
|
||
|
|
|
||
|
|
General instructions:
|
||
|
|
1. Fix obvious transcription errors
|
||
|
|
2. Organize with markdown headers (##)
|
||
|
|
3. Extract key points as bullet points
|
||
|
|
4. Identify action items / TODOs if present (format: - [ ] task)
|
||
|
|
5. Keep the original meaning but make it readable and structured
|
||
|
|
6. RESPOND IN THE SAME LANGUAGE AS THE TRANSCRIPT
|
||
|
|
|
||
|
|
Reply ONLY with the organized notes, no introduction or explanation.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
TRANSCRIPT:
|
||
|
|
{transcript}
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def gather_pkm_context(note_type: str = None, is_new_daily: bool = False) -> list:
|
||
|
|
"""
|
||
|
|
Gather relevant context files from the PKM/Obsidian vault.
|
||
|
|
Returns a list of file paths to include as context.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
note_type: The type of note being processed
|
||
|
|
is_new_daily: If True, include yesterday's daily for task carryover
|
||
|
|
"""
|
||
|
|
from datetime import timedelta
|
||
|
|
|
||
|
|
if not PKM_CONTEXT.get("enabled", False):
|
||
|
|
return []
|
||
|
|
|
||
|
|
context_files = []
|
||
|
|
|
||
|
|
# Special handling for "daily" type - include yesterday's daily note
|
||
|
|
# Only when creating a NEW daily note (not when appending to existing)
|
||
|
|
if note_type == "daily" and is_new_daily:
|
||
|
|
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||
|
|
yesterday_note_name = DAILY_NOTE_PATTERN.format(date=yesterday)
|
||
|
|
yesterday_path = BASE_OUTPUT_DIR / FOLDERS["daily"] / yesterday_note_name
|
||
|
|
if yesterday_path.exists():
|
||
|
|
context_files.append(yesterday_path)
|
||
|
|
|
||
|
|
# 1. Recent daily notes
|
||
|
|
if PKM_CONTEXT["daily_notes"]["enabled"]:
|
||
|
|
daily_folder = OBSIDIAN_VAULT / PKM_CONTEXT["daily_notes"]["folder"]
|
||
|
|
if daily_folder.exists():
|
||
|
|
daily_files = sorted(
|
||
|
|
daily_folder.glob("*.md"),
|
||
|
|
key=lambda f: f.stat().st_mtime,
|
||
|
|
reverse=True
|
||
|
|
)[:PKM_CONTEXT["daily_notes"]["count"]]
|
||
|
|
context_files.extend(daily_files)
|
||
|
|
|
||
|
|
# 2. Project/context files (always included)
|
||
|
|
if PKM_CONTEXT["project_files"]["enabled"]:
|
||
|
|
for rel_path in PKM_CONTEXT["project_files"]["files"]:
|
||
|
|
full_path = OBSIDIAN_VAULT / rel_path
|
||
|
|
if full_path.exists():
|
||
|
|
context_files.append(full_path)
|
||
|
|
|
||
|
|
# 3. Tag-based context (search for files with matching tags)
|
||
|
|
if PKM_CONTEXT["tag_context"]["enabled"] and note_type:
|
||
|
|
tags = PKM_CONTEXT["tag_context"]["type_tags"].get(note_type, [])
|
||
|
|
if tags:
|
||
|
|
max_files = PKM_CONTEXT["tag_context"]["max_files"]
|
||
|
|
found_files = search_vault_by_tags(tags, max_files)
|
||
|
|
context_files.extend(found_files)
|
||
|
|
|
||
|
|
# Remove duplicates while preserving order
|
||
|
|
seen = set()
|
||
|
|
unique_files = []
|
||
|
|
for f in context_files:
|
||
|
|
if f not in seen:
|
||
|
|
seen.add(f)
|
||
|
|
unique_files.append(f)
|
||
|
|
|
||
|
|
return unique_files
|
||
|
|
|
||
|
|
|
||
|
|
def search_vault_by_tags(tags: list, max_files: int = 5) -> list:
|
||
|
|
"""
|
||
|
|
Search the Obsidian vault for files containing specific tags.
|
||
|
|
Returns a list of file paths.
|
||
|
|
"""
|
||
|
|
import re
|
||
|
|
|
||
|
|
matching_files = []
|
||
|
|
tag_patterns = [re.compile(rf'#({tag})\b', re.IGNORECASE) for tag in tags]
|
||
|
|
yaml_tag_patterns = [re.compile(rf'^\s*-\s*{tag}\s*$', re.IGNORECASE | re.MULTILINE) for tag in tags]
|
||
|
|
|
||
|
|
# Search in vault (limit to certain folders for performance)
|
||
|
|
search_folders = [
|
||
|
|
OBSIDIAN_VAULT / "+",
|
||
|
|
OBSIDIAN_VAULT / "Projects",
|
||
|
|
OBSIDIAN_VAULT / "Areas",
|
||
|
|
]
|
||
|
|
|
||
|
|
for folder in search_folders:
|
||
|
|
if not folder.exists():
|
||
|
|
continue
|
||
|
|
|
||
|
|
for md_file in folder.rglob("*.md"):
|
||
|
|
if len(matching_files) >= max_files:
|
||
|
|
break
|
||
|
|
|
||
|
|
try:
|
||
|
|
content = md_file.read_text(encoding='utf-8', errors='ignore')
|
||
|
|
|
||
|
|
# Check for inline tags (#tag) or YAML frontmatter tags
|
||
|
|
for pattern in tag_patterns + yaml_tag_patterns:
|
||
|
|
if pattern.search(content):
|
||
|
|
matching_files.append(md_file)
|
||
|
|
break
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
|
||
|
|
if len(matching_files) >= max_files:
|
||
|
|
break
|
||
|
|
|
||
|
|
return matching_files[:max_files]
|
||
|
|
|
||
|
|
|
||
|
|
class VoiceRecorder:
|
||
|
|
def __init__(self, root):
|
||
|
|
self.root = root
|
||
|
|
self.root.title("Voice Recorder")
|
||
|
|
self.root.geometry("400x680")
|
||
|
|
self.root.resizable(False, False)
|
||
|
|
self.root.configure(bg="#0d1117")
|
||
|
|
|
||
|
|
# Set window icon
|
||
|
|
try:
|
||
|
|
icon_path = Path(__file__).parent / "voice_recorder.ico"
|
||
|
|
if icon_path.exists():
|
||
|
|
self.root.iconbitmap(str(icon_path))
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Recording state
|
||
|
|
self.is_recording = False
|
||
|
|
self.is_paused = False
|
||
|
|
self.audio_data = []
|
||
|
|
self.stream = None
|
||
|
|
self.start_time = None
|
||
|
|
self.elapsed_time = 0
|
||
|
|
self.pause_start = None
|
||
|
|
self.recording_indicator_visible = True
|
||
|
|
|
||
|
|
# Temp file for audio
|
||
|
|
self.temp_audio_path = None
|
||
|
|
|
||
|
|
# Last saved note info
|
||
|
|
self.last_note_path = None
|
||
|
|
self.last_transcript = None
|
||
|
|
self.is_daily_note = False
|
||
|
|
self.is_new_daily_note = False # True only when creating a new daily (not appending)
|
||
|
|
|
||
|
|
# Selected note type
|
||
|
|
self.selected_type = tk.StringVar(value="")
|
||
|
|
|
||
|
|
# Auto-process setting
|
||
|
|
self.auto_process = tk.BooleanVar(value=AUTO_PROCESS)
|
||
|
|
|
||
|
|
# Dependencies
|
||
|
|
self.sd = None
|
||
|
|
self.np = None
|
||
|
|
self.whisper = None
|
||
|
|
self.whisper_model = None
|
||
|
|
|
||
|
|
# Colors - stored as instance vars for reuse
|
||
|
|
self.colors = {
|
||
|
|
'bg_dark': "#0d1117",
|
||
|
|
'bg_card': "#161b22",
|
||
|
|
'bg_elevated': "#1c2128",
|
||
|
|
'border': "#30363d",
|
||
|
|
'text_primary': "#e6edf3",
|
||
|
|
'text_secondary': "#7d8590",
|
||
|
|
'text_muted': "#484f58",
|
||
|
|
'accent_red': "#f85149",
|
||
|
|
'accent_green': "#3fb950",
|
||
|
|
'accent_blue': "#58a6ff",
|
||
|
|
'accent_purple': "#a371f7",
|
||
|
|
'accent_orange': "#d29922",
|
||
|
|
}
|
||
|
|
|
||
|
|
self.setup_ui()
|
||
|
|
self.check_dependencies()
|
||
|
|
self.update_timer()
|
||
|
|
self.update_recording_indicator()
|
||
|
|
|
||
|
|
def log(self, message, level="INFO"):
|
||
|
|
"""Add a message to the debug log."""
|
||
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||
|
|
|
||
|
|
self.log_text.configure(state=tk.NORMAL)
|
||
|
|
self.log_text.insert(tk.END, f"[{timestamp}] ")
|
||
|
|
self.log_text.insert(tk.END, f"{level}: ", level)
|
||
|
|
self.log_text.insert(tk.END, f"{message}\n")
|
||
|
|
self.log_text.see(tk.END)
|
||
|
|
self.log_text.configure(state=tk.DISABLED)
|
||
|
|
|
||
|
|
print(f"[{timestamp}] {level}: {message}")
|
||
|
|
|
||
|
|
def create_styled_button(self, parent, text, command, bg_color, width=12, state=tk.NORMAL):
|
||
|
|
"""Create a styled button with hover effect."""
|
||
|
|
btn = tk.Button(
|
||
|
|
parent,
|
||
|
|
text=text,
|
||
|
|
font=("Segoe UI Semibold", 10),
|
||
|
|
width=width,
|
||
|
|
height=2,
|
||
|
|
bg=bg_color,
|
||
|
|
fg="white",
|
||
|
|
activebackground=self._adjust_color(bg_color, 0.85),
|
||
|
|
activeforeground="white",
|
||
|
|
relief=tk.FLAT,
|
||
|
|
cursor="hand2",
|
||
|
|
command=command,
|
||
|
|
state=state,
|
||
|
|
bd=0,
|
||
|
|
highlightthickness=0
|
||
|
|
)
|
||
|
|
# Hover effects
|
||
|
|
btn.bind("<Enter>", lambda e, b=btn, c=bg_color: b.configure(bg=self._adjust_color(c, 1.1)) if b['state'] != 'disabled' else None)
|
||
|
|
btn.bind("<Leave>", lambda e, b=btn, c=bg_color: b.configure(bg=c) if b['state'] != 'disabled' else None)
|
||
|
|
return btn
|
||
|
|
|
||
|
|
def _adjust_color(self, hex_color, factor):
|
||
|
|
"""Adjust color brightness. factor > 1 = lighter, < 1 = darker."""
|
||
|
|
hex_color = hex_color.lstrip('#')
|
||
|
|
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||
|
|
adjusted = tuple(min(255, max(0, int(c * factor))) for c in rgb)
|
||
|
|
return f"#{adjusted[0]:02x}{adjusted[1]:02x}{adjusted[2]:02x}"
|
||
|
|
|
||
|
|
def update_recording_indicator(self):
|
||
|
|
"""Animate the recording indicator dot."""
|
||
|
|
if self.is_recording and not self.is_paused:
|
||
|
|
self.recording_indicator_visible = not self.recording_indicator_visible
|
||
|
|
self.recording_dot.configure(
|
||
|
|
text="\u25cf" if self.recording_indicator_visible else "",
|
||
|
|
fg=self.colors['accent_red']
|
||
|
|
)
|
||
|
|
elif self.is_paused:
|
||
|
|
self.recording_dot.configure(text="\u25cf", fg=self.colors['accent_orange'])
|
||
|
|
else:
|
||
|
|
self.recording_dot.configure(text="")
|
||
|
|
self.root.after(500, self.update_recording_indicator)
|
||
|
|
|
||
|
|
def setup_ui(self):
|
||
|
|
c = self.colors
|
||
|
|
|
||
|
|
# Main container
|
||
|
|
main_frame = tk.Frame(self.root, bg=c['bg_dark'], padx=20, pady=16)
|
||
|
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
|
|
||
|
|
# Header with title
|
||
|
|
header_frame = tk.Frame(main_frame, bg=c['bg_dark'])
|
||
|
|
header_frame.pack(fill=tk.X, pady=(0, 12))
|
||
|
|
|
||
|
|
title = tk.Label(
|
||
|
|
header_frame,
|
||
|
|
text="Voice Recorder",
|
||
|
|
font=("Segoe UI Semibold", 16),
|
||
|
|
fg=c['text_primary'],
|
||
|
|
bg=c['bg_dark']
|
||
|
|
)
|
||
|
|
title.pack(side=tk.LEFT)
|
||
|
|
|
||
|
|
# Timer card with border effect
|
||
|
|
timer_outer = tk.Frame(main_frame, bg=c['border'])
|
||
|
|
timer_outer.pack(fill=tk.X, pady=(0, 12))
|
||
|
|
|
||
|
|
timer_card = tk.Frame(timer_outer, bg=c['bg_card'], padx=24, pady=20)
|
||
|
|
timer_card.pack(fill=tk.X, padx=1, pady=1)
|
||
|
|
|
||
|
|
# Recording indicator + timer in same row
|
||
|
|
timer_row = tk.Frame(timer_card, bg=c['bg_card'])
|
||
|
|
timer_row.pack()
|
||
|
|
|
||
|
|
self.recording_dot = tk.Label(
|
||
|
|
timer_row,
|
||
|
|
text="",
|
||
|
|
font=("Segoe UI", 8),
|
||
|
|
fg=c['accent_red'],
|
||
|
|
bg=c['bg_card'],
|
||
|
|
width=2
|
||
|
|
)
|
||
|
|
self.recording_dot.pack(side=tk.LEFT, padx=(0, 4))
|
||
|
|
|
||
|
|
self.timer_label = tk.Label(
|
||
|
|
timer_row,
|
||
|
|
text="00:00:00",
|
||
|
|
font=("Consolas", 38, "bold"),
|
||
|
|
fg=c['text_muted'],
|
||
|
|
bg=c['bg_card']
|
||
|
|
)
|
||
|
|
self.timer_label.pack(side=tk.LEFT)
|
||
|
|
|
||
|
|
self.status_label = tk.Label(
|
||
|
|
timer_card,
|
||
|
|
text="Initializing...",
|
||
|
|
font=("Segoe UI", 10),
|
||
|
|
fg=c['text_secondary'],
|
||
|
|
bg=c['bg_card']
|
||
|
|
)
|
||
|
|
self.status_label.pack(pady=(8, 0))
|
||
|
|
|
||
|
|
# Recording controls
|
||
|
|
controls_frame = tk.Frame(main_frame, bg=c['bg_dark'])
|
||
|
|
controls_frame.pack(pady=12)
|
||
|
|
|
||
|
|
self.record_btn = self.create_styled_button(
|
||
|
|
controls_frame, "Record", self.toggle_recording,
|
||
|
|
c['accent_red'], width=15, state=tk.DISABLED
|
||
|
|
)
|
||
|
|
self.record_btn.grid(row=0, column=0, padx=4)
|
||
|
|
|
||
|
|
self.pause_btn = self.create_styled_button(
|
||
|
|
controls_frame, "Pause", self.toggle_pause,
|
||
|
|
c['border'], width=15, state=tk.DISABLED
|
||
|
|
)
|
||
|
|
self.pause_btn.grid(row=0, column=1, padx=4)
|
||
|
|
|
||
|
|
# Separator
|
||
|
|
sep1 = tk.Frame(main_frame, bg=c['border'], height=1)
|
||
|
|
sep1.pack(fill=tk.X, pady=12)
|
||
|
|
|
||
|
|
# Note type selector
|
||
|
|
type_frame = tk.Frame(main_frame, bg=c['bg_dark'])
|
||
|
|
type_frame.pack(fill=tk.X)
|
||
|
|
|
||
|
|
type_header = tk.Frame(type_frame, bg=c['bg_dark'])
|
||
|
|
type_header.pack(fill=tk.X, pady=(0, 8))
|
||
|
|
|
||
|
|
type_label = tk.Label(
|
||
|
|
type_header,
|
||
|
|
text="Note Type",
|
||
|
|
font=("Segoe UI Semibold", 10),
|
||
|
|
fg=c['text_secondary'],
|
||
|
|
bg=c['bg_dark']
|
||
|
|
)
|
||
|
|
type_label.pack(side=tk.LEFT)
|
||
|
|
|
||
|
|
clear_btn = tk.Label(
|
||
|
|
type_header,
|
||
|
|
text="Clear",
|
||
|
|
font=("Segoe UI", 9),
|
||
|
|
fg=c['text_muted'],
|
||
|
|
bg=c['bg_dark'],
|
||
|
|
cursor="hand2"
|
||
|
|
)
|
||
|
|
clear_btn.pack(side=tk.RIGHT)
|
||
|
|
clear_btn.bind("<Button-1>", lambda e: self.selected_type.set(""))
|
||
|
|
clear_btn.bind("<Enter>", lambda e: clear_btn.configure(fg=c['text_secondary']))
|
||
|
|
clear_btn.bind("<Leave>", lambda e: clear_btn.configure(fg=c['text_muted']))
|
||
|
|
|
||
|
|
# Type buttons in a 2-row grid
|
||
|
|
type_buttons_frame = tk.Frame(type_frame, bg=c['bg_dark'])
|
||
|
|
type_buttons_frame.pack(fill=tk.X)
|
||
|
|
|
||
|
|
self.type_buttons = {}
|
||
|
|
col = 0
|
||
|
|
for type_id, type_info in NOTE_TYPES.items():
|
||
|
|
btn = tk.Radiobutton(
|
||
|
|
type_buttons_frame,
|
||
|
|
text=type_info['label'],
|
||
|
|
variable=self.selected_type,
|
||
|
|
value=type_id,
|
||
|
|
font=("Segoe UI", 9),
|
||
|
|
fg=c['text_secondary'],
|
||
|
|
bg=c['bg_card'],
|
||
|
|
selectcolor=type_info['color'],
|
||
|
|
activebackground=c['bg_card'],
|
||
|
|
activeforeground=c['text_primary'],
|
||
|
|
indicatoron=0,
|
||
|
|
width=11,
|
||
|
|
height=2,
|
||
|
|
relief=tk.FLAT,
|
||
|
|
bd=0,
|
||
|
|
highlightthickness=1,
|
||
|
|
highlightbackground=c['border'],
|
||
|
|
highlightcolor=type_info['color'],
|
||
|
|
cursor="hand2"
|
||
|
|
)
|
||
|
|
btn.grid(row=col // 3, column=col % 3, padx=2, pady=2, sticky="ew")
|
||
|
|
self.type_buttons[type_id] = btn
|
||
|
|
col += 1
|
||
|
|
|
||
|
|
for i in range(3):
|
||
|
|
type_buttons_frame.columnconfigure(i, weight=1)
|
||
|
|
|
||
|
|
# Separator
|
||
|
|
sep2 = tk.Frame(main_frame, bg=c['border'], height=1)
|
||
|
|
sep2.pack(fill=tk.X, pady=12)
|
||
|
|
|
||
|
|
# Action buttons
|
||
|
|
actions_frame = tk.Frame(main_frame, bg=c['bg_dark'])
|
||
|
|
actions_frame.pack(fill=tk.X)
|
||
|
|
|
||
|
|
self.save_btn = self.create_styled_button(
|
||
|
|
actions_frame, "Save to Obsidian", self.save_to_obsidian,
|
||
|
|
c['accent_purple'], width=42, state=tk.DISABLED
|
||
|
|
)
|
||
|
|
self.save_btn.pack(fill=tk.X, pady=(0, 6))
|
||
|
|
|
||
|
|
self.claude_btn = self.create_styled_button(
|
||
|
|
actions_frame, "Process with Claude", self.process_with_claude,
|
||
|
|
c['accent_blue'], width=42, state=tk.DISABLED
|
||
|
|
)
|
||
|
|
self.claude_btn.pack(fill=tk.X)
|
||
|
|
|
||
|
|
# Auto-process checkbox
|
||
|
|
auto_frame = tk.Frame(main_frame, bg=c['bg_dark'])
|
||
|
|
auto_frame.pack(fill=tk.X, pady=(10, 0))
|
||
|
|
|
||
|
|
self.auto_check = tk.Checkbutton(
|
||
|
|
auto_frame,
|
||
|
|
text="Auto-process after save",
|
||
|
|
variable=self.auto_process,
|
||
|
|
font=("Segoe UI", 9),
|
||
|
|
fg=c['text_muted'],
|
||
|
|
bg=c['bg_dark'],
|
||
|
|
selectcolor=c['bg_card'],
|
||
|
|
activebackground=c['bg_dark'],
|
||
|
|
activeforeground=c['text_secondary'],
|
||
|
|
cursor="hand2"
|
||
|
|
)
|
||
|
|
self.auto_check.pack(anchor="w")
|
||
|
|
|
||
|
|
# Output folder link
|
||
|
|
folder_btn = tk.Label(
|
||
|
|
main_frame,
|
||
|
|
text=f"Open output folder",
|
||
|
|
font=("Segoe UI", 9, "underline"),
|
||
|
|
fg=c['accent_blue'],
|
||
|
|
bg=c['bg_dark'],
|
||
|
|
cursor="hand2"
|
||
|
|
)
|
||
|
|
folder_btn.pack(pady=(8, 12))
|
||
|
|
folder_btn.bind("<Button-1>", lambda e: self.open_output_folder())
|
||
|
|
|
||
|
|
# Log section
|
||
|
|
log_header = tk.Frame(main_frame, bg=c['bg_dark'])
|
||
|
|
log_header.pack(fill=tk.X)
|
||
|
|
|
||
|
|
log_label = tk.Label(
|
||
|
|
log_header,
|
||
|
|
text="Activity",
|
||
|
|
font=("Segoe UI Semibold", 10),
|
||
|
|
fg=c['text_secondary'],
|
||
|
|
bg=c['bg_dark']
|
||
|
|
)
|
||
|
|
log_label.pack(anchor="w")
|
||
|
|
|
||
|
|
# Log text area with border
|
||
|
|
log_outer = tk.Frame(main_frame, bg=c['border'])
|
||
|
|
log_outer.pack(fill=tk.BOTH, expand=True, pady=(6, 0))
|
||
|
|
|
||
|
|
self.log_text = scrolledtext.ScrolledText(
|
||
|
|
log_outer,
|
||
|
|
height=5,
|
||
|
|
font=("Consolas", 9),
|
||
|
|
bg=c['bg_card'],
|
||
|
|
fg=c['text_muted'],
|
||
|
|
relief=tk.FLAT,
|
||
|
|
state=tk.DISABLED,
|
||
|
|
wrap=tk.WORD,
|
||
|
|
padx=10,
|
||
|
|
pady=8,
|
||
|
|
insertbackground=c['text_primary']
|
||
|
|
)
|
||
|
|
self.log_text.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)
|
||
|
|
|
||
|
|
# Configure log colors
|
||
|
|
self.log_text.tag_configure("INFO", foreground=c['text_muted'])
|
||
|
|
self.log_text.tag_configure("OK", foreground=c['accent_green'])
|
||
|
|
self.log_text.tag_configure("WARN", foreground=c['accent_orange'])
|
||
|
|
self.log_text.tag_configure("ERROR", foreground=c['accent_red'])
|
||
|
|
|
||
|
|
def check_dependencies(self):
|
||
|
|
"""Check if required dependencies are installed."""
|
||
|
|
self.log("Checking dependencies...")
|
||
|
|
|
||
|
|
# Check sounddevice
|
||
|
|
try:
|
||
|
|
import sounddevice as sd
|
||
|
|
self.sd = sd
|
||
|
|
self.log(f"sounddevice {sd.__version__}", "OK")
|
||
|
|
except ImportError as e:
|
||
|
|
self.log(f"sounddevice: NOT FOUND", "ERROR")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check numpy
|
||
|
|
try:
|
||
|
|
import numpy as np
|
||
|
|
self.np = np
|
||
|
|
self.log(f"numpy {np.__version__}", "OK")
|
||
|
|
except ImportError as e:
|
||
|
|
self.log(f"numpy: NOT FOUND", "ERROR")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check audio devices
|
||
|
|
try:
|
||
|
|
input_device = sd.query_devices(kind='input')
|
||
|
|
self.log(f"Mic: {input_device['name'][:30]}", "OK")
|
||
|
|
except Exception as e:
|
||
|
|
self.log(f"Audio device error: {e}", "ERROR")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check whisper
|
||
|
|
try:
|
||
|
|
import whisper
|
||
|
|
self.whisper = whisper
|
||
|
|
self.log(f"whisper ({MODEL})", "OK")
|
||
|
|
except ImportError as e:
|
||
|
|
self.log(f"whisper: NOT FOUND", "ERROR")
|
||
|
|
|
||
|
|
# Check torch/CUDA
|
||
|
|
try:
|
||
|
|
import torch
|
||
|
|
if torch.cuda.is_available():
|
||
|
|
gpu_name = torch.cuda.get_device_name(0)[:25]
|
||
|
|
self.log(f"CUDA: {gpu_name}", "OK")
|
||
|
|
else:
|
||
|
|
self.log("CUDA: CPU mode", "WARN")
|
||
|
|
except Exception as e:
|
||
|
|
self.log(f"torch: {e}", "WARN")
|
||
|
|
|
||
|
|
# Check Claude CLI
|
||
|
|
try:
|
||
|
|
result = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=5)
|
||
|
|
if result.returncode == 0:
|
||
|
|
self.log("Claude CLI ready", "OK")
|
||
|
|
else:
|
||
|
|
self.log("Claude CLI: error", "WARN")
|
||
|
|
except FileNotFoundError:
|
||
|
|
self.log("Claude CLI: NOT FOUND", "WARN")
|
||
|
|
except Exception as e:
|
||
|
|
self.log(f"Claude: {e}", "WARN")
|
||
|
|
|
||
|
|
# All good - enable recording
|
||
|
|
self.record_btn.configure(state=tk.NORMAL)
|
||
|
|
self.status_label.configure(text="Ready to record", fg=self.colors['accent_green'])
|
||
|
|
self.log("Ready!", "OK")
|
||
|
|
|
||
|
|
def toggle_recording(self):
|
||
|
|
if not self.is_recording:
|
||
|
|
self.start_recording()
|
||
|
|
else:
|
||
|
|
self.stop_recording()
|
||
|
|
|
||
|
|
def start_recording(self):
|
||
|
|
self.log("Starting recording...")
|
||
|
|
|
||
|
|
try:
|
||
|
|
self.is_recording = True
|
||
|
|
self.is_paused = False
|
||
|
|
self.audio_data = []
|
||
|
|
self.start_time = datetime.now()
|
||
|
|
self.elapsed_time = 0
|
||
|
|
|
||
|
|
self.record_btn.configure(text="Stop", bg=self.colors['border'])
|
||
|
|
self.pause_btn.configure(state=tk.NORMAL, bg=self.colors['accent_orange'])
|
||
|
|
self.save_btn.configure(state=tk.DISABLED)
|
||
|
|
self.claude_btn.configure(state=tk.DISABLED)
|
||
|
|
self.status_label.configure(text="Recording...", fg=self.colors['accent_red'])
|
||
|
|
self.timer_label.configure(fg=self.colors['text_primary'])
|
||
|
|
|
||
|
|
self.stream = self.sd.InputStream(
|
||
|
|
samplerate=SAMPLE_RATE,
|
||
|
|
channels=CHANNELS,
|
||
|
|
callback=self.audio_callback
|
||
|
|
)
|
||
|
|
self.stream.start()
|
||
|
|
self.log("Recording started", "OK")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
self.log(f"Failed to start: {e}", "ERROR")
|
||
|
|
self.is_recording = False
|
||
|
|
self.record_btn.configure(text="Record", bg=self.colors['accent_red'])
|
||
|
|
|
||
|
|
def stop_recording(self):
|
||
|
|
self.log("Stopping...")
|
||
|
|
|
||
|
|
self.is_recording = False
|
||
|
|
self.is_paused = False
|
||
|
|
|
||
|
|
if self.stream:
|
||
|
|
self.stream.stop()
|
||
|
|
self.stream.close()
|
||
|
|
self.stream = None
|
||
|
|
|
||
|
|
self.record_btn.configure(text="Record", bg=self.colors['accent_red'])
|
||
|
|
self.pause_btn.configure(state=tk.DISABLED, text="Pause", bg=self.colors['border'])
|
||
|
|
self.timer_label.configure(fg=self.colors['accent_green'])
|
||
|
|
|
||
|
|
if self.audio_data:
|
||
|
|
self.log(f"Captured {len(self.audio_data)} chunks", "INFO")
|
||
|
|
self.save_temp_audio()
|
||
|
|
self.save_btn.configure(state=tk.NORMAL)
|
||
|
|
self.status_label.configure(text="Ready to save", fg=self.colors['accent_green'])
|
||
|
|
else:
|
||
|
|
self.log("No audio captured!", "WARN")
|
||
|
|
self.status_label.configure(text="No audio", fg=self.colors['accent_orange'])
|
||
|
|
|
||
|
|
def toggle_pause(self):
|
||
|
|
if not self.is_paused:
|
||
|
|
self.is_paused = True
|
||
|
|
self.pause_start = datetime.now()
|
||
|
|
self.pause_btn.configure(text="Resume", bg=self.colors['accent_green'])
|
||
|
|
self.status_label.configure(text="Paused", fg=self.colors['accent_orange'])
|
||
|
|
self.timer_label.configure(fg=self.colors['accent_orange'])
|
||
|
|
if self.stream:
|
||
|
|
self.stream.stop()
|
||
|
|
self.log("Paused", "INFO")
|
||
|
|
else:
|
||
|
|
self.is_paused = False
|
||
|
|
if self.pause_start:
|
||
|
|
from datetime import timedelta
|
||
|
|
self.start_time = datetime.now() - timedelta(seconds=self.elapsed_time)
|
||
|
|
self.pause_start = None
|
||
|
|
self.pause_btn.configure(text="Pause", bg=self.colors['accent_orange'])
|
||
|
|
self.status_label.configure(text="Recording...", fg=self.colors['accent_red'])
|
||
|
|
self.timer_label.configure(fg=self.colors['text_primary'])
|
||
|
|
if self.stream:
|
||
|
|
self.stream.start()
|
||
|
|
self.log("Resumed", "INFO")
|
||
|
|
|
||
|
|
def audio_callback(self, indata, frames, time, status):
|
||
|
|
if status:
|
||
|
|
self.root.after(0, lambda: self.log(f"Audio: {status}", "WARN"))
|
||
|
|
if self.is_recording and not self.is_paused:
|
||
|
|
self.audio_data.append(indata.copy())
|
||
|
|
|
||
|
|
def save_temp_audio(self):
|
||
|
|
"""Save recorded audio to a temporary WAV file."""
|
||
|
|
if not self.audio_data:
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
audio_array = self.np.concatenate(self.audio_data, axis=0)
|
||
|
|
self.temp_audio_path = os.path.join(tempfile.gettempdir(), "voice_recording.wav")
|
||
|
|
|
||
|
|
with wave.open(self.temp_audio_path, 'wb') as wf:
|
||
|
|
wf.setnchannels(CHANNELS)
|
||
|
|
wf.setsampwidth(2)
|
||
|
|
wf.setframerate(SAMPLE_RATE)
|
||
|
|
audio_int16 = (audio_array * 32767).astype(self.np.int16)
|
||
|
|
wf.writeframes(audio_int16.tobytes())
|
||
|
|
|
||
|
|
file_size = os.path.getsize(self.temp_audio_path) / 1024
|
||
|
|
self.log(f"Saved: {file_size:.1f} KB", "OK")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
self.log(f"Save failed: {e}", "ERROR")
|
||
|
|
|
||
|
|
def save_to_obsidian(self):
|
||
|
|
"""Transcribe and save to Obsidian."""
|
||
|
|
if not self.temp_audio_path or not os.path.exists(self.temp_audio_path):
|
||
|
|
messagebox.showerror("Error", "No recording to save!")
|
||
|
|
return
|
||
|
|
|
||
|
|
self.log("Transcribing...", "INFO")
|
||
|
|
self.save_btn.configure(state=tk.DISABLED, text="Transcribing...")
|
||
|
|
self.claude_btn.configure(state=tk.DISABLED)
|
||
|
|
self.status_label.configure(text="Transcribing...", fg=self.colors['accent_orange'])
|
||
|
|
self.root.update()
|
||
|
|
|
||
|
|
thread = threading.Thread(target=self._transcribe_and_save)
|
||
|
|
thread.start()
|
||
|
|
|
||
|
|
def _transcribe_and_save(self):
|
||
|
|
"""Background transcription task."""
|
||
|
|
try:
|
||
|
|
timestamp = datetime.now().strftime("%Y-%m-%d %H-%M")
|
||
|
|
date_today = datetime.now().strftime("%Y-%m-%d")
|
||
|
|
time_now = datetime.now().strftime("%H:%M")
|
||
|
|
|
||
|
|
# Include type in filename if selected
|
||
|
|
type_id = self.selected_type.get()
|
||
|
|
|
||
|
|
self.root.after(0, lambda: self.log(f"Model: {MODEL}", "INFO"))
|
||
|
|
|
||
|
|
if self.whisper_model is None:
|
||
|
|
self.root.after(0, lambda: self.log("Loading model...", "INFO"))
|
||
|
|
self.whisper_model = self.whisper.load_model(MODEL)
|
||
|
|
self.root.after(0, lambda: self.log("Model loaded!", "OK"))
|
||
|
|
|
||
|
|
self.root.after(0, lambda: self.log("Transcribing...", "INFO"))
|
||
|
|
result = self.whisper_model.transcribe(self.temp_audio_path)
|
||
|
|
transcript = result["text"].strip()
|
||
|
|
|
||
|
|
self.last_transcript = transcript
|
||
|
|
self.root.after(0, lambda: self.log(f"Transcript: {len(transcript)} chars", "OK"))
|
||
|
|
|
||
|
|
# Determine output folder based on type
|
||
|
|
folder_name = FOLDERS.get(type_id, FOLDERS["default"])
|
||
|
|
output_dir = BASE_OUTPUT_DIR / folder_name
|
||
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
# Check if we should append to daily note (for Daily type)
|
||
|
|
if type_id == "daily" and DAILY_NOTE_ENABLED:
|
||
|
|
note_name = DAILY_NOTE_PATTERN.format(date=date_today)
|
||
|
|
output_path = output_dir / note_name
|
||
|
|
|
||
|
|
if output_path.exists():
|
||
|
|
# Append to existing daily note - add to history section
|
||
|
|
existing_content = output_path.read_text(encoding='utf-8')
|
||
|
|
|
||
|
|
# Add new transcript to history (append at the end)
|
||
|
|
new_entry = f"""
|
||
|
|
### {time_now}
|
||
|
|
|
||
|
|
{transcript}
|
||
|
|
"""
|
||
|
|
updated_content = existing_content.rstrip() + "\n" + new_entry
|
||
|
|
output_path.write_text(updated_content, encoding='utf-8')
|
||
|
|
self.root.after(0, lambda: self.log(f"Appended to: {note_name}", "OK"))
|
||
|
|
self.is_new_daily_note = False # Appending, not new
|
||
|
|
|
||
|
|
else:
|
||
|
|
# Create new daily note with clean structure
|
||
|
|
note_content = f"""---
|
||
|
|
created: {datetime.now().strftime("%Y-%m-%d %H:%M")}
|
||
|
|
type: daily-todo
|
||
|
|
status: active
|
||
|
|
tags:
|
||
|
|
- daily
|
||
|
|
- todo
|
||
|
|
---
|
||
|
|
|
||
|
|
# Daily Tasks - {date_today}
|
||
|
|
|
||
|
|
## Compiled Tasks
|
||
|
|
|
||
|
|
_En attente du traitement Claude..._
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Transcripts
|
||
|
|
|
||
|
|
### {time_now}
|
||
|
|
|
||
|
|
{transcript}
|
||
|
|
"""
|
||
|
|
output_path.write_text(note_content, encoding='utf-8')
|
||
|
|
self.root.after(0, lambda: self.log(f"Created: {note_name}", "OK"))
|
||
|
|
self.is_new_daily_note = True # New daily - will carry over yesterday's tasks
|
||
|
|
|
||
|
|
self.last_note_path = output_path
|
||
|
|
self.is_daily_note = True
|
||
|
|
|
||
|
|
else:
|
||
|
|
# Standard behavior - create new note
|
||
|
|
self.is_daily_note = False
|
||
|
|
|
||
|
|
if type_id and type_id in NOTE_TYPES:
|
||
|
|
type_info = NOTE_TYPES[type_id]
|
||
|
|
note_name = f"{type_info['label']} - {timestamp}.md"
|
||
|
|
else:
|
||
|
|
note_name = f"Voice Note {timestamp}.md"
|
||
|
|
|
||
|
|
# Build tags based on selected type
|
||
|
|
tags = ["transcript", "voice-memo"]
|
||
|
|
if type_id:
|
||
|
|
tags.append(type_id)
|
||
|
|
|
||
|
|
tags_yaml = "\n".join([f" - {tag}" for tag in tags])
|
||
|
|
|
||
|
|
note_content = f"""---
|
||
|
|
created: {datetime.now().strftime("%Y-%m-%d %H:%M")}
|
||
|
|
type: voice-note
|
||
|
|
status: raw
|
||
|
|
tags:
|
||
|
|
{tags_yaml}
|
||
|
|
---
|
||
|
|
|
||
|
|
# {note_name.replace('.md', '')}
|
||
|
|
|
||
|
|
## Metadata
|
||
|
|
|
||
|
|
- **Duration:** {self.format_time(self.elapsed_time)}
|
||
|
|
- **Transcribed:** {datetime.now().strftime("%Y-%m-%d %H:%M")}
|
||
|
|
{f"- **Type:** {NOTE_TYPES[type_id]['label']}" if type_id else ""}
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Raw Transcript
|
||
|
|
|
||
|
|
{transcript}
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Notes distillees
|
||
|
|
|
||
|
|
_Cliquer sur "Process with Claude" pour organiser automatiquement_
|
||
|
|
"""
|
||
|
|
output_path = output_dir / note_name
|
||
|
|
output_path.write_text(note_content, encoding='utf-8')
|
||
|
|
self.last_note_path = output_path
|
||
|
|
self.root.after(0, lambda: self.log(f"Saved: {note_name}", "OK"))
|
||
|
|
|
||
|
|
self.root.after(0, lambda n=note_name: self._save_complete(n))
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
error_msg = str(e)
|
||
|
|
self.root.after(0, lambda msg=error_msg: self.log(f"Error: {msg}", "ERROR"))
|
||
|
|
self.root.after(0, lambda msg=error_msg: self._show_error(msg))
|
||
|
|
|
||
|
|
def _save_complete(self, note_name):
|
||
|
|
"""Called when save is complete."""
|
||
|
|
self.save_btn.configure(text="Save to Obsidian", state=tk.DISABLED)
|
||
|
|
self.timer_label.configure(text="00:00:00")
|
||
|
|
self.elapsed_time = 0
|
||
|
|
self.audio_data = []
|
||
|
|
|
||
|
|
# Auto-process with Claude if enabled
|
||
|
|
if self.auto_process.get():
|
||
|
|
self.log("Auto-processing with Claude...", "INFO")
|
||
|
|
self.status_label.configure(text="Auto-processing...", fg=self.colors['accent_blue'])
|
||
|
|
self.claude_btn.configure(state=tk.DISABLED)
|
||
|
|
# Small delay to let UI update, then process
|
||
|
|
self.root.after(100, self.process_with_claude)
|
||
|
|
else:
|
||
|
|
self.claude_btn.configure(state=tk.NORMAL)
|
||
|
|
self.status_label.configure(text="Saved! Ready for Claude", fg=self.colors['accent_green'])
|
||
|
|
self.log("Click 'Process with Claude'", "INFO")
|
||
|
|
|
||
|
|
def _build_context_content(self, context_files: list) -> str:
|
||
|
|
"""Build context content from PKM files."""
|
||
|
|
context_parts = []
|
||
|
|
max_chars_per_file = 2000 # Limit each file to avoid huge prompts
|
||
|
|
|
||
|
|
for file_path in context_files:
|
||
|
|
try:
|
||
|
|
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||
|
|
# Truncate if too long
|
||
|
|
if len(content) > max_chars_per_file:
|
||
|
|
content = content[:max_chars_per_file] + "\n[...]"
|
||
|
|
|
||
|
|
rel_path = file_path.relative_to(OBSIDIAN_VAULT) if OBSIDIAN_VAULT in file_path.parents else file_path.name
|
||
|
|
context_parts.append(f"### {rel_path}\n\n{content}")
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
|
||
|
|
return "\n\n---\n\n".join(context_parts)
|
||
|
|
|
||
|
|
def process_with_claude(self):
|
||
|
|
"""Process the transcript with Claude CLI."""
|
||
|
|
if not self.last_transcript:
|
||
|
|
messagebox.showerror("Error", "No transcript to process!")
|
||
|
|
return
|
||
|
|
|
||
|
|
self.log("Calling Claude...", "INFO")
|
||
|
|
self.claude_btn.configure(state=tk.DISABLED, text="Processing...")
|
||
|
|
self.status_label.configure(text="Claude processing...", fg=self.colors['accent_blue'])
|
||
|
|
self.root.update()
|
||
|
|
|
||
|
|
thread = threading.Thread(target=self._process_with_claude)
|
||
|
|
thread.start()
|
||
|
|
|
||
|
|
def _process_with_claude(self):
|
||
|
|
"""Background Claude processing task."""
|
||
|
|
try:
|
||
|
|
import re
|
||
|
|
|
||
|
|
# Build type-specific instruction
|
||
|
|
type_id = self.selected_type.get()
|
||
|
|
if type_id and type_id in NOTE_TYPES:
|
||
|
|
type_instruction = NOTE_TYPES[type_id]['prompt']
|
||
|
|
else:
|
||
|
|
type_instruction = "Analyse le contenu et détermine le type de note (meeting, idées, tâches, réflexion, etc.) puis organise en conséquence."
|
||
|
|
|
||
|
|
# For daily notes, get ALL transcripts from Transcripts section to compile
|
||
|
|
transcript_to_process = self.last_transcript
|
||
|
|
if hasattr(self, 'is_daily_note') and self.is_daily_note and self.last_note_path and self.last_note_path.exists():
|
||
|
|
note_content = self.last_note_path.read_text(encoding='utf-8')
|
||
|
|
# Extract all content after "## Transcripts" header
|
||
|
|
transcripts_match = re.search(r'## Transcripts\s*\n(.*)', note_content, re.DOTALL)
|
||
|
|
if transcripts_match:
|
||
|
|
transcript_to_process = transcripts_match.group(1).strip()
|
||
|
|
self.root.after(0, lambda: self.log("Compiling all transcripts...", "INFO"))
|
||
|
|
|
||
|
|
# Gather PKM context files
|
||
|
|
# Pass is_new_daily to only fetch yesterday's tasks on first daily creation
|
||
|
|
is_new_daily = getattr(self, 'is_new_daily_note', False)
|
||
|
|
context_files = gather_pkm_context(type_id, is_new_daily=is_new_daily)
|
||
|
|
if context_files:
|
||
|
|
self.root.after(0, lambda n=len(context_files): self.log(f"PKM context: {n} files", "OK"))
|
||
|
|
|
||
|
|
prompt = CLAUDE_BASE_PROMPT.format(
|
||
|
|
type_instruction=type_instruction,
|
||
|
|
transcript=transcript_to_process
|
||
|
|
)
|
||
|
|
|
||
|
|
self.root.after(0, lambda: self.log("Waiting for Claude...", "INFO"))
|
||
|
|
|
||
|
|
# Set UTF-8 encoding for Windows
|
||
|
|
env = os.environ.copy()
|
||
|
|
env['PYTHONIOENCODING'] = 'utf-8'
|
||
|
|
|
||
|
|
# Build Claude CLI command with context
|
||
|
|
cmd = ["claude", "-p", prompt, "--output-format", "text"]
|
||
|
|
|
||
|
|
# Add context files using --add-dir for each file's directory
|
||
|
|
# or read files and include in prompt if --add-dir doesn't work well
|
||
|
|
if context_files:
|
||
|
|
# Include context as part of the prompt for reliability
|
||
|
|
context_content = self._build_context_content(context_files)
|
||
|
|
if context_content:
|
||
|
|
prompt = f"""CONTEXTE DE TON PKM (notes récentes et pertinentes):
|
||
|
|
|
||
|
|
{context_content}
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
{prompt}"""
|
||
|
|
cmd = ["claude", "-p", prompt, "--output-format", "text"]
|
||
|
|
|
||
|
|
result = subprocess.run(
|
||
|
|
cmd,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
encoding='utf-8',
|
||
|
|
errors='replace',
|
||
|
|
timeout=180,
|
||
|
|
env=env
|
||
|
|
)
|
||
|
|
|
||
|
|
if result.returncode != 0:
|
||
|
|
error = result.stderr or "Unknown error"
|
||
|
|
self.root.after(0, lambda e=error: self.log(f"Claude error: {e[:50]}", "ERROR"))
|
||
|
|
self.root.after(0, lambda: self._claude_error("Claude returned an error"))
|
||
|
|
return
|
||
|
|
|
||
|
|
processed_notes = result.stdout.strip()
|
||
|
|
|
||
|
|
if not processed_notes:
|
||
|
|
self.root.after(0, lambda: self._claude_error("Empty response"))
|
||
|
|
return
|
||
|
|
|
||
|
|
self.root.after(0, lambda: self.log(f"Response: {len(processed_notes)} chars", "OK"))
|
||
|
|
|
||
|
|
if self.last_note_path and self.last_note_path.exists():
|
||
|
|
note_content = self.last_note_path.read_text(encoding='utf-8')
|
||
|
|
|
||
|
|
# Check if this is a daily note (Daily type)
|
||
|
|
if hasattr(self, 'is_daily_note') and self.is_daily_note:
|
||
|
|
# For daily notes, update the Compiled Tasks section
|
||
|
|
# Replace content between "## Compiled Tasks" and "---"
|
||
|
|
pattern = r'(## Compiled Tasks\s*\n).*?(\n---)'
|
||
|
|
replacement = f'\\1\n{processed_notes}\n\\2'
|
||
|
|
updated_content = re.sub(pattern, replacement, note_content, flags=re.DOTALL)
|
||
|
|
updated_content = updated_content.replace("status: active", "status: updated")
|
||
|
|
|
||
|
|
else:
|
||
|
|
# Standard note processing
|
||
|
|
old_section = """## Notes distillees
|
||
|
|
|
||
|
|
_Cliquer sur "Process with Claude" pour organiser automatiquement_"""
|
||
|
|
|
||
|
|
new_section = f"""## Notes distillees
|
||
|
|
|
||
|
|
{processed_notes}"""
|
||
|
|
|
||
|
|
updated_content = note_content.replace(old_section, new_section)
|
||
|
|
updated_content = updated_content.replace("status: raw", "status: processed")
|
||
|
|
|
||
|
|
self.last_note_path.write_text(updated_content, encoding='utf-8')
|
||
|
|
self.root.after(0, lambda: self.log("Note updated!", "OK"))
|
||
|
|
|
||
|
|
self.root.after(0, self._claude_complete)
|
||
|
|
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
self.root.after(0, lambda: self.log("Claude timeout", "ERROR"))
|
||
|
|
self.root.after(0, lambda: self._claude_error("Timeout (>3 min)"))
|
||
|
|
except Exception as e:
|
||
|
|
error_msg = str(e)
|
||
|
|
self.root.after(0, lambda msg=error_msg: self.log(f"Error: {msg}", "ERROR"))
|
||
|
|
self.root.after(0, lambda msg=error_msg: self._claude_error(msg))
|
||
|
|
|
||
|
|
def _claude_complete(self):
|
||
|
|
"""Called when Claude processing is complete."""
|
||
|
|
self.claude_btn.configure(text="Process with Claude", state=tk.DISABLED)
|
||
|
|
self.status_label.configure(text="Done!", fg=self.colors['accent_green'])
|
||
|
|
self.log("Processing complete!", "OK")
|
||
|
|
messagebox.showinfo("Success", f"Note processed!\n\n{self.last_note_path.name}")
|
||
|
|
|
||
|
|
def _claude_error(self, message):
|
||
|
|
"""Called when Claude processing fails."""
|
||
|
|
self.claude_btn.configure(text="Process with Claude", state=tk.NORMAL)
|
||
|
|
self.status_label.configure(text="Claude error", fg=self.colors['accent_red'])
|
||
|
|
messagebox.showerror("Claude Error", message)
|
||
|
|
|
||
|
|
def _show_error(self, message):
|
||
|
|
"""Show error message."""
|
||
|
|
self.save_btn.configure(text="Save to Obsidian", state=tk.NORMAL)
|
||
|
|
self.status_label.configure(text="Error", fg=self.colors['accent_red'])
|
||
|
|
messagebox.showerror("Error", message)
|
||
|
|
|
||
|
|
def update_timer(self):
|
||
|
|
"""Update the timer display."""
|
||
|
|
if self.is_recording and not self.is_paused:
|
||
|
|
self.elapsed_time = (datetime.now() - self.start_time).total_seconds()
|
||
|
|
self.timer_label.configure(text=self.format_time(self.elapsed_time))
|
||
|
|
self.root.after(100, self.update_timer)
|
||
|
|
|
||
|
|
def format_time(self, seconds):
|
||
|
|
"""Format seconds as HH:MM:SS."""
|
||
|
|
hours = int(seconds // 3600)
|
||
|
|
minutes = int((seconds % 3600) // 60)
|
||
|
|
secs = int(seconds % 60)
|
||
|
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||
|
|
|
||
|
|
def open_output_folder(self):
|
||
|
|
"""Open the output folder in explorer."""
|
||
|
|
BASE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
|
os.startfile(BASE_OUTPUT_DIR)
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
root = tk.Tk()
|
||
|
|
app = VoiceRecorder(root)
|
||
|
|
root.mainloop()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|