Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 0 additions & 87 deletions Package.resolved

This file was deleted.

19 changes: 3 additions & 16 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,12 @@ import PackageDescription

let package = Package(
name: "Plynn",
platforms: [.macOS("26.0")],
dependencies: [
.package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.5"),
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", from: "2.29.1"),
.package(url: "https://github.com/sparkle-project/Sparkle.git", from: "2.6.0"),
],
platforms: [.macOS(.v15)],
targets: [
.target(
name: "PlynnKit",
dependencies: [
.product(name: "FluidAudio", package: "FluidAudio"),
.product(name: "MLXLLM", package: "mlx-swift-examples"),
]),
.target(name: "PlynnKit"),
.executableTarget(
name: "Plynn",
dependencies: [
"PlynnKit",
.product(name: "Sparkle", package: "Sparkle"),
]),
dependencies: ["PlynnKit"]),
.testTarget(
name: "PlynnKitTests",
dependencies: ["PlynnKit"],
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,21 @@ I built this because I loved what Wispr Flow could do but didn't love sending my

Grab it at [plynn.vercel.app](https://plynn.vercel.app), or grab `Plynn.dmg` from [Releases](../../releases), drag Plynn to Applications, and launch. The app is notarized, so there's nothing to bypass. Onboarding asks for microphone and accessibility permissions, and the speech models (about 1 GB) download in the background while Apple's built-in engine covers your first dictations.

Requires macOS 26 (Tahoe) on Apple Silicon. Enable Apple Intelligence in System Settings to get the best polish engine.
**This tree is a Sequoia 15 / Intel compatibility port.** Upstream Plynn requires macOS 26 (Tahoe) on Apple Silicon. Here the hold-to-talk loop, paste, dictionary, snippets, and rules polish run on macOS 15.7 Intel using Apple's on-device Speech recognizer. Parakeet, Apple Intelligence, and the Qwen MLX polish model are not available on this hardware.

## Build from source

```bash
git clone https://github.com/31Carlton7/plynn.git
cd plynn
./scripts/make-app.sh
open build/Plynn.app
```

You'll need Xcode 26 with the Metal toolchain component (`xcodebuild -downloadComponent MetalToolchain`). The build uses `xcodebuild` rather than plain `swift build` because the MLX Metal shaders require it. `swift test` runs the 80+ unit tests.
Needs the macOS Command Line Tools (Swift 6.1+) or Xcode 16. No Xcode 26 / Metal toolchain. After the first launch, grant Microphone, Speech Recognition, and Accessibility, then relaunch.

## How it's put together

Speech recognition is Parakeet TDT running on the Neural Engine via [FluidAudio](https://github.com/FluidInference/FluidAudio), streaming partials as you speak. A deterministic rules pass handles spoken punctuation instantly, then a small on-device language model does the heavier cleanup, but only when the transcript actually needs it. Clean short dictations skip the model entirely and paste immediately. A latency gate, basically.
On this Sequoia Intel port, speech recognition is Apple's on-device Speech framework. A deterministic rules pass still handles spoken punctuation, snippets, and your dictionary. The Neural Engine Parakeet stack and the MLX polish model are Apple Silicon / macOS 26 only.

Your dictionary, snippets, and history live in one SQLite file at `~/Library/Application Support/Plynn/`. Nothing is sent anywhere, ever. There's no account, no telemetry, and no server to go down.

Expand Down
10 changes: 8 additions & 2 deletions Sources/Plynn/OnboardingWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SwiftUI
struct OnboardingView: View {
let engineManager: EngineManager
@State private var mic = Permissions.micGranted()
@State private var speech = Permissions.speechGranted()
@State private var ax = Permissions.accessibilityGranted()
@State private var globe = Permissions.globeKeySafe()
@State private var grantedAxThisRun = false
Expand All @@ -15,7 +16,7 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 18) {
Text("Set up Plynn")
.font(.title2.bold())
Text("Three quick steps, then hold **fn** anywhere and talk.")
Text("A few quick steps, then hold **fn** anywhere and talk.")
.foregroundStyle(.secondary)
Text("Using a third-party keyboard and fn doesn't do anything? Change the activation key under Settings → Hotkey.")
.font(.caption)
Expand All @@ -25,6 +26,10 @@ struct OnboardingView: View {
detail: "Plynn hears you only while a hotkey is held.") {
Button("Grant") { Permissions.requestMic() }
}
row(done: speech, title: "Speech Recognition",
detail: "Turns what you say into text on this Mac — nothing is uploaded.") {
Button("Grant") { Permissions.requestSpeech() }
}
row(done: ax, title: "Accessibility",
detail: "Lets Plynn see the fn key and paste text for you.") {
Button("Grant") {
Expand Down Expand Up @@ -52,7 +57,7 @@ struct OnboardingView: View {
}
.font(.callout)

if mic && ax {
if mic && speech && ax {
Text("Ready — focus any text field, hold **fn**, and speak.")
.font(.callout)
.foregroundStyle(.green)
Expand All @@ -62,6 +67,7 @@ struct OnboardingView: View {
.frame(width: 460)
.onReceive(timer) { _ in
mic = Permissions.micGranted()
speech = Permissions.speechGranted()
let axNow = Permissions.accessibilityGranted()
if axNow && !ax { grantedAxThisRun = true }
ax = axNow
Expand Down
15 changes: 7 additions & 8 deletions Sources/Plynn/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,12 @@ struct SettingsPane: View {

var body: some View {
Form {
Section("Transcription") {
Picker("Engine", selection: $engineManager.preferred) {
Text("Automatic").tag(EngineChoice.auto)
Text("Parakeet (local)").tag(EngineChoice.parakeet)
Text("Apple (built-in)").tag(EngineChoice.apple)
}
LabeledContent("Status", value: engineManager.statusLine)
Section {
LabeledContent("Engine", value: engineManager.statusLine)
} header: {
Text("Transcription")
} footer: {
Text("This Sequoia Intel build uses Apple's on-device Speech recognizer. Parakeet and the Qwen polish model need Apple Silicon and macOS 26.")
}
Section {
Picker("Activation key", selection: $hotkeyTrigger) {
Expand All @@ -80,7 +79,7 @@ struct SettingsPane: View {
} header: {
Text("Formatting")
} footer: {
Text("Polish removes filler words, applies self-corrections, formats lists, and matches tone to the app — on-device via Apple Intelligence. Corrections you make right after a paste teach the dictionary automatically. Everything stays on this Mac.")
Text("On this Intel Sequoia build, polish is the on-device rules pass: spoken punctuation, snippets, and your dictionary. Apple Intelligence and the local Qwen model are not available on Intel. Corrections you make right after a paste still teach the dictionary.")
}
Section {
Toggle("Sound effects", isOn: $soundEffects)
Expand Down
22 changes: 7 additions & 15 deletions Sources/Plynn/main.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import AppKit
import PlynnKit
import Sparkle

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
Expand All @@ -19,8 +18,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.onboarding.show()
}
var statusItem: NSStatusItem!
let updater = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)

let store = try? PersonalStore(path: PersonalStore.defaultPath())
lazy var formatter = TranscriptFormatter(personalization: { [store] in
Expand Down Expand Up @@ -61,7 +58,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Metrics.residentMB(), AXIsProcessTrusted() ? 1 : 0)
setUpStatusItem()

if !Permissions.micGranted() || !Permissions.accessibilityGranted() {
if !Permissions.micGranted() || !Permissions.speechGranted()
|| !Permissions.accessibilityGranted()
{
onboarding.show()
}

Expand All @@ -86,7 +85,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSLog("plynn: polish engine %@; RSS %.0f MB",
await formatter.polishEngine ?? "none (rules only)", Metrics.residentMB())
if let reason = await formatter.appleFMStatus {
NSLog("plynn: Apple Intelligence %@", reason)
NSLog("plynn: polish %@", reason)
}
}

Expand Down Expand Up @@ -315,7 +314,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let title = Self.defaultMeetingTitle(started)
meetingID = try? store?.addMeeting(title: title, startedAt: started)

// Meetings always run on Parakeet: it streams and has no session cap.
// Meetings use the same on-device Apple Speech engine as dictation.
let engine = engineManager.engineForNewSession()
meetingEngine = engine
let (stream, continuation) = AsyncStream.makeStream(of: [Float].self)
Expand Down Expand Up @@ -372,8 +371,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var meetingSegmentStart: TimeInterval = 0
func meetingPartial(_ text: String) {
let elapsed = meetingRecorder?.elapsed ?? 0
// Parakeet's partial grows monotonically within an utterance and
// shrinks when a new one starts — the shrink is our segment boundary.
// The recognizer's partial grows within an utterance and shrinks
// when a new one starts — the shrink is our segment boundary.
if text.count < meetingLastPartial.count, !meetingLastPartial.isEmpty {
meetingTranscript.append(meetingLastPartial, at: meetingSegmentStart)
meetingSegmentStart = elapsed
Expand Down Expand Up @@ -511,13 +510,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
meetingItem.target = self
menu.addItem(meetingItem)
menu.addItem(.separator())
let updateItem = NSMenuItem(
title: "Check for Updates…",
action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)),
keyEquivalent: "")
updateItem.target = updater
menu.addItem(updateItem)
menu.addItem(.separator())
menu.addItem(NSMenuItem(
title: "Quit Plynn", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem.menu = menu
Expand Down
44 changes: 9 additions & 35 deletions Sources/PlynnKit/AppleFMFormatter.swift
Original file line number Diff line number Diff line change
@@ -1,51 +1,25 @@
import Foundation
import FoundationModels

/// AI polish on Apple's on-device Foundation Model (Apple Intelligence).
/// The default polish engine — no download, Apple-tuned for the hardware.
/// Falls back to the input text on every failure mode, like all polish paths.
/// Apple Intelligence polish is unavailable on Intel Sequoia (Foundation
/// Models requires macOS 26 + Apple Silicon). The actor stays so the rest of
/// the formatting pipeline compiles unchanged; every call degrades to the
/// input text, same as the upstream failure path.
public actor AppleFMFormatter {
public init() {}

public nonisolated var ready: Bool {
SystemLanguageModel.default.isAvailable
}
public nonisolated var ready: Bool { false }

/// Human-readable availability, for logs and Settings.
public nonisolated var availabilityDescription: String {
switch SystemLanguageModel.default.availability {
case .available: return "available"
case .unavailable(.appleIntelligenceNotEnabled):
return "Apple Intelligence is not enabled in System Settings"
case .unavailable(.modelNotReady):
return "model still downloading — will be used once ready"
case .unavailable(.deviceNotEligible):
return "this Mac doesn't support Apple Intelligence"
case .unavailable(let reason):
return "unavailable (\(reason))"
}
"Apple Intelligence needs Apple Silicon and macOS 26 — this Sequoia Intel build uses rules-only polish"
}

/// Ask the system to page the model in so the first dictation is fast.
public func warm() {
guard ready else { return }
LanguageModelSession().prewarm()
}
public func warm() {}

/// One stateless prompt → raw completion (nil on timeout/error/unavailable).
public func complete(_ prompt: String) async -> String? {
guard ready else { return nil }
return await withTaskTimeout(seconds: 10) {
try await LanguageModelSession().respond(to: prompt).content
}
}
public func complete(_ prompt: String) async -> String? { nil }

public func format(
_ text: String, tone: Tone, technical: Bool, preferredSpellings: [String] = []
) async -> String {
let prompt = PolishPrompt.build(
transcript: text, tone: tone, technical: technical,
preferredSpellings: preferredSpellings)
return PolishPrompt.sanitize(await complete(prompt), input: text)
text
}
}
Loading