Skip to content

Commit 1980a6f

Browse files
committed
feat(memo): implement voice memo plugin with push-to-talk, transcription, and AI restructuring
- Add voice memo plugin with hold-to-record global shortcut (Cmd+Shift+M) - Add floating overlay window for recording when app is unfocused - Implement Web Speech API live transcription with Whisper API fallback - Add custom audio waveform pillbox player - Fix push-to-talk press/release race conditions - Add macOS microphone permission (Info.plist) - Add memoEnabled setting with auto-upgrade for existing users
1 parent b9a2973 commit 1980a6f

21 files changed

Lines changed: 1435 additions & 16 deletions

AUDIT_LOG.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,74 @@
22

33
This log tracks all significant changes, updates, and versions in the PaperCache project.
44

5+
## 2026-07-01 (Web Speech API Transcription Priority & UI Responsiveness Fix)
6+
**Change:** fix(memo): prioritize Web Speech API live transcription over Whisper API fallback to avoid 401 Unauthorized errors with OpenRouter API keys, and stop click propagation in MemoVoicePanel to prevent focus trapping and editor squishing.
7+
8+
**Details/Why:**
9+
When the user recorded a voice note, `MemoVoicePanel` previously always attempted to call Whisper (`openaiTranscribe`) first whenever an API key was configured. If the configured API key was for OpenRouter (`sk-or-v1-...`), the request failed with `401 Unauthorized: Incorrect API key provided`, overwriting the natural Web Speech API transcription with a large error message and skipping AI command restructuring (`openAIChat`). Furthermore, clicking inside the voice memo results panel caused the main app container to steal focus back to CodeMirror, while unbounded panel height squished the editor, making it feel frozen and blocking note deletion or editing. Updated `MemoVoicePanel` to use the Web Speech API transcription directly without invoking Whisper (unless Web Speech API captured nothing), added event propagation stopping to keep focus stable, and capped panel max-height to ensure the editor remains fully accessible.
10+
11+
**Files changed:** `src/components/MemoVoicePanel.tsx`, `src/App.css`, `AUDIT_LOG.md`, `CHANGELOG.md`.
12+
13+
---
14+
15+
## 2026-07-01 (IPC Parameter Key Mapping Fix for `read_asset`)
16+
**Change:** fix(api): map `assetPath` argument to `{ path: assetPath }` in `tauriApi.readAsset`
17+
18+
**Details/Why:**
19+
The Tauri Rust backend command `pub async fn read_asset(path: String)` expects an argument object keyed by `path`. The frontend wrapper in `src/api.ts` was passing `{ assetPath }`, resulting in a Tauri IPC error (`invalid args 'path' for command 'read_asset': command read_asset missing required key path`) whenever the app attempted to load saved voice recordings or image assets. Updated `src/api.ts` to pass `{ path: assetPath }`.
20+
21+
**Files changed:** `src/api.ts`, `AUDIT_LOG.md`, `CHANGELOG.md`.
22+
23+
---
24+
25+
## 2026-07-01 (Voice Memo Overlay Visibility, Race Conditions & CSP Fixes)
26+
**Change:** fix(memo): show overlay window before recording/processing, fix early release race condition before getUserMedia resolves, update CSP `media-src` to permit local audio playback, and ensure processing/error states stay visible
27+
28+
**Details/Why:**
29+
1. **Overlay Window Visibility (`MemoVoicePanel.tsx`)**: When recording started via global shortcut while the main app was hidden, the floating `voice-indicator` window received the recording event but remained hidden (`visible: false` in `tauri.conf.json`). Added `getCurrentWindow().show()` when starting recording or entering processing/done states when `isOverlay` is true so the user always sees the live indicator and playback pill.
30+
2. **Push-to-Talk Race Condition**: If the user released `Cmd+Shift+M` before `getUserMedia` finished initializing, `trigger-voice-memo-release` ignored the release because `panelStateRef.current` was still `'idle'`. Removed early returns from release handlers and added delay scheduling so quick taps reliably capture audio and stop recording cleanly.
31+
3. **Audio Playback CSP (`tauri.conf.json`)**: Updated Content Security Policy to include `media-src 'self' data: blob: file: https:;` and expanded `img-src`/`connect-src` so recorded audio pills can load and play without browser CSP blocks.
32+
33+
**Files changed:** `src/components/MemoVoicePanel.tsx`, `src-tauri/tauri.conf.json`, `AUDIT_LOG.md`, `CHANGELOG.md`.
34+
35+
---
36+
37+
## 2026-07-01 (Voice Memo macOS Audio Permission, IPC Listener Stability & Default State Fix)
38+
**Change:** fix(memo): add `NSMicrophoneUsageDescription` in `Info.plist`, eliminate IPC listener re-registration race conditions using stable refs, ensure `memoEnabled` defaults to true, and require window focus before intercepting shortcuts
39+
40+
**Details/Why:**
41+
1. **macOS Microphone Permission (`Info.plist`)**: Created `src-tauri/Info.plist` containing `NSMicrophoneUsageDescription` and configured `"infoPlist": "Info.plist"` in `tauri.conf.json`. Without this explicit plist description, macOS CoreAudio / TCC blocks WKWebView `getUserMedia` requests.
42+
2. **IPC Event Listener Stability & Race Conditions**: Replaced stateful `panelState` dependencies in `MemoVoicePanel.tsx` with stable `panelStateRef` and `isRecordingRequestedRef`. This prevents event listeners from unregistering and dropping `trigger-voice-memo-release` events over IPC when transitioning from idle to recording. Added explicit error rendering card when microphone access fails instead of silently returning to idle.
43+
3. **Default State & Window Focus Routing**: Updated `useSettingsStore` and added an auto-upgrade in `App.tsx` so voice memos are enabled by default (`memoEnabled: true`). Updated `shortcuts.rs` so that if `main_win` is unfocused or hidden while the user holds `Cmd+Shift+M`, recording routes to the bottom-left floating overlay window.
44+
45+
**Files changed:** `src-tauri/Info.plist` [NEW], `src-tauri/tauri.conf.json`, `src-tauri/src/commands/shortcuts.rs`, `src/store/useSettingsStore.ts`, `src/App.tsx`, `src/components/MemoVoicePanel.tsx`, `AUDIT_LOG.md`.
46+
47+
---
48+
49+
## 2026-07-01 (Voice Memo Push-to-Talk Press/Release Fix & Stop Button Removal)
50+
**Change:** fix(memo): resolve global shortcut push-to-talk press vs release events (`Cmd+Shift+M`), remove unnecessary stop button, prevent empty audio blobs on quick release, and add explicit error reporting for AI transcription
51+
52+
**Details/Why:**
53+
1. **Push-to-Talk Press & Release Handling**: Updated global shortcut registration (`shortcuts.rs`) to emit distinct `trigger-voice-memo-press` on key press and `trigger-voice-memo-release` on key release. Updated `MemoVoicePanel.tsx` to ignore keyboard auto-repeats while recording and stop recording upon key release.
54+
2. **Stop Button Removal**: Removed the `■ Stop` button from the recording pillbox since push-to-talk recording automatically stops when releasing `Cmd+Shift+M`.
55+
3. **Audio Integrity & Error Reporting**: Added `stopRecordingSafe` to ensure at least 400ms of audio is captured on rapid key release, preventing 0-byte unplayable audio blobs. Updated `openai_transcribe` base URL logic and added explicit frontend error display if transcription or AI interpretation fails.
56+
57+
**Files changed:** `src-tauri/src/commands/shortcuts.rs`, `src/components/MemoVoicePanel.tsx`, `AUDIT_LOG.md`.
58+
59+
---
60+
61+
## 2026-07-01 (Voice Memo Plugin & Floating Indicator Overlay)
62+
**Change:** feat(memo): implement voice memo plugin support with hold-to-record global shortcut (`Cmd+Shift+M`), custom waveform pillbox player, floating background overlay indicator, and AI restructuring
63+
64+
**Details/Why:**
65+
1. **Hold-to-Record & Global Shortcut**: Registered `Cmd+Shift+M` global shortcut. Added floating overlay window (`voice-indicator`) that appears in the bottom-left corner when PaperCache is hidden or unfocused, displaying real-time recording waveform and status.
66+
2. **Custom Audio Pillbox & Waveform Player**: Replaced standard mic icon and default `<audio>` element with a custom sleek pillbox featuring a play/pause button (`AudioWaveformPill`) and dynamic CSS waveform animation (`.memo-waveform-visual`, `.memo-wave-bar`).
67+
3. **Transcription & AI Processing**: Captured speech is transcribed and rendered in gray slanted italic text (`.memo-gray-slanted`), then processed via user's configured AI model with PaperCache slash command context to produce structured action items inserted directly into the active note.
68+
69+
**Files changed:** `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json`, `src-tauri/src/commands/ai.rs`, `src-tauri/src/commands/fs.rs`, `src-tauri/src/commands/shortcuts.rs`, `src-tauri/src/lib.rs`, `src/App.css`, `src/App.tsx`, `src/main.tsx`, `src/components/MemoVoicePanel.tsx` [NEW], `src/components/Editor.tsx`, `src/hooks/useGlobalHotkey.ts`, `src/Settings.tsx`, `src/store/useSettingsStore.ts`, `src/api.ts`, `src/types.d.ts`, `src/setupTests.ts`, `CHANGELOG.md`, `AUDIT_LOG.md`.
70+
71+
---
72+
573
## 2026-07-01 (v0.5.9 Release: Image Support & UI Consistency)
674
**Change:** feat(release): bump version to 0.5.9; implement image paste support and markdown image widget; align background blur and font typography across modals and timers; extract audio recording features to external project
775

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Voice Memo Plugin & Floating Overlay**: Press and hold `Command+Shift+M` from anywhere to record voice notes. When PaperCache is hidden or unfocused, a floating bottom-left waveform pillbox indicator displays your live recording status.
12+
- **Custom Waveform Player & AI Restructuring**: Voice notes feature sleek audio playback pillboxes with animated waveforms, live gray slanted transcriptions, and automatic restructuring into structured action items using your configured AI model and PaperCache slash commands.
13+
14+
15+
### Fixed
16+
- **Voice Memo Transcription Priority & Responsiveness**: Fixed an issue where recording a voice note with an OpenRouter API key configured resulted in `401 Unauthorized` errors because the app always attempted Whisper API transcription before AI restructuring. Voice memos now prioritize the natural Web Speech API transcription directly and send it to your configured AI model (`openAIChat`) to format PaperCache slash commands.
17+
- **Voice Panel Focus Trapping & Editor Layout**: Fixed an issue where clicking inside the voice memo result box caused the app container to force focus away, while large result blocks squished the note editor. Added click propagation stopping and maximum panel height limits so you can easily type under voice notes, delete notes, and interact with the editor normally.
18+
- **Asset Reading (`read_asset`) IPC Mapping**: Fixed an issue where reading saved voice note audio files or pasted images threw `invalid args 'path' for command 'read_asset'` due to a parameter key mismatch between the frontend and Tauri backend.
19+
- **Floating Overlay Visibility (`Command+Shift+M`)**: Fixed an issue where recording via global shortcut while the main app was hidden recorded audio in the background but failed to reveal the floating bottom-left waveform player and transcript result. The overlay indicator window now automatically shows and focuses when recording or processing voice notes.
20+
- **Push-to-Talk Race Condition & Audio Playback**: Fixed an issue where releasing `Command+Shift+M` immediately before microphone access resolved would ignore the release event. Also updated Content Security Policy (`media-src`) to allow recorded audio waveform pills to play smoothly.
21+
- **macOS Microphone Permission (`Info.plist`)**: Added `NSMicrophoneUsageDescription` in the macOS app bundle `Info.plist` so macOS CoreAudio properly grants microphone access instead of silently blocking audio recording.
22+
- **Push-to-Talk Press & Release Recording**: Fixed an issue where holding `Command+Shift+M` immediately stopped recording due to keyboard auto-repeat events. Replaced unnecessary Stop button with seamless press-to-record and release-to-stop behavior.
23+
- **Audio & Transcription Error Display**: Resolved 0-byte audio creation on rapid shortcut release and ensured explicit error messages appear in the transcript block if API keys or endpoints fail. Also ensured the voice memo plugin is enabled by default for all users.
1024

