macOS menu bar voice input tool with dual-engine local ASR, multi-provider cloud ASR, and LLM post-processing.
Local ASR: SenseVoice via native sherpa-onnx (streaming) + Qwen3-ASR (final calibration, Python WebSocket service managed by SenseVoiceServerManager).
Cloud ASR: 9 providers implemented (Volcano, StepFun batch, OpenAI, Deepgram, AssemblyAI, ElevenLabs, Soniox, Bailian, Baidu).
Swift Package Manager project, no Xcode project file. Optional sherpa-onnx.xcframework for punctuation restoration.
# Qwen3-ASR server setup (optional, Apple Silicon only)
cd qwen3-asr-server && python3.12 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt && cd ..
# Optional: punctuation restoration module (~5 min, requires cmake)
bash scripts/build-sherpa.sh
swift build -c releaseThe built binary is at .build/release/Type4Me. To package it as a .app bundle, see scripts/deploy.sh.
Three product variants are built from the same codebase via conditional compilation flags. The compact CppJieba experiment is an independent optional capability:
| Variant | HAS_SHERPA_ONNX |
HAS_CLOUD_SUBSCRIPTION |
Arch | Description |
|---|---|---|---|---|
| pure | no | no | universal | Open-source cloud edition (BYOK API keys) |
| official | no | yes | universal | Official member edition (subscription + cloud proxy) — archived 2026-04, see below |
| local | yes | no | arm64 | Open-source local edition (bundled SenseVoice + Qwen3-ASR) |
ENABLE_CPPJIEBA=1 keeps CppJiebaBridge/marker visible while running build-dmg.sh, which adds the C++ bridge and compact dictionary resources. It defaults to 0, so normal release packages do not pay the binary/resource cost until the experiment is explicitly enabled. A compiled build also has the local tf_cppJiebaExperimentEnabled runtime switch; disabling it falls back to NLTokenizer without rebuilding.
We are not developing the subscription feature for the foreseeable future. The
Type4Me/CloudSubscription/marker file has been renamed to
marker.archived-no-subscription, so swift build, deploy.sh, and
build-dmg.sh VARIANT=pure|local all default to the no-subscription path.
build-dmg.sh VARIANT=official now fails fast with re-enable instructions.
The Type4Me/CloudSubscription/ source directory is preserved as-is for
future reactivation. To re-enable:
mv Type4Me/CloudSubscription/marker.archived-no-subscription \
Type4Me/CloudSubscription/marker
swift package cleanPublic GitHub Releases ship only pure (universal) and local (arm64).
HAS_SHERPA_ONNX: controlled byFrameworks/sherpa-onnx.xcframework/Info.plistpresence (existing pattern)HAS_CLOUD_SUBSCRIPTION: controlled byType4Me/CloudSubscription/markerfile presencePackage.swiftdetects these files at manifest evaluation time and sets compiler defines + source excludesbuild-dmg.shtemporarily hides marker files to build each variant
# Open-source cloud edition (no subscription, no local ASR)
VARIANT=pure bash scripts/build-dmg.sh
# Same edition with the compact CppJieba experiment enabled
ENABLE_CPPJIEBA=1 VARIANT=pure bash scripts/build-dmg.sh
# Open-source local edition (bundled models, Apple Silicon only)
VARIANT=local bash scripts/build-dmg.sh
# Official member edition — archived, see "Subscription paused" above.
# VARIANT=official bash scripts/build-dmg.shAll subscription/cloud-proxy code lives in Type4Me/CloudSubscription/ (13 files). Main code uses #if HAS_CLOUD_SUBSCRIPTION guards at ~11 touch points. When the marker is absent, the directory is excluded from compilation entirely.
Important: SPM caches manifest evaluation in ~/Library/Caches/org.swift.swiftpm. When switching variants manually (not via build-dmg.sh), clear this cache: rm -rf .build ~/Library/Caches/org.swift.swiftpm
Multi-provider ASR support via ASRProvider enum + ASRProviderConfig protocol + ASRProviderRegistry.
ASRProviderenum: 16 cases + conditionalcloudcase (sherpa/openai/azure/google/aws/deepgram/assemblyai/elevenlabs/volcano/stepfunBatch/aliyun/bailian/tencent/baidu/iflytek/custom, pluscloudwhenHAS_CLOUD_SUBSCRIPTION)- Each provider has its own Config type (e.g.,
SherpaASRConfig,VolcanoASRConfig) definingcredentialFieldsfor dynamic UI rendering ASRProviderRegistry: maps provider to config type + client factory;capabilitiesindicates availability and streaming support- Fully implemented: sherpa (local, batch), volcano (streaming), StepFun (batch; Step Plan or standard endpoint), deepgram (streaming), assemblyai (streaming), elevenlabs (streaming), soniox (streaming), bailian (streaming), baidu (streaming), openai (batch)
- Config only (no client): azure, google, aws, aliyun, tencent, iflytek, custom
- Create a Config file in
Type4Me/ASR/Providers/, implementingASRProviderConfig - Write the client (implementing
SpeechRecognizerprotocol) - Register
createClientinASRProviderRegistry.all
- SenseVoice: Native sherpa-onnx integration (Swift), provides real-time streaming recognition (partial results as you speak). No Python dependency.
- Qwen3-ASR (
qwen3-asr-server/): Python WebSocket service using MLX (Metal GPU), provides final calibration on complete audio for higher accuracy. Apple Silicon only. SenseVoiceServerManager: manages the Qwen3-ASR Python server process, auto-detects Apple Silicon vs Intel, assigns dynamic ports, saves PIDs for graceful shutdown
SenseVoiceWSClientconnects to local Python servers via WebSocket- Three modes: SenseVoice streaming only, Qwen3-only (final result), or hybrid (SenseVoice streaming + Qwen3 final calibration)
- Qwen3 incremental speculative transcription with debounce for progressive results
SherpaPunctuationProcessor(optional) — CT-Transformer post-processing adds punctuation (requiressherpa-onnx.xcframework)
- One streaming model in
ModelManager.StreamingModel:senseVoiceSmall(~228MB, zh/en/yue/ja/ko) - Auxiliary models:
offlineParaformer(~700MB),punctuationCT-Transformer (~72MB) - Models downloaded from GitHub releases (tar.bz2), stored at
~/Library/Application Support/Type4Me/models/
SherpaOnnxBridge.swift— Swift wrapper over C API (no Obj-C bridging header needed)sherpa-onnx.xcframework— built locally viascripts/build-sherpa.sh, not checked into gitPackage.swiftuses runtime detection:hasSherpaFrameworkflag conditionally definesHAS_SHERPA_ONNXand links SherpaOnnxLib
- Progress tracking via delegate-based
URLSession.downloadTask(NOT asyncsession.download()which doesn't report progress) - Resumable downloads: captures
NSURLSessionDownloadTaskResumeDatafrom errors, usesdownloadTask(withResumeData:)to resume - Auto-retry up to N times with exponential backoff
- Active sessions stored in
activeSessionsdict for cancellation viainvalidateAndCancel() - Cancel clears: activeTasks, activeSessions, downloadProgress, resumeData
Credentials use a hybrid storage model:
- Secure fields (
isSecure: truein CredentialField, e.g. API keys): stored in macOS Keychain (com.type4me.grouped/com.type4me.scalarservices) - Non-secure fields (model, language, etc.): stored in
~/Library/Application Support/Type4Me/credentials.json(file permissions 0600) - Auto-migration on first launch moves existing secure fields from JSON to Keychain
Do not rely on environment variables for credentials in production. GUI-launched apps cannot read shell env vars from ~/.zshrc. Credentials must be configured through the Settings UI.
{
"tf_asr_volcano": { "appKey": "...", "resourceId": "..." },
"tf_asr_openai": {},
"tf_llmModel": "...",
"tf_llmBaseURL": "..."
}API keys and other secure values are stored in Keychain, not in this file.
| Permission | Purpose |
|---|---|
| Microphone | Audio capture |
| Accessibility | Global hotkey listening + text injection into other apps |
| Path | Responsibility |
|---|---|
Type4Me/ASR/ASRProvider.swift |
Provider enum + protocol + CredentialField |
Type4Me/ASR/ASRProviderRegistry.swift |
Registry: provider → config + client factory + capabilities |
Type4Me/ASR/Providers/*.swift |
Per-vendor Config implementations |
Type4Me/ASR/SpeechRecognizer.swift |
SpeechRecognizer protocol + LLMConfig + event types |
Type4Me/ASR/SenseVoiceWSClient.swift |
Local ASR client (WebSocket to Python servers, dual-engine) |
Type4Me/ASR/VolcASRClient.swift |
Cloud streaming ASR (Volcano, WebSocket) |
Type4Me/ASR/DeepgramASRClient.swift |
Cloud streaming ASR (Deepgram, WebSocket) |
Type4Me/ASR/ElevenLabsASRClient.swift |
Cloud streaming ASR (ElevenLabs Scribe v2, WebSocket) |
Type4Me/ASR/OpenAIASRClient.swift |
Cloud batch ASR (OpenAI, REST) |
Type4Me/ASR/SherpaPunctuationProcessor.swift |
Optional punctuation restoration (SherpaOnnx) |
Type4Me/Bridge/SherpaOnnxBridge.swift |
SherpaOnnx C API Swift bridge (conditional) |
Type4Me/Services/SenseVoiceServerManager.swift |
Local Qwen3-ASR Python server lifecycle |
Type4Me/Session/RecognitionSession.swift |
Core state machine: record → ASR → inject |
Type4Me/Audio/AudioCaptureEngine.swift |
Audio capture, getRecordedAudio() returns full recording |
Type4Me/UI/AppState.swift |
ProcessingMode definition, built-in mode list |
Type4Me/Services/ModelManager.swift |
SenseVoice model download, validation, selection |
Type4Me/Services/KeychainService.swift |
Credential read/write (provider groups + migration) |
Type4Me/Services/HotwordStorage.swift |
ASR hotword storage (UserDefaults) |
Type4Me/LLM/LLMProvider.swift |
13 LLM providers (incl. local Qwen offline) |
Type4Me/LLM/LLMProviderRegistry.swift |
LLM provider → config + client factory |
Type4Me/Session/SoundFeedback.swift |
Start/stop/error sounds, multiple sound styles |
qwen3-asr-server/server.py |
Qwen3-ASR calibration engine (MLX/Metal, Apple Silicon) |
scripts/deploy.sh |
Build + deploy + launch |
scripts/build-sherpa.sh |
Build sherpa-onnx.xcframework (optional, for punctuation) |
- Streaming ASR emits partial results that get replaced by final results
- Must track
confirmedText(finalized segments) separately fromcurrentPartial - Display
confirmedText + currentPartial, replace partial on each update, append on segment finalization - Endpoint detection signals segment boundaries
- Recording start sound bleeds into first ~400ms of audio
- Solution: skip initial 6400 samples (at 16kHz) in the ASR client before feeding to recognizer
- This dramatically improves first-character recognition accuracy
async let (url, response) = session.download(for: request)does NOT trigger delegate progress callbacks- Must use
session.downloadTask(with:)+DownloadProgressDelegatefor real-time progress - Store URLSession reference in a dict for proper cancellation
- GitHub public forks cannot use Git LFS — keep large binaries out of repo
- For downloads >100MB, connection drops are common (error -1005)
NSURLSessionDownloadTaskResumeDatain error's userInfo enables resume- Also check
NSUnderlyingErrorKeyfor nested resume data
- Dangerous actions (delete) should require two-step confirmation (show button → confirm)
- Undownloaded items shouldn't show selection UI (radio buttons) — show download button instead
- Test/action buttons should be spatially separated from destructive actions
- Download progress UI must use
@Publishedproperties on@MainActorfor SwiftUI updates
sherpa-onnx.xcframework(156MB) cannot be pushed to GitHub public forks (no LFS)- Solution:
.gitignorethe framework, providescripts/build-sherpa.shfor local builds - When merging upstream:
git fetch upstream && git rebase upstream/main - Resolve conflicts by combining both sides (e.g., keep upstream's Deepgram + our Sherpa)
- Force push after rebase:
git push origin main --force --tags
let hasSherpaFramework = FileManager.default.fileExists(
atPath: packageDir + "/Frameworks/sherpa-onnx.xcframework/Info.plist"
)
// Conditionally add binary target and linker settingsThis allows the project to build even without the framework (graceful degradation).
StartSoundStyleenum: off, chime (synthesized), waterDrop1, waterDrop2- Bundled WAV files in
Type4Me/Resources/Sounds/, copied to app bundle by deploy.sh - Use
AVAudioPlayerfor bundled sounds (cached),afplayvia Process for synthesized tones - Sound selection persisted via UserDefaults key
tf_startSound