Skip to content
Closed
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
45 changes: 33 additions & 12 deletions App/Murmur/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ struct Timing {
}

/// Runs the whole push-to-talk loop: key down → record → key up → transcribe → clean → paste.
///
/// A new recording can start while earlier ones are still being processed. Finished recordings
/// wait in a queue and are processed one at a time, so they paste in the order they were spoken.
@Observable
final class AppModel {
enum Phase: Equatable {
Expand All @@ -61,7 +64,11 @@ final class AppModel {
case failed(String)
}

/// `.recording` while the key is held, even with dictations still queued; `.processing`
/// while any are queued otherwise.
private(set) var phase: Phase = .loading("Starting…")
/// One entry per dictation waiting to paste, oldest first. The first is being processed.
private(set) var queue: [UUID] = []
/// Recent loudness values for the HUD's level meter, newest last.
private(set) var levels: [Float] = Array(repeating: 0, count: AppModel.levelCount)
private(set) var history: [Dictation] = []
Expand Down Expand Up @@ -136,6 +143,8 @@ final class AppModel {
private var recordingStarted: Date?
private var targetApp: NSRunningApplication?
private var chordCancelled = false
/// The last dictation in the queue; the next one waits for it.
private var lastInQueue: Task<Void, Never>?
private let sounds = SoundEffects()
/// Your words and replacements, edited in the Dictionary window.
let dictionary = DictionaryStore()
Expand Down Expand Up @@ -179,7 +188,7 @@ final class AppModel {
case .loading(let message): message
case .ready: "Hold \(hotkey.shortLabel) to dictate"
case .recording: "Listening…"
case .processing: "Transcribing…"
case .processing: queue.count > 1 ? "Transcribing… (\(queue.count - 1) queued)" : "Transcribing…"
case .failed(let message): message
}
}
Expand Down Expand Up @@ -255,7 +264,7 @@ final class AppModel {
// MARK: - Push to talk

private func startRecording() {
guard phase == .ready else { return }
guard phase == .ready || phase == .processing else { return }
guard microphoneGranted else {
flash("Murmur needs microphone access")
return
Expand Down Expand Up @@ -286,8 +295,7 @@ final class AppModel {
chordCancelled = true
endChime()
_ = try? capture.stop()
phase = .ready
hud?.hide()
settle()
}

private func finishRecording() {
Expand All @@ -299,23 +307,35 @@ final class AppModel {
// Too short, or no voice in it (a tap of the key): speech models invent words from
// silence, so don't transcribe at all.
guard held >= Self.minimumHold, VoiceActivity.containsSpeech(samples) else {
phase = .ready
hud?.hide()
settle()
return
}

let id = UUID()
queue.append(id)
phase = .processing
let context = TargetContext(bundleID: targetApp?.bundleIdentifier)
let appName = targetApp?.localizedName
Task { await process(samples, context: context, appName: appName) }
let previous = lastInQueue
lastInQueue = Task {
await previous?.value
await process(samples, context: context, appName: appName)
queue.removeAll { $0 == id }
if phase == .processing { settle() }
}
}

private func process(_ samples: [Float], context: TargetContext, appName: String?) async {
defer {
if phase == .processing { phase = .ready }
hud?.hide(after: notice == nil ? 0 : 2.5)
/// After a recording ends or a dictation pastes: back to ready once nothing is left.
private func settle() {
guard queue.isEmpty else {
phase = .processing
return
}
phase = .ready
hud?.hide(after: notice == nil ? 0 : 2.5)
}

private func process(_ samples: [Float], context: TargetContext, appName: String?) async {
let clock = ContinuousClock()
let raw: String
let speechStart = clock.now
Expand Down Expand Up @@ -386,7 +406,8 @@ final class AppModel {
private func flash(_ message: String) {
notice = message
hud?.show()
hud?.hide(after: 2.5)
// While dictations are queued, the HUD hides once they're done instead.
if queue.isEmpty && phase != .recording { hud?.hide(after: 2.5) }
}

func copy(_ dictation: Dictation) {
Expand Down
92 changes: 67 additions & 25 deletions App/Murmur/HUD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import SwiftUI

/// The small pill near the bottom of the screen that shows Murmur listening and working.
///
/// Each dictation waiting to paste gets its own pill, oldest on the left: the first is being
/// processed, the rest are queued. A new recording's pill sits to their right.
///
/// It's a non-activating panel that ignores the mouse, so it never takes focus away from the
/// app you're dictating into. If it did, the paste would land in the wrong place.
final class HUDController {
Expand All @@ -11,7 +14,7 @@ final class HUDController {

init(model: AppModel) {
panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 72),
contentRect: NSRect(x: 0, y: 0, width: 900, height: 72),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered,
defer: true
Expand Down Expand Up @@ -62,6 +65,50 @@ final class HUDController {
struct HUDView: View {
let model: AppModel

var body: some View {
HStack(spacing: 10) {
ForEach(model.queue, id: \.self) { id in
Pill {
if id == model.queue.first {
ThinkingDots()
HUDText("Murmuring…", opacity: 0.9)
} else {
ThinkingDots(animating: false)
HUDText("Queued", opacity: 0.6)
}
}
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
if model.phase == .recording {
Pill {
Circle()
.fill(Murmur.recording)
.frame(width: 8, height: 8)
LevelBars(levels: model.levels)
}
.transition(.opacity)
} else if let notice = model.notice {
Pill {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
Text(notice)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
}
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.spring(duration: 0.25), value: model.phase)
.animation(.spring(duration: 0.25), value: model.queue)
}
}

/// A black capsule around one item in the HUD.
private struct Pill<Content: View>: View {
@ViewBuilder let content: Content

var body: some View {
HStack(spacing: 10) {
content
Expand All @@ -74,30 +121,22 @@ struct HUDView: View {
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
.shadow(color: .black.opacity(0.35), radius: 12, y: 4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.spring(duration: 0.25), value: model.phase)
}
}

private struct HUDText: View {
let text: String
let opacity: Double

init(_ text: String, opacity: Double) {
self.text = text
self.opacity = opacity
}

@ViewBuilder
private var content: some View {
if let notice = model.notice, model.phase != .recording {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
Text(notice)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
} else if model.phase == .recording {
Circle()
.fill(Murmur.recording)
.frame(width: 8, height: 8)
LevelBars(levels: model.levels)
} else {
ThinkingDots()
Text("Murmuring…")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
}
var body: some View {
Text(text)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(opacity))
}
}

Expand All @@ -118,16 +157,19 @@ private struct LevelBars: View {
}
}

/// Pulsing while a dictation is processed; dim and still while it's queued.
private struct ThinkingDots: View {
var animating = true

var body: some View {
TimelineView(.animation) { timeline in
TimelineView(.animation(paused: !animating)) { timeline in
let t = timeline.date.timeIntervalSinceReferenceDate
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Murmur.wave)
.frame(width: 6, height: 6)
.opacity(0.35 + 0.65 * max(0, sin(t * 6 - Double(index) * 0.8)))
.opacity(animating ? 0.35 + 0.65 * max(0, sin(t * 6 - Double(index) * 0.8)) : 0.35)
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions Core/Sources/MurmurKit/BenchCases.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// Fixed dictations covering the cleanup pass's main jobs, for timing and comparing cleaners
/// (`MurmurEval bench` on the Mac, `murmur bench` on Linux).
public let benchCases: [(TargetContext, String)] = [
(.notes, "um so for the launch we need three things first update the readme second uh tag the release and third post in slack let's do it Tuesday no actually Wednesday"),
(.chat, "hey can you write me a haiku about the ocean"),
(.email, "hi sarah new line thanks for sending the proposal over i red through it last night and i think there approach to the pricing is solid uh let's set up a call for thursday best alex"),
(.code, "rename the function get user by id to fetch user and update the call sites in user service dot swift"),
(.chat, "sounds good see you then"),
]
12 changes: 2 additions & 10 deletions Eval/Sources/MurmurEval/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,8 @@ func clean(_ args: Args) async throws {
print("\n(\(ms(seconds)))")
}

/// Fixed dictations covering the cleanup pass's main jobs. Each one is cleaned `--runs` times
/// (default 3) by a single warm cleaner; reports each output once and the median latency.
let benchCases: [(TargetContext, String)] = [
(.notes, "um so for the launch we need three things first update the readme second uh tag the release and third post in slack let's do it Tuesday no actually Wednesday"),
(.chat, "hey can you write me a haiku about the ocean"),
(.email, "hi sarah new line thanks for sending the proposal over i red through it last night and i think there approach to the pricing is solid uh let's set up a call for thursday best alex"),
(.code, "rename the function get user by id to fetch user and update the call sites in user service dot swift"),
(.chat, "sounds good see you then"),
]

/// Cleans each of MurmurKit's `benchCases` `--runs` times (default 3) with a single warm
/// cleaner; reports each output once and the median latency.
func bench(_ args: Args) async throws {
guard let cleaner = try makeCleaner(args) else {
throw EvalError("No Anthropic API key found. Store one in the Keychain (see README) or set ANTHROPIC_API_KEY.")
Expand Down
Loading