1125
## [v0.5.9] - 2026-07-01
1226

src-tauri/Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ tauri-plugin-updater = "2.0.0"
1818
tauri-plugin-global-shortcut = "2.0.0"
1919
tauri-plugin-dialog = "2.0.0"
2020

21-
reqwest = { version = "0.11", features = ["json", "stream"] }
21+
reqwest = { version = "0.11", features = ["json", "stream", "multipart"] }
2222
tokio = { version = "1", features = ["full"] }
2323
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
2424
serde = { version = "1", features = ["derive"] }

src-tauri/Info.plist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSMicrophoneUsageDescription</key>
6+
<string>PaperCache requires microphone access to record voice notes and memos.</string>
7+
</dict>
8+
</plist>

src-tauri/src/commands/ai.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use keyring::Entry;
2+
use reqwest::multipart::{Form, Part};
23
use reqwest::Client;
34
use serde_json::json;
45

@@ -68,3 +69,94 @@ pub async fn openai_chat(
6869
.await
6970
.map_err(|e| format!("Failed to parse API response: {}", e))
7071
}
72+
73+
#[tauri::command]
74+
pub async fn openai_transcribe(
75+
file_path: String,
76+
base_url: String,
77+
) -> Result<String, String> {
78+
if file_path.trim().is_empty() {
79+
return Err("Invalid file path provided".into());
80+
}
81+
82+
let entry = Entry::new(SERVICE_NAME, "openai_api_key")
83+
.map_err(|e| format!("Failed to access keyring: {}", e))?;
84+
let api_key = entry
85+
.get_password()
86+
.map_err(|_| "API key not found. Please set it in settings.".to_string())?;
87+
88+
let resolved_path = if std::path::Path::new(&file_path).exists() {
89+
std::path::PathBuf::from(&file_path)
90+
} else {
91+
let clean = file_path.trim_start_matches('/');
92+
crate::commands::fs::get_papercache_dir()
93+
.map_err(|e| format!("Failed to get app directory: {}", e))?
94+
.join(clean)
95+
};
96+
97+
let file_bytes = tokio::fs::read(&resolved_path)
98+
.await
99+
.map_err(|e| format!("Failed to read audio file ({}): {}", resolved_path.display(), e))?;
100+
101+
let file_name = std::path::Path::new(&file_path)
102+
.file_name()
103+
.and_then(|n| n.to_str())
104+
.unwrap_or("audio.webm")
105+
.to_string();
106+
107+
let client = Client::new();
108+
109+
let mut base = if base_url.is_empty()
110+
|| base_url.contains("openrouter.ai")
111+
|| base_url.contains("googleapis.com")
112+
|| base_url.contains("anthropic.com")
113+
{
114+
DEFAULT_BASE_URL.to_string()
115+
} else {
116+
base_url.trim_end_matches('/').to_string()
117+
};
118+
if !base.ends_with("/audio/transcriptions") {
119+
base.push_str("/audio/transcriptions");
120+
}
121+
122+
let part = Part::bytes(file_bytes)
123+
.file_name(file_name)
124+
.mime_str("application/octet-stream")
125+
.map_err(|e| format!("Failed to create multipart part: {}", e))?;
126+
127+
let form = Form::new()
128+
.part("file", part)
129+
.text("model", "whisper-1");
130+
131+
let response = client
132+
.post(&base)
133+
.header("Authorization", format!("Bearer {}", api_key))
134+
.multipart(form)
135+
.send()
136+
.await
137+
.map_err(|e| format!("Network request failed: {}", e))?;
138+
139+
if !response.status().is_success() {
140+
let status = response.status();
141+
let error_text = response
142+
.text()
143+
.await
144+
.unwrap_or_else(|_| "Unknown error".to_string());
145+
return Err(format!(
146+
"Transcription API request failed with status {}: {}",
147+
status, error_text
148+
));
149+
}
150+
151+
let res_json: serde_json::Value = response
152+
.json()
153+
.await
154+
.map_err(|e| format!("Failed to parse transcription API response: {}", e))?;
155+
156+
if let Some(text) = res_json.get("text").and_then(|t| t.as_str()) {
157+
Ok(text.to_string())
158+
} else {
159+
Err("No transcript returned from API".to_string())
160+
}
161+
}
162+

