cuecard 0.2.13__py3-none-any.whl
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.
- cuecard/__init__.py +3 -0
- cuecard/__main__.py +4 -0
- cuecard/_swift/listen/Package.swift +18 -0
- cuecard/_swift/listen/Sources/cuecard-listen/Capture.swift +276 -0
- cuecard/_swift/listen/Sources/cuecard-listen/Info.plist +16 -0
- cuecard/_swift/listen/Sources/cuecard-listen/Output.swift +26 -0
- cuecard/_swift/listen/Sources/cuecard-listen/Pipeline.swift +441 -0
- cuecard/_swift/listen/Sources/cuecard-listen/Speaker.swift +172 -0
- cuecard/_swift/listen/Sources/cuecard-listen/Translate.swift +81 -0
- cuecard/_swift/listen/Sources/cuecard-listen/main.swift +273 -0
- cuecard/cli.py +536 -0
- cuecard/config.py +160 -0
- cuecard/engine.py +3882 -0
- cuecard/heuristics.py +1547 -0
- cuecard/learn.py +328 -0
- cuecard/listen.py +188 -0
- cuecard/llm.py +906 -0
- cuecard/memory.py +598 -0
- cuecard/panel.py +124 -0
- cuecard/plain.py +92 -0
- cuecard/prompts.py +409 -0
- cuecard/server.py +97 -0
- cuecard/tui.py +438 -0
- cuecard/turn.py +127 -0
- cuecard/voiceprint.py +533 -0
- cuecard/web.py +377 -0
- cuecard/webui/app.js +536 -0
- cuecard/webui/index.html +191 -0
- cuecard/webui/panel.css +127 -0
- cuecard/webui/panel.html +79 -0
- cuecard/webui/panel.js +269 -0
- cuecard/webui/style.css +203 -0
- cuecard-0.2.13.dist-info/METADATA +292 -0
- cuecard-0.2.13.dist-info/RECORD +36 -0
- cuecard-0.2.13.dist-info/WHEEL +4 -0
- cuecard-0.2.13.dist-info/entry_points.txt +2 -0
cuecard/__init__.py
ADDED
cuecard/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// swift-tools-version:6.0
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
let package = Package(
|
|
5
|
+
name: "cuecard-listen",
|
|
6
|
+
platforms: [.macOS("26.0")],
|
|
7
|
+
targets: [
|
|
8
|
+
.executableTarget(
|
|
9
|
+
name: "cuecard-listen",
|
|
10
|
+
path: "Sources/cuecard-listen",
|
|
11
|
+
linkerSettings: [
|
|
12
|
+
.unsafeFlags(["-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist",
|
|
13
|
+
"-Xlinker", "Sources/cuecard-listen/Info.plist"])
|
|
14
|
+
]
|
|
15
|
+
)
|
|
16
|
+
],
|
|
17
|
+
swiftLanguageModes: [.v5]
|
|
18
|
+
)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import AudioToolbox
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import CoreAudio
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
private func getProp<T>(_ obj: AudioObjectID, _ selector: AudioObjectPropertySelector, _ value: inout T) -> OSStatus {
|
|
7
|
+
var addr = AudioObjectPropertyAddress(mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
|
|
8
|
+
var size = UInt32(MemoryLayout<T>.size)
|
|
9
|
+
return AudioObjectGetPropertyData(obj, &addr, 0, nil, &size, &value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// Captures all system output audio (except our own process) via a Core Audio process tap.
|
|
13
|
+
final class SystemTap: @unchecked Sendable {
|
|
14
|
+
private let pipeline: SourcePipeline
|
|
15
|
+
private var tapID = AudioObjectID(kAudioObjectUnknown)
|
|
16
|
+
private var aggID = AudioObjectID(kAudioObjectUnknown)
|
|
17
|
+
private var procID: AudioDeviceIOProcID?
|
|
18
|
+
private var format: AVAudioFormat?
|
|
19
|
+
private var excludePIDs: [pid_t]
|
|
20
|
+
private var listenerBlock: AudioObjectPropertyListenerBlock?
|
|
21
|
+
private let lock = NSLock()
|
|
22
|
+
|
|
23
|
+
init(pipeline: SourcePipeline, excludePIDs: [pid_t]) {
|
|
24
|
+
self.pipeline = pipeline
|
|
25
|
+
self.excludePIDs = excludePIDs
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static func processObject(for pid: pid_t) -> AudioObjectID? {
|
|
29
|
+
var addr = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
|
|
30
|
+
var p = pid
|
|
31
|
+
var obj = AudioObjectID(kAudioObjectUnknown)
|
|
32
|
+
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
|
33
|
+
let err = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, UInt32(MemoryLayout<pid_t>.size), &p, &size, &obj)
|
|
34
|
+
return (err == noErr && obj != kAudioObjectUnknown) ? obj : nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func start() throws {
|
|
38
|
+
lock.lock(); defer { lock.unlock() }
|
|
39
|
+
try build()
|
|
40
|
+
installDeviceListener()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private func build() throws {
|
|
44
|
+
let excluded = excludePIDs.compactMap { SystemTap.processObject(for: $0) }
|
|
45
|
+
let desc = CATapDescription(stereoGlobalTapButExcludeProcesses: excluded)
|
|
46
|
+
desc.uuid = UUID()
|
|
47
|
+
desc.muteBehavior = .unmuted
|
|
48
|
+
desc.isPrivate = true
|
|
49
|
+
desc.name = "cuecard-tap"
|
|
50
|
+
var err = AudioHardwareCreateProcessTap(desc, &tapID)
|
|
51
|
+
guard err == noErr else { throw NSError(domain: "cuecard.tap", code: Int(err), userInfo: [NSLocalizedDescriptionKey: "AudioHardwareCreateProcessTap failed (\(err)); grant System Audio Recording permission"]) }
|
|
52
|
+
|
|
53
|
+
var asbd = AudioStreamBasicDescription()
|
|
54
|
+
err = getProp(tapID, kAudioTapPropertyFormat, &asbd)
|
|
55
|
+
guard err == noErr, let fmt = AVAudioFormat(streamDescription: &asbd) else {
|
|
56
|
+
throw NSError(domain: "cuecard.tap", code: Int(err), userInfo: [NSLocalizedDescriptionKey: "cannot read tap format"])
|
|
57
|
+
}
|
|
58
|
+
format = fmt
|
|
59
|
+
|
|
60
|
+
var outDev = AudioObjectID(0)
|
|
61
|
+
_ = getProp(AudioObjectID(kAudioObjectSystemObject), kAudioHardwarePropertyDefaultSystemOutputDevice, &outDev)
|
|
62
|
+
var uid: CFString = "" as CFString
|
|
63
|
+
_ = getProp(outDev, kAudioDevicePropertyDeviceUID, &uid)
|
|
64
|
+
let outUID = uid as String
|
|
65
|
+
|
|
66
|
+
let agg: [String: Any] = [
|
|
67
|
+
kAudioAggregateDeviceNameKey: "Cuecard Tap",
|
|
68
|
+
kAudioAggregateDeviceUIDKey: UUID().uuidString,
|
|
69
|
+
kAudioAggregateDeviceMainSubDeviceKey: outUID,
|
|
70
|
+
kAudioAggregateDeviceIsPrivateKey: true,
|
|
71
|
+
kAudioAggregateDeviceIsStackedKey: false,
|
|
72
|
+
kAudioAggregateDeviceTapAutoStartKey: true,
|
|
73
|
+
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outUID]],
|
|
74
|
+
kAudioAggregateDeviceTapListKey: [[kAudioSubTapDriftCompensationKey: true, kAudioSubTapUIDKey: desc.uuid.uuidString]],
|
|
75
|
+
]
|
|
76
|
+
err = AudioHardwareCreateAggregateDevice(agg as CFDictionary, &aggID)
|
|
77
|
+
guard err == noErr else { throw NSError(domain: "cuecard.tap", code: Int(err), userInfo: [NSLocalizedDescriptionKey: "aggregate device failed (\(err))"]) }
|
|
78
|
+
|
|
79
|
+
let pipeline = self.pipeline
|
|
80
|
+
err = AudioDeviceCreateIOProcIDWithBlock(&procID, aggID, nil) { _, inData, _, _, _ in
|
|
81
|
+
guard let src = AVAudioPCMBuffer(pcmFormat: fmt, bufferListNoCopy: inData, deallocator: nil),
|
|
82
|
+
src.frameLength > 0,
|
|
83
|
+
let copy = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: src.frameLength) else { return }
|
|
84
|
+
copy.frameLength = src.frameLength
|
|
85
|
+
let srcList = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: src.audioBufferList))
|
|
86
|
+
let dstList = UnsafeMutableAudioBufferListPointer(copy.mutableAudioBufferList)
|
|
87
|
+
for i in 0..<min(srcList.count, dstList.count) {
|
|
88
|
+
if let s = srcList[i].mData, let d = dstList[i].mData {
|
|
89
|
+
memcpy(d, s, Int(min(srcList[i].mDataByteSize, dstList[i].mDataByteSize)))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pipeline.push(copy)
|
|
93
|
+
}
|
|
94
|
+
guard err == noErr else { throw NSError(domain: "cuecard.tap", code: Int(err), userInfo: [NSLocalizedDescriptionKey: "IOProc failed (\(err))"]) }
|
|
95
|
+
err = AudioDeviceStart(aggID, procID)
|
|
96
|
+
guard err == noErr else { throw NSError(domain: "cuecard.tap", code: Int(err), userInfo: [NSLocalizedDescriptionKey: "device start failed (\(err))"]) }
|
|
97
|
+
Out.log("system tap started fmt=\(fmt) output=\(outUID) excluded=\(excluded)")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func teardown() {
|
|
101
|
+
if aggID != kAudioObjectUnknown {
|
|
102
|
+
if let procID { AudioDeviceStop(aggID, procID); AudioDeviceDestroyIOProcID(aggID, procID) }
|
|
103
|
+
AudioHardwareDestroyAggregateDevice(aggID)
|
|
104
|
+
}
|
|
105
|
+
if tapID != kAudioObjectUnknown { AudioHardwareDestroyProcessTap(tapID) }
|
|
106
|
+
procID = nil
|
|
107
|
+
aggID = AudioObjectID(kAudioObjectUnknown)
|
|
108
|
+
tapID = AudioObjectID(kAudioObjectUnknown)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Rebuild when the default output device changes (headphones plugged, Bluetooth, etc).
|
|
112
|
+
private func installDeviceListener() {
|
|
113
|
+
var addr = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDefaultSystemOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
|
|
114
|
+
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
|
115
|
+
guard let self else { return }
|
|
116
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {
|
|
117
|
+
self.lock.lock(); defer { self.lock.unlock() }
|
|
118
|
+
self.teardown()
|
|
119
|
+
do {
|
|
120
|
+
try self.build()
|
|
121
|
+
Out.emit("status", ["src": "them", "message": "output device changed; capture rebuilt"])
|
|
122
|
+
} catch {
|
|
123
|
+
Out.error("them", "device", "\(error.localizedDescription)")
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
listenerBlock = block
|
|
128
|
+
AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &addr, DispatchQueue.global(), block)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func stop() {
|
|
132
|
+
lock.lock(); defer { lock.unlock() }
|
|
133
|
+
teardown()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Microphone capture via AVAudioEngine.
|
|
138
|
+
final class MicCapture: @unchecked Sendable {
|
|
139
|
+
private let engine = AVAudioEngine()
|
|
140
|
+
private let pipeline: SourcePipeline
|
|
141
|
+
private let aec: Bool
|
|
142
|
+
private let ctl = DispatchQueue(label: "cuecard.mic") // start / pause / resume / device changes, one at a time
|
|
143
|
+
private var paused = false
|
|
144
|
+
private var observer: NSObjectProtocol?
|
|
145
|
+
|
|
146
|
+
init(pipeline: SourcePipeline, aec: Bool) {
|
|
147
|
+
self.pipeline = pipeline
|
|
148
|
+
self.aec = aec
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// `open: false` sets everything up but leaves the microphone closed (ME turned it off): `resume()` opens it.
|
|
152
|
+
func start(open: Bool = true) throws {
|
|
153
|
+
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
154
|
+
if status == .denied || status == .restricted {
|
|
155
|
+
throw NSError(domain: "cuecard.mic", code: 1, userInfo: [NSLocalizedDescriptionKey: "microphone permission denied"])
|
|
156
|
+
}
|
|
157
|
+
if aec {
|
|
158
|
+
do {
|
|
159
|
+
try engine.inputNode.setVoiceProcessingEnabled(true)
|
|
160
|
+
engine.inputNode.voiceProcessingOtherAudioDuckingConfiguration = .init(enableAdvancedDucking: false, duckingLevel: .min)
|
|
161
|
+
} catch { Out.log("voice processing unavailable: \(error)") }
|
|
162
|
+
}
|
|
163
|
+
try ctl.sync {
|
|
164
|
+
if open { try installAndStart() } else { paused = true }
|
|
165
|
+
}
|
|
166
|
+
// A new input device (AirPods, a USB headset, unplugging one) stops the engine and may change the format:
|
|
167
|
+
// re-install the tap with the new format and start again, or the mic silently goes dead.
|
|
168
|
+
observer = NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine,
|
|
169
|
+
queue: nil) { [weak self] _ in
|
|
170
|
+
guard let self else { return }
|
|
171
|
+
self.ctl.async {
|
|
172
|
+
if self.paused { return } // resume() re-installs with whatever device is current then
|
|
173
|
+
do {
|
|
174
|
+
try self.installAndStart()
|
|
175
|
+
Out.log("mic restarted after an audio device change")
|
|
176
|
+
} catch {
|
|
177
|
+
Out.error(self.pipeline.src, "device", "microphone stopped after an audio device change: \(error.localizedDescription)")
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// On `ctl`: (re-)install the tap with the input's current format and start the engine.
|
|
184
|
+
private func installAndStart() throws {
|
|
185
|
+
let input = engine.inputNode
|
|
186
|
+
input.removeTap(onBus: 0)
|
|
187
|
+
let fmt = input.outputFormat(forBus: 0)
|
|
188
|
+
guard fmt.sampleRate > 0 else {
|
|
189
|
+
throw NSError(domain: "cuecard.mic", code: 2, userInfo: [NSLocalizedDescriptionKey: "no microphone input device"])
|
|
190
|
+
}
|
|
191
|
+
let pipeline = self.pipeline
|
|
192
|
+
input.installTap(onBus: 0, bufferSize: 1024, format: fmt) { buf, _ in
|
|
193
|
+
guard let copy = AVAudioPCMBuffer(pcmFormat: buf.format, frameCapacity: buf.frameLength) else { return }
|
|
194
|
+
copy.frameLength = buf.frameLength
|
|
195
|
+
let s = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: buf.audioBufferList))
|
|
196
|
+
let d = UnsafeMutableAudioBufferListPointer(copy.mutableAudioBufferList)
|
|
197
|
+
for i in 0..<min(s.count, d.count) {
|
|
198
|
+
if let sp = s[i].mData, let dp = d[i].mData { memcpy(dp, sp, Int(min(s[i].mDataByteSize, d[i].mDataByteSize))) }
|
|
199
|
+
}
|
|
200
|
+
pipeline.push(copy)
|
|
201
|
+
}
|
|
202
|
+
engine.prepare()
|
|
203
|
+
try engine.start()
|
|
204
|
+
Out.log("mic started fmt=\(fmt) aec=\(aec)")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func stop() {
|
|
208
|
+
ctl.sync {
|
|
209
|
+
if let observer { NotificationCenter.default.removeObserver(observer) }
|
|
210
|
+
observer = nil
|
|
211
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
212
|
+
engine.stop()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Paused = the microphone is really closed (the macOS mic indicator goes off).
|
|
217
|
+
func pause() {
|
|
218
|
+
ctl.sync {
|
|
219
|
+
paused = true
|
|
220
|
+
engine.stop()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func resume() {
|
|
225
|
+
ctl.sync {
|
|
226
|
+
paused = false
|
|
227
|
+
do { try installAndStart() } catch {
|
|
228
|
+
Out.error(pipeline.src, "mic", "resume failed: \(error.localizedDescription)")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// Replays an audio file into a pipeline, paced at real time when requested.
|
|
235
|
+
final class FileSource: @unchecked Sendable {
|
|
236
|
+
let path: String
|
|
237
|
+
let pipeline: SourcePipeline
|
|
238
|
+
let realtime: Bool
|
|
239
|
+
|
|
240
|
+
init(path: String, pipeline: SourcePipeline, realtime: Bool) {
|
|
241
|
+
self.path = path
|
|
242
|
+
self.pipeline = pipeline
|
|
243
|
+
self.realtime = realtime
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Blocks until the file is fully fed. `anchor` is the shared wall-clock start.
|
|
247
|
+
func run(anchor: Date, tailSilence: Double) throws {
|
|
248
|
+
let file = try AVAudioFile(forReading: URL(fileURLWithPath: path))
|
|
249
|
+
let fmt = file.processingFormat
|
|
250
|
+
let chunk = AVAudioFrameCount(fmt.sampleRate * 0.02)
|
|
251
|
+
var fed = 0.0
|
|
252
|
+
while true {
|
|
253
|
+
let remain = AVAudioFrameCount(max(0, file.length - file.framePosition))
|
|
254
|
+
if remain == 0 { break }
|
|
255
|
+
guard let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: chunk) else { break }
|
|
256
|
+
try file.read(into: buf, frameCount: min(chunk, remain))
|
|
257
|
+
if buf.frameLength == 0 { break }
|
|
258
|
+
pipeline.push(buf)
|
|
259
|
+
fed += Double(buf.frameLength) / fmt.sampleRate
|
|
260
|
+
if realtime {
|
|
261
|
+
let wait = fed - Date().timeIntervalSince(anchor)
|
|
262
|
+
if wait > 0 { Thread.sleep(forTimeInterval: wait) }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Trailing silence so VAD/ASR can close the last turn, paced as well.
|
|
266
|
+
let steps = Int(tailSilence / 0.1)
|
|
267
|
+
for _ in 0..<steps {
|
|
268
|
+
pipeline.pushSilence(seconds: 0.1)
|
|
269
|
+
fed += 0.1
|
|
270
|
+
if realtime {
|
|
271
|
+
let wait = fed - Date().timeIntervalSince(anchor)
|
|
272
|
+
if wait > 0 { Thread.sleep(forTimeInterval: wait) }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>dev.cuecard.listen</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>cuecard-listen</string>
|
|
9
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
10
|
+
<string>Cuecard transcribes your microphone for meeting context.</string>
|
|
11
|
+
<key>NSAudioCaptureUsageDescription</key>
|
|
12
|
+
<string>Cuecard records system audio to transcribe what the other side says.</string>
|
|
13
|
+
<key>NSSpeechRecognitionUsageDescription</key>
|
|
14
|
+
<string>Cuecard uses on-device speech recognition for live captions.</string>
|
|
15
|
+
</dict>
|
|
16
|
+
</plist>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Thread-safe NDJSON writer on stdout. Every event gets a wall-clock timestamp.
|
|
4
|
+
enum Out {
|
|
5
|
+
private static let lock = NSLock()
|
|
6
|
+
|
|
7
|
+
static func emit(_ type: String, _ fields: [String: Any] = [:]) {
|
|
8
|
+
var obj = fields
|
|
9
|
+
obj["type"] = type
|
|
10
|
+
obj["wall"] = Date().timeIntervalSince1970
|
|
11
|
+
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.withoutEscapingSlashes]) else { return }
|
|
12
|
+
lock.lock()
|
|
13
|
+
FileHandle.standardOutput.write(data)
|
|
14
|
+
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
|
15
|
+
lock.unlock()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func log(_ msg: String) {
|
|
19
|
+
FileHandle.standardError.write("[listen] \(msg)\n".data(using: .utf8)!)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static func error(_ src: String, _ code: String, _ message: String) {
|
|
23
|
+
emit("error", ["src": src, "code": code, "message": message])
|
|
24
|
+
log("error \(src) \(code): \(message)")
|
|
25
|
+
}
|
|
26
|
+
}
|