A cross-platform voice-to-text desktop application built with Laravel, NativePHP, and whisper.cpp. Record your voice, transcribe it locally using OpenAI's Whisper model, and automatically type the text wherever your cursor is.
- Menu bar application - Runs quietly in your system tray/menu bar
- Global keyboard shortcut - Toggle recording from anywhere (configurable, default:
Alt+W) - Floating recorder window - Shows audio visualization while recording
- Local transcription - Uses whisper.cpp for 100% offline, private transcription
- Auto-type - Transcribed text is automatically typed at your cursor position
- Settings panel - Toggle notifications, auto-paste, and floating window
- GPU acceleration - Utilizes Metal on macOS for fast transcription
- Cross-platform - Works on macOS, Linux, and Windows
- PHP 8.2+
- Node.js 18+
- Composer
- FFmpeg (for audio conversion)
- whisper.cpp (for transcription)
git clone https://github.com/yourusername/larawisper.git
cd larawispercomposer installnpm installChoose your operating system:
# Install FFmpeg and whisper.cpp
brew install ffmpeg whisper-cpp
# Verify installation
ffmpeg -version
whisper-cli --help# Install FFmpeg
sudo apt update
sudo apt install ffmpeg
# Install whisper.cpp from source
git clone https://github.com/ggerganov/whisper.cpp.git
cd whisper.cpp
make
sudo cp main /usr/local/bin/whisper-cli
cd ..
# For auto-paste functionality, install xdotool (X11) or wtype (Wayland)
sudo apt install xdotool # For X11
# OR
sudo apt install wtype # For Wayland-
Install FFmpeg:
- Download from ffmpeg.org
- Extract to
C:\ffmpeg - Add
C:\ffmpeg\binto your PATH environment variable
-
Install whisper.cpp:
- Download pre-built binaries from whisper.cpp releases
- Extract to
C:\whisper-cpp - Add to PATH or set
WISPER_WHISPER_PATHin.env
-
Verify installation:
ffmpeg -version whisper-cli --help
Download a Whisper model file to storage/app/models/ (for development) or extras/models/ (for production builds):
# Create models directory
mkdir -p storage/app/models
mkdir -p extras/models
# Download base.en model (recommended, ~150MB)
curl -L -o storage/app/models/ggml-base.en.bin \
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"
# For production builds, also copy to extras (bundled with app)
cp storage/app/models/ggml-base.en.bin extras/models/Note: Model files are not included in git due to size (~150MB). You must download them after cloning.
Available models:
| Model | Size | Description |
|---|---|---|
tiny.en |
~75MB | Fastest, lower accuracy |
base.en |
~150MB | Recommended balance |
small.en |
~500MB | Better accuracy |
medium.en |
~1.5GB | High accuracy |
large |
~3GB | Best accuracy, slowest |
cp .env.example .env
php artisan key:generateEdit .env if needed:
# Optional: Custom binary paths (if not in PATH)
WISPER_FFMPEG_PATH=/path/to/ffmpeg
WISPER_WHISPER_PATH=/path/to/whisper-cli
# Optional: Change the model
WISPER_MODEL=base.en
# Optional: Change the keyboard shortcut
WISPER_SHORTCUT=Ctrl+Shift+Space
# Optional: Disable auto-paste
WISPER_AUTO_PASTE=truenpm run buildphp artisan native:serve- Start the app - Look for the Wisper icon in your menu bar/system tray
- Click the icon or press
Ctrl+Shift+Spaceto start recording - Speak your message
- Click again or press the shortcut to stop recording
- Wait for transcription (usually 2-5 seconds)
- Text is automatically pasted at your cursor position
All configuration options are in config/wisper.php:
| Option | Default | Description |
|---|---|---|
model |
base.en |
Whisper model to use |
shortcut |
Ctrl+Shift+Space |
Global keyboard shortcut |
language |
en |
Transcription language |
auto_paste |
true |
Auto-paste after transcription |
max_recording_seconds |
60 |
Maximum recording duration |
ffmpeg_path |
ffmpeg |
Path to FFmpeg binary |
whisper_path |
whisper-cli |
Path to whisper-cli binary |
- Microphone: Automatically prompted on first use
- Accessibility: Required for auto-paste. Go to:
System Settings > Privacy & Security > Accessibilityand enable the app
- Ensure your user has access to the microphone
- For auto-paste:
xdotool(X11) orwtype(Wayland) must be installed
- Microphone permission will be requested on first use
- Run as administrator if auto-paste doesn't work
- Ensure FFmpeg is installed and accessible
- Check the path:
which ffmpeg(macOS/Linux) orwhere ffmpeg(Windows) - Set explicit path in
.env:WISPER_FFMPEG_PATH=/full/path/to/ffmpeg
- Ensure whisper-cli is installed and accessible
- Verify the model file exists in
storage/app/models/ - Check logs:
cat storage/logs/laravel.log | tail -50
- Try a different shortcut in
.env:WISPER_SHORTCUT=Alt+Space - On macOS, some shortcuts may conflict with system functions
- macOS: Grant Accessibility permission in System Settings
- Linux: Install
xdotoolorwtype - Windows: Try running as administrator
# Run in development mode with hot reload
npm run dev
# In another terminal
php artisan native:serve
# Run tests
php artisan test
# Format code
vendor/bin/pint# Build for current platform
php artisan native:build
# Build for specific platform
php artisan native:build --os=mac
php artisan native:build --os=linux
php artisan native:build --os=win- Backend: Laravel 12, PHP 8.3
- Desktop: NativePHP Desktop v2 (Electron)
- Transcription: whisper.cpp (local, offline)
- Audio: Web MediaRecorder API
- Styling: Tailwind CSS v4
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Electron Shell β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββββββ β
β β Menu Bar β β Floating β β Global Shortcut β β
β β Window β β Window β β Listener β β
β ββββββββ¬βββββββ ββββββββ¬βββββββ βββββββββββββ¬ββββββββββββββ β
βββββββββββΌβββββββββββββββββΌβββββββββββββββββββββββΌβββββββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Laravel Backend (PHP) β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β Recording β β Transcription β β Event β β
β β Controller β β Service β β Broadcasting β β
β ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ β
βββββββββββββΌβββββββββββββββββββββΌββββββββββββββββββββββΌββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββββββ
β Web Audio β β whisper-cpp β β ChildProcess β
β Recording β β (Local AI) β β (AppleScript/ β
β (Browser) β β β β xdotool/PS) β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββββββ
This section explains the complete flow from application boot to text being typed.
When you run php artisan native:serve, NativePHP starts an Electron shell that runs a Laravel server. The entry point is NativeAppServiceProvider::boot():
File: app/Providers/NativeAppServiceProvider.php
public function boot(): void
{
// 1. Create menu bar (system tray) with context menu
MenuBar::create()
->route('recording') // Loads recording.blade.php
->withContextMenu(Menu::make(...)); // Right-click shows Quit option
// 2. Create floating window (transparent, always-on-top)
Window::open('floating-recorder')
->route('floating-recorder') // Loads floating-recorder.blade.php
->alwaysOnTop()
->frameless()
->transparent();
// 3. Register global keyboard shortcut
GlobalShortcut::key('Alt+W') // Works even when app is not focused
->event(RecordingToggled::class) // Dispatches this event
->register();
}When the shortcut is pressed anywhere on the system:
- Electron detects the key combination
- NativePHP dispatches the
RecordingToggledevent - The event broadcasts on the
nativephpchannel - Frontend JavaScript receives it via
Native.on()
File: app/Events/RecordingToggled.php
class RecordingToggled implements ShouldBroadcastNow
{
public function broadcastOn(): array
{
return [new Channel('nativephp')];
}
}File: resources/views/recording.blade.php (JavaScript)
// Wait for NativePHP to be ready
window.addEventListener('native:init', function() {
// Listen for the shortcut event
Native.on('App\\Events\\RecordingToggled', function() {
toggleRecording(); // Start or stop recording
});
});The toggleRecording() function uses the Web MediaRecorder API:
async function toggleRecording() {
if (!isRecording) {
// Request microphone access
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Create MediaRecorder with WebM format
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
mediaRecorder.start();
isRecording = true;
// Notify backend to update UI state
fetch('/api/recording-state', {
method: 'POST',
body: JSON.stringify({ recording: true, state: 'recording' })
});
}
}The backend receives the state update and broadcasts it:
File: app/Http/Controllers/TranscriptionController.php
public function setRecordingState(Request $request): JsonResponse
{
$state = $request->input('state'); // 'recording', 'processing', or 'idle'
// Store in cache for polling fallback
cache()->put('wisper_recording_state', $state, 300);
// Update menu bar icon
MenuBar::label($isRecording ? 'π΄' : '');
// Broadcast state to floating window
FloatingWindowState::dispatch($state);
return response()->json(['success' => true]);
}File: app/Events/FloatingWindowState.php
class FloatingWindowState implements ShouldBroadcastNow
{
public function __construct(public string $state) {}
public function broadcastOn(): array
{
return [new Channel('nativephp')];
}
}The floating window receives the state two ways:
-
Event Broadcasting (primary):
Native.on('App\\Events\\FloatingWindowState', function(payload) { setState(payload.state); // Update UI });
-
HTTP Polling (fallback, every 200ms):
setInterval(async function() { const response = await fetch('/api/recording-state'); const data = await response.json(); if (data.state !== currentState) { setState(data.state); } }, 200);
function toggleRecording() {
if (isRecording) {
mediaRecorder.stop(); // Triggers ondataavailable event
mediaRecorder.ondataavailable = async (event) => {
// Create blob from recorded audio
const audioBlob = new Blob([event.data], { type: 'audio/webm' });
// Update state to "processing"
await fetch('/api/recording-state', {
body: JSON.stringify({ state: 'processing' })
});
// Send audio to backend for transcription
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const response = await fetch('/api/transcribe', {
method: 'POST',
body: formData
});
const result = await response.json();
// Text is now typed at cursor (handled by backend)
};
}
}File: app/Http/Controllers/TranscriptionController.php
public function transcribe(Request $request): JsonResponse
{
// Save uploaded audio file
$audioPath = $request->file('audio')->store('recordings');
// Transcribe using Whisper
$text = $this->transcriptionService->transcribe($audioPath);
// Copy to clipboard and auto-type
$this->transcriptionService->setClipboardAndPaste($text);
return response()->json(['success' => true, 'text' => $text]);
}File: app/Services/TranscriptionService.php
public function transcribe(string $audioPath): string
{
// Step 1: Convert WebM to 16kHz WAV (required by Whisper)
$wavPath = $this->convertToWav($audioPath);
// Command: ffmpeg -i input.webm -ar 16000 -ac 1 output.wav
// Step 2: Run whisper-cpp CLI
$command = sprintf(
'%s --model "%s" --language %s "%s"',
$this->getWhisperPath(), // /opt/homebrew/bin/whisper-cli
$this->getModelPath(), // storage/app/models/ggml-base.en.bin
config('wisper.language'), // 'en'
$wavPath
);
Process::timeout(120)->run($command);
// Step 3: Read transcription from output file
$text = file_get_contents($wavPath . '.txt');
return trim($text);
}After transcription, the text is typed at the cursor position:
public function setClipboardAndPaste(string $text): void
{
// Copy to system clipboard
Clipboard::text($text);
// Show notification (if enabled)
if (config('wisper.notifications_enabled')) {
Notification::title('Text Copied!')->message($text)->show();
}
// Auto-type at cursor (if enabled)
if (config('wisper.auto_paste')) {
$this->typeText($text);
}
}The typeText() method uses OS-specific approaches because ChildProcess doesn't support command chaining (&& or ;):
macOS (AppleScript):
// Write script to temp file
$script = "delay 0.5\ntell application \"System Events\" to keystroke \"$text\"";
file_put_contents('/tmp/larawisper_type.scpt', $script);
// Execute with osascript
ChildProcess::start(cmd: '/usr/bin/osascript /tmp/larawisper_type.scpt');Linux (xdotool):
// Write bash script
$script = "#!/bin/bash\nsleep 0.5\nxdotool type --clearmodifiers '$text'";
file_put_contents('/tmp/larawisper_type.sh', $script);
chmod('/tmp/larawisper_type.sh', 0755);
ChildProcess::start(cmd: '/bin/bash /tmp/larawisper_type.sh');Windows (PowerShell):
// Write PowerShell script
$script = "Start-Sleep -Milliseconds 500\n";
$script .= "Add-Type -AssemblyName System.Windows.Forms\n";
$script .= "[System.Windows.Forms.SendKeys]::SendWait(\"$text\")";
file_put_contents('C:\temp\larawisper_type.ps1', $script);
ChildProcess::start(cmd: 'powershell -ExecutionPolicy Bypass -File C:\temp\larawisper_type.ps1');After typing completes, the state is set back to idle:
// After transcription completes
await fetch('/api/recording-state', {
body: JSON.stringify({ state: 'idle' })
});The floating window fades out and the menu bar icon clears.
larawisper/
βββ app/
β βββ Events/
β β βββ RecordingToggled.php # Dispatched on global shortcut
β β βββ FloatingWindowState.php # Updates floating window UI
β βββ Http/Controllers/
β β βββ TranscriptionController.php # API endpoints
β βββ Providers/
β β βββ NativeAppServiceProvider.php # NativePHP boot configuration
β βββ Services/
β βββ TranscriptionService.php # Whisper integration & auto-type
βββ config/
β βββ wisper.php # App configuration
βββ resources/
β βββ js/
β β βββ audio-recorder.js # Shared recording utilities
β βββ views/
β βββ recording.blade.php # Menu bar window UI
β βββ floating-recorder.blade.php # Floating indicator UI
βββ routes/
β βββ web.php # API routes
βββ storage/app/
β βββ models/ # Whisper model files (.bin)
β βββ recordings/ # Temporary audio files
βββ docs/
βββ nativephp-reference.md # NativePHP API reference
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/transcribe |
Upload audio file, returns transcription |
GET |
/api/status |
Check if Whisper model is ready |
POST |
/api/recording-state |
Update recording state (recording/processing/idle) |
GET |
/api/recording-state |
Get current state (for polling) |
POST |
/api/settings |
Update user preferences |
POST |
/api/open-accessibility-settings |
Open macOS Accessibility settings |
| File | Purpose |
|---|---|
NativeAppServiceProvider.php |
Boot configuration: menu bar, windows, shortcuts |
TranscriptionController.php |
API endpoints for recording and transcription |
TranscriptionService.php |
Audio conversion, Whisper CLI, auto-typing |
RecordingToggled.php |
Event dispatched when shortcut is pressed |
FloatingWindowState.php |
Event to update floating window state |
recording.blade.php |
Main menu bar window with settings |
floating-recorder.blade.php |
Floating indicator with waveform visualization |
config/wisper.php |
All configurable options |
MIT License - see LICENSE for details.
- OpenAI Whisper - Speech recognition model
- whisper.cpp - C++ port of Whisper
- NativePHP - Desktop app framework for Laravel
- FFmpeg - Audio processing