agentix-cli 0.58.0 → 0.59.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/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/Parakeet.swift +264 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +10 -2
- 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 +5 -1
- package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +23 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +12 -26
- package/apps/mac-voice/build.sh +10 -1
- package/dist/{App-Z5VOAUXB.js → App-QEJGXZTY.js} +2 -2
- package/dist/{ChatApp-BE7ONQKS.js → ChatApp-VKWQN5GM.js} +2 -2
- package/dist/{agent-Q74EISKP.js → agent-MJKLI2L5.js} +2 -2
- package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-2KVT72J7.js} +221 -138
- package/dist/board-dashboard-2KVT72J7.js.map +1 -0
- package/dist/{chunk-I2LYULR4.js → chunk-2XZGHIVP.js} +5 -5
- package/dist/{chunk-5LLGIX5M.js → chunk-3EVCHRJF.js} +2 -2
- package/dist/{chunk-6QNPCVRN.js → chunk-4ZTV6JV7.js} +3 -3
- package/dist/chunk-4ZTV6JV7.js.map +1 -0
- package/dist/{chunk-VI7GBDKV.js → chunk-5OF3EGCZ.js} +2 -2
- package/dist/{chunk-RXEAACVH.js → chunk-6DUTIPTY.js} +2 -2
- package/dist/{chunk-CQUVUVXR.js → chunk-6ZV2QFXE.js} +2 -2
- package/dist/{chunk-2MIOMYT2.js → chunk-DAAWUFO4.js} +2 -2
- package/dist/{chunk-547G3HYQ.js → chunk-EJQOKSKP.js} +2 -2
- package/dist/{chunk-POL7RAJQ.js → chunk-ILXW3ERG.js} +117 -117
- package/dist/chunk-ILXW3ERG.js.map +1 -0
- package/dist/{chunk-ERL6AS3O.js → chunk-IW4XIZAZ.js} +8 -8
- package/dist/{chunk-ERL6AS3O.js.map → chunk-IW4XIZAZ.js.map} +1 -1
- package/dist/{chunk-Q6SMLSRO.js → chunk-JUY77L7C.js} +2 -2
- package/dist/{chunk-KW7TIOS3.js → chunk-K62U5EJJ.js} +2 -2
- package/dist/{chunk-KKGFZEXS.js → chunk-L5OCF33B.js} +37 -36
- package/dist/chunk-L5OCF33B.js.map +1 -0
- package/dist/{chunk-XTD3SZIP.js → chunk-N5GOBLUI.js} +2 -2
- package/dist/{chunk-VVBGPT6W.js → chunk-OIDMPPV5.js} +2 -2
- package/dist/{chunk-OZZPJ2YG.js → chunk-SDBU6747.js} +190 -177
- package/dist/chunk-SDBU6747.js.map +1 -0
- package/dist/{chunk-NKMFHEGV.js → chunk-WFPRVZBA.js} +2 -2
- package/dist/{chunk-6OABTCRA.js → chunk-WI5R64WO.js} +2 -2
- package/dist/{chunk-5W7NJHCS.js → chunk-YCIZ2WHX.js} +2 -2
- package/dist/{chunk-U5YBQJ5Q.js → chunk-YQU74AOL.js} +2 -2
- package/dist/{chunk-V7ZK6ZIO.js → chunk-YRB75GMN.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-X2PTYKVX.js +2 -0
- package/dist/{config-P6YXC6XZ.js → config-XB2ORKWS.js} +2 -2
- package/dist/config-mutator-KMXR3XDG.js +2 -0
- package/dist/doctor-6BIMNEGU.js +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1 -1
- package/dist/program-DHDC6XYB.js +2 -0
- package/dist/{promote-KVPLDSKT.js → promote-GBR6YSQW.js} +2 -2
- package/dist/query-672RGMXO.js +2 -0
- package/dist/{registry-AUOI4TW2.js → registry-P535FNLB.js} +2 -2
- package/dist/{request-planner-LMZ5ZAXC.js → request-planner-KHTCHF7N.js} +2 -2
- package/dist/{review-candidates-WCBXLLDS.js → review-candidates-Y5KCRUNU.js} +2 -2
- package/dist/{routing-FBASYKUL.js → routing-RWCCDJ4D.js} +2 -2
- package/dist/schedule-tool-H46AK53H.js +2 -0
- package/dist/scheduler-3XLWBCX7.js +2 -0
- package/dist/{seat-DBFW3Z5P.js → seat-PJPKLH6L.js} +2 -2
- package/dist/{select-6CNVVFA5.js → select-VMTJAX5X.js} +2 -2
- package/dist/{set-enabled-AYZH3KSB.js → set-enabled-LPXH3ZQP.js} +2 -2
- package/dist/{tool-PKS4NBZ6.js → tool-MWXG44OT.js} +2 -2
- package/dist/wiki-QHMHVFWU.js +2 -0
- package/package.json +1 -1
- package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
- package/dist/chunk-6QNPCVRN.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-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-QEJGXZTY.js.map} +0 -0
- /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-VKWQN5GM.js.map} +0 -0
- /package/dist/{agent-Q74EISKP.js.map → agent-MJKLI2L5.js.map} +0 -0
- /package/dist/{chunk-I2LYULR4.js.map → chunk-2XZGHIVP.js.map} +0 -0
- /package/dist/{chunk-5LLGIX5M.js.map → chunk-3EVCHRJF.js.map} +0 -0
- /package/dist/{chunk-VI7GBDKV.js.map → chunk-5OF3EGCZ.js.map} +0 -0
- /package/dist/{chunk-RXEAACVH.js.map → chunk-6DUTIPTY.js.map} +0 -0
- /package/dist/{chunk-CQUVUVXR.js.map → chunk-6ZV2QFXE.js.map} +0 -0
- /package/dist/{chunk-2MIOMYT2.js.map → chunk-DAAWUFO4.js.map} +0 -0
- /package/dist/{chunk-547G3HYQ.js.map → chunk-EJQOKSKP.js.map} +0 -0
- /package/dist/{chunk-Q6SMLSRO.js.map → chunk-JUY77L7C.js.map} +0 -0
- /package/dist/{chunk-KW7TIOS3.js.map → chunk-K62U5EJJ.js.map} +0 -0
- /package/dist/{chunk-XTD3SZIP.js.map → chunk-N5GOBLUI.js.map} +0 -0
- /package/dist/{chunk-VVBGPT6W.js.map → chunk-OIDMPPV5.js.map} +0 -0
- /package/dist/{chunk-NKMFHEGV.js.map → chunk-WFPRVZBA.js.map} +0 -0
- /package/dist/{chunk-6OABTCRA.js.map → chunk-WI5R64WO.js.map} +0 -0
- /package/dist/{chunk-5W7NJHCS.js.map → chunk-YCIZ2WHX.js.map} +0 -0
- /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-YQU74AOL.js.map} +0 -0
- /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-YRB75GMN.js.map} +0 -0
- /package/dist/{client-EFP2PKA3.js.map → client-X2PTYKVX.js.map} +0 -0
- /package/dist/{config-P6YXC6XZ.js.map → config-XB2ORKWS.js.map} +0 -0
- /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-KMXR3XDG.js.map} +0 -0
- /package/dist/{doctor-JEWMHXYH.js.map → doctor-6BIMNEGU.js.map} +0 -0
- /package/dist/{program-7DUQJDSQ.js.map → program-DHDC6XYB.js.map} +0 -0
- /package/dist/{promote-KVPLDSKT.js.map → promote-GBR6YSQW.js.map} +0 -0
- /package/dist/{query-AGOMCYTT.js.map → query-672RGMXO.js.map} +0 -0
- /package/dist/{registry-AUOI4TW2.js.map → registry-P535FNLB.js.map} +0 -0
- /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-KHTCHF7N.js.map} +0 -0
- /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-Y5KCRUNU.js.map} +0 -0
- /package/dist/{routing-FBASYKUL.js.map → routing-RWCCDJ4D.js.map} +0 -0
- /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-H46AK53H.js.map} +0 -0
- /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-3XLWBCX7.js.map} +0 -0
- /package/dist/{seat-DBFW3Z5P.js.map → seat-PJPKLH6L.js.map} +0 -0
- /package/dist/{select-6CNVVFA5.js.map → select-VMTJAX5X.js.map} +0 -0
- /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-LPXH3ZQP.js.map} +0 -0
- /package/dist/{tool-PKS4NBZ6.js.map → tool-MWXG44OT.js.map} +0 -0
- /package/dist/{wiki-QW6BDNEQ.js.map → wiki-QHMHVFWU.js.map} +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// On-device models, fetched on first use into ~/.agentx/models and never
|
|
5
|
+
/// shipped with the app or the repository.
|
|
6
|
+
///
|
|
7
|
+
/// Every file is pinned twice: by the Hugging Face revision it comes from
|
|
8
|
+
/// and by its SHA-256. A file that does not match is deleted, so a
|
|
9
|
+
/// truncated download or a changed upstream repo can never be loaded. A
|
|
10
|
+
/// model counts as installed only when a `.verified` stamp sits next to
|
|
11
|
+
/// it, written after every file checked out, so a half-finished download
|
|
12
|
+
/// is simply "not installed" and the next attempt starts over.
|
|
13
|
+
struct LocalModel: Sendable {
|
|
14
|
+
struct File: Sendable {
|
|
15
|
+
let path: String
|
|
16
|
+
let size: Int64
|
|
17
|
+
let sha256: String
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Short name for the command line and the logs: "vad", "parakeet".
|
|
21
|
+
let name: String
|
|
22
|
+
let repo: String
|
|
23
|
+
let revision: String
|
|
24
|
+
let files: [File]
|
|
25
|
+
|
|
26
|
+
var totalBytes: Int64 { files.reduce(0) { $0 + $1.size } }
|
|
27
|
+
|
|
28
|
+
/// ~/.agentx/models/<repo name>/
|
|
29
|
+
var directory: URL {
|
|
30
|
+
ModelStore.root.appendingPathComponent(repo.split(separator: "/").last.map(String.init) ?? repo)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func url(_ path: String) -> URL { directory.appendingPathComponent(path) }
|
|
34
|
+
|
|
35
|
+
private var stamp: URL { directory.appendingPathComponent(".verified") }
|
|
36
|
+
|
|
37
|
+
/// Every file is present and was verified for this revision.
|
|
38
|
+
var isInstalled: Bool {
|
|
39
|
+
guard let s = try? String(contentsOf: stamp, encoding: .utf8) else { return false }
|
|
40
|
+
return s.trimmingCharacters(in: .whitespacesAndNewlines) == revision
|
|
41
|
+
&& files.allSatisfy { FileManager.default.fileExists(atPath: url($0.path).path) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fileprivate func markInstalled() throws {
|
|
45
|
+
try revision.write(to: stamp, atomically: true, encoding: .utf8)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
enum ModelStore {
|
|
50
|
+
static let root: URL = {
|
|
51
|
+
if let dir = ProcessInfo.processInfo.environment["AGENTX_MODELS_DIR"], !dir.isEmpty {
|
|
52
|
+
return URL(fileURLWithPath: dir)
|
|
53
|
+
}
|
|
54
|
+
return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".agentx/models")
|
|
55
|
+
}()
|
|
56
|
+
|
|
57
|
+
/// Silero VAD v6, converted to Core ML by FluidInference (MIT). 0.9 MB.
|
|
58
|
+
/// One call scores 32 ms of 16 kHz audio (512 samples plus 64 of context).
|
|
59
|
+
static let vad = LocalModel(
|
|
60
|
+
name: "vad",
|
|
61
|
+
repo: "FluidInference/silero-vad-coreml",
|
|
62
|
+
revision: "b419383c55c110e2c9271fa6ee0ea83d03c70d96",
|
|
63
|
+
files: [
|
|
64
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "2141be60ea0adf7acb1232fbcfaffb2be308ae02e6672d3762aedf36611ea9fd"),
|
|
65
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/coremldata.bin", size: 593, sha256: "f460dcdf796b19c04bc38ab6e69601831f634e5e74d499487f0c8fe17ca12f0f"),
|
|
66
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/metadata.json", size: 3232, sha256: "282b1b788b5d4d66d6a70d468464a7ada71d2469a8705526b741fd9ab841295a"),
|
|
67
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/model.mil", size: 25126, sha256: "79eea23c368f3e7edf85af798a953f5b0910e4b08ff48e55fff8545dd05fd047"),
|
|
68
|
+
.init(path: "silero-vad-unified-v6.0.0.mlmodelc/weights/weight.bin", size: 882304, sha256: "853cf34740d3f5061f977ebe2976f7c921b064261c9c4753b3a1196f2dba42b4"),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
/// NVIDIA Parakeet TDT 0.6B v3 (CC-BY-4.0), converted to Core ML by
|
|
72
|
+
/// FluidInference. 483 MB, almost all of it the encoder. The same build
|
|
73
|
+
/// FluidAudio loads by default: 6-bit palettized encoder, 15 s window.
|
|
74
|
+
static let parakeet = LocalModel(
|
|
75
|
+
name: "parakeet",
|
|
76
|
+
repo: "FluidInference/parakeet-tdt-0.6b-v3-coreml",
|
|
77
|
+
revision: "7dd20fe6b1797d35f5e3307e8b1732d9a178edfe",
|
|
78
|
+
files: [
|
|
79
|
+
.init(path: "Preprocessor.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "c9beeb989c8d66f8be11df59bc6df277ec76cee404f6865b46243835ef562f6d"),
|
|
80
|
+
.init(path: "Preprocessor.mlmodelc/coremldata.bin", size: 486, sha256: "dbde3f2300842c1fd51ef3ff948a0bcffe65ffd2dca10707f2509f32c1d65b1d"),
|
|
81
|
+
.init(path: "Preprocessor.mlmodelc/metadata.json", size: 2841, sha256: "2a98699e22d279dd37fa1d238aeb1c6db1df0d6fad687775324157689d8f3acf"),
|
|
82
|
+
.init(path: "Preprocessor.mlmodelc/model.mil", size: 28181, sha256: "4b8518a956450fec57f06c2a21bdffc26973f7f1fa6842fb38fe917f896b6b93"),
|
|
83
|
+
.init(path: "Preprocessor.mlmodelc/weights/weight.bin", size: 491072, sha256: "129b76e3aeafa8afa3ea76d995b964b145fe83700d579f6ff42c4c38fa0968ea"),
|
|
84
|
+
.init(path: "Encoder.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "42e638870d73f26b332918a3496ce36793fbb413a81cbd3d16ba01328637a105"),
|
|
85
|
+
.init(path: "Encoder.mlmodelc/coremldata.bin", size: 485, sha256: "d48034a167a82e88fc3df64f60af963ab3983538271175b8319e7d5720a0fb86"),
|
|
86
|
+
.init(path: "Encoder.mlmodelc/metadata.json", size: 2921, sha256: "da24da9cca943fb29d7fa8e376d57fca7cb3aa08ca51b956b0b0e56813f087e9"),
|
|
87
|
+
.init(path: "Encoder.mlmodelc/model.mil", size: 959769, sha256: "ed7b19156ca29fa7dfd6891deb9fda4b0e8893f68597c985d135736546a43808"),
|
|
88
|
+
.init(path: "Encoder.mlmodelc/weights/weight.bin", size: 445187200, sha256: "e2020f323703477a5b21d7c2d282c403e371afb5962e79877e3033e73ba6f421"),
|
|
89
|
+
.init(path: "Decoder.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "4238c4e81ecd0dc94bd7dfbb60f7e2cc824107c1ffe0387b8607b72833dba350"),
|
|
90
|
+
.init(path: "Decoder.mlmodelc/coremldata.bin", size: 554, sha256: "18647af085d87bd8f3121c8a9b4d4564c1ede038dab63d295b4e745cf2d7fb99"),
|
|
91
|
+
.init(path: "Decoder.mlmodelc/metadata.json", size: 3427, sha256: "a39e93cd8371b8ded92635c7804fcd0590f0d1dd9415c6d19a0484be073077d9"),
|
|
92
|
+
.init(path: "Decoder.mlmodelc/model.mil", size: 13110, sha256: "ef2a0a281695398a62fde86ac269c68f73d5b578d7ed3b31f2ba91a2d1ea1f35"),
|
|
93
|
+
.init(path: "Decoder.mlmodelc/weights/weight.bin", size: 23604992, sha256: "48adf0f0d47c406c8253d4f7fef967436a39da14f5a65e66d5a4b407be355d41"),
|
|
94
|
+
.init(path: "JointDecisionv3.mlmodelc/analytics/coremldata.bin", size: 243, sha256: "26def4bf73dd56d29dee21c8ef97cb8969e62f6120ed1adc91e46828e2737b6c"),
|
|
95
|
+
.init(path: "JointDecisionv3.mlmodelc/coremldata.bin", size: 521, sha256: "f5fc08b741400f0088492c9e839418b1e18522f19cba28d361dd030c5f398342"),
|
|
96
|
+
.init(path: "JointDecisionv3.mlmodelc/metadata.json", size: 3453, sha256: "d9307211b9a37e0f0ac260c7660b1571a3de25841035cfdf9b58fd40425f890f"),
|
|
97
|
+
.init(path: "JointDecisionv3.mlmodelc/model.mil", size: 11775, sha256: "be60732943389a047175111a83f8839f3eb39d4803adafa828a0871b2f39818d"),
|
|
98
|
+
.init(path: "JointDecisionv3.mlmodelc/weights/weight.bin", size: 12642764, sha256: "4e0e63d840032f7f07ddb1d64446051166281e5491bf22da8a945c41f6eedb3e"),
|
|
99
|
+
.init(path: "parakeet_vocab.json", size: 151122, sha256: "7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735"),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
static let all = [vad, parakeet]
|
|
103
|
+
|
|
104
|
+
enum StoreError: LocalizedError {
|
|
105
|
+
case http(String, Int)
|
|
106
|
+
case mismatch(String)
|
|
107
|
+
var errorDescription: String? {
|
|
108
|
+
switch self {
|
|
109
|
+
case .http(let path, let code): return "download of \(path) failed with HTTP \(code)"
|
|
110
|
+
case .mismatch(let path): return "\(path) did not match its checksum and was deleted"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Downloads whatever is missing or wrong, checks every file, then
|
|
116
|
+
/// stamps the model installed. `progress` gets bytes done and total.
|
|
117
|
+
static func install(_ model: LocalModel, progress: ((Int64, Int64) -> Void)? = nil) async throws {
|
|
118
|
+
if model.isInstalled { return }
|
|
119
|
+
let fm = FileManager.default
|
|
120
|
+
try fm.createDirectory(at: model.directory, withIntermediateDirectories: true)
|
|
121
|
+
var done: Int64 = 0
|
|
122
|
+
for file in model.files {
|
|
123
|
+
let dest = model.url(file.path)
|
|
124
|
+
if fm.fileExists(atPath: dest.path), (try? sha256(of: dest)) == file.sha256 {
|
|
125
|
+
done += file.size
|
|
126
|
+
progress?(done, model.totalBytes)
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
try? fm.removeItem(at: dest)
|
|
130
|
+
try fm.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
131
|
+
let source = URL(string: "https://huggingface.co/\(model.repo)/resolve/\(model.revision)/\(file.path)")!
|
|
132
|
+
let (tmp, response) = try await URLSession.shared.download(from: source)
|
|
133
|
+
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
134
|
+
try? fm.removeItem(at: tmp)
|
|
135
|
+
throw StoreError.http(file.path, http.statusCode)
|
|
136
|
+
}
|
|
137
|
+
guard (try? sha256(of: tmp)) == file.sha256 else {
|
|
138
|
+
try? fm.removeItem(at: tmp)
|
|
139
|
+
throw StoreError.mismatch(file.path)
|
|
140
|
+
}
|
|
141
|
+
try fm.moveItem(at: tmp, to: dest)
|
|
142
|
+
done += file.size
|
|
143
|
+
progress?(done, model.totalBytes)
|
|
144
|
+
}
|
|
145
|
+
try model.markInstalled()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Streams the file, so the 445 MB encoder is never held in memory.
|
|
149
|
+
static func sha256(of url: URL) throws -> String {
|
|
150
|
+
let handle = try FileHandle(forReadingFrom: url)
|
|
151
|
+
defer { try? handle.close() }
|
|
152
|
+
var hasher = SHA256()
|
|
153
|
+
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
|
|
154
|
+
hasher.update(data: chunk)
|
|
155
|
+
}
|
|
156
|
+
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private static let background = BackgroundInstalls()
|
|
160
|
+
|
|
161
|
+
/// Starts one download of `model` in the background unless it is
|
|
162
|
+
/// installed or already downloading. For first use: the turn that
|
|
163
|
+
/// found it missing falls back, and a later one gets the model.
|
|
164
|
+
static func installInBackground(_ model: LocalModel, log: @escaping @Sendable (String) -> Void) {
|
|
165
|
+
guard !model.isInstalled, background.begin(model.name) else { return }
|
|
166
|
+
log("downloading the \(model.name) model (\(model.totalBytes / 1_000_000) MB) into \(model.directory.path)")
|
|
167
|
+
Task.detached {
|
|
168
|
+
do {
|
|
169
|
+
try await install(model)
|
|
170
|
+
log("the \(model.name) model is installed")
|
|
171
|
+
} catch {
|
|
172
|
+
log("the \(model.name) model could not be downloaded: \(error.localizedDescription)")
|
|
173
|
+
}
|
|
174
|
+
background.end(model.name)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private final class BackgroundInstalls: @unchecked Sendable {
|
|
180
|
+
private let lock = NSLock()
|
|
181
|
+
private var running = Set<String>()
|
|
182
|
+
func begin(_ name: String) -> Bool { lock.lock(); defer { lock.unlock() }; return running.insert(name).inserted }
|
|
183
|
+
func end(_ name: String) { lock.lock(); running.remove(name); lock.unlock() }
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
114
|
-
|
|
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
|
|
@@ -365,7 +365,15 @@ private struct GeneralTab: View {
|
|
|
365
365
|
Picker("Speech to text", selection: binding(\.stt)) {
|
|
366
366
|
Text("Automatic").tag("auto")
|
|
367
367
|
Text("ElevenLabs").tag("elevenlabs")
|
|
368
|
-
Text("On this Mac
|
|
368
|
+
Text("On this Mac").tag("local")
|
|
369
|
+
}
|
|
370
|
+
Picker("On-this-Mac engine", selection: binding(\.localStt)) {
|
|
371
|
+
Text("Whisper (mlx-whisper)").tag("mlx-whisper")
|
|
372
|
+
Text("Parakeet (downloads 483 MB)").tag("parakeet")
|
|
373
|
+
}
|
|
374
|
+
Picker("End of a hands-free turn", selection: binding(\.endOfTurn)) {
|
|
375
|
+
Text("Voice detection (Silero)").tag("vad")
|
|
376
|
+
Text("Volume").tag("volume")
|
|
369
377
|
}
|
|
370
378
|
Picker("Default voice provider", selection: binding(\.provider)) {
|
|
371
379
|
Text("Mac voices (free)").tag("system")
|
|
@@ -374,7 +382,7 @@ private struct GeneralTab: View {
|
|
|
374
382
|
} header: {
|
|
375
383
|
Text("Speech")
|
|
376
384
|
} footer: {
|
|
377
|
-
Text("Automatic uses ElevenLabs when a key is set, and
|
|
385
|
+
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
386
|
.font(.caption).foregroundStyle(.secondary)
|
|
379
387
|
}
|
|
380
388
|
Section {
|