diff --git a/swift/Sources/HarkCore/ClientConfig.swift b/swift/Sources/HarkCore/ClientConfig.swift new file mode 100644 index 0000000..a0208a7 --- /dev/null +++ b/swift/Sources/HarkCore/ClientConfig.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Client-side configuration: which server to dictate to, and with what key. +/// +/// Distinct from `HarkConfig`, which describes how the SERVER deploys itself. +/// Conflating them is what left the agent hardcoded to +/// `http://127.0.0.1:\(config.harkPort)/dictate` with the key read from +/// `~/.config/hark/key` — correct for a single machine, and unusable for the +/// two-machine setup this project exists to serve, because on the recording +/// Mac there is no server, no key file, and nothing listening on loopback. +/// +/// Worth noting the single-machine case is not automatically loopback either: +/// a server that binds a tailnet address to serve a laptop is NOT reachable at +/// 127.0.0.1, so the machine running the server needs a real client address +/// too. +/// +/// Lives at `~/.config/hark/client.json`: +/// +/// { +/// "server": "http://100.64.66.46:8911/dictate", +/// "key": "...", +/// "allowPlaintext": true +/// } +public struct ClientConfig { + public let serverURL: URL + public let key: String + + public enum Failure: Error, CustomStringConvertible { + case unreadable(String) + case malformed(String) + case insecure(String) + + public var description: String { + switch self { + case .unreadable(let m), .malformed(let m), .insecure(let m): return m + } + } + } + + /// Test-only override, mirroring `KeyFile.pathOverride`. + public static var pathOverride: URL? + + /// HARK_CLIENT_CONFIG overrides the path, mirroring the server side's + /// HARK_CONFIG. Neither FileManager.homeDirectoryForCurrentUser nor + /// NSHomeDirectory() reliably honours $HOME on macOS — both resolve through + /// the user database — so `HOME=... hark agent` silently reads the real + /// config. That produced a round of "passing" policy checks that were all + /// reading the same live file. An explicit variable is the only honest way + /// to point this somewhere else. + public static var path: URL { + if let pathOverride { return pathOverride } + if let env = ProcessInfo.processInfo.environment["HARK_CLIENT_CONFIG"], !env.isEmpty { + return URL(fileURLWithPath: env) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/hark/client.json") + } + + private struct Wire: Decodable { + let server: String? + let key: String? + let allowPlaintext: Bool? + } + + /// Reads client.json. Falls back to the single-machine defaults — loopback + /// plus `~/.config/hark/key` — when the file is absent, so an existing + /// same-machine install keeps working untouched. + public static func load(defaultPort: Int) throws -> ClientConfig { + guard let data = try? Data(contentsOf: path) else { + let url = URL(string: "http://127.0.0.1:\(defaultPort)/dictate")! + return ClientConfig(serverURL: url, key: KeyFile.load() ?? "") + } + guard let wire = try? JSONDecoder().decode(Wire.self, from: data) else { + throw Failure.malformed("\(path.path) is not valid JSON") + } + + let raw = wire.server ?? "http://127.0.0.1:\(defaultPort)/dictate" + guard let url = URL(string: raw), let host = url.host, let scheme = url.scheme else { + throw Failure.malformed("\(path.path): \"\(raw)\" is not a usable URL") + } + + try validateTransport(url: url, host: host, scheme: scheme, + allowPlaintext: wire.allowPlaintext ?? false) + + let key = wire.key ?? KeyFile.load() ?? "" + guard !key.isEmpty else { + throw Failure.unreadable( + "no shared secret: set \"key\" in \(path.path), or place the server's " + + "~/.config/hark/key on this Mac") + } + return ClientConfig(serverURL: url, key: key) + } + + /// The transport policy from the design doc, enforced rather than assumed: + /// + /// - plain HTTP to numeric loopback is always fine — nothing leaves the host + /// - plain HTTP to a numeric IP is a defensible choice on a tailnet, but it + /// must be a STATED one, so it needs `allowPlaintext` + /// - plain HTTP to a hostname is refused outright. A name resolves through + /// something, and "the tailnet is trusted" stops being true the moment + /// the name resolves somewhere else. Use the tailnet IP. + static func validateTransport(url: URL, host: String, scheme: String, + allowPlaintext: Bool) throws { + guard scheme == "http" else { return } // https needs no argument + if isLoopback(host) { return } + + guard isNumericIP(host) else { + throw Failure.insecure( + "\(path.path): plain HTTP to the hostname \"\(host)\" is not allowed. " + + "Use the numeric address (a Tailscale MagicDNS name is a hostname), " + + "or https://") + } + guard allowPlaintext else { + throw Failure.insecure( + "\(path.path): plain HTTP to \(host) needs \"allowPlaintext\": true. " + + "Audio and transcripts cross the network unencrypted; on a tailnet " + + "that is defensible, but it should be a decision you made.") + } + } + + static func isLoopback(_ host: String) -> Bool { + host == "127.0.0.1" || host == "::1" || host == "localhost" + } + + /// IPv4 dotted-quad or anything bracketed/colon-bearing (IPv6). Deliberately + /// narrow: the question is only "did the user give an address or a name". + static func isNumericIP(_ host: String) -> Bool { + if host.contains(":") { return true } + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { p in + guard !p.isEmpty, p.count <= 3, p.allSatisfy(\.isNumber) else { return false } + return Int(p).map { (0...255).contains($0) } ?? false + } + } +} diff --git a/swift/Sources/hark/AgentController.swift b/swift/Sources/hark/AgentController.swift index 6292f5c..e6ee71d 100644 --- a/swift/Sources/hark/AgentController.swift +++ b/swift/Sources/hark/AgentController.swift @@ -15,6 +15,7 @@ public enum AgentState { case idle, starting, recording, stopping, uploading } public final class AgentController: NSObject { private let config: HarkConfig + private let clientConfig: ClientConfig private let hotkey = Hotkey() private let recorder = Recorder() private var dictate: DictateClient @@ -31,8 +32,24 @@ public final class AgentController: NSObject { public init(config: HarkConfig) { self.config = config self.log = Log() - let url = URL(string: "http://127.0.0.1:\(config.harkPort)/dictate")! - self.dictate = DictateClient(url: url, key: KeyFile.load() ?? "") + + // Client addressing is NOT the server's deployment config. A recording + // Mac has no server, no key file and nothing on loopback; and a server + // bound to a tailnet address is unreachable at 127.0.0.1 even on the + // machine running it. See ClientConfig. + let client: ClientConfig + do { + client = try ClientConfig.load(defaultPort: config.harkPort) + } catch { + // Fatal on purpose. Continuing would build a client pointed at + // loopback that fails on every utterance with a connection error, + // which reads as "the server is down" rather than "your config is + // wrong" — and that misdirection is expensive. + FileHandle.standardError.write("hark: \(error)\n".data(using: .utf8)!) + exit(2) + } + self.clientConfig = client + self.dictate = DictateClient(url: client.serverURL, key: client.key) super.init() } @@ -45,7 +62,7 @@ public final class AgentController: NSObject { hotkey.onPress = { [weak self] in self?.beginCapture() } hotkey.onRelease = { [weak self] in self?.endCapture() } if !hotkey.register() { - alert("hark: Accessibility is NOT granted. The hotkey (Ctrl+Alt+Space) cannot work until you enable it.") + alert("hark: could not bind Ctrl+Alt+Space — something else is probably holding it.") } heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in @@ -139,7 +156,21 @@ public final class AgentController: NSObject { // MARK: - Paste - private func apply(_ text: String) { + private func apply(_ transcript: String) { + // A trailing space, so consecutive dictations do not run together. + // + // The server sanitiser ends with .strip(), and Whisper's own leading + // space goes with it — correct for an API, which should return the + // transcript and not presentation whitespace. But two dictations into + // the same field then paste as "one, two, three.one, two, three." with + // nothing between them. Long-standing; the Lua client did the same. + // + // Trailing rather than leading: a leading space would open an empty + // field with whitespace, and there is no reliable way to read what sits + // immediately before the cursor to decide. Trailing is occasionally + // redundant and never wrong. + let text = transcript + " " + // Set the pasteboard and verify the write before synthesising ⌘V. let pb = NSPasteboard.general pb.clearContents() @@ -147,7 +178,7 @@ public final class AgentController: NSObject { alert("hark: could not write to the pasteboard — not pasting.") return } - log.info("pasting \(text.count) chars") + log.info("pasting \(transcript.count) chars") // Paste-target policy: never type into whatever gained focus since release. if frontmostAppName() != frontmostAtRelease { brief("transcript is on the clipboard — paste withheld (focus moved)") @@ -161,14 +192,36 @@ public final class AgentController: NSObject { } private func paste() { + // Re-checked here, not just at startup: the grant can be revoked, or + // silently invalidated by a rebuild, while the process keeps running, + // and this is the moment it matters. Without it every step above still + // reports success — the pasteboard write, the log line — and an + // untrusted process is indistinguishable from a dropped event. + guard accessibilityTrusted() else { + alert("hark: Accessibility is not granted, so the transcript cannot be pasted. " + + "It IS on the clipboard — press ⌘V. " + + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark.") + return + } + // Synthesise ⌘V. NEVER Return/Enter — auto-submit is a hard non-goal. let source = CGEventSource(stateID: .hidSystemState) let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true) // kVK_ANSI_V down?.flags = .maskCommand down?.post(tap: .cghidEventTap) - let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) - up?.flags = .maskCommand - up?.post(tap: .cghidEventTap) + + // HOLD THE KEY. Posting key-up immediately after key-down is a + // zero-duration keystroke, which some apps silently drop — the events + // arrive, nothing acts on them, and the transcript never appears. + // hs.eventtap.keyStroke, the implementation being replaced, holds for + // 200 ms (`local keyDelay = 200000`, then usleep between down and up); + // the tap and the flags are otherwise identical. Async rather than a + // sleep so the run loop is not stalled behind it. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) + up?.flags = .maskCommand + up?.post(tap: .cghidEventTap) + } } private func clearIfOwned() { @@ -182,6 +235,14 @@ public final class AgentController: NSObject { private func showRecordingIndicator(_ on: Bool) { statusItem?.button?.title = on ? "●" : "hark" + // The menu bar title alone is easy to miss on a crowded bar, and it is + // the only signal that the mic is actually open. nil duration: cleared + // when capture really ends, never on a timer. + if on { + Overlay.shared.show("● Recording…", duration: nil) + } else { + Overlay.shared.hide() + } } private func setupMenuBar() { @@ -197,10 +258,21 @@ public final class AgentController: NSObject { private func alert(_ message: String) { log.info(message) - NSWorkspace.shared.notificationCenter.post(name: .init("HarkAlert"), object: message) + // Was posted to an NSWorkspace notification nobody observed, which + // discarded the whole diagnostic surface. Show it, and beep: if + // dictation does nothing the instinct is to try again, and a second + // silent failure reads as a broken mic rather than a stale key. + Overlay.beep() + Overlay.shared.show(message, duration: 7) NSSound(named: "Basso")?.play() } - private func brief(_ message: String) { _ = message } + /// Short, non-error feedback — "heard nothing", "paste withheld". Was a + /// no-op, so the two states a user is most likely to hit and misread as a + /// hang produced no feedback at all. No beep: neither is a failure. + private func brief(_ message: String) { + log.info(message) + Overlay.shared.show(message, duration: 2) + } private func present(_ error: DictateError) -> String { switch error { @@ -222,7 +294,7 @@ public final class AgentController: NSObject { return "" } - private var dictateServer: String { "http://127.0.0.1:\(config.harkPort)/dictate" } + private var dictateServer: String { clientConfig.serverURL.absoluteString } // MARK: - Permissions @@ -239,7 +311,22 @@ public final class AgentController: NSObject { private func probePermissions() { // Raise the microphone dialog if never asked. if microphoneStatus() == .notDetermined { - AVCaptureDevice.requestAccess(for: .audio) { _ in } + // Wait for the answer rather than discarding it. The completion is + // delivered on an unspecified queue, so this pumps the run loop + // instead of blocking on a semaphore, which would deadlock if that + // queue turned out to be main. Continuing without the answer means + // recording the substituted silence macOS hands an ungranted + // process — full-length buffers of zeros, indistinguishable from a + // quiet room until you check the peak. + var granted: Bool? + AVCaptureDevice.requestAccess(for: .audio) { granted = $0 } + let deadline = Date(timeIntervalSinceNow: 60) + while granted == nil, Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + if granted != true { + log.info("microphone access was not granted") + } } // AXIsProcessTrusted alone checks but never prompts; use the option. let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary @@ -251,7 +338,7 @@ public final class AgentController: NSObject { private func writeHeartbeat() { let heartbeat = Heartbeat.current(microphone: microphoneStatus() == .authorized ? "authorized" : "denied", accessibility: accessibilityTrusted() ? "trusted" : "not_trusted", - hotkey: "registered") + hotkey: hotkey.isRegistered ? "registered" : "not_registered") heartbeat.write() } } diff --git a/swift/Sources/hark/Hotkey.swift b/swift/Sources/hark/Hotkey.swift index b97057f..beba38b 100644 --- a/swift/Sources/hark/Hotkey.swift +++ b/swift/Sources/hark/Hotkey.swift @@ -1,76 +1,121 @@ import ApplicationServices +import Carbon.HIToolbox import CoreGraphics import Foundation /// Global hold-to-talk hotkey (Ctrl+Alt+Space) for the agent. /// -/// ⚠️ PHASE 0 OPEN QUESTION (per the native-client design, §Phase 0): the exact -/// mechanism — a keyboard `CGEventTap` (this implementation) versus Carbon -/// `RegisterEventHotKey` — is meant to be decided by a hardware spike, because -/// it decides whether one TCC grant (Accessibility) is enough or a keyboard -/// event tap also needs an Input Monitoring grant. This file implements the -/// tap variant as a working default; Phase 0 must validate which grant set the -/// final signed bundle actually needs before release. +/// PHASE 0 IS DECIDED: Carbon `RegisterEventHotKey`, not a keyboard +/// `CGEventTap`. The design left this to a hardware spike; the spike has now +/// been run, and the tap loses on correctness rather than on taste. +/// +/// WHY THE TAP FAILED +/// +/// A tap sees raw key events, so a chord must be reconstructed from the +/// keycode plus the modifier flags carried on that event — and those flags +/// describe the instant the event was generated. Releasing Ctrl+Alt+Space +/// almost always lifts a modifier at or before the space bar, so the space +/// key-UP arrives with the modifier bits already clear. A chord test applied +/// to both edges therefore matches the press and misses the release. +/// +/// Measured with a bare session tap over 45 s of ordinary use: +/// +/// DOWN events: 303 (auto-repeat while held) +/// UP events: 1 +/// +/// One release out of dozens. In the agent that meant a capture that never +/// ended, and because `beginCapture()` guards on `state == .idle`, every +/// subsequent press was ignored — presenting as "the hotkey stopped working" +/// rather than as a missed key-up. +/// +/// The fix is NOT to loosen the key-up test. Tracking "a capture is open, so +/// end it on any space key-up" re-implements, badly, something the OS already +/// does correctly: `RegisterEventHotKey` delivers `kEventHotKeyPressed` and +/// `kEventHotKeyReleased` as distinct events, and the release is not +/// conditional on the modifiers still being held. +/// +/// Two further advantages, both resolving open questions in the design: +/// +/// - It CONSUMES the chord, so Ctrl+Alt+Space does not also reach whatever +/// app has focus. A global tap that passes events through cannot. +/// - It needs no permission of its own, settling the Phase 0 question of +/// whether a keyboard tap would additionally require Input Monitoring. +/// Accessibility is still required — for synthesising the ⌘V paste — so +/// the agent asks for exactly one grant either way. +/// +/// It is also what `hs.hotkey` used underneath, making it the mechanism +/// already proven against this exact chord and hold pattern. public final class Hotkey { public var onPress: (() -> Void)? public var onRelease: (() -> Void)? - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? + private var hotKeyRef: EventHotKeyRef? + private var handlerRef: EventHandlerRef? - private static let keySpace: Int64 = 49 // kVK_Space + /// The Carbon handler is a C function pointer and can capture nothing, so + /// the live instance is reached through this. Only one hotkey is ever + /// registered; a second `register()` replaces the first. + fileprivate static weak var current: Hotkey? - /// Register the global tap. Returns false if Accessibility is not granted - /// (the tap cannot be created), so the controller can surface it. + /// Returns false if the hotkey could not be bound — most likely because + /// something else already owns Ctrl+Alt+Space. @discardableResult public func register() -> Bool { - guard AXIsProcessTrusted() else { return false } + unregister() + Hotkey.current = self - let mask = CGEventMask(1 << CGEventType.keyDown.rawValue) - | CGEventMask(1 << CGEventType.keyUp.rawValue) - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: mask, - callback: { proxy, type, event, refcon in - guard let refcon else { return Unmanaged.passUnretained(event) } - let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - me.handle(type: type, event: event) - return Unmanaged.passUnretained(event) - }, - userInfo: Unmanaged.passUnretained(self).toOpaque()) else { - return false - } - eventTap = tap - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - runLoopSource = source - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - return true + var specs = [ + EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)), + EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyReleased)), + ] + + let installed = InstallEventHandler( + GetApplicationEventTarget(), hotKeyEventHandler, specs.count, &specs, nil, &handlerRef) + guard installed == noErr else { return false } + + // 'HARK' as an OSType — the conventional four-char signature. + let id = EventHotKeyID(signature: OSType(0x4841_524B), id: 1) + + // Ctrl+Alt+Space, NOT Cmd+Alt+Space: the latter is macOS's Finder + // search shortcut and the system wins that fight. + let registered = RegisterEventHotKey( + UInt32(kVK_Space), UInt32(controlKey | optionKey), + id, GetApplicationEventTarget(), 0, &hotKeyRef) + return registered == noErr } public func unregister() { - if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } - if let runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) } - eventTap = nil - runLoopSource = nil + if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) } + if let handlerRef { RemoveEventHandler(handlerRef) } + hotKeyRef = nil + handlerRef = nil + if Hotkey.current === self { Hotkey.current = nil } } - private func handle(type: CGEventType, event: CGEvent) { - // Ignore auto-repeat (the key is held) — nothing happens on repeat. - let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 - let keycode = event.getIntegerValueField(.keyboardEventKeycode) - let flags = event.flags - let isChord = keycode == Self.keySpace - && flags.contains(.maskControl) - && flags.contains(.maskAlternate) - guard isChord, !isRepeat else { return } + /// True when the chord is actually bound — used by the heartbeat, which + /// previously reported a hardcoded "registered" whether or not anything + /// had been bound. + public var isRegistered: Bool { hotKeyRef != nil } - if type == .keyDown { - onPress?() - } else if type == .keyUp { - onRelease?() + fileprivate func handle(kind: Int) { + switch kind { + case kEventHotKeyPressed: onPress?() + case kEventHotKeyReleased: onRelease?() + default: break } } } + +/// Carbon dispatches on the main thread, which is where the agent's state +/// machine lives, so no queue hop is needed. +private let hotKeyEventHandler: EventHandlerUPP = { _, event, _ -> OSStatus in + guard let event, let hotkey = Hotkey.current else { return OSStatus(eventNotHandledErr) } + let kind = Int(GetEventKind(event)) + guard kind == kEventHotKeyPressed || kind == kEventHotKeyReleased else { + return OSStatus(eventNotHandledErr) + } + hotkey.handle(kind: kind) + return noErr +} diff --git a/swift/Sources/hark/Overlay.swift b/swift/Sources/hark/Overlay.swift new file mode 100644 index 0000000..10e2c8e --- /dev/null +++ b/swift/Sources/hark/Overlay.swift @@ -0,0 +1,100 @@ +import AppKit +import Foundation + +/// On-screen presentation for the agent: the recording indicator and every +/// user-facing message. +/// +/// This exists because the agent had no presentation layer at all. `alert()` +/// posted an `NSWorkspace` notification named "HarkAlert" that nothing +/// anywhere observed, and `brief()` was `{ _ = message }` — so the entire +/// diagnostic surface, 401/415/400/503, transport failures, "heard nothing", +/// "paste withheld (focus moved)", was silently discarded. The menu bar title +/// flipping to "●" was the only feedback of any kind, and it is easy to miss +/// on a crowded menu bar. +/// +/// A silent failure is the worst outcome here. If dictation does nothing the +/// instinct is to try again, and a second silent failure reads as "the mic is +/// broken" when the real cause might be a stale key or a downed tailnet link. +final class Overlay { + static let shared = Overlay() + + private var panel: NSPanel? + private var dismissTimer: Timer? + + /// `duration: nil` pins the panel until `hide()` — used by the recording + /// indicator, which is cleared when capture actually ends rather than on a + /// timer. + func show(_ text: String, duration: TimeInterval?) { + precondition(Thread.isMainThread) + hide() + + let label = NSTextField(wrappingLabelWithString: text) + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = .white + label.alignment = .center + label.isEditable = false + label.isSelectable = false + label.drawsBackground = false + label.preferredMaxLayoutWidth = 520 + + let size = label.sizeThatFits(NSSize(width: 520, height: CGFloat.greatestFiniteMagnitude)) + let padding: CGFloat = 22 + let frame = NSRect(x: 0, y: 0, + width: size.width + padding * 2, + height: size.height + padding * 2) + + // .nonactivatingPanel is load-bearing: without it, showing the panel + // pulls keyboard focus away from whatever the user is dictating into, + // which is the one thing this tool must never do. + let panel = NSPanel(contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.level = .floating + panel.ignoresMouseEvents = true + panel.hasShadow = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + + let container = NSVisualEffectView(frame: frame) + container.material = .hudWindow + container.blendingMode = .behindWindow + container.state = .active + container.wantsLayer = true + container.layer?.cornerRadius = 14 + container.layer?.masksToBounds = true + + label.frame = NSRect(x: padding, y: padding, width: size.width, height: size.height) + container.addSubview(label) + panel.contentView = container + + if let screen = NSScreen.main { + let visible = screen.visibleFrame + panel.setFrameOrigin(NSPoint(x: visible.midX - frame.width / 2, + y: visible.minY + visible.height * 0.18)) + } + + panel.orderFrontRegardless() + self.panel = panel + + if let duration { + dismissTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in + DispatchQueue.main.async { Overlay.shared.hide() } + } + } + } + + func hide() { + dismissTimer?.invalidate() + dismissTimer = nil + panel?.orderOut(nil) + panel = nil + } + + /// "Basso" is a built-in macOS alert sound, chosen because it reads as a + /// failure tone rather than routine feedback. + static func beep() { + NSSound(named: NSSound.Name("Basso"))?.play() + } +} diff --git a/swift/Sources/hark/Recorder.swift b/swift/Sources/hark/Recorder.swift index f1b5bf7..5f9a893 100644 --- a/swift/Sources/hark/Recorder.swift +++ b/swift/Sources/hark/Recorder.swift @@ -49,6 +49,16 @@ public final class Recorder { /// Permission must already be granted. Throws on a device failure before /// the engine starts. public func start() throws { + // Reset per-capture state. Recorder is long-lived and both of these + // are instance properties, so without this every capture appends to + // all previous audio: capture 2 transcribes 1+2, capture 3 transcribes + // 1+2+3, and the WAV grows without bound. Presents as the transcript + // repeating what you said last time. + queue.sync { + samples.removeAll(keepingCapacity: true) + peak = 0 + } + let input = engine.inputNode let inFormat = input.inputFormat(forBus: 0) guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {