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.
Files changed (130) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
  9. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
  10. package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
  11. package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
  12. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
  13. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
  14. package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
  15. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +20 -5
  16. package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
  17. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
  18. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -1
  19. package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
  20. package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
  21. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +54 -0
  22. package/apps/mac-voice/Sources/AgentXVoice/main.swift +35 -36
  23. package/apps/mac-voice/build.sh +10 -1
  24. package/dist/{App-Z5VOAUXB.js → App-LQXWKBVY.js} +2 -2
  25. package/dist/{ChatApp-BE7ONQKS.js → ChatApp-75TL4LKW.js} +2 -2
  26. package/dist/{agent-Q74EISKP.js → agent-DH4FMMMY.js} +2 -2
  27. package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-BVRRDYAG.js} +223 -138
  28. package/dist/board-dashboard-BVRRDYAG.js.map +1 -0
  29. package/dist/{chunk-NKMFHEGV.js → chunk-27P5NLTC.js} +2 -2
  30. package/dist/{chunk-5LLGIX5M.js → chunk-2VQIPJH5.js} +2 -2
  31. package/dist/{chunk-V7ZK6ZIO.js → chunk-3XVTFNYM.js} +2 -2
  32. package/dist/{chunk-KW7TIOS3.js → chunk-B3DCYBP4.js} +2 -2
  33. package/dist/{chunk-547G3HYQ.js → chunk-CRSXKQLZ.js} +2 -2
  34. package/dist/{chunk-5W7NJHCS.js → chunk-DTEQNXK3.js} +2 -2
  35. package/dist/{chunk-XTD3SZIP.js → chunk-E2BJVHBR.js} +2 -2
  36. package/dist/{chunk-RXEAACVH.js → chunk-E2PWCU2N.js} +2 -2
  37. package/dist/{chunk-I2LYULR4.js → chunk-JO3PSLWL.js} +5 -5
  38. package/dist/{chunk-U5YBQJ5Q.js → chunk-JORXRF4E.js} +2 -2
  39. package/dist/{chunk-2MIOMYT2.js → chunk-LXUG2X26.js} +2 -2
  40. package/dist/{chunk-Q6SMLSRO.js → chunk-N5KD3TVN.js} +2 -2
  41. package/dist/{chunk-VI7GBDKV.js → chunk-NBGNPKAF.js} +2 -2
  42. package/dist/{chunk-6QNPCVRN.js → chunk-PAOV37KQ.js} +3 -3
  43. package/dist/chunk-PAOV37KQ.js.map +1 -0
  44. package/dist/{chunk-OZZPJ2YG.js → chunk-RYFUFX6F.js} +194 -181
  45. package/dist/chunk-RYFUFX6F.js.map +1 -0
  46. package/dist/chunk-S7TCVIET.js +15 -0
  47. package/dist/chunk-S7TCVIET.js.map +1 -0
  48. package/dist/{chunk-POL7RAJQ.js → chunk-TM762AXU.js} +159 -153
  49. package/dist/chunk-TM762AXU.js.map +1 -0
  50. package/dist/{chunk-6OABTCRA.js → chunk-WBGC7MWX.js} +2 -2
  51. package/dist/{chunk-CQUVUVXR.js → chunk-WIBMNQKJ.js} +2 -2
  52. package/dist/{chunk-KKGFZEXS.js → chunk-WOPFHKBF.js} +37 -36
  53. package/dist/chunk-WOPFHKBF.js.map +1 -0
  54. package/dist/{chunk-VVBGPT6W.js → chunk-YQCOX4R3.js} +2 -2
  55. package/dist/cli.js +1 -1
  56. package/dist/client-QVZIZFV4.js +2 -0
  57. package/dist/config-3XC7EYB7.js +2 -0
  58. package/dist/config-mutator-HKU63FJM.js +2 -0
  59. package/dist/doctor-YFA4ZSRH.js +2 -0
  60. package/dist/index.d.ts +66 -7
  61. package/dist/index.js +1 -1
  62. package/dist/program-BOCLU6ES.js +2 -0
  63. package/dist/{promote-KVPLDSKT.js → promote-UR2GSHKC.js} +2 -2
  64. package/dist/query-XUP3BMVS.js +2 -0
  65. package/dist/{registry-AUOI4TW2.js → registry-PGALUW5L.js} +2 -2
  66. package/dist/{request-planner-LMZ5ZAXC.js → request-planner-NHE3IRJN.js} +2 -2
  67. package/dist/{review-candidates-WCBXLLDS.js → review-candidates-6GKRSRBQ.js} +2 -2
  68. package/dist/{routing-FBASYKUL.js → routing-AQKHAU3Z.js} +2 -2
  69. package/dist/schedule-tool-6NMCLTIH.js +2 -0
  70. package/dist/scheduler-RAYGNDWT.js +2 -0
  71. package/dist/{seat-DBFW3Z5P.js → seat-6FMW2QI4.js} +2 -2
  72. package/dist/{select-6CNVVFA5.js → select-5VONJMLH.js} +2 -2
  73. package/dist/{set-enabled-AYZH3KSB.js → set-enabled-YHD3IJJZ.js} +2 -2
  74. package/dist/{tool-PKS4NBZ6.js → tool-MXJGLJZ5.js} +2 -2
  75. package/dist/wiki-ZQLEYY24.js +2 -0
  76. package/package.json +1 -1
  77. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
  78. package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
  79. package/dist/chunk-6QNPCVRN.js.map +0 -1
  80. package/dist/chunk-ERL6AS3O.js +0 -15
  81. package/dist/chunk-ERL6AS3O.js.map +0 -1
  82. package/dist/chunk-KKGFZEXS.js.map +0 -1
  83. package/dist/chunk-OZZPJ2YG.js.map +0 -1
  84. package/dist/chunk-POL7RAJQ.js.map +0 -1
  85. package/dist/client-EFP2PKA3.js +0 -2
  86. package/dist/config-P6YXC6XZ.js +0 -2
  87. package/dist/config-mutator-3GKXLTHH.js +0 -2
  88. package/dist/doctor-JEWMHXYH.js +0 -2
  89. package/dist/program-7DUQJDSQ.js +0 -2
  90. package/dist/query-AGOMCYTT.js +0 -2
  91. package/dist/schedule-tool-RJRJ63SS.js +0 -2
  92. package/dist/scheduler-ATA7Z44S.js +0 -2
  93. package/dist/wiki-QW6BDNEQ.js +0 -2
  94. /package/dist/{App-Z5VOAUXB.js.map → App-LQXWKBVY.js.map} +0 -0
  95. /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-75TL4LKW.js.map} +0 -0
  96. /package/dist/{agent-Q74EISKP.js.map → agent-DH4FMMMY.js.map} +0 -0
  97. /package/dist/{chunk-NKMFHEGV.js.map → chunk-27P5NLTC.js.map} +0 -0
  98. /package/dist/{chunk-5LLGIX5M.js.map → chunk-2VQIPJH5.js.map} +0 -0
  99. /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-3XVTFNYM.js.map} +0 -0
  100. /package/dist/{chunk-KW7TIOS3.js.map → chunk-B3DCYBP4.js.map} +0 -0
  101. /package/dist/{chunk-547G3HYQ.js.map → chunk-CRSXKQLZ.js.map} +0 -0
  102. /package/dist/{chunk-5W7NJHCS.js.map → chunk-DTEQNXK3.js.map} +0 -0
  103. /package/dist/{chunk-XTD3SZIP.js.map → chunk-E2BJVHBR.js.map} +0 -0
  104. /package/dist/{chunk-RXEAACVH.js.map → chunk-E2PWCU2N.js.map} +0 -0
  105. /package/dist/{chunk-I2LYULR4.js.map → chunk-JO3PSLWL.js.map} +0 -0
  106. /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-JORXRF4E.js.map} +0 -0
  107. /package/dist/{chunk-2MIOMYT2.js.map → chunk-LXUG2X26.js.map} +0 -0
  108. /package/dist/{chunk-Q6SMLSRO.js.map → chunk-N5KD3TVN.js.map} +0 -0
  109. /package/dist/{chunk-VI7GBDKV.js.map → chunk-NBGNPKAF.js.map} +0 -0
  110. /package/dist/{chunk-6OABTCRA.js.map → chunk-WBGC7MWX.js.map} +0 -0
  111. /package/dist/{chunk-CQUVUVXR.js.map → chunk-WIBMNQKJ.js.map} +0 -0
  112. /package/dist/{chunk-VVBGPT6W.js.map → chunk-YQCOX4R3.js.map} +0 -0
  113. /package/dist/{client-EFP2PKA3.js.map → client-QVZIZFV4.js.map} +0 -0
  114. /package/dist/{config-P6YXC6XZ.js.map → config-3XC7EYB7.js.map} +0 -0
  115. /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-HKU63FJM.js.map} +0 -0
  116. /package/dist/{doctor-JEWMHXYH.js.map → doctor-YFA4ZSRH.js.map} +0 -0
  117. /package/dist/{program-7DUQJDSQ.js.map → program-BOCLU6ES.js.map} +0 -0
  118. /package/dist/{promote-KVPLDSKT.js.map → promote-UR2GSHKC.js.map} +0 -0
  119. /package/dist/{query-AGOMCYTT.js.map → query-XUP3BMVS.js.map} +0 -0
  120. /package/dist/{registry-AUOI4TW2.js.map → registry-PGALUW5L.js.map} +0 -0
  121. /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-NHE3IRJN.js.map} +0 -0
  122. /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
  123. /package/dist/{routing-FBASYKUL.js.map → routing-AQKHAU3Z.js.map} +0 -0
  124. /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
  125. /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-RAYGNDWT.js.map} +0 -0
  126. /package/dist/{seat-DBFW3Z5P.js.map → seat-6FMW2QI4.js.map} +0 -0
  127. /package/dist/{select-6CNVVFA5.js.map → select-5VONJMLH.js.map} +0 -0
  128. /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
  129. /package/dist/{tool-PKS4NBZ6.js.map → tool-MXJGLJZ5.js.map} +0 -0
  130. /package/dist/{wiki-QW6BDNEQ.js.map → wiki-ZQLEYY24.js.map} +0 -0
