@kaeawc/auto-mobile 0.0.43 → 0.0.45

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 (30) hide show
  1. package/README.md +3 -3
  2. package/README.md.backup +3 -3
  3. package/dist/ios/screen-capture/Package.swift +35 -0
  4. package/dist/ios/screen-capture/Sources/ScreenCaptureCore/AudioPcm16Encoder.swift +22 -0
  5. package/dist/ios/screen-capture/Sources/ScreenCaptureCore/CommandLineOptions.swift +152 -0
  6. package/dist/ios/screen-capture/Sources/ScreenCaptureCore/DeviceInfo.swift +25 -0
  7. package/dist/ios/screen-capture/Sources/ScreenCaptureCore/FrameProtocol.swift +113 -0
  8. package/dist/ios/screen-capture/Sources/ScreenCaptureCore/FrameWriter.swift +62 -0
  9. package/dist/ios/screen-capture/Sources/ScreenCaptureCore/SimulatorWindowInfo.swift +51 -0
  10. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/AudioSampleBuffer.swift +41 -0
  11. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/CMIOSystem.swift +26 -0
  12. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/DeviceCaptureSession.swift +117 -0
  13. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/DeviceDiscovery.swift +29 -0
  14. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/FrameWriter+PixelBuffer.swift +25 -0
  15. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/SimulatorCaptureSession.swift +159 -0
  16. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/SimulatorWindowDiscovery.swift +37 -0
  17. package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/main.swift +255 -0
  18. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/AudioPcm16EncoderTests.swift +17 -0
  19. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/CommandLineOptionsTests.swift +181 -0
  20. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/DeviceInfoTests.swift +35 -0
  21. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/FrameProtocolTests.swift +123 -0
  22. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/FrameWriterTests.swift +62 -0
  23. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/SimulatorAudioCaptureAvailabilityTests.swift +27 -0
  24. package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/SimulatorWindowInfoTests.swift +40 -0
  25. package/dist/schemas/tool-definitions.json +1274 -849
  26. package/dist/src/db/migrations/2026_07_03_000_repair_datetime_now_defaults.ts +11 -2
  27. package/dist/src/index.js +684 -413
  28. package/dist/src/index.js.map +1 -1
  29. package/package.json +25 -6
  30. package/schemas/tool-definitions.json +1274 -849
