MacOS voice input tool with local/cloud ASR engines, LLM text optimization, and fully local storage built in Swift
triggers
["add a new ASR provider to type4me","build and deploy type4me from source","configure local voice recognition with sherpa","set up volcengine speech recognition","add custom prompt mode for voice input","implement speech recognizer protocol","troubleshoot type4me voice input not working","extend type4me with new cloud ASR service"]
Type4Me is a macOS voice input tool that captures audio via global hotkey, transcribes it using local (SherpaOnnx/Paraformer/Zipformer) or cloud (Volcengine/Deepgram) ASR engines, optionally post-processes text via LLM, and injects the result into any app. All credentials and history are stored locally — no telemetry, no cloud sync.
Architecture Overview
Type4Me/
├── ASR/ # ASR engine abstraction
│ ├── ASRProvider.swift # Provider enum + protocols
│ ├── ASRProviderRegistry.swift # Plugin registry
│ ├── Providers/ # Per-vendor config files
│ ├── SherpaASRClient.swift # Local streaming ASR
│ ├── SherpaOfflineASRClient.swift
│ ├── VolcASRClient.swift # Volcengine streaming ASR
│ └── DeepgramASRClient.swift # Deepgram streaming ASR
├── Bridge/ # SherpaOnnx C API Swift bridge
├── Audio/ # Audio capture
├── Session/ # Core state machine: record→ASR→inject
├── Input/ # Global hotkey management
├── Services/ # Credentials, hotwords, model manager
├── Protocol/ # Volcengine WebSocket codec
└── UI/ # SwiftUI (FloatingBar + Settings)
Installation
Prerequisites
# Xcode Command Line Tools
xcode-select --install
# CMake (for local ASR engine)
brew install cmake
Build & Deploy from Source
git clone https://github.com/joewongjc/type4me.git
cd type4me
# Step 1: Compile SherpaOnnx local engine (~5 min, one-time)
bash scripts/build-sherpa.sh
# Step 2: Build, bundle, sign, install to /Applications, and launch
bash scripts/deploy.sh
Download Pre-built App
Download Type4Me-v1.2.3.dmg from releases (cloud ASR only, no local engine):
import Foundation
import AVFoundation
finalclassOpenAIWhisperASRClient: SpeechRecognizer {
var partialResultHandler: ((String) -> Void)?privatelet apiKey: Stringprivatelet model: Stringprivatelet config: RecognitionConfigprivatevar audioData: Data=Data()
init(apiKey: String, model: String, config: RecognitionConfig) {
self.apiKey = apiKey
self.model = model
self.config = config
}
funcstartRecognition() asyncthrows {
audioData =Data()
}
funcappendAudio(_buffer: AVAudioPCMBuffer) async {
// Convert PCM buffer to raw bytes and accumulateguardlet channelData = buffer.floatChannelData?[0] else { return }
let frameCount =Int(buffer.frameLength)
let bytes =UnsafeBufferPointer(start: channelData, count: frameCount)
// Convert Float32 PCM to Int16 for Whisper APIlet int16Samples = bytes.map { sample -> Int16inreturnInt16(max(-32768, min(32767, Int(sample *32767))))
}
int16Samples.withUnsafeBytes { ptr in
audioData.append(contentsOf: ptr)
}
}
funcstopRecognition() asyncthrows -> String {
// Build multipart form request to Whisper APIvar request =URLRequest(url: URL(string: "https://api.openai.com/v1/audio/transcriptions")!)
request.httpMethod ="POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let boundary =UUID().uuidString
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
var body =Data()
// Append audio file part
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"audio.raw\"\r\n".data(using: .utf8)!)
body.append("Content-Type: audio/raw\r\n\r\n".data(using: .utf8)!)
body.append(audioData)
body.append("\r\n".data(using: .utf8)!)
// Append model part
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"model\"\r\n\r\n".data(using: .utf8)!)
body.append("\(model)\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (data, response) =tryawaitURLSession.shared.data(for: request)
guardlet httpResponse = response as?HTTPURLResponse,
httpResponse.statusCode ==200else {
throwASRError.networkError("Whisper API returned error")
}
let result =tryJSONDecoder().decode(WhisperResponse.self, from: data)
return result.text
}
funccancelRecognition() async {
audioData =Data()
}
}
privatestructWhisperResponse: Codable {
let text: String
}
Step 3: Register the Provider
In Type4Me/ASR/ASRProviderRegistry.swift, add to the all array:
structASRProviderRegistry {
staticlet all: [anyASRProviderConfig.Type] = [
SherpaParaformerProvider.self,
VolcengineProvider.self,
DeepgramProvider.self,
OpenAIWhisperProvider.self, // ← Add your provider here
]
}
Credentials Storage
Credentials are stored at ~/Library/Application Support/Type4Me/credentials.json with permissions 0600. Never hardcode secrets — always load via CredentialStore:
Processing modes use LLM post-processing with three context variables:
Variable
Value
{text}
Recognized speech text
{selected}
Text selected in active app at record start
{clipboard}
Clipboard content at record start
Example custom mode prompts:
// Translate selection using voice commandlet translatePrompt ="""
The user selected this text: {selected}
Voice command: {text}
Execute the command on the selected text. Output only the result.
"""// Code review via voicelet codeReviewPrompt ="""
Code to review:
{clipboard}
Review instruction: {text}
Provide focused feedback addressing the instruction.
"""// Email reply draftinglet emailPrompt ="""
Original email: {selected}
My reply intent (spoken): {text}
Write a professional email reply. Output only the email body.
"""
Built-in Processing Modes
enumProcessingMode {
case fast // Direct ASR output, zero latencycase performance // Dual-channel: streaming + offline refinementcase englishTranslation // Chinese speech → English textcase promptOptimize // Raw prompt → optimized prompt via LLMcase command // Voice command + selected/clipboard context → LLM actioncase custom(prompt: String) // User-defined prompt template
}
Session State Machine
The core recording flow in Session/:
[Idle]
→ hotkey pressed → [Recording] → audio streams to ASR client
→ hotkey released/pressed again → [Processing]
→ ASR returns text → [LLM Post-processing] (if mode requires)
→ [Injecting] → text injected into active app
→ [Idle]
Updating After Source Changes
cd type4me
git pull
bash scripts/deploy.sh
# SherpaOnnx does NOT need recompiling unless engine version changed
ls ~/Library/Application\ Support/Type4Me/Models/sherpa-onnx-streaming-paraformer-bilingual-zh-en/
# Must show: encoder.int8.onnx decoder.int8.onnx tokens.txt
SherpaOnnx build fails
# Ensure cmake is installed
brew install cmake
# Clean and retryrm -rf Frameworks/
bash scripts/build-sherpa.sh
New ASR provider not appearing in Settings
Confirm the provider type is added to ASRProviderRegistry.all
Ensure providerID is unique across all providers
Clean build: swift package clean && bash scripts/deploy.sh
Audio not captured / no floating bar
Grant microphone permission: System Settings → Privacy & Security → Microphone → Type4Me ✓
Grant Accessibility permission for text injection: System Settings → Privacy & Security → Accessibility → Type4Me ✓
Credentials not saving
# Check file exists and has correct permissionsls -la ~/Library/Application\ Support/Type4Me/credentials.json
# Should show: -rw------- (0600)# Fix permissions if needed:chmod 0600 ~/Library/Application\ Support/Type4Me/credentials.json
Export history to CSV
Open Settings → History → select date range → Export CSV. The SQLite database is at:
~/Library/Application\ Support/Type4Me/history.db
# Direct query:
sqlite3 ~/Library/Application\ Support/Type4Me/history.db \
"SELECT datetime(timestamp,'unixepoch'), text FROM records ORDER BY timestamp DESC LIMIT 20;"
System Requirements
macOS 14.0 (Sonoma) or later
Apple Silicon (M1/M2/M3/M4) recommended for local ASR inference
Xcode Command Line Tools + CMake for source builds
Internet connection only needed for cloud ASR providers