agentix-cli 0.58.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
- package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
- package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
- package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
- package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
- package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +20 -5
- package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -1
- package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
- package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +54 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -36
- package/apps/mac-voice/build.sh +10 -1
- package/dist/{App-Z5VOAUXB.js → App-LQXWKBVY.js} +2 -2
- package/dist/{ChatApp-BE7ONQKS.js → ChatApp-75TL4LKW.js} +2 -2
- package/dist/{agent-Q74EISKP.js → agent-DH4FMMMY.js} +2 -2
- package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-BVRRDYAG.js} +223 -138
- package/dist/board-dashboard-BVRRDYAG.js.map +1 -0
- package/dist/{chunk-NKMFHEGV.js → chunk-27P5NLTC.js} +2 -2
- package/dist/{chunk-5LLGIX5M.js → chunk-2VQIPJH5.js} +2 -2
- package/dist/{chunk-V7ZK6ZIO.js → chunk-3XVTFNYM.js} +2 -2
- package/dist/{chunk-KW7TIOS3.js → chunk-B3DCYBP4.js} +2 -2
- package/dist/{chunk-547G3HYQ.js → chunk-CRSXKQLZ.js} +2 -2
- package/dist/{chunk-5W7NJHCS.js → chunk-DTEQNXK3.js} +2 -2
- package/dist/{chunk-XTD3SZIP.js → chunk-E2BJVHBR.js} +2 -2
- package/dist/{chunk-RXEAACVH.js → chunk-E2PWCU2N.js} +2 -2
- package/dist/{chunk-I2LYULR4.js → chunk-JO3PSLWL.js} +5 -5
- package/dist/{chunk-U5YBQJ5Q.js → chunk-JORXRF4E.js} +2 -2
- package/dist/{chunk-2MIOMYT2.js → chunk-LXUG2X26.js} +2 -2
- package/dist/{chunk-Q6SMLSRO.js → chunk-N5KD3TVN.js} +2 -2
- package/dist/{chunk-VI7GBDKV.js → chunk-NBGNPKAF.js} +2 -2
- package/dist/{chunk-6QNPCVRN.js → chunk-PAOV37KQ.js} +3 -3
- package/dist/chunk-PAOV37KQ.js.map +1 -0
- package/dist/{chunk-OZZPJ2YG.js → chunk-RYFUFX6F.js} +194 -181
- package/dist/chunk-RYFUFX6F.js.map +1 -0
- package/dist/chunk-S7TCVIET.js +15 -0
- package/dist/chunk-S7TCVIET.js.map +1 -0
- package/dist/{chunk-POL7RAJQ.js → chunk-TM762AXU.js} +159 -153
- package/dist/chunk-TM762AXU.js.map +1 -0
- package/dist/{chunk-6OABTCRA.js → chunk-WBGC7MWX.js} +2 -2
- package/dist/{chunk-CQUVUVXR.js → chunk-WIBMNQKJ.js} +2 -2
- package/dist/{chunk-KKGFZEXS.js → chunk-WOPFHKBF.js} +37 -36
- package/dist/chunk-WOPFHKBF.js.map +1 -0
- package/dist/{chunk-VVBGPT6W.js → chunk-YQCOX4R3.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-QVZIZFV4.js +2 -0
- package/dist/config-3XC7EYB7.js +2 -0
- package/dist/config-mutator-HKU63FJM.js +2 -0
- package/dist/doctor-YFA4ZSRH.js +2 -0
- package/dist/index.d.ts +66 -7
- package/dist/index.js +1 -1
- package/dist/program-BOCLU6ES.js +2 -0
- package/dist/{promote-KVPLDSKT.js → promote-UR2GSHKC.js} +2 -2
- package/dist/query-XUP3BMVS.js +2 -0
- package/dist/{registry-AUOI4TW2.js → registry-PGALUW5L.js} +2 -2
- package/dist/{request-planner-LMZ5ZAXC.js → request-planner-NHE3IRJN.js} +2 -2
- package/dist/{review-candidates-WCBXLLDS.js → review-candidates-6GKRSRBQ.js} +2 -2
- package/dist/{routing-FBASYKUL.js → routing-AQKHAU3Z.js} +2 -2
- package/dist/schedule-tool-6NMCLTIH.js +2 -0
- package/dist/scheduler-RAYGNDWT.js +2 -0
- package/dist/{seat-DBFW3Z5P.js → seat-6FMW2QI4.js} +2 -2
- package/dist/{select-6CNVVFA5.js → select-5VONJMLH.js} +2 -2
- package/dist/{set-enabled-AYZH3KSB.js → set-enabled-YHD3IJJZ.js} +2 -2
- package/dist/{tool-PKS4NBZ6.js → tool-MXJGLJZ5.js} +2 -2
- package/dist/wiki-ZQLEYY24.js +2 -0
- package/package.json +1 -1
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
- package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
- package/dist/chunk-6QNPCVRN.js.map +0 -1
- package/dist/chunk-ERL6AS3O.js +0 -15
- package/dist/chunk-ERL6AS3O.js.map +0 -1
- package/dist/chunk-KKGFZEXS.js.map +0 -1
- package/dist/chunk-OZZPJ2YG.js.map +0 -1
- package/dist/chunk-POL7RAJQ.js.map +0 -1
- package/dist/client-EFP2PKA3.js +0 -2
- package/dist/config-P6YXC6XZ.js +0 -2
- package/dist/config-mutator-3GKXLTHH.js +0 -2
- package/dist/doctor-JEWMHXYH.js +0 -2
- package/dist/program-7DUQJDSQ.js +0 -2
- package/dist/query-AGOMCYTT.js +0 -2
- package/dist/schedule-tool-RJRJ63SS.js +0 -2
- package/dist/scheduler-ATA7Z44S.js +0 -2
- package/dist/wiki-QW6BDNEQ.js +0 -2
- /package/dist/{App-Z5VOAUXB.js.map → App-LQXWKBVY.js.map} +0 -0
- /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-75TL4LKW.js.map} +0 -0
- /package/dist/{agent-Q74EISKP.js.map → agent-DH4FMMMY.js.map} +0 -0
- /package/dist/{chunk-NKMFHEGV.js.map → chunk-27P5NLTC.js.map} +0 -0
- /package/dist/{chunk-5LLGIX5M.js.map → chunk-2VQIPJH5.js.map} +0 -0
- /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-3XVTFNYM.js.map} +0 -0
- /package/dist/{chunk-KW7TIOS3.js.map → chunk-B3DCYBP4.js.map} +0 -0
- /package/dist/{chunk-547G3HYQ.js.map → chunk-CRSXKQLZ.js.map} +0 -0
- /package/dist/{chunk-5W7NJHCS.js.map → chunk-DTEQNXK3.js.map} +0 -0
- /package/dist/{chunk-XTD3SZIP.js.map → chunk-E2BJVHBR.js.map} +0 -0
- /package/dist/{chunk-RXEAACVH.js.map → chunk-E2PWCU2N.js.map} +0 -0
- /package/dist/{chunk-I2LYULR4.js.map → chunk-JO3PSLWL.js.map} +0 -0
- /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-JORXRF4E.js.map} +0 -0
- /package/dist/{chunk-2MIOMYT2.js.map → chunk-LXUG2X26.js.map} +0 -0
- /package/dist/{chunk-Q6SMLSRO.js.map → chunk-N5KD3TVN.js.map} +0 -0
- /package/dist/{chunk-VI7GBDKV.js.map → chunk-NBGNPKAF.js.map} +0 -0
- /package/dist/{chunk-6OABTCRA.js.map → chunk-WBGC7MWX.js.map} +0 -0
- /package/dist/{chunk-CQUVUVXR.js.map → chunk-WIBMNQKJ.js.map} +0 -0
- /package/dist/{chunk-VVBGPT6W.js.map → chunk-YQCOX4R3.js.map} +0 -0
- /package/dist/{client-EFP2PKA3.js.map → client-QVZIZFV4.js.map} +0 -0
- /package/dist/{config-P6YXC6XZ.js.map → config-3XC7EYB7.js.map} +0 -0
- /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-HKU63FJM.js.map} +0 -0
- /package/dist/{doctor-JEWMHXYH.js.map → doctor-YFA4ZSRH.js.map} +0 -0
- /package/dist/{program-7DUQJDSQ.js.map → program-BOCLU6ES.js.map} +0 -0
- /package/dist/{promote-KVPLDSKT.js.map → promote-UR2GSHKC.js.map} +0 -0
- /package/dist/{query-AGOMCYTT.js.map → query-XUP3BMVS.js.map} +0 -0
- /package/dist/{registry-AUOI4TW2.js.map → registry-PGALUW5L.js.map} +0 -0
- /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-NHE3IRJN.js.map} +0 -0
- /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
- /package/dist/{routing-FBASYKUL.js.map → routing-AQKHAU3Z.js.map} +0 -0
- /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
- /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-RAYGNDWT.js.map} +0 -0
- /package/dist/{seat-DBFW3Z5P.js.map → seat-6FMW2QI4.js.map} +0 -0
- /package/dist/{select-6CNVVFA5.js.map → select-5VONJMLH.js.map} +0 -0
- /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
- /package/dist/{tool-PKS4NBZ6.js.map → tool-MXJGLJZ5.js.map} +0 -0
- /package/dist/{wiki-QW6BDNEQ.js.map → wiki-ZQLEYY24.js.map} +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import CoreML
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Silero VAD on Core ML: how likely each 32 ms of 16 kHz audio is speech.
|
|
5
|
+
///
|
|
6
|
+
/// Core ML rather than ONNX Runtime: the model is 0.9 MB, macOS already
|
|
7
|
+
/// ships the runtime, and the app keeps building with plain swiftc and no
|
|
8
|
+
/// package dependency. The weights come from ModelStore.vad on first use.
|
|
9
|
+
///
|
|
10
|
+
/// Streaming: the model is an LSTM, so each call carries the previous
|
|
11
|
+
/// call's state and the last 64 samples as context, exactly as Silero's
|
|
12
|
+
/// own ONNX wrapper does. One `SileroVAD` per recording; the loaded model
|
|
13
|
+
/// is shared.
|
|
14
|
+
final class SileroVAD {
|
|
15
|
+
static let chunk = 512
|
|
16
|
+
static let context = 64
|
|
17
|
+
static let sampleRate = 16_000.0
|
|
18
|
+
static var frameDuration: TimeInterval { Double(chunk) / sampleRate }
|
|
19
|
+
|
|
20
|
+
private static let lock = NSLock()
|
|
21
|
+
nonisolated(unsafe) private static var cached: MLModel?
|
|
22
|
+
|
|
23
|
+
/// The shared model, loaded once. Nil when it is not installed or will
|
|
24
|
+
/// not load; the caller then falls back to the volume threshold.
|
|
25
|
+
static func loadModel() throws -> MLModel? {
|
|
26
|
+
lock.lock(); defer { lock.unlock() }
|
|
27
|
+
if let cached { return cached }
|
|
28
|
+
guard ModelStore.vad.isInstalled else { return nil }
|
|
29
|
+
let config = MLModelConfiguration()
|
|
30
|
+
// Tiny and called every 32 ms: the CPU answers faster than a trip
|
|
31
|
+
// to the Neural Engine.
|
|
32
|
+
config.computeUnits = .cpuOnly
|
|
33
|
+
let url = ModelStore.vad.url("silero-vad-unified-v6.0.0.mlmodelc")
|
|
34
|
+
let model = try MLModel(contentsOf: url, configuration: config)
|
|
35
|
+
cached = model
|
|
36
|
+
return model
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private let model: MLModel
|
|
40
|
+
private let audio: MLMultiArray
|
|
41
|
+
private var hidden: MLMultiArray
|
|
42
|
+
private var cell: MLMultiArray
|
|
43
|
+
private var pending: [Float] = []
|
|
44
|
+
private var tail = [Float](repeating: 0, count: SileroVAD.context)
|
|
45
|
+
|
|
46
|
+
init(model: MLModel) throws {
|
|
47
|
+
self.model = model
|
|
48
|
+
audio = try MLMultiArray(shape: [1, NSNumber(value: Self.context + Self.chunk)], dataType: .float32)
|
|
49
|
+
hidden = try Self.zeros()
|
|
50
|
+
cell = try Self.zeros()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private static func zeros() throws -> MLMultiArray {
|
|
54
|
+
let a = try MLMultiArray(shape: [1, 128], dataType: .float32)
|
|
55
|
+
for i in 0..<a.count { a[i] = 0 }
|
|
56
|
+
return a
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Feeds samples (16 kHz, -1…1) and returns one probability per
|
|
60
|
+
/// complete 512-sample frame; leftovers wait for the next call.
|
|
61
|
+
func process(_ samples: [Float]) throws -> [Float] {
|
|
62
|
+
pending.append(contentsOf: samples)
|
|
63
|
+
var out: [Float] = []
|
|
64
|
+
var start = 0
|
|
65
|
+
while pending.count - start >= Self.chunk {
|
|
66
|
+
let frame = pending[start..<start + Self.chunk]
|
|
67
|
+
out.append(try score(frame))
|
|
68
|
+
start += Self.chunk
|
|
69
|
+
}
|
|
70
|
+
pending.removeFirst(start)
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func score(_ frame: ArraySlice<Float>) throws -> Float {
|
|
75
|
+
let ptr = audio.dataPointer.bindMemory(to: Float.self, capacity: audio.count)
|
|
76
|
+
for i in 0..<Self.context { ptr[i] = tail[i] }
|
|
77
|
+
var i = Self.context
|
|
78
|
+
for s in frame { ptr[i] = s; i += 1 }
|
|
79
|
+
tail = Array(frame.suffix(Self.context))
|
|
80
|
+
|
|
81
|
+
let input = try MLDictionaryFeatureProvider(dictionary: [
|
|
82
|
+
"audio_input": MLFeatureValue(multiArray: audio),
|
|
83
|
+
"hidden_state": MLFeatureValue(multiArray: hidden),
|
|
84
|
+
"cell_state": MLFeatureValue(multiArray: cell),
|
|
85
|
+
])
|
|
86
|
+
let result = try model.prediction(from: input)
|
|
87
|
+
if let h = result.featureValue(for: "new_hidden_state")?.multiArrayValue { hidden = h }
|
|
88
|
+
if let c = result.featureValue(for: "new_cell_state")?.multiArrayValue { cell = c }
|
|
89
|
+
guard let p = result.featureValue(for: "vad_output")?.multiArrayValue, p.count > 0 else { return 0 }
|
|
90
|
+
return p[0].floatValue
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -38,16 +38,18 @@ enum Speech {
|
|
|
38
38
|
// MARK: Speech to text
|
|
39
39
|
|
|
40
40
|
/// `engine` is agentx.json's `voice.stt`: "auto" and "elevenlabs" use
|
|
41
|
-
/// ElevenLabs when there is a key, falling back to
|
|
42
|
-
/// never sends audio off the Mac.
|
|
43
|
-
|
|
41
|
+
/// ElevenLabs when there is a key, falling back to the local engine;
|
|
42
|
+
/// "local" never sends audio off the Mac. `local` is `voice.localStt`:
|
|
43
|
+
/// "mlx-whisper" or "parakeet".
|
|
44
|
+
static func transcribe(wav: Data, engine: String = "auto", local: String = "mlx-whisper") async throws -> String {
|
|
44
45
|
if engine == "elevenlabs" && Config.elevenLabsKey == nil {
|
|
45
|
-
Log.warn("speech to text is set to ElevenLabs but no key is set; using
|
|
46
|
+
Log.warn("speech to text is set to ElevenLabs but no key is set; using \(local)")
|
|
46
47
|
}
|
|
47
48
|
if engine != "local", let key = Config.elevenLabsKey {
|
|
48
49
|
do { return try await elevenLabsSTT(wav: wav, key: key) }
|
|
49
|
-
catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to
|
|
50
|
+
catch { Log.warn("ElevenLabs STT failed (\(error.localizedDescription)); falling back to \(local)") }
|
|
50
51
|
}
|
|
52
|
+
if local == "parakeet", let text = await LocalSTT.parakeet(wav: wav) { return text }
|
|
51
53
|
return try await mlxWhisper(wav: wav)
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -23,6 +23,8 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
23
23
|
/// The daemon's speaking queue, polled while anything is in flight.
|
|
24
24
|
private var queue: AgentClient.QueueState?
|
|
25
25
|
private var queuePoll: Timer?
|
|
26
|
+
/// The last three voice exchanges, for a quick replay.
|
|
27
|
+
private let recent = RecentExchanges()
|
|
26
28
|
|
|
27
29
|
/// Questions this widget has in flight, per agent. Set by the app.
|
|
28
30
|
var thinking: [String: Int] = [:] {
|
|
@@ -68,6 +70,15 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
68
70
|
return NSColor(srgbRed: c.r, green: c.g, blue: c.b, alpha: 1)
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/// The agent's orb palette, five colours deep to light, or nil when
|
|
74
|
+
/// the daemon sent none.
|
|
75
|
+
func palette(of id: String) -> [NSColor]? {
|
|
76
|
+
guard case .loaded(let agents) = roster,
|
|
77
|
+
let hexes = agents.first(where: { $0.id == id })?.palette?.colors, hexes.count == 5 else { return nil }
|
|
78
|
+
let colors = hexes.compactMap(OrbMath.parseHex).map { NSColor(srgbRed: $0.r, green: $0.g, blue: $0.b, alpha: 1) }
|
|
79
|
+
return colors.count == 5 ? colors : nil
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
/// Open the menu from the keyboard.
|
|
72
83
|
func open() { item.button?.performClick(nil) }
|
|
73
84
|
|
|
@@ -76,6 +87,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
76
87
|
let agents = await AgentClient.agents()
|
|
77
88
|
roster = agents.map { .loaded($0) } ?? .down
|
|
78
89
|
queue = await AgentClient.queueState()
|
|
90
|
+
await recent.refresh()
|
|
79
91
|
rebuild()
|
|
80
92
|
showBadge()
|
|
81
93
|
if reopenAfterRefresh { reopenAfterRefresh = false; open() }
|
|
@@ -175,6 +187,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
175
187
|
menu.addItem(row)
|
|
176
188
|
}
|
|
177
189
|
}
|
|
190
|
+
recent.addItems(to: menu, name: name(of:))
|
|
178
191
|
|
|
179
192
|
menu.addItem(.separator())
|
|
180
193
|
menu.addItem(action("Stop speaking", #selector(stop), key: ".", modifiers: [.command, .option]))
|
|
@@ -251,7 +264,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
251
264
|
|
|
252
265
|
@objc private func openSettings() { onSettings?() }
|
|
253
266
|
@objc private func openDashboardHome() { openDashboard("/admin") }
|
|
254
|
-
@objc private func openHistory() {
|
|
267
|
+
@objc private func openHistory() { HistoryWindow.shared.show() }
|
|
255
268
|
|
|
256
269
|
private func openDashboard(_ path: String) {
|
|
257
270
|
guard let url = URL(string: Config.dashboardURL + path) else { return }
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// The pill's background, drawn the way macOS draws its own floating
|
|
4
|
+
/// surfaces: popover vibrancy, a continuous-corner shape, a hairline
|
|
5
|
+
/// border, and the window's soft shadow around it.
|
|
6
|
+
///
|
|
7
|
+
/// It follows light and dark, Increase Contrast and Reduce Transparency as
|
|
8
|
+
/// they change, since layer colours are fixed values that do not update
|
|
9
|
+
/// themselves.
|
|
10
|
+
final class Surface: NSVisualEffectView {
|
|
11
|
+
/// An opaque-ish wash over the blur, and it is not a style preference.
|
|
12
|
+
///
|
|
13
|
+
/// `blendingMode = .behindWindow` composites whatever is behind the
|
|
14
|
+
/// pill, so an editor's panel dividers came through it as a faint
|
|
15
|
+
/// rectangle that read as a border the widget was drawing. The wash
|
|
16
|
+
/// keeps enough blur to feel native while making the pill read as one
|
|
17
|
+
/// solid object wherever it is parked.
|
|
18
|
+
private let wash = NSView()
|
|
19
|
+
|
|
20
|
+
/// Lays out the pill's row and its answer on every size change. Set by
|
|
21
|
+
/// the panel: the row stays put while the widget grows around it, which
|
|
22
|
+
/// autoresizing masks cannot say.
|
|
23
|
+
var layoutContent: ((NSRect) -> Void)?
|
|
24
|
+
|
|
25
|
+
override init(frame: NSRect) {
|
|
26
|
+
super.init(frame: frame)
|
|
27
|
+
material = .popover
|
|
28
|
+
blendingMode = .behindWindow
|
|
29
|
+
state = .active
|
|
30
|
+
wantsLayer = true
|
|
31
|
+
layer?.cornerRadius = Brand.Radius.lg
|
|
32
|
+
layer?.cornerCurve = .continuous
|
|
33
|
+
layer?.masksToBounds = true
|
|
34
|
+
// The layer's corners clip the wash and the answer, but not the
|
|
35
|
+
// blur: the window server draws that behind the whole rectangle
|
|
36
|
+
// unless the view has a mask image of the same shape.
|
|
37
|
+
maskImage = Self.mask(radius: Brand.Radius.lg)
|
|
38
|
+
|
|
39
|
+
wash.wantsLayer = true
|
|
40
|
+
wash.frame = bounds
|
|
41
|
+
wash.autoresizingMask = [.width, .height]
|
|
42
|
+
addSubview(wash)
|
|
43
|
+
|
|
44
|
+
NSWorkspace.shared.notificationCenter.addObserver(
|
|
45
|
+
self, selector: #selector(displayOptionsChanged),
|
|
46
|
+
name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil)
|
|
47
|
+
restyle()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@available(*, unavailable)
|
|
51
|
+
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
|
52
|
+
|
|
53
|
+
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
|
54
|
+
super.resizeSubviews(withOldSize: oldSize)
|
|
55
|
+
layoutContent?(bounds)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override func viewDidChangeEffectiveAppearance() {
|
|
59
|
+
super.viewDidChangeEffectiveAppearance()
|
|
60
|
+
restyle()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override func viewDidChangeBackingProperties() {
|
|
64
|
+
super.viewDidChangeBackingProperties()
|
|
65
|
+
restyle()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@objc private func displayOptionsChanged() { restyle() }
|
|
69
|
+
|
|
70
|
+
/// A stretchable continuous-corner shape: the corners stay, the middle
|
|
71
|
+
/// stretches to any size.
|
|
72
|
+
private static func mask(radius: CGFloat) -> NSImage {
|
|
73
|
+
// A continuous corner starts bending before `radius` from the edge.
|
|
74
|
+
let cap = ceil(radius * 1.6)
|
|
75
|
+
let side = cap * 2 + 1
|
|
76
|
+
let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in
|
|
77
|
+
guard let ctx = NSGraphicsContext.current?.cgContext else { return false }
|
|
78
|
+
let shape = CALayer()
|
|
79
|
+
shape.frame = rect
|
|
80
|
+
shape.backgroundColor = NSColor.black.cgColor
|
|
81
|
+
shape.cornerRadius = radius
|
|
82
|
+
shape.cornerCurve = .continuous
|
|
83
|
+
shape.render(in: ctx)
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
image.capInsets = NSEdgeInsets(top: cap, left: cap, bottom: cap, right: cap)
|
|
87
|
+
image.resizingMode = .stretch
|
|
88
|
+
return image
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Colours for the current appearance and accessibility settings.
|
|
92
|
+
private func restyle() {
|
|
93
|
+
let ws = NSWorkspace.shared
|
|
94
|
+
let contrast = ws.accessibilityDisplayShouldIncreaseContrast
|
|
95
|
+
let solid = ws.accessibilityDisplayShouldReduceTransparency
|
|
96
|
+
effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
97
|
+
wash.layer?.backgroundColor = NSColor.windowBackgroundColor
|
|
98
|
+
.withAlphaComponent(solid || contrast ? 0.96 : 0.72).cgColor
|
|
99
|
+
// One device pixel: a hairline, as system popovers have. With
|
|
100
|
+
// Increase Contrast it becomes a clear line in the text colour.
|
|
101
|
+
layer?.borderColor = (contrast ? NSColor.labelColor.withAlphaComponent(0.6) : NSColor.separatorColor).cgColor
|
|
102
|
+
layer?.borderWidth = (contrast ? 1.5 : 1) / (window?.backingScaleFactor ?? 2)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// When a hands-free turn has ended: the person spoke, then stopped for
|
|
4
|
+
/// long enough that the sentence is over.
|
|
5
|
+
///
|
|
6
|
+
/// It is fed one audio frame at a time with how likely that frame is to be
|
|
7
|
+
/// speech (0…1) and how long it lasted. Two sources feed it:
|
|
8
|
+
///
|
|
9
|
+
/// - Silero VAD, 32 ms frames with a real probability. A voice model
|
|
10
|
+
/// tells a word from a fan, a keyboard or a café, so the noise of a room
|
|
11
|
+
/// neither holds the microphone open nor counts as talking.
|
|
12
|
+
/// - The old volume check when the VAD model is not available: 1 above
|
|
13
|
+
/// `speechLevel`, 0 below, per audio buffer.
|
|
14
|
+
///
|
|
15
|
+
/// Foundation only and time passed in, so the tests can drive it with
|
|
16
|
+
/// synthetic frames.
|
|
17
|
+
struct TurnEnd {
|
|
18
|
+
struct Tuning: Equatable {
|
|
19
|
+
/// A frame at or above this starts speech (Silero's own default).
|
|
20
|
+
var start: Float
|
|
21
|
+
/// Once talking, a frame at or above this still counts as speech.
|
|
22
|
+
/// Lower than `start`: the soft end of a word is not a pause.
|
|
23
|
+
var keep: Float
|
|
24
|
+
/// Speech must last this long in one go before the turn counts as
|
|
25
|
+
/// started, so a cough, a door or a click does not open it.
|
|
26
|
+
var minSpeech: TimeInterval
|
|
27
|
+
/// A burst this short inside a pause does not restart the pause:
|
|
28
|
+
/// one noisy frame must not hold the microphone open.
|
|
29
|
+
var resume: TimeInterval
|
|
30
|
+
/// A pause this long after speech ends the turn. People pause
|
|
31
|
+
/// inside sentences; this is longer than those pauses.
|
|
32
|
+
var endSilence: TimeInterval
|
|
33
|
+
|
|
34
|
+
/// For Silero VAD probabilities.
|
|
35
|
+
static let vad = Tuning(start: 0.5, keep: 0.35, minSpeech: 0.25, resume: 0.1, endSilence: 1.2)
|
|
36
|
+
|
|
37
|
+
/// The volume threshold as it always worked: any buffer above the
|
|
38
|
+
/// level is speech, and 1.2 s below it ends the turn.
|
|
39
|
+
static let level = Tuning(start: 0.5, keep: 0.5, minSpeech: 0, resume: 0, endSilence: 1.2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
enum State: Equatable {
|
|
43
|
+
/// Nothing said yet.
|
|
44
|
+
case waiting
|
|
45
|
+
/// Speech heard; the turn is open (possibly in a short pause).
|
|
46
|
+
case speaking
|
|
47
|
+
/// Speech, then `endSilence` of quiet: send it.
|
|
48
|
+
case ended
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// RMS above which the microphone is hearing a voice rather than a
|
|
52
|
+
/// room. The fallback's whole notion of speech.
|
|
53
|
+
static let speechLevel: Float = 0.02
|
|
54
|
+
|
|
55
|
+
let tuning: Tuning
|
|
56
|
+
private(set) var state: State = .waiting
|
|
57
|
+
/// How long the current run of speech frames has lasted.
|
|
58
|
+
private var speechRun: TimeInterval = 0
|
|
59
|
+
/// How long the current pause has lasted (a short burst does not
|
|
60
|
+
/// reset it).
|
|
61
|
+
private var silence: TimeInterval = 0
|
|
62
|
+
|
|
63
|
+
init(_ tuning: Tuning) { self.tuning = tuning }
|
|
64
|
+
|
|
65
|
+
/// One frame: `probability` that it is speech, `duration` it covers.
|
|
66
|
+
@discardableResult
|
|
67
|
+
mutating func feed(probability: Float, duration: TimeInterval) -> State {
|
|
68
|
+
guard state != .ended else { return state }
|
|
69
|
+
let threshold = state == .speaking && silence == 0 ? tuning.keep : tuning.start
|
|
70
|
+
let speech = probability >= threshold
|
|
71
|
+
|
|
72
|
+
switch state {
|
|
73
|
+
case .waiting:
|
|
74
|
+
speechRun = speech ? speechRun + duration : 0
|
|
75
|
+
if speech && speechRun >= tuning.minSpeech {
|
|
76
|
+
state = .speaking
|
|
77
|
+
silence = 0
|
|
78
|
+
}
|
|
79
|
+
case .speaking:
|
|
80
|
+
if speech {
|
|
81
|
+
speechRun += duration
|
|
82
|
+
if silence == 0 || speechRun >= tuning.resume {
|
|
83
|
+
silence = 0
|
|
84
|
+
} else {
|
|
85
|
+
silence += duration
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
speechRun = 0
|
|
89
|
+
silence += duration
|
|
90
|
+
}
|
|
91
|
+
if silence >= tuning.endSilence { state = .ended }
|
|
92
|
+
case .ended:
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
return state
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// The fallback's frame: the smoothed microphone level.
|
|
99
|
+
@discardableResult
|
|
100
|
+
mutating func feed(level: Float, duration: TimeInterval) -> State {
|
|
101
|
+
feed(probability: level > Self.speechLevel ? 1 : 0, duration: duration)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The microphone level after one buffer of loudness `rms`. Attack
|
|
105
|
+
/// fast, release slow: a level that drops instantly makes a pause
|
|
106
|
+
/// between words look like the end of a sentence.
|
|
107
|
+
static func smooth(level: Float, rms: Float) -> Float {
|
|
108
|
+
rms > level ? rms : level * 0.82 + rms * 0.18
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -8,11 +8,43 @@ struct VoiceSettings: Codable, Equatable {
|
|
|
8
8
|
var stop: String
|
|
9
9
|
var paste: String
|
|
10
10
|
}
|
|
11
|
+
/// The answer shown in the pill: seconds open once spoken (0: until
|
|
12
|
+
/// closed), and its tallest height in points.
|
|
13
|
+
struct Card: Codable, Equatable {
|
|
14
|
+
var timeout: Double
|
|
15
|
+
var maxHeight: Double
|
|
16
|
+
static let standard = Card(timeout: 30, maxHeight: 320)
|
|
17
|
+
}
|
|
11
18
|
struct General: Codable, Equatable {
|
|
12
19
|
var provider: String
|
|
13
20
|
var fallback: String
|
|
14
21
|
var stt: String
|
|
22
|
+
/// The on-device engine: "mlx-whisper" or "parakeet".
|
|
23
|
+
var localStt: String
|
|
24
|
+
/// How a hands-free turn's end is heard: "vad" or "volume".
|
|
25
|
+
var endOfTurn: String
|
|
15
26
|
var hotkeys: Hotkeys
|
|
27
|
+
/// Nil from a daemon older than the card settings.
|
|
28
|
+
var card: Card?
|
|
29
|
+
|
|
30
|
+
init(provider: String, fallback: String, stt: String, localStt: String = "mlx-whisper",
|
|
31
|
+
endOfTurn: String = "vad", hotkeys: Hotkeys, card: Card? = nil) {
|
|
32
|
+
self.provider = provider; self.fallback = fallback; self.stt = stt
|
|
33
|
+
self.localStt = localStt; self.endOfTurn = endOfTurn; self.hotkeys = hotkeys
|
|
34
|
+
self.card = card
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// A daemon from before these fields leaves them out: the defaults.
|
|
38
|
+
init(from decoder: Decoder) throws {
|
|
39
|
+
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
40
|
+
provider = try c.decode(String.self, forKey: .provider)
|
|
41
|
+
fallback = try c.decode(String.self, forKey: .fallback)
|
|
42
|
+
stt = try c.decode(String.self, forKey: .stt)
|
|
43
|
+
localStt = try c.decodeIfPresent(String.self, forKey: .localStt) ?? "mlx-whisper"
|
|
44
|
+
endOfTurn = try c.decodeIfPresent(String.self, forKey: .endOfTurn) ?? "vad"
|
|
45
|
+
hotkeys = try c.decode(Hotkeys.self, forKey: .hotkeys)
|
|
46
|
+
card = try c.decodeIfPresent(Card.self, forKey: .card)
|
|
47
|
+
}
|
|
16
48
|
}
|
|
17
49
|
struct Voice: Codable, Equatable {
|
|
18
50
|
var provider: String?
|
|
@@ -33,6 +65,10 @@ struct VoiceSettings: Codable, Equatable {
|
|
|
33
65
|
let name: String
|
|
34
66
|
var color: String
|
|
35
67
|
var colorSet: Bool
|
|
68
|
+
/// The chosen orb palette; nil follows the colour.
|
|
69
|
+
var palette: String?
|
|
70
|
+
/// The palette the colour gives when none is chosen.
|
|
71
|
+
let paletteDefault: String?
|
|
36
72
|
var voice: Voice
|
|
37
73
|
let speaks: Speaks
|
|
38
74
|
}
|
|
@@ -42,9 +78,18 @@ struct VoiceSettings: Codable, Equatable {
|
|
|
42
78
|
let locale: String
|
|
43
79
|
}
|
|
44
80
|
|
|
81
|
+
/// One of the orb's nature palettes: five colours, deep to light.
|
|
82
|
+
struct Palette: Codable, Equatable, Identifiable {
|
|
83
|
+
let id: String
|
|
84
|
+
let label: String
|
|
85
|
+
let colors: [String]
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
var general: General
|
|
46
89
|
var agents: [Agent]
|
|
47
90
|
let systemVoices: [SystemVoice]
|
|
91
|
+
/// Nil from a daemon older than the palettes.
|
|
92
|
+
let palettes: [Palette]?
|
|
48
93
|
let menuHotkey: String
|
|
49
94
|
|
|
50
95
|
/// The change from `old` to this, as POST /voice/settings takes it:
|
|
@@ -55,11 +100,19 @@ struct VoiceSettings: Codable, Equatable {
|
|
|
55
100
|
var general: [String: Any] = [:]
|
|
56
101
|
if self.general.provider != old.general.provider { general["provider"] = self.general.provider }
|
|
57
102
|
if self.general.stt != old.general.stt { general["stt"] = self.general.stt }
|
|
103
|
+
if self.general.localStt != old.general.localStt { general["localStt"] = self.general.localStt }
|
|
104
|
+
if self.general.endOfTurn != old.general.endOfTurn { general["endOfTurn"] = self.general.endOfTurn }
|
|
58
105
|
var keys: [String: Any] = [:]
|
|
59
106
|
if self.general.hotkeys.talk != old.general.hotkeys.talk { keys["talk"] = self.general.hotkeys.talk }
|
|
60
107
|
if self.general.hotkeys.stop != old.general.hotkeys.stop { keys["stop"] = self.general.hotkeys.stop }
|
|
61
108
|
if self.general.hotkeys.paste != old.general.hotkeys.paste { keys["paste"] = self.general.hotkeys.paste }
|
|
62
109
|
if !keys.isEmpty { general["hotkeys"] = keys }
|
|
110
|
+
if let card = self.general.card {
|
|
111
|
+
var c: [String: Any] = [:]
|
|
112
|
+
if card.timeout != old.general.card?.timeout { c["timeout"] = card.timeout }
|
|
113
|
+
if card.maxHeight != old.general.card?.maxHeight { c["maxHeight"] = card.maxHeight }
|
|
114
|
+
if !c.isEmpty { general["card"] = c }
|
|
115
|
+
}
|
|
63
116
|
if !general.isEmpty { out["general"] = general }
|
|
64
117
|
|
|
65
118
|
var agents: [String: Any] = [:]
|
|
@@ -77,6 +130,7 @@ struct VoiceSettings: Codable, Equatable {
|
|
|
77
130
|
field("narrate", agent.voice.narrate, before.voice.narrate)
|
|
78
131
|
field("priority", agent.voice.priority, before.voice.priority)
|
|
79
132
|
field("hotkey", agent.voice.hotkey, before.voice.hotkey)
|
|
133
|
+
field("palette", agent.palette, before.palette)
|
|
80
134
|
if agent.colorSet != before.colorSet || (agent.colorSet && agent.color != before.color) {
|
|
81
135
|
a["color"] = agent.colorSet ? agent.color as Any : NSNull()
|
|
82
136
|
}
|
|
@@ -18,7 +18,6 @@ import Carbon.HIToolbox
|
|
|
18
18
|
final class App: NSObject, NSApplicationDelegate {
|
|
19
19
|
private let panel = Panel()
|
|
20
20
|
private let statusMenu = StatusMenu()
|
|
21
|
-
private let card = ResultCard()
|
|
22
21
|
/// The agent whose by-name answer is on screen now, while no turn runs.
|
|
23
22
|
private var asideSpeaker: String?
|
|
24
23
|
private let recorder = Recorder()
|
|
@@ -155,6 +154,9 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
155
154
|
/// Settings from the daemon: shortcuts now, the rest on next use.
|
|
156
155
|
private func apply(_ saved: VoiceSettings) {
|
|
157
156
|
settings = saved
|
|
157
|
+
let card = saved.general.card ?? .standard
|
|
158
|
+
panel.cardTimeout = card.timeout
|
|
159
|
+
panel.cardMaxHeight = card.maxHeight
|
|
158
160
|
if settingsWindow.model.recording == nil { registerHotkeys() }
|
|
159
161
|
// Colours may have changed.
|
|
160
162
|
statusMenu.refresh()
|
|
@@ -170,6 +172,14 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
170
172
|
return busy && !turnAgent.isEmpty ? turnAgent : Config.effectiveAgentID
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/// The answer's "Open in chat": the agent's page in the dashboard, with
|
|
176
|
+
/// its chat open.
|
|
177
|
+
private func openChat() {
|
|
178
|
+
let who = shownAgent
|
|
179
|
+
guard !who.isEmpty, let url = URL(string: "\(Config.dashboardURL)/admin/agents/\(who)#chat") else { return }
|
|
180
|
+
NSWorkspace.shared.open(url)
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
/// Close, Esc or "Hide pill": the pill goes and the voice stops, the
|
|
174
184
|
/// same stop as ⌘⌥. An open microphone closes without sending. The
|
|
175
185
|
/// next talk key brings the pill back.
|
|
@@ -246,8 +256,11 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
246
256
|
panel.orb.setAnimated(Config.animatedOrb)
|
|
247
257
|
panel.orb.levelSource = { [weak self] in self?.recorder.level ?? 0 }
|
|
248
258
|
panel.onDismiss = { [weak self] in self?.dismissPill() }
|
|
249
|
-
panel.
|
|
250
|
-
|
|
259
|
+
panel.agentPalette = { [weak self] in
|
|
260
|
+
guard let self else { return nil }
|
|
261
|
+
return self.statusMenu.palette(of: self.shownAgent)
|
|
262
|
+
}
|
|
263
|
+
panel.answer.onOpenChat = { [weak self] in self?.openChat() }
|
|
251
264
|
statusMenu.pillVisible = { [weak self] in self?.panel.isVisible ?? false }
|
|
252
265
|
statusMenu.onHidePill = { [weak self] in self?.dismissPill() }
|
|
253
266
|
statusMenu.onResetPosition = { [weak self] in self?.panel.resetPosition() }
|
|
@@ -321,20 +334,14 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
321
334
|
// Ending on SILENCE rather than a timer is the whole trick. A fixed
|
|
322
335
|
// window either cuts people off mid-sentence or leaves the mic open
|
|
323
336
|
// staring at them; the only honest signal that a sentence has finished
|
|
324
|
-
// is the
|
|
337
|
+
// is the voice stopping. The recorder judges that (TurnEnd.swift).
|
|
325
338
|
|
|
326
|
-
/// RMS above which the microphone is hearing a voice rather than a room.
|
|
327
|
-
private let speechLevel: Float = 0.02
|
|
328
|
-
/// Quiet this long after speech ends the turn.
|
|
329
|
-
private let endSilence: TimeInterval = 1.2
|
|
330
339
|
/// How long an unprompted follow-up window waits before giving up.
|
|
331
340
|
private let followUpPatience: TimeInterval = 4.0
|
|
332
341
|
/// How long a clicked session waits for you to start talking.
|
|
333
342
|
private let clickPatience: TimeInterval = 8.0
|
|
334
343
|
|
|
335
344
|
private var listenPoll: Timer?
|
|
336
|
-
private var heardSpeech = false
|
|
337
|
-
private var quietSince: Date?
|
|
338
345
|
private var openedAt = Date()
|
|
339
346
|
private var patience: TimeInterval = 8.0
|
|
340
347
|
/// True when nothing was said and the window should close in silence.
|
|
@@ -343,14 +350,13 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
343
350
|
/// Open the microphone with no key held.
|
|
344
351
|
private func listenHandsFree(followUp: Bool) {
|
|
345
352
|
guard !busy, !recorder.isRecording else { return }
|
|
353
|
+
recorder.endOfTurn = settings?.general.endOfTurn ?? "vad"
|
|
346
354
|
do {
|
|
347
355
|
try recorder.start()
|
|
348
356
|
} catch {
|
|
349
357
|
panel.render(.error(error.localizedDescription))
|
|
350
358
|
return
|
|
351
359
|
}
|
|
352
|
-
heardSpeech = false
|
|
353
|
-
quietSince = nil
|
|
354
360
|
openedAt = Date()
|
|
355
361
|
patience = followUp ? followUpPatience : clickPatience
|
|
356
362
|
silentClose = followUp
|
|
@@ -369,23 +375,13 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
369
375
|
|
|
370
376
|
private func pollLevel() {
|
|
371
377
|
guard recorder.isRecording else { stopPolling(); return }
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
if heardSpeech {
|
|
380
|
-
// Speech has stopped. Give it a beat before deciding the
|
|
381
|
-
// sentence is over — people pause inside sentences.
|
|
382
|
-
let since = quietSince ?? Date()
|
|
383
|
-
quietSince = since
|
|
384
|
-
if Date().timeIntervalSince(since) >= endSilence {
|
|
385
|
-
stopPolling()
|
|
386
|
-
stopAndSend()
|
|
387
|
-
}
|
|
378
|
+
switch recorder.turnState {
|
|
379
|
+
case .speaking: return
|
|
380
|
+
case .ended:
|
|
381
|
+
stopPolling()
|
|
382
|
+
stopAndSend()
|
|
388
383
|
return
|
|
384
|
+
case .waiting: break
|
|
389
385
|
}
|
|
390
386
|
// Nothing said yet. Close quietly rather than making the person
|
|
391
387
|
// dismiss a window they did not ask for.
|
|
@@ -444,6 +440,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
444
440
|
lastSpokeAt = Date()
|
|
445
441
|
talkCheck = Task { await AgentClient.hush() }
|
|
446
442
|
Log.info("door: opened\(busy ? " (a turn is running)" : "")")
|
|
443
|
+
// Push-to-talk ends on key release; no end-of-turn detection needed.
|
|
444
|
+
recorder.endOfTurn = "hold"
|
|
447
445
|
do {
|
|
448
446
|
try recorder.start()
|
|
449
447
|
asideSpeaker = nil
|
|
@@ -474,7 +472,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
474
472
|
|
|
475
473
|
Task { @MainActor in
|
|
476
474
|
var heard = ""
|
|
477
|
-
do { heard = try await Speech.transcribe(wav: wav, engine: settings?.general.stt ?? "auto"
|
|
475
|
+
do { heard = try await Speech.transcribe(wav: wav, engine: settings?.general.stt ?? "auto",
|
|
476
|
+
local: settings?.general.localStt ?? "mlx-whisper") }
|
|
478
477
|
catch { Log.warn("transcription failed: \(error.localizedDescription)") }
|
|
479
478
|
guard !heard.isEmpty else {
|
|
480
479
|
_ = await door.value
|
|
@@ -566,10 +565,10 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
566
565
|
switch result {
|
|
567
566
|
case .success(let answer):
|
|
568
567
|
Log.info("aside answer (\(answer.agentID ?? agent)): \(answer.text)")
|
|
569
|
-
if
|
|
568
|
+
if AnswerView.isWorthShowing(spoken: answer.text, written: answer.written,
|
|
570
569
|
buttons: answer.buttons, imageURL: answer.imageURL) {
|
|
571
|
-
|
|
572
|
-
|
|
570
|
+
panel.showAnswer(spoken: answer.text, written: answer.written,
|
|
571
|
+
buttons: answer.buttons, imageURL: answer.imageURL)
|
|
573
572
|
}
|
|
574
573
|
// Nothing else on screen: the pill shows this answer, its
|
|
575
574
|
// orb in this agent's colour, while it is spoken.
|
|
@@ -631,12 +630,12 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
631
630
|
// the speech cannot deliver — a link, an image, or more
|
|
632
631
|
// text than was read aloud. A card that opens on every
|
|
633
632
|
// "Ok." teaches you to ignore it.
|
|
634
|
-
if
|
|
633
|
+
if AnswerView.isWorthShowing(spoken: answer.text, written: answer.written,
|
|
635
634
|
buttons: answer.buttons, imageURL: answer.imageURL) {
|
|
636
|
-
|
|
637
|
-
|
|
635
|
+
panel.showAnswer(spoken: answer.text, written: answer.written,
|
|
636
|
+
buttons: answer.buttons, imageURL: answer.imageURL)
|
|
638
637
|
} else {
|
|
639
|
-
|
|
638
|
+
panel.collapse()
|
|
640
639
|
}
|
|
641
640
|
// Stopped while it was thinking: the answer is not spoken.
|
|
642
641
|
if silenced { silenced = false; panel.render(.idle); busy = false; return }
|
package/apps/mac-voice/build.sh
CHANGED
|
@@ -18,9 +18,18 @@ rm -rf build && mkdir -p "$BIN" "$APP/Contents/Resources"
|
|
|
18
18
|
swiftc -O \
|
|
19
19
|
-o "$BIN/AgentXVoice" \
|
|
20
20
|
Sources/AgentXVoice/*.swift \
|
|
21
|
-
-framework AppKit -framework AVFoundation -framework Carbon -framework SwiftUI \
|
|
21
|
+
-framework AppKit -framework AVFoundation -framework Carbon -framework CoreML -framework SwiftUI \
|
|
22
22
|
-target arm64-apple-macosx14.0
|
|
23
23
|
|
|
24
|
+
# The on-device models from a terminal: fetch them, transcribe a file, see
|
|
25
|
+
# where a turn would end. Same sources as the app; the models themselves
|
|
26
|
+
# are downloaded on first use into ~/.agentx/models, never bundled.
|
|
27
|
+
swiftc -O \
|
|
28
|
+
-o "$BIN/agentx-voice-local" \
|
|
29
|
+
Sources/AgentXVoice/ModelStore.swift Sources/AgentXVoice/SileroVAD.swift \
|
|
30
|
+
Sources/AgentXVoice/Parakeet.swift Sources/AgentXVoice/TurnEnd.swift Tools/LocalSTT/main.swift \
|
|
31
|
+
-framework CoreML -target arm64-apple-macosx14.0
|
|
32
|
+
|
|
24
33
|
cp Resources/Info.plist "$APP/Contents/Info.plist"
|
|
25
34
|
|
|
26
35
|
# Ad-hoc signature. Unsigned bundles get a fresh TCC identity on every
|