Files
Anto01 659bc7fb2e Add Voice Recorder - Whisper transcription tool for Obsidian
Features:
- Audio recording with pause/resume and visual feedback
- Local Whisper transcription (tiny/base/small models)
- 7 note types: instructions, capture, meeting, idea, daily, review, journal
- Claude CLI integration for intelligent note processing
- PKM context integration (reads vault files for better processing)
- Auto-organization into type-specific folders
- Daily notes with yesterday's task carryover
- Language-adaptive responses (matches transcript language)
- Custom icon and Windows desktop shortcut helpers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:51:53 -05:00

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()