src-tauri/src/commands/fs.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,9 +534,10 @@ pub async fn save_asset(data_base64: String, ext: String, folder: String) -> Res
534534
let prefix = folder_name.trim_start_matches('.');
535535

536536
// Generate unique filename with random suffix to avoid collisions
537-
use rand::Rng;
538-
let mut rng = rand::thread_rng();
539-
let random_suffix: u32 = rng.gen();
537+
let random_suffix: u32 = {
538+
use rand::Rng;
539+
rand::thread_rng().gen()
540+
};
540541
let filename = format!("{}_{}_{:08x}.{}", prefix, timestamp, random_suffix, clean_ext);
541542
let file_path = asset_dir.join(&filename);
542543

src-tauri/src/commands/shortcuts.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,43 @@ impl Default for GlobalShortcutState {
1515
}
1616
}
1717

18-
fn handle_shortcut_trigger(app: &AppHandle, action: &str) {
18+
fn handle_shortcut_trigger(app: &AppHandle, action: &str, state: ShortcutState) {
19+
if action == "voice-memo" {
20+
let event_name = if state == ShortcutState::Pressed {
21+
"trigger-voice-memo-press"
22+
} else {
23+
"trigger-voice-memo-release"
24+
};
25+
let mut handled = false;
26+
if let Some(main_win) = app.get_webview_window("main") {
27+
if main_win.is_visible().unwrap_or(false) && main_win.is_focused().unwrap_or(false) {
28+
let _ = main_win.emit(event_name, ());
29+
handled = true;
30+
}
31+
}
32+
if !handled {
33+
if let Some(ind_win) = app.get_webview_window("voice-indicator") {
34+
if state == ShortcutState::Pressed {
35+
if let Ok(Some(monitor)) = ind_win.current_monitor() {
36+
let size = monitor.size();
37+
let scale = monitor.scale_factor();
38+
let logical_height = size.height as f64 / scale;
39+
let x = 20.0;
40+
let y = logical_height - 350.0;
41+
let _ = ind_win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
42+
}
43+
let _ = ind_win.show();
44+
}
45+
let _ = ind_win.emit(event_name, ());
46+
}
47+
}
48+
return;
49+
}
50+
51+
if state != ShortcutState::Pressed {
52+
return;
53+
}
54+
1955
if action == "new-note" {
2056
if let Some(window) = app.get_webview_window("main") {
2157
if !window.is_visible().unwrap_or(false) {
@@ -54,9 +90,7 @@ pub fn update_global_shortcut(
5490
let action_clone = action.clone();
5591
app.global_shortcut()
5692
.on_shortcut(shortcut, move |app, _shortcut, event| {
57-
if event.state() == ShortcutState::Pressed {
58-
handle_shortcut_trigger(app, &action_clone);
59-
}
93+
handle_shortcut_trigger(app, &action_clone, event.state());
6094
})
6195
.map_err(|e| format!("Failed to register shortcut: {}", e))?;
6296
}
@@ -87,9 +121,7 @@ pub fn resume_shortcuts(app: AppHandle) -> Result<(), String> {
87121
let _ = app
88122
.global_shortcut()
89123
.on_shortcut(shortcut, move |app, _, event| {
90-
if event.state() == ShortcutState::Pressed {
91-
handle_shortcut_trigger(app, &action_clone);
92-
}
124+
handle_shortcut_trigger(app, &action_clone, event.state());
93125
});
94126
}
95127
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ pub fn run() {
178178
commands::keychain::safe_storage_encrypt,
179179
commands::keychain::safe_storage_decrypt,
180180
commands::ai::openai_chat,
181+
commands::ai::openai_transcribe,
181182
commands::shortcuts::update_global_shortcut,
182183
commands::shortcuts::pause_shortcuts,
183184
commands::shortcuts::resume_shortcuts,

0 commit comments

Comments
 (0)