@@ -0,0 +1,264 @@
1
+ import CoreML
2
+ import Foundation
3
+
4
+ /// NVIDIA Parakeet TDT 0.6B v3 on Core ML: on-device speech to text with
5
+ /// no Python, from the model files ModelStore.parakeet fetches.
6
+ ///
7
+ /// Four Core ML models, run in turn: the preprocessor turns 15 s of audio
8
+ /// into a mel spectrogram, the encoder turns that into one vector per
9
+ /// 80 ms, and the decoder (an LSTM over the words so far) and the joint
10
+ /// network walk those vectors greedily. The joint picks a token and how
11
+ /// many frames to skip (the "TDT" part), which is why it is fast.
12
+ ///
13
+ /// v3 covers 25 European languages and detects which one on its own. It
14
+ /// has NO Arabic; see the benchmark in docs/dashboard/voice.md.
15
+ final class Parakeet: @unchecked Sendable {
16
+ enum ParakeetError: LocalizedError {
17
+ case notInstalled
18
+ case badOutput(String)
19
+ var errorDescription: String? {
20
+ switch self {
21
+ case .notInstalled: return "the Parakeet model is not downloaded yet"
22
+ case .badOutput(let what): return "Parakeet returned no \(what)"
23
+ }
24
+ }
25
+ }
26
+
27
+ static let sampleRate = 16_000
28
+ /// The Core ML export takes exactly 15 s.
29
+ static let window = 240_000
30
+ /// One encoder frame covers 80 ms.
31
+ static let samplesPerFrame = 1_280
32
+ static let blank = 8_192
33
+ static let durations = [0, 1, 2, 3, 4]
34
+ static let maxSymbolsPerFrame = 10
35
+
36
+ private let preprocessor: MLModel
37
+ private let encoder: MLModel
38
+ private let decoder: MLModel
39
+ private let joint: MLModel
40
+ private let vocabulary: [Int: String]
41
+
42
+ private static let lock = NSLock()
43
+ nonisolated(unsafe) private static var shared: Parakeet?
44
+
45
+ /// The loaded engine, kept for the life of the app: loading costs
46
+ /// seconds (Core ML prepares the encoder for the Neural Engine), a
47
+ /// transcription well under one.
48
+ static func loaded() throws -> Parakeet {
49
+ lock.lock(); defer { lock.unlock() }
50
+ if let shared { return shared }
51
+ let p = try Parakeet()
52
+ shared = p
53
+ return p
54
+ }
55
+
56
+ init() throws {
57
+ let store = ModelStore.parakeet
58
+ guard store.isInstalled else { throw ParakeetError.notInstalled }
59
+ func load(_ name: String, _ units: MLComputeUnits) throws -> MLModel {
60
+ let config = MLModelConfiguration()
61
+ config.computeUnits = units
62
+ return try MLModel(contentsOf: store.url(name), configuration: config)
63
+ }
64
+ // The same placement FluidAudio uses: the mel front end on the CPU,
65
+ // the rest wherever Core ML runs it best (the Neural Engine).
66
+ preprocessor = try load("Preprocessor.mlmodelc", .cpuOnly)
67
+ encoder = try load("Encoder.mlmodelc", .cpuAndNeuralEngine)
68
+ decoder = try load("Decoder.mlmodelc", .cpuAndNeuralEngine)
69
+ joint = try load("JointDecisionv3.mlmodelc", .cpuAndNeuralEngine)
70
+
71
+ let data = try Data(contentsOf: store.url("parakeet_vocab.json"))
72
+ let raw = try JSONSerialization.jsonObject(with: data) as? [String: String] ?? [:]
73
+ var vocab: [Int: String] = [:]
74
+ for (k, v) in raw { if let id = Int(k) { vocab[id] = v } }
75
+ vocabulary = vocab
76
+ }
77
+
78
+ /// A 16 kHz mono 16-bit WAV, as Recorder makes.
79
+ func transcribe(wav: Data) throws -> String {
80
+ try transcribe(samples: Self.samples(wav: wav))
81
+ }
82
+
83
+ func transcribe(samples: [Float]) throws -> String {
84
+ var tokens: [Int] = []
85
+ var state = try DecoderState(decoder: decoder)
86
+ for range in Self.windows(samples) {
87
+ tokens += try decode(Array(samples[range]), state: &state)
88
+ }
89
+ return Self.text(tokens, vocabulary: vocabulary)
90
+ }
91
+
92
+ // MARK: Pure helpers (tested)
93
+
94
+ /// Samples of a 16-bit PCM WAV as -1…1, found by its "data" chunk.
95
+ static func samples(wav: Data) -> [Float] {
96
+ let bytes = [UInt8](wav)
97
+ var offset = 12
98
+ while offset + 8 <= bytes.count {
99
+ let id = String(decoding: bytes[offset..<offset + 4], as: UTF8.self)
100
+ let size = Int(UInt32(bytes[offset + 4]) | UInt32(bytes[offset + 5]) << 8
101
+ | UInt32(bytes[offset + 6]) << 16 | UInt32(bytes[offset + 7]) << 24)
102
+ let body = offset + 8
103
+ if id == "data" {
104
+ let end = min(bytes.count, body + size)
105
+ var out = [Float]()
106
+ out.reserveCapacity((end - body) / 2)
107
+ var i = body
108
+ while i + 1 < end {
109
+ out.append(Float(Int16(bitPattern: UInt16(bytes[i]) | UInt16(bytes[i + 1]) << 8)) / 32768)
110
+ i += 2
111
+ }
112
+ return out
113
+ }
114
+ offset = body + size + (size & 1)
115
+ }
116
+ return []
117
+ }
118
+
119
+ /// Splits audio longer than the 15 s window, each cut at the quietest
120
+ /// 20 ms of the window's last 3 s, so a cut rarely lands in a word.
121
+ static func windows(_ samples: [Float], window: Int = Parakeet.window) -> [Range<Int>] {
122
+ var out: [Range<Int>] = []
123
+ var start = 0
124
+ let step = sampleRate / 50
125
+ while samples.count - start > window {
126
+ let searchFrom = start + window - 3 * sampleRate
127
+ var best = start + window, bestEnergy = Float.greatestFiniteMagnitude
128
+ var at = searchFrom
129
+ while at + step <= start + window {
130
+ var e: Float = 0
131
+ for i in at..<at + step { e += samples[i] * samples[i] }
132
+ if e < bestEnergy { bestEnergy = e; best = at + step / 2 }
133
+ at += step
134
+ }
135
+ out.append(start..<best)
136
+ start = best
137
+ }
138
+ if start < samples.count { out.append(start..<samples.count) }
139
+ return out
140
+ }
141
+
142
+ /// SentencePiece pieces to text: "▁" marks the start of a word.
143
+ static func text(_ tokens: [Int], vocabulary: [Int: String]) -> String {
144
+ var s = ""
145
+ for t in tokens {
146
+ guard let piece = vocabulary[t], !(piece.hasPrefix("<") && piece.hasSuffix(">")) else { continue }
147
+ s += piece.replacingOccurrences(of: "\u{2581}", with: " ")
148
+ }
149
+ return s.trimmingCharacters(in: .whitespacesAndNewlines)
150
+ }
151
+
152
+ // MARK: Inference
153
+
154
+ private struct DecoderState {
155
+ var hidden: MLMultiArray
156
+ var cell: MLMultiArray
157
+ /// The decoder's output for the last token emitted (blank at first).
158
+ var output: MLMultiArray?
159
+ let target: MLMultiArray
160
+ let targetLength: MLMultiArray
161
+
162
+ init(decoder: MLModel) throws {
163
+ hidden = try MLMultiArray(shape: [2, 1, 640], dataType: .float32)
164
+ cell = try MLMultiArray(shape: [2, 1, 640], dataType: .float32)
165
+ for i in 0..<hidden.count { hidden[i] = 0; cell[i] = 0 }
166
+ target = try MLMultiArray(shape: [1, 1], dataType: .int32)
167
+ targetLength = try MLMultiArray(shape: [1], dataType: .int32)
168
+ targetLength[0] = 1
169
+ }
170
+ }
171
+
172
+ private func step(_ token: Int, _ state: inout DecoderState) throws {
173
+ state.target[0] = NSNumber(value: token)
174
+ let out = try decoder.prediction(from: MLDictionaryFeatureProvider(dictionary: [
175
+ "targets": MLFeatureValue(multiArray: state.target),
176
+ "target_length": MLFeatureValue(multiArray: state.targetLength),
177
+ "h_in": MLFeatureValue(multiArray: state.hidden),
178
+ "c_in": MLFeatureValue(multiArray: state.cell),
179
+ ]))
180
+ guard let d = out.featureValue(for: "decoder")?.multiArrayValue,
181
+ let h = out.featureValue(for: "h_out")?.multiArrayValue,
182
+ let c = out.featureValue(for: "c_out")?.multiArrayValue else { throw ParakeetError.badOutput("decoder state") }
183
+ state.output = d; state.hidden = h; state.cell = c
184
+ }
185
+
186
+ /// One window: preprocessor, encoder, then greedy TDT decoding.
187
+ private func decode(_ chunk: [Float], state: inout DecoderState) throws -> [Int] {
188
+ guard chunk.count >= Self.samplesPerFrame else { return [] }
189
+ let audio = try MLMultiArray(shape: [1, NSNumber(value: Self.window)], dataType: .float32)
190
+ let ptr = audio.dataPointer.bindMemory(to: Float.self, capacity: Self.window)
191
+ ptr.initialize(repeating: 0, count: Self.window)
192
+ chunk.withUnsafeBufferPointer { src in ptr.update(from: src.baseAddress!, count: min(chunk.count, Self.window)) }
193
+ let length = try MLMultiArray(shape: [1], dataType: .int32)
194
+ length[0] = NSNumber(value: chunk.count)
195
+
196
+ let mel = try preprocessor.prediction(from: MLDictionaryFeatureProvider(dictionary: [
197
+ "audio_signal": MLFeatureValue(multiArray: audio),
198
+ "audio_length": MLFeatureValue(multiArray: length),
199
+ ]))
200
+ guard let melArray = mel.featureValue(for: "mel")?.multiArrayValue,
201
+ let melLength = mel.featureValue(for: "mel_length")?.multiArrayValue else { throw ParakeetError.badOutput("mel") }
202
+ let enc = try encoder.prediction(from: MLDictionaryFeatureProvider(dictionary: [
203
+ "mel": MLFeatureValue(multiArray: melArray),
204
+ "mel_length": MLFeatureValue(multiArray: melLength),
205
+ ]))
206
+ guard let frames = enc.featureValue(for: "encoder")?.multiArrayValue,
207
+ let encLength = enc.featureValue(for: "encoder_length")?.multiArrayValue else { throw ParakeetError.badOutput("encoder output") }
208
+
209
+ let hiddenSize = frames.shape[1].intValue
210
+ let available = frames.shape[2].intValue
211
+ let actual = (chunk.count + Self.samplesPerFrame - 1) / Self.samplesPerFrame
212
+ let count = min(encLength[0].intValue, available, actual)
213
+ let hStride = frames.strides[1].intValue, tStride = frames.strides[2].intValue
214
+
215
+ let encStep = try MLMultiArray(shape: [1, NSNumber(value: hiddenSize), 1], dataType: .float32)
216
+ let encStride = encStep.strides[1].intValue
217
+ let encDest = encStep.dataPointer.bindMemory(to: Float.self, capacity: encStep.count)
218
+ func load(frame t: Int) {
219
+ switch frames.dataType {
220
+ case .float16:
221
+ let src = frames.dataPointer.bindMemory(to: Float16.self, capacity: frames.count)
222
+ for h in 0..<hiddenSize { encDest[h * encStride] = Float(src[h * hStride + t * tStride]) }
223
+ default:
224
+ let src = frames.dataPointer.bindMemory(to: Float.self, capacity: frames.count)
225
+ for h in 0..<hiddenSize { encDest[h * encStride] = src[h * hStride + t * tStride] }
226
+ }
227
+ }
228
+
229
+ if state.output == nil { try step(Self.blank, &state) }
230
+ var tokens: [Int] = []
231
+ var t = 0
232
+ var symbolsHere = 0
233
+ var loaded = -1
234
+ while t < count {
235
+ if loaded != t { load(frame: t); loaded = t }
236
+ guard let decoderOut = state.output else { throw ParakeetError.badOutput("decoder output") }
237
+ let out = try joint.prediction(from: MLDictionaryFeatureProvider(dictionary: [
238
+ "encoder_step": MLFeatureValue(multiArray: encStep),
239
+ "decoder_step": MLFeatureValue(multiArray: decoderOut),
240
+ ]))
241
+ guard let tokenArray = out.featureValue(for: "token_id")?.multiArrayValue,
242
+ let durArray = out.featureValue(for: "duration")?.multiArrayValue else { throw ParakeetError.badOutput("joint decision") }
243
+ let token = tokenArray[0].intValue
244
+ let bin = durArray[0].intValue
245
+ let duration = Self.durations.indices.contains(bin) ? Self.durations[bin] : 1
246
+
247
+ if token == Self.blank {
248
+ t += max(duration, 1)
249
+ symbolsHere = 0
250
+ continue
251
+ }
252
+ tokens.append(token)
253
+ try step(token, &state)
254
+ if duration > 0 {
255
+ t += duration
256
+ symbolsHere = 0
257
+ } else {
258
+ symbolsHere += 1
259
+ if symbolsHere >= Self.maxSymbolsPerFrame { t += 1; symbolsHere = 0 }
260
+ }
261
+ }
262
+ return tokens
263
+ }
264
+ }
@@ -1,13 +1,11 @@
1
1
  import CoreGraphics
