Production-ready voice dictation application with Google OAuth authentication, built with Go backend and Electron desktop app.
- 🎤 Voice-to-Text - Groq Whisper Large v3 Turbo for fast, accurate transcription
- 🤖 AI Cleanup - Filler removal, punctuation, and formatting via Groq Llama 4 Scout
- 🌐 Multi-language - 12 languages supported end-to-end (transcription + cleanup)
- 🔐 Google OAuth - Exchange-code auth flow with nonce-validated redirect
- ⚡ Low-latency hot path - Desktop calls Groq directly; backend only for auth + storage
- 🖥️ Cross-platform - Native desktop apps for Mac (signed builds) and Windows
- ⌨️ Global push-to-talk - Hold
fnkey to record via a macOS CGEventTap native module - 📝 Auto-paste - Simulated
Cmd+Vinjects cleaned text at your cursor - 🎯 Jargon-preserving - Cleanup prompt keeps proper nouns, brand names, and code verbatim
- 📊 Usage tracking - Dashboard with transcription history and stats
- 💳 Free plan - 3,000 words/month included
| Light | Dark |
|---|---|
![]() |
![]() |
See docs/HOW_IT_WORKS.md for a walkthrough of the push-to-talk hot path.
- Go (Gin) - High-performance API server
- MongoDB - NoSQL database for users, transcriptions, settings
- Groq API - Whisper Large v3 Turbo (STT) + Llama 4 Scout (cleanup)
- Google OAuth 2.0 - Authentication with exchange-code + client-nonce flow
- JWT - Stateless token-based auth; 32-byte secret minimum enforced at startup
- Electron - Cross-platform desktop framework
- React + TypeScript - UI framework
- Vite - Build tool
- electron-store - Local persistence for tokens and settings
- Native C++/Objective-C module - macOS
fnkey capture via CGEventTap
airtype/
├── backend/ # Go API server
├── desktop/ # Electron desktop app
├── infrastructure/ # Docker & deployment configs
└── docker-compose.yml # Local development setup
You'll need Go 1.21+, Node 18+, Docker, a Groq API key, and a Google OAuth client (Web application, redirect URI http://localhost:3001/api/auth/google/callback).
1. Install
git clone https://github.com/codecube47/airtype.git && cd airtype
docker-compose up -d mongodb
(cd backend && go mod download)
(cd desktop && npm install)2. Configure
cp backend/.env.example backend/.env
# fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GROQ_API_KEY
# generate JWT_SECRET with: openssl rand -base64 323. Run
# terminal 1
cd backend && go run cmd/server/main.go
# terminal 2
cd desktop && npm run dev4. Use it — sign in with Google, grant mic + accessibility permissions when prompted, then hold fn and speak. Cleaned text pastes at your cursor in under 500ms.
Longer walkthroughs: SETUP.md · docs/HOW_IT_WORKS.md.
GET /api/auth/google/login?clientNonce=<nonce>- Get Google OAuth URL.clientNonceis echoed back in the airtype:// redirect and validated by the desktop app as a CSRF defense against reflected protocol invocations.GET /api/auth/google/callback- OAuth callback handler. Redirects toairtype://auth/callback?code=<exchange_code>&nonce=<clientNonce>— tokens are NOT put in the redirect URL.POST /api/auth/exchange- One-time redemption of exchange code for{accessToken, refreshToken}. Code is single-use and expires after 2 minutes.POST /api/auth/refresh- Refresh access token (rejects suspended users).GET /api/auth/me- Get current user (protected).
POST /api/transcribe- Upload audio, get transcription (protected, rate-limited: 30 req/min per user, burst 10).POST /api/transcriptions/save- Save a pre-transcribed result from direct Groq calls (protected, rate-limited: same limits).GET /api/transcriptions?page=N&limit=M- Paginated history (protected).GET /api/transcriptions/stats- Aggregate stats + free-plan usage (protected).
GET /api/settings- Get user settings (language, autoFormat, removeFillers, customPrompt).PUT /api/settings- Update settings. Debounced-synced from the desktop app.GET /api/config- Returns Groq API credentials + cleanup prompt for the desktop to call Groq directly (protected). Note: this exposes the sharedGROQ_API_KEYto authenticated clients — a deliberate tradeoff to keep audio out of the backend on the hot path.
- JWT_SECRET minimum length: 32 bytes enforced at startup. Server refuses to boot with a weaker secret.
- OAuth flow: exchange-code + client-nonce (tokens never appear in redirect URL, browser history, or server access logs).
- Rate limiting: in-memory per-user token bucket on transcription endpoints. Swap for Redis if horizontally scaled.
- Token storage (desktop):
electron-storewith a passphrase — this is obfuscation, not true encryption. Acceptable for a single-user desktop context, not for shared machines. - Prompt injection: user-dictated text is wrapped in
<<< … >>>delimiters before the LLM call, and the cleanup prompt instructs the model to treat those contents as data only.
cd backend
# Run with hot reload
go install github.com/cosmtrek/air@latest
air
# Run tests
go test ./...
# Format code
go fmt ./...
# Lint
golangci-lint runcd desktop
# Development mode
npm run dev
# Build for production
npm run build
# Package for distribution
npm run package# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop all services
docker-compose downRailway is the recommended platform for deploying the backend. Build is Dockerfile-based (backend/Dockerfile), healthcheck is /health, config lives in backend/railway.json.
Use backend/deploy.sh for the initial deploy. It reads backend/.env.prod, links (or creates) a Railway project, provisions MongoDB, syncs every GOOGLE_* / GROQ_* / JWT_* variable, generates the Railway domain, and sets GOOGLE_REDIRECT_URL automatically.
brew install railway # or: npm install -g @railway/cli
railway login
cd backend
./deploy.sh
⚠️ deploy.shfalls back toJWT_SECRET=$(openssl rand -base64 32)if.env.proddoesn't define one. If you re-rundeploy.shlater without aJWT_SECRETline in.env.prod, it regenerates the secret — invalidating every existing user's session. For routine re-deploys, userailway upbelow, not./deploy.sh.
Once the project is linked and variables are set, the minimal redeploy is:
cd backend
railway up # or: railway up --detach (don't stream logs)
railway logs --build # watch the Docker build
railway logs # runtime logs after deployThis ships the current working tree (no git push required), doesn't touch environment variables, and avoids the JWT_SECRET regeneration risk above.
- Push your code to GitHub
- Go to railway.app and sign in
- Create New Project → Deploy from GitHub repo
- Set root directory to
backend - Add MongoDB from the Railway dashboard
- Set environment variables in the dashboard
-
Update Google OAuth: Add your Railway URL to Google Cloud Console:
- Authorized redirect URIs:
https://your-app.railway.app/api/auth/google/callback
- Authorized redirect URIs:
-
Update Desktop App: Edit
desktop/.env.production(this is the file Vite reads for--mode productionbuilds;desktop/.envis dev-only):VITE_API_URL=https://your-app.railway.app/api -
Rebuild the desktop app (
make package-mac) — the URL is baked into the packaged bundle.
railway logs # View logs
railway open # Open dashboard
railway up # Deploy changes
railway status # Check deployment statuscd backend
# Build for current platform
go build -o airtype-server cmd/server/main.go
# Build for Linux (cloud servers)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o airtype-server cmd/server/main.go
# Build with Docker
docker build -t airtype-api .
docker run -p 3001:3001 --env-file .env airtype-apiUse the root Makefile targets — they rebuild the native fn_key.node module for the target arch before packaging (critical if you've recently built on a different arch).
Prerequisite: desktop/.env.production must define VITE_API_URL pointing at your deployed backend. Without it, the packaged build silently falls back to http://localhost:3001/api.
# Apple Silicon (default on M-series hosts)
make package-mac
# Intel Mac (from an Apple Silicon host, explicit --arch=x64 for native + electron-builder)
make package-mac-x64
# Output: desktop/dist/
# - AirType-<version>-arm64.dmg (or -x64.dmg)
# - AirType-<version>-arm64.zip
# - mac-arm64/AirType.app (runnable bundle, no install)<version> is whatever is in desktop/package.json (currently 1.1.0).
The make package-mac flow is equivalent to cd desktop && npm run package:mac, but adds make native as a dependency so the fn-key native module is rebuilt first. Prefer the Makefile — npm run electron:build skips the native rebuild.
make package-win
# Output: desktop/dist/
# - AirType Setup <version>.exe (NSIS installer)
# - AirType <version>.exe (portable build)Windows packaging requires Windows host or Wine on macOS/Linux.
electron-builder will auto-sign with a local Developer ID if one is available in the keychain, but skips notarization unless these env vars are set:
export APPLE_ID=your@email.com
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
export APPLE_TEAM_ID=XXXXXXXXXX
make package-macWithout notarization, first-launch on other Macs hits a Gatekeeper warning (right-click → Open to bypass).
The packaged app requires these permissions (users must grant manually):
-
System Settings → Privacy & Security → Microphone
- Enable for AirType
-
System Settings → Privacy & Security → Accessibility
- Enable for AirType (required for fn key hotkey and text paste)
- Backend deployed to Railway (or other cloud)
- MongoDB database provisioned
- Environment variables set on server
- Google OAuth redirect URI updated for production URL
-
desktop/.env.productionupdated with productionVITE_API_URL - Desktop app rebuilt with production config (
make package-mac) - macOS app signed and notarized (optional)
- Test login flow end-to-end
- Test transcription with fn key
# Server
PORT=3001
ENV=development
# MongoDB
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=airtype
# JWT
JWT_SECRET=your-super-secret-key-min-32-chars
# Google OAuth
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
GOOGLE_REDIRECT_URL=http://localhost:3001/api/auth/google/callback
# Groq
GROQ_API_KEY=gsk_your_api_key_here
# Desktop
DESKTOP_CALLBACK_URL=airtype://auth/callback- User clicks "Sign in with Google"
- Desktop main process generates a random
clientNonce, persists it toelectron-store, and fetches the OAuth URL from/api/auth/google/login?clientNonce=… - Desktop opens the URL in the system browser
- User authenticates with Google
- Google redirects to backend
/api/auth/google/callbackwith auth code + state - Backend validates state, exchanges the Google code for user info, creates/finds the user in MongoDB
- Backend generates JWT tokens, stores them under a one-time exchange code with a 2-minute TTL
- Backend redirects to
airtype://auth/callback?code=<exchangeCode>&nonce=<clientNonce>— tokens are NOT in the URL - Desktop
open-urlhandler validates the nonce against its stored copy, then POSTs/api/auth/exchangewith the code - Desktop saves the returned tokens to
electron-store
The hot path bypasses the backend entirely. Audio never touches your server; the backend is only involved for post-hoc storage and usage tracking.
- User holds
fnkey (push-to-talk) - Native fn-key module emits
down→ desktop main process forwards to the recording widget - Widget starts
MediaRecorderat 16kHz mono (Opus in WebM) - User releases
fn - Widget POSTs audio directly to
api.groq.com/v1/audio/transcriptions(Whisper Large v3 Turbo) — the Groq API key was fetched from/api/configon app startup - Widget POSTs raw text directly to
api.groq.com/v1/chat/completions(Llama 4 Scout) with the language-aware cleanup prompt - Widget simulates
Cmd+Vin the focused app to paste the cleaned text - Widget POSTs the raw + cleaned text to
/api/transcriptions/saveafter the paste (fire-and-forget, doesn't block UX)
- ✅ Google OAuth 2.0 - Exchange-code + client-nonce flow; tokens never in redirect URL
- ✅ JWT tokens - Stateless API access; suspended users can't refresh
⚠️ Token storage (desktop) -electron-storewith a passphrase — obfuscation, not true encryption. Acceptable for single-user desktop context; not for shared machines.- ✅ HTTPS only - All production traffic encrypted;
open-externalIPC rejects non-https schemes - ✅ Rate limiting - Per-user token bucket (30 req/min, burst 10) on transcription endpoints
- ✅ CORS - Explicit allowed-origins in production; loud warning in dev fallback
- ✅ Input validation - Request bodies validated; 25 MB audio upload cap
- ✅ Prompt-injection hardened - User-dictated text is wrapped in
<<< … >>>delimiters and the cleanup prompt instructs the model to treat those contents as data only
- Fork the repository
- Create feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open Pull Request
Apache License 2.0 — see LICENSE for the full text.
- Documentation: https://docs.airtype.com
- Issues: https://github.com/codecube47/airtype/issues
- Email: support@airtype.com
End-to-end support (Whisper and Llama 4 Scout cleanup) is restricted to the 12 languages Meta officially validates Llama 4 Scout on:
English (en) |
French (fr) |
Indonesian (id) |
Arabic (ar) |
German (de) |
Italian (it) |
Hindi (hi) |
Portuguese (pt) |
Spanish (es) |
Tagalog (tl) |
Thai (th) |
Vietnamese (vi) |
Whisper itself supports ~100 languages. Adding more to the dropdown requires either (a) accepting that cleanup quality may degrade for non-Llama-Scout-supported languages, or (b) swapping to a model with broader coverage (e.g. llama-3.3-70b-versatile).
-
Multi-language support— shipped (12 languages, language-aware cleanup prompt) - Auto-detect language (tested, reverted; may revisit when detection latency improves)
- Mobile apps (iOS/Android)
- Web version
- Team workspaces
- Custom vocabulary learning
- Voice commands ("new line", "delete that")
- Real-time streaming transcription (blocked: Groq Whisper returns full text, not a stream)
- Offline mode (local Whisper via whisper.cpp)
- macOS code signing + notarization for public distribution
Built with ❤️ using Go, Electron, and Groq API