@@ -0,0 +1,29 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import ScreenCaptureCore
4
+
5
+ /// Discovers USB-connected iOS devices visible to AVFoundation as external
6
+ /// muxed (audio + video) capture devices.
7
+ enum DeviceDiscovery {
8
+ static func discover() -> [AVCaptureDevice] {
9
+ let types: [AVCaptureDevice.DeviceType] = [.external]
10
+ return AVCaptureDevice.DiscoverySession(
11
+ deviceTypes: types,
12
+ mediaType: .muxed,
13
+ position: .unspecified
14
+ ).devices
15
+ }
16
+
17
+ static func toInfo(_ device: AVCaptureDevice) -> DeviceInfo {
18
+ DeviceInfo(
19
+ uniqueID: device.uniqueID,
20
+ localizedName: device.localizedName,
21
+ modelID: device.modelID,
22
+ manufacturer: device.manufacturer
23
+ )
24
+ }
25
+
26
+ static func find(uniqueID: String) -> AVCaptureDevice? {
27
+ discover().first { $0.uniqueID == uniqueID }
28
+ }
29
+ }
@@ -0,0 +1,25 @@
1
+ import CoreMedia
2
+ import CoreVideo
3
+ import Foundation
4
+ import ScreenCaptureCore
5
+
6
+ extension FrameWriter {
7
+ /// Writes one frame from a `CMSampleBuffer`, locking the underlying pixel
8
+ /// buffer for the duration of the synchronous sink write. Returns `false`
9
+ /// when the sample buffer has no image (e.g. dropped/idle frames).
10
+ @discardableResult
11
+ func write(sampleBuffer: CMSampleBuffer) -> Bool {
12
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return false }
13
+ CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
14
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
15
+ guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return false }
16
+
17
+ write(
18
+ width: CVPixelBufferGetWidth(pixelBuffer),
19
+ height: CVPixelBufferGetHeight(pixelBuffer),
20
+ bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
21
+ baseAddress: base
22
+ )
23
+ return true
24
+ }
25
+ }
@@ -0,0 +1,159 @@
1
+ import CoreMedia
2
+ import CoreVideo
3
+ import Foundation
4
+ import ScreenCaptureKit
5
+ import ScreenCaptureCore
6
+
7
+ /// Streams BGRA frames from a single iOS Simulator window via ScreenCaptureKit.
8
+ /// The frame rate is configurable (5–60); 5 is the default for typical MCP
9
+ /// automation workloads. Size changes (e.g. device rotation) trigger a stream
10
+ /// reconfiguration so frames don't get cropped.
11
+ final class SimulatorCaptureSession: NSObject, SCStreamOutput, SCStreamDelegate {
12
+ private let writer: FrameWriter
13
+ private let onFatalError: (Error) -> Void
14
+ private let queue = DispatchQueue(label: "automobile.simulator-capture.frames")
15
+ private var stream: SCStream?
16
+ private var configuredPixelWidth: Int = 0
17
+ private var configuredPixelHeight: Int = 0
18
+ private var hasReceivedFrame = false
19
+ private var fps: Int = CommandLineOptions.defaultSimulatorFPS
20
+ private var audioEnabled = false
21
+
22
+ init(writer: FrameWriter, onFatalError: @escaping (Error) -> Void) {
23
+ self.writer = writer
24
+ self.onFatalError = onFatalError
25
+ }
26
+
27
+ func start(window: SCWindow, fps: Int, audio: Bool) async throws {
28
+ self.fps = fps
29
+ audioEnabled = audio
30
+ let filter = SCContentFilter(desktopIndependentWindow: window)
31
+ let config = SimulatorCaptureSession.makeConfiguration(window: window, fps: fps, audio: audio)
32
+ configuredPixelWidth = config.width
33
+ configuredPixelHeight = config.height
34
+
35
+ let stream = SCStream(filter: filter, configuration: config, delegate: self)
36
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
37
+ if audio {
38
+ try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
39
+ }
40
+ try await stream.startCapture()
41
+ self.stream = stream
42
+ }
43
+
44
+ func stop() async {
45
+ guard let stream = stream else { return }
46
+ // removeStreamOutput breaks the SCStream → self retain cycle so the
47
+ // session is collectable even before SCStream itself goes away.
48
+ try? stream.removeStreamOutput(self, type: .screen)
49
+ try? await stream.stopCapture()
50
+ self.stream = nil
51
+ }
52
+
53
+ /// Whether at least one frame has been delivered since `start()`.
54
+ /// Used by the CLI to detect a silent screen-recording permission denial.
55
+ var hasReceivedAnyFrame: Bool { hasReceivedFrame }
56
+
57
+ // MARK: - SCStreamOutput
58
+
59
+ func stream(
60
+ _ stream: SCStream,
61
+ didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
62
+ of type: SCStreamOutputType
63
+ ) {
64
+ guard sampleBuffer.isValid else { return }
65
+ if type == .audio {
66
+ if let pcm16le = pcm16leAudio(sampleBuffer: sampleBuffer) {
67
+ writer.writeAudio(pcm16le: pcm16le)
68
+ }
69
+ return
70
+ }
71
+ guard type == .screen else { return }
72
+
73
+ // Drop non-complete statuses (idle, blank, suspended, stopped) so we
74
+ // don't re-emit identical pixels or partial buffers.
75
+ if let status = SimulatorCaptureSession.frameStatus(of: sampleBuffer),
76
+ status != .complete {
77
+ return
78
+ }
79
+
80
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
81
+ let actualWidth = CVPixelBufferGetWidth(pixelBuffer)
82
+ let actualHeight = CVPixelBufferGetHeight(pixelBuffer)
83
+ if actualWidth != configuredPixelWidth || actualHeight != configuredPixelHeight {
84
+ reconfigure(width: actualWidth, height: actualHeight)
85
+ }
86
+
87
+ if writer.write(sampleBuffer: sampleBuffer) {
88
+ hasReceivedFrame = true
89
+ }
90
+ }
91
+
92
+ // MARK: - SCStreamDelegate
93
+
94
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
95
+ onFatalError(error)
96
+ }
97
+
98
+ // MARK: - Internals
99
+
100
+ private func reconfigure(width: Int, height: Int) {
101
+ guard let stream = stream else { return }
102
+ configuredPixelWidth = width
103
+ configuredPixelHeight = height
104
+
105
+ let updated = SCStreamConfiguration()
106
+ updated.width = width
107
+ updated.height = height
108
+ updated.pixelFormat = kCVPixelFormatType_32BGRA
109
+ updated.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
110
+ updated.showsCursor = false
111
+ updated.scalesToFit = false
112
+ updated.capturesAudio = audioEnabled
113
+ updated.sampleRate = 8_000
114
+ updated.channelCount = 1
115
+
116
+ // Fire-and-forget: if the update fails we keep using the old config and
117
+ // either resync on the next size change or stop with a delegate error.
118
+ Task {
119
+ do {
120
+ try await stream.updateConfiguration(updated)
121
+ } catch {
122
+ FileHandle.standardError.write(
123
+ Data("warn: failed to update stream configuration: \(error)\n".utf8)
124
+ )
125
+ }
126
+ }
127
+ }
128
+
129
+ private static func makeConfiguration(window: SCWindow, fps: Int, audio: Bool) -> SCStreamConfiguration {
130
+ let config = SCStreamConfiguration()
131
+ // Logical (points) size; the delivered CVPixelBuffer is in native
132
+ // pixels (2x/3x for Retina), and downstream consumers must use
133
+ // CVPixelBufferGetWidth/Height — not these values — to allocate sinks.
134
+ config.width = Int(window.frame.width)
135
+ config.height = Int(window.frame.height)
136
+ config.pixelFormat = kCVPixelFormatType_32BGRA
137
+ config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
138
+ config.showsCursor = false
139
+ config.scalesToFit = false
140
+ config.capturesAudio = audio
141
+ config.sampleRate = 8_000
142
+ config.channelCount = 1
143
+ return config
144
+ }
145
+
146
+ private static func frameStatus(of sampleBuffer: CMSampleBuffer) -> SCFrameStatus? {
147
+ guard
148
+ let attachments = CMSampleBufferGetSampleAttachmentsArray(
149
+ sampleBuffer, createIfNecessary: false
150
+ ) as? [[SCStreamFrameInfo: Any]],
151
+ let info = attachments.first,
152
+ let rawStatus = info[.status] as? Int,
153
+ let status = SCFrameStatus(rawValue: rawStatus)
154
+ else {
155
+ return nil
156
+ }
157
+ return status
158
+ }
159
+ }
@@ -0,0 +1,37 @@
1
+ import Foundation
2
+ import ScreenCaptureKit
3
+ import ScreenCaptureCore
4
+
5
+ enum SimulatorWindowDiscovery {
6
+ /// Discovers visible iOS Simulator windows via ScreenCaptureKit.
7
+ /// Requires screen-recording permission; throws if the user has not granted it.
8
+ static func discover() async throws -> [SimulatorWindowInfo] {
9
+ let content = try await SCShareableContent.excludingDesktopWindows(
10
+ false,
11
+ onScreenWindowsOnly: true
12
+ )
13
+ return content.windows
14
+ .filter { $0.owningApplication?.bundleIdentifier == simulatorBundleIdentifier }
15
+ .map(toInfo)
16
+ }
17
+
18
+ static func find(windowID: UInt32) async throws -> SCWindow? {
19
+ let content = try await SCShareableContent.excludingDesktopWindows(
20
+ false,
21
+ onScreenWindowsOnly: true
22
+ )
23
+ return content.windows.first { $0.windowID == windowID }
24
+ }
25
+
26
+ static func toInfo(_ window: SCWindow) -> SimulatorWindowInfo {
27
+ SimulatorWindowInfo(
28
+ windowID: window.windowID,
29
+ title: window.title,
30
+ applicationName: window.owningApplication?.applicationName ?? "",
31
+ bundleIdentifier: window.owningApplication?.bundleIdentifier ?? "",
32
+ processID: window.owningApplication?.processID ?? 0,
33
+ width: Int(window.frame.width),
34
+ height: Int(window.frame.height)
35
+ )
36
+ }
37
+ }
@@ -0,0 +1,255 @@
1
+ import AppKit
2
+ import AVFoundation
3
+ import Foundation
4
+ import ScreenCaptureKit
5
+ import ScreenCaptureCore
6
+
7
+ // MARK: - Constants
8
+
9
+ // ScreenCaptureKit cold-starts just beyond two seconds on hosted macOS runners.
10
+ // Keep the permission hint behind the source's first-frame timeout so startup
11
+ // latency is not misreported as a missing Screen Recording entitlement.
12
+ let simulatorPermissionTimeoutSeconds: TimeInterval = 10.0
13
+
14
+ // A command-line process has no AppKit application by default. ScreenCaptureKit
15
+ // reaches CoreGraphics when creating an SCStream, and CoreGraphics aborts with
16
+ // CGS_REQUIRE_INIT unless this connection is initialized first.
17
+ _ = NSApplication.shared
18
+
19
+ // MARK: - Logging
20
+
21
+ func logError(_ message: String) {
22
+ FileHandle.standardError.write(Data("\(message)\n".utf8))
23
+ }
24
+
25
+ // ScreenCaptureKit failures can surface as Objective-C exceptions, which would
26
+ // otherwise terminate this subprocess with only SIGABRT visible to its parent.
27
+ // Emit the exception while stderr is still connected so the daemon artifact
28
+ // identifies the failing capture stage.
29
+ NSSetUncaughtExceptionHandler { exception in
30
+ logError(
31
+ "fatal: uncaught Objective-C exception \(exception.name.rawValue): "
32
+ + (exception.reason ?? "no reason provided")
33
+ )
34
+ }
35
+
36
+ func writeJSON<T: Encodable>(_ value: T) {
37
+ let encoder = JSONEncoder()
38
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
39
+ do {
40
+ let data = try encoder.encode(value)
41
+ FileHandle.standardOutput.write(data)
42
+ FileHandle.standardOutput.write(Data("\n".utf8))
43
+ } catch {
44
+ logError("error: failed to encode JSON: \(error)")
45
+ exit(1)
46
+ }
47
+ }
48
+
49
+ // MARK: - Argument parsing
50
+
51
+ let options: CommandLineOptions
52
+ do {
53
+ options = try CommandLineOptions.parse(CommandLine.arguments)
54
+ } catch let CommandLineOptions.ParseError.missingValue(flag) {
55
+ logError("error: missing value for \(flag)")
56
+ logError(CommandLineOptions.helpText)
57
+ exit(2)
58
+ } catch let CommandLineOptions.ParseError.unknownArgument(arg) {
59
+ logError("error: unknown argument \(arg)")
60
+ logError(CommandLineOptions.helpText)
61
+ exit(2)
62
+ } catch let CommandLineOptions.ParseError.invalidValue(flag, value) {
63
+ logError("error: invalid value '\(value)' for \(flag)")
64
+ logError(CommandLineOptions.helpText)
65
+ exit(2)
66
+ } catch let CommandLineOptions.ParseError.conflictingFlags(message) {
67
+ logError("error: conflicting flags — \(message)")
68
+ logError(CommandLineOptions.helpText)
69
+ exit(2)
70
+ } catch {
71
+ logError("error: \(error)")
72
+ exit(2)
73
+ }
74
+
75
+ // MARK: - Dispatch
76
+
77
+ switch options.mode {
78
+ case .help:
79
+ print(CommandLineOptions.helpText)
80
+ exit(0)
81
+
82
+ case .listDevices:
83
+ CMIOSystem.enableScreenCaptureDevices()
84
+ // Allow a brief moment for the system to register USB devices.
85
+ Thread.sleep(forTimeInterval: 0.5)
86
+ let infos = DeviceDiscovery.discover().map(DeviceDiscovery.toInfo)
87
+ writeJSON(DeviceListResponse(devices: infos))
88
+ exit(0)
89
+
90
+ case .listSimulators:
91
+ switch runBlocking({ try await SimulatorWindowDiscovery.discover() }) {
92
+ case .success(let windows):
93
+ writeJSON(SimulatorWindowListResponse(windows: windows))
94
+ exit(0)
95
+ case .failure(let error):
96
+ logError("error: failed to query simulator windows: \(error)")
97
+ logError("hint: grant Screen Recording permission to your terminal/IDE.")
98
+ exit(1)
99
+ }
100
+
101
+ case .captureSimulator(let windowID, let fps, let audio):
102
+ if audio {
103
+ switch runBlocking({ try await SimulatorWindowDiscovery.discover() }) {
104
+ case .success(let windows):
105
+ if let error = SimulatorAudioCaptureAvailability.errorMessage(for: windows) {
106
+ logError("error: \(error)")
107
+ exit(1)
108
+ }
109
+ case .failure(let error):
110
+ logError("error: failed to query simulator windows: \(error)")
111
+ exit(1)
112
+ }
113
+ }
114
+
115
+ let window: SCWindow
116
+ switch runBlocking({ try await SimulatorWindowDiscovery.find(windowID: windowID) }) {
117
+ case .success(.some(let resolved)):
118
+ window = resolved
119
+ case .success(.none):
120
+ logError("error: no window with CGWindowID \(windowID)")
121
+ exit(1)
122
+ case .failure(let error):
123
+ logError("error: failed to query simulator windows: \(error)")
124
+ exit(1)
125
+ }
126
+
127
+ let sink = FileHandleFrameSink(handle: .standardOutput)
128
+ let writer = FrameWriter(sink: sink)
129
+ let simSession = SimulatorCaptureSession(writer: writer) { error in
130
+ logError("error: ScreenCaptureKit stream stopped: \(error)")
131
+ }
132
+
133
+ if case .failure(let error) = runBlocking({
134
+ try await simSession.start(window: window, fps: fps, audio: audio)
135
+ }) {
136
+ logError("error: failed to start simulator capture: \(error)")
137
+ exit(1)
138
+ }
139
+
140
+ // ScreenCaptureKit silently emits no frames when the host process lacks
141
+ // Screen Recording permission — there's no error to surface. Emit a hint
142
+ // on stderr if nothing arrives within the deadline; this leaves stdout
143
+ // (the frame channel) untouched.
144
+ DispatchQueue.main.asyncAfter(deadline: .now() + simulatorPermissionTimeoutSeconds) {
145
+ if !simSession.hasReceivedAnyFrame {
146
+ logError(
147
+ "warn: no frames received within \(simulatorPermissionTimeoutSeconds)s. "
148
+ + "Grant 'Screen Recording' to your terminal/IDE in "
149
+ + "System Settings → Privacy & Security → Screen Recording."
150
+ )
151
+ }
152
+ }
153
+
154
+ installShutdownHandlers {
155
+ runBlocking { await simSession.stop() }
156
+ exit(0)
157
+ }
158
+ RunLoop.main.run()
159
+
160
+ case .capture(let deviceID):
161
+ CMIOSystem.enableScreenCaptureDevices()
162
+ Thread.sleep(forTimeInterval: 0.5)
163
+
164
+ let resolved: AVCaptureDevice?
165
+ if let id = deviceID {
166
+ resolved = DeviceDiscovery.find(uniqueID: id)
167
+ if resolved == nil {
168
+ logError("error: no device with uniqueID '\(id)'")
169
+ exit(1)
170
+ }
171
+ } else {
172
+ resolved = DeviceDiscovery.discover().first
173
+ if resolved == nil {
174
+ logError("error: no muxed external capture devices found")
175
+ exit(1)
176
+ }
177
+ }
178
+ guard let device = resolved else { exit(1) }
179
+
180
+ let sink = FileHandleFrameSink(handle: .standardOutput)
181
+ let writer = FrameWriter(sink: sink)
182
+ let captureSession = DeviceCaptureSession(writer: writer) { error in
183
+ logError("error: iOS device capture failed: \(error)")
184
+ }
185
+
186
+ do {
187
+ try captureSession.start(device: device)
188
+ } catch {
189
+ logError("error: failed to start capture session: \(error)")
190
+ exit(1)
191
+ }
192
+
193
+ installShutdownHandlers {
194
+ captureSession.stop()
195
+ exit(0)
196
+ }
197
+ RunLoop.main.run()
198
+ }
199
+
200
+ // MARK: - Async/sync bridges
201
+
202
+ // A reference box so the Task closure mutates a captured class instead of a
203
+ // `var`. Swift 5.9 (Xcode 15) rejects `var` capture in `Task { … }` even when
204
+ // the semaphore enforces a happens-before relationship; class capture is fine.
205
+ private final class Box<T> {
206
+ var value: T
207
+ init(_ value: T) { self.value = value }
208
+ }
209
+
210
+ func runBlocking<T>(_ body: @escaping () async throws -> T) -> Result<T, Error> {
211
+ let semaphore = DispatchSemaphore(value: 0)
212
+ let box = Box<Result<T, Error>>(.failure(CancellationError()))
213
+ Task {
214
+ do {
215
+ box.value = .success(try await body())
216
+ } catch {
217
+ box.value = .failure(error)
218
+ }
219
+ semaphore.signal()
220
+ }
221
+ semaphore.wait()
222
+ return box.value
223
+ }
224
+
225
+ func runBlocking<T>(_ body: @escaping () async -> T) -> T {
226
+ let semaphore = DispatchSemaphore(value: 0)
227
+ let box = Box<T?>(nil)
228
+ Task {
229
+ box.value = await body()
230
+ semaphore.signal()
231
+ }
232
+ semaphore.wait()
233
+ // box.value is set by the async body before semaphore.signal(); wait() blocks until then.
234
+ return box.value! // swiftlint:disable:this force_unwrapping
235
+ }
236
+
237
+ // MARK: - Signal handling
238
+
239
+ // Retain signal sources beyond `installShutdownHandlers` to keep them alive
240
+ // for the duration of the run loop.
241
+ private var retainedSignalSources: [DispatchSourceSignal] = []
242
+
243
+ func installShutdownHandlers(_ handler: @escaping () -> Void) {
244
+ let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
245
+ let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
246
+ // Ignore the default disposition so the DispatchSources are the only
247
+ // handlers that run.
248
+ signal(SIGTERM, SIG_IGN)
249
+ signal(SIGINT, SIG_IGN)
250
+ termSource.setEventHandler(handler: handler)
251
+ intSource.setEventHandler(handler: handler)
252
+ termSource.resume()
253
+ intSource.resume()
254
+ retainedSignalSources = [termSource, intSource]
255
+ }
@@ -0,0 +1,17 @@
1
+ import XCTest
2
+ @testable import ScreenCaptureCore
3
+
4
+ final class AudioPcm16EncoderTests: XCTestCase {
5
+ func testEncodesFloat32LittleEndianAsPCM16LE() {
6
+ let values: [Float] = [-1, 0, 1]
7
+ let input = values.withUnsafeBytes { Data($0) }
8
+
9
+ let output = AudioPcm16Encoder.encodeFloat32LE(input)
10
+
11
+ XCTAssertEqual(output, Data([0x01, 0x80, 0x00, 0x00, 0xFF, 0x7F]))
12
+ }
13
+
14
+ func testRejectsTruncatedFloat32Input() {
15
+ XCTAssertNil(AudioPcm16Encoder.encodeFloat32LE(Data([0, 0, 0])))
16
+ }
17
+ }