2
2
 
3
- /// Where the pill and its answer card go on screen, kept free of AppKit so
3
+ /// Where the pill goes on screen, and how it grows into its answer, kept free of AppKit so
4
4
  /// the tests can check it without a window. Frames are in screen
5
5
  /// coordinates, origin bottom-left, as AppKit uses them.
6
6
  enum PillPlacement {
7
7
  /// Distance from the screen's edges for the default corner.
8
8
  static let inset: CGFloat = 24
9
- /// Space between the pill and the answer card.
10
- static let cardGap: CGFloat = 8
11
9
 
12
10
  /// Bottom-right of `visible` (a screen's frame minus the menu bar and
13
11
  /// Dock), clear of both.
@@ -38,23 +36,36 @@ enum PillPlacement {
38
36
  return inside(frame, screen).origin
39
37
  }
40
38
 
41
- /// Which side of the pill the answer card opens on, and where.
42
- struct Card: Equatable {
43
- let origin: CGPoint
44
- /// True: above the pill. False: below it.
39
+ /// The pill grown into its answer: where the whole widget goes, and
40
+ /// which way it grew.
41
+ struct Expanded: Equatable {
42
+ let frame: CGRect
43
+ /// True: the answer opens above the pill's row. False: below it.
45
44
  let above: Bool
45
+ /// True: right edges line up with the pill's. False: left edges.
46
+ let alignRight: Bool
46
47
  }
47
48
 
48
- /// Above the pill when there is at least as much room above it as
49
- /// below, else below. Right edges line up with the pill's, and the
50
- /// card is kept on the screen.
51
- static func card(size: CGSize, pill: CGRect, visible: CGRect) -> Card {
52
- let roomAbove = visible.maxY - pill.maxY
53
- let roomBelow = pill.minY - visible.minY
54
- let above = roomAbove >= roomBelow
55
- let y = above ? pill.maxY + cardGap : pill.minY - cardGap - size.height
56
- let frame = CGRect(x: pill.maxX - size.width, y: y, width: size.width, height: size.height)
57
- return Card(origin: inside(frame, visible).origin, above: above)
49
+ /// The pill at `pill` grown to `size`. It grows up when there is at
50
+ /// least as much room above the pill as below, else down, so its row
51
+ /// stays where it was. It grows leftwards, keeping the right edge,
52
+ /// unless that would leave the screen. Too tall for the screen: kept
53
+ /// on it, top-left in view.
54
+ static func expanded(size: CGSize, pill: CGRect, visible: CGRect) -> Expanded {
55
+ let above = visible.maxY - pill.maxY >= pill.minY - visible.minY
56
+ let alignRight = pill.maxX - size.width >= visible.minX
57
+ let frame = CGRect(x: alignRight ? pill.maxX - size.width : pill.minX,
58
+ y: above ? pill.minY : pill.maxY - size.height,
59
+ width: size.width, height: size.height)
60
+ return Expanded(frame: inside(frame, visible), above: above, alignRight: alignRight)
61
+ }
62
+
63
+ /// Where the pill goes back to when the widget at `frame` collapses:
64
+ /// its row's end on the side it grew from.
65
+ static func collapsed(from frame: CGRect, size: CGSize, above: Bool, alignRight: Bool) -> CGRect {
66
+ CGRect(x: alignRight ? frame.maxX - size.width : frame.minX,
67
+ y: above ? frame.minY : frame.maxY - size.height,
68
+ width: size.width, height: size.height)
58
69
  }
59
70
 
60
71
  /// `frame` moved the least distance that puts it inside `bounds`. A
@@ -29,6 +29,28 @@ final class Recorder {
29
29
  /// between two adjacent 100ms windows — which changes nothing.
30
30
  private(set) var level: Float = 0
31
31
 
32
+ /// How a hands-free turn's end is heard: "vad" (Silero VAD, once its
33
+ /// model is downloaded; the volume threshold until then) or "volume",
34
+ /// from agentx.json's `voice.endOfTurn`. Push-to-talk sets "hold": its
35
+ /// turn ends on key release, so no detector runs. Set before `start()`.
36
+ var endOfTurn = "vad"
37
+ /// True while this recording's turn is judged by Silero VAD.
38
+ private(set) var usingVAD = false
39
+ private var vad: SileroVAD?
40
+ private var turn = TurnEnd(.level)
41
+ private let turnLock = NSLock()
42
+ /// Bumped by every start, so VAD work queued for an earlier recording
43
+ /// never lands in this one.
44
+ private var generation = 0
45
+ private let vadQueue = DispatchQueue(label: "tn.acme.agentx.voice.vad")
46
+
47
+ /// Where the hands-free turn stands: nothing said, talking (a pause
48
+ /// included), or over. Read from the main thread.
49
+ var turnState: TurnEnd.State {
50
+ turnLock.lock(); defer { turnLock.unlock() }
51
+ return turn.state
52
+ }
53
+
32
54
  /// 16 kHz mono int16 — the format both ElevenLabs and whisper prefer,
33
55
  /// and small enough that a 30-second utterance is under 1 MB.
34
56
  private let target = AVAudioFormat(
@@ -58,6 +80,7 @@ final class Recorder {
58
80
  level = 0
59
81
  guard !isRecording else { return }
60
82
  lock.lock(); pcm.removeAll(keepingCapacity: true); lock.unlock()
83
+ prepareTurn()
61
84
 
62
85
  let input = engine.inputNode
63
86
  let inputFormat = input.inputFormat(forBus: 0)
@@ -110,9 +133,8 @@ final class Recorder {
110
133
  sum += sample * sample
111
134
  }
112
135
  let rms = (sum / Float(max(Int(out.frameLength), 1))).squareRoot()
113
- // Attack fast, release slow: a level that drops instantly makes a
114
- // pause between words look like the end of a sentence.
115
- level = rms > level ? rms : level * 0.82 + rms * 0.18
136
+ level = TurnEnd.smooth(level: level, rms: rms)
137
+ hearTurn(channel, count: Int(out.frameLength))
116
138
 
117
139
  let bytes = Int(out.frameLength) * MemoryLayout<Int16>.size
118
140
  lock.lock()
@@ -122,6 +144,62 @@ final class Recorder {
122
144
  lock.unlock()
123
145
  }
124
146
 
147
+ /// A fresh turn for a new recording, judged by Silero VAD when its
148
+ /// model is installed and loads; otherwise by the volume threshold,
149
+ /// with the model downloading in the background for next time.
150
+ private func prepareTurn() {
151
+ var detector: SileroVAD?
152
+ // Only a hands-free turn listens for its own end; push-to-talk ("hold")
153
+ // ends on key release, so it never loads or runs the model.
154
+ if endOfTurn == "vad" {
155
+ do {
156
+ if let model = try SileroVAD.loadModel() {
157
+ detector = try SileroVAD(model: model)
158
+ } else {
159
+ ModelStore.installInBackground(ModelStore.vad) { Log.info($0) }
160
+ }
161
+ } catch {
162
+ Log.warn("Silero VAD would not load (\(error.localizedDescription)); using the volume threshold")
163
+ }
164
+ }
165
+ turnLock.lock()
166
+ generation += 1
167
+ vad = detector
168
+ usingVAD = detector != nil
169
+ turn = TurnEnd(detector != nil ? .vad : .level)
170
+ turnLock.unlock()
171
+ }
172
+
173
+ /// One converted buffer: to the VAD off the audio thread, or straight
174
+ /// to the volume check.
175
+ private func hearTurn(_ channel: UnsafePointer<Int16>, count: Int) {
176
+ turnLock.lock()
177
+ let detector = vad, gen = generation
178
+ if detector == nil {
179
+ turn.feed(level: level, duration: Double(count) / target.sampleRate)
180
+ }
181
+ turnLock.unlock()
182
+ guard let detector else { return }
183
+ var samples = [Float](repeating: 0, count: count)
184
+ for i in 0..<count { samples[i] = Float(channel[i]) / 32768.0 }
185
+ vadQueue.async { [weak self] in
186
+ guard let self else { return }
187
+ let probabilities: [Float]
188
+ do { probabilities = try detector.process(samples) } catch {
189
+ Log.warn("Silero VAD failed (\(error.localizedDescription)); using the volume threshold")
190
+ self.turnLock.lock()
191
+ if self.generation == gen { self.vad = nil; self.usingVAD = false; self.turn = TurnEnd(.level) }
192
+ self.turnLock.unlock()
193
+ return
194
+ }
195
+ self.turnLock.lock()
196
+ if self.generation == gen {
197
+ for p in probabilities { self.turn.feed(probability: p, duration: SileroVAD.frameDuration) }
198
+ }
199
+ self.turnLock.unlock()
200
+ }
201
+ }
202
+
125
203
  /// Minimal RIFF/WAVE header for 16-bit mono PCM.
126
204
  private func wav(_ body: Data) -> Data {
127
205
  let rate = UInt32(target.sampleRate), channels: UInt16 = 1, bits: UInt16 = 16
@@ -0,0 +1,90 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// The settings window's look controls (#211): each agent's orb palette,
5
+ /// and how long and how tall the answer in the pill is. Both are saved in
6
+ /// agentx.json through the daemon, like everything else in the window.
7
+
8
+ /// A palette as a small gradient disc, for menus that only show images.
9
+ enum PaletteSwatch {
10
+ static func image(_ hexes: [String], size: CGFloat = 14) -> NSImage {
11
+ let colors = hexes.compactMap(OrbMath.parseHex).map { NSColor(srgbRed: $0.r, green: $0.g, blue: $0.b, alpha: 1) }
12
+ return NSImage(size: NSSize(width: size, height: size), flipped: false) { rect in
13
+ let disc = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5))
14
+ NSGradient(colors: colors.isEmpty ? [.gray] : colors)?.draw(in: disc, angle: -45)
15
+ NSColor.separatorColor.setStroke()
16
+ disc.lineWidth = 0.5
17
+ disc.stroke()
18
+ return true
19
+ }
20
+ }
21
+ }
22
+
23
+ /// "Orb palette": one of the nature palettes, or the one nearest the
24
+ /// agent's colour.
25
+ struct PalettePicker: View {
26
+ @Binding var agent: VoiceSettings.Agent
27
+ let palettes: [VoiceSettings.Palette]
28
+
29
+ private func palette(_ id: String?) -> VoiceSettings.Palette? { palettes.first { $0.id == id } }
30
+
31
+ var body: some View {
32
+ let shown = palette(agent.palette ?? agent.paletteDefault)
33
+ HStack {
34
+ Picker("Orb palette", selection: $agent.palette) {
35
+ if let fallback = palette(agent.paletteDefault) {
36
+ Label { Text("Match the colour: \(fallback.label)") } icon: { Image(nsImage: PaletteSwatch.image(fallback.colors)) }
37
+ .tag(String?.none)
38
+ } else {
39
+ Text("Match the colour").tag(String?.none)
40
+ }
41
+ Divider()
42
+ ForEach(palettes) { p in
43
+ Label { Text(p.label) } icon: { Image(nsImage: PaletteSwatch.image(p.colors)) }
44
+ .tag(String?.some(p.id))
45
+ }
46
+ }
47
+ if let shown {
48
+ Image(nsImage: PaletteSwatch.image(shown.colors, size: 20))
49
+ .accessibilityHidden(true)
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ /// "Answer in the pill": how long it stays open once spoken, and how tall
56
+ /// it grows before it scrolls.
57
+ struct AnswerCardSection: View {
58
+ @Binding var card: VoiceSettings.Card
59
+
60
+ /// The usual choices, plus whatever agentx.json already says.
61
+ private var timeouts: [Double] {
62
+ let usual: [Double] = [10, 20, 30, 60, 120, 300]
63
+ return usual.contains(card.timeout) || card.timeout == 0 ? usual : (usual + [card.timeout]).sorted()
64
+ }
65
+
66
+ private static func label(_ s: Double) -> String {
67
+ if s < 60 || s.truncatingRemainder(dividingBy: 60) != 0 { return "\(Int(s)) seconds" }
68
+ return s == 60 ? "1 minute" : "\(Int(s / 60)) minutes"
69
+ }
70
+
71
+ var body: some View {
72
+ Section {
73
+ Picker("Keep the answer open", selection: $card.timeout) {
74
+ ForEach(timeouts, id: \.self) { s in
75
+ Text(Self.label(s)).tag(s)
76
+ }
77
+ Divider()
78
+ Text("Until I close it").tag(0.0)
79
+ }
80
+ Stepper(value: $card.maxHeight, in: 120...800, step: 40) {
81
+ LabeledContent("Tallest answer", value: "\(Int(card.maxHeight)) points")
82
+ }
83
+ } header: {
84
+ Text("Answer in the pill")
85
+ } footer: {
86
+ Text("When an answer has a link, a picture or more to read than was spoken, the pill grows to show it. It closes this long after the answer has been spoken, but not while the pointer is over it. A longer answer scrolls.")
87
+ .font(.caption).foregroundStyle(.secondary)
88
+ }
89
+ }
90
+ }
@@ -326,16 +326,19 @@ private struct AgentForm: View {
326
326
  get: { Color(hex: agent.color) },
327
327
  set: { agent.color = $0.hex; agent.colorSet = true })
328
328
  let footer = agent.colorSet
329
- ? "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour also paints this agent's pointer."
330
- : "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour comes from the agent's id until you pick one."
329
+ ? "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour also paints this agent's pointer, and picks the orb's palette unless you choose one."
330
+ : "Hold the shortcut and speak to ask this agent without changing who is ticked in the menu. The colour comes from the agent's id until you pick one, and picks the orb's palette unless you choose one."
331
331
  return Section {
332
332
  HotkeyField(title: "Ask with shortcut", value: $agent.voice.hotkey, optional: true, model: model, id: "agent.\(agent.id)")
333
333
  HStack {
334
334
  ColorPicker("Orb colour", selection: color, supportsOpacity: false)
335
335
  Button("Use default") { agent.colorSet = false }.disabled(!agent.colorSet)
336
336
  }
337
+ if let palettes = settings.palettes, !palettes.isEmpty {
338
+ PalettePicker(agent: $agent, palettes: palettes)
339
+ }
337
340
  } header: {
338
- Text("Shortcut and colour")
341
+ Text("Shortcut and look")
339
342
  } footer: {
340
343
  Text(footer).font(.caption).foregroundStyle(.secondary)
341
344
  }
@@ -365,7 +368,15 @@ private struct GeneralTab: View {
365
368
  Picker("Speech to text", selection: binding(\.stt)) {
366
369
  Text("Automatic").tag("auto")
367
370
  Text("ElevenLabs").tag("elevenlabs")
368
- Text("On this Mac (Whisper)").tag("local")
371
+ Text("On this Mac").tag("local")
372
+ }
373
+ Picker("On-this-Mac engine", selection: binding(\.localStt)) {
374
+ Text("Whisper (mlx-whisper)").tag("mlx-whisper")
375
+ Text("Parakeet (downloads 483 MB)").tag("parakeet")
376
+ }
377
+ Picker("End of a hands-free turn", selection: binding(\.endOfTurn)) {
378
+ Text("Voice detection (Silero)").tag("vad")
379
+ Text("Volume").tag("volume")
369
380
  }
370
381
  Picker("Default voice provider", selection: binding(\.provider)) {
371
382
  Text("Mac voices (free)").tag("system")
@@ -374,9 +385,13 @@ private struct GeneralTab: View {
374
385
  } header: {
375
386
  Text("Speech")
376
387
  } footer: {
377
- Text("Automatic uses ElevenLabs when a key is set, and Whisper on this Mac otherwise. The default provider is for agents that don't choose their own. Both apply to the next question.")
388
+ Text("Automatic uses ElevenLabs when a key is set, and the engine on this Mac otherwise. Parakeet has no Arabic; until its model has downloaded, Whisper answers instead. Voice detection ends a turn when you stop talking, not when the room goes quiet. The default provider is for agents that don't choose their own. All apply to the next question.")
378
389
  .font(.caption).foregroundStyle(.secondary)
379
390
  }
391
+ if draft.general.card != nil {
392
+ AnswerCardSection(card: Binding(get: { model.draft?.general.card ?? .standard },
393
+ set: { model.draft?.general.card = $0 }))
394
+ }
380
395
  Section {
381
396
  Toggle("Launch at login", isOn: Binding(get: { model.launchAtLogin }, set: { model.setLaunchAtLogin($0) }))
382
397
  .disabled(LoginItem.managedByInstaller)