@phystack/hub-device 4.4.45 → 4.4.47

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.
@@ -1,5 +1,876 @@
1
1
  import si from 'systeminformation';
2
- // import { execSync } from 'child_process';
2
+ import { execSync } from 'child_process';
3
+ import {
4
+ AudioDevice,
5
+ AudioDevices,
6
+ AudioPort,
7
+ AvailabilityStatus,
8
+ } from '../types/twin.types';
9
+
10
+ /**
11
+ * PulseAudio commands
12
+ */
13
+ const PULSEAUDIO_COMMANDS = {
14
+ INFO: 'pactl info',
15
+ KILL: 'pulseaudio -k 2>/dev/null || true',
16
+ START_DAEMON: 'pulseaudio --daemonize --exit-idle-time=-1 --system=false',
17
+ WAIT: 'sleep 1',
18
+ LIST_MODULES: 'pactl list modules short',
19
+ LOAD_ALSA_MODULE: 'pactl load-module module-alsa-card',
20
+ LIST_SINKS: 'pactl list sinks',
21
+ LIST_SOURCES: 'pactl list sources',
22
+ LIST_CARDS: 'pactl list cards',
23
+ LIST_CARDS_SHORT: 'pactl list cards short',
24
+ LOAD_ALSA_SINK: (hwDevice: string, sinkName: string, description: string) =>
25
+ `pactl load-module module-alsa-sink device=${hwDevice} sink_name=${sinkName} sink_properties="device.description=${description}"`,
26
+ LOAD_ALSA_SOURCE: (hwDevice: string, sourceName: string, description: string) =>
27
+ `pactl load-module module-alsa-source device=${hwDevice} source_name=${sourceName} source_properties="device.description=${description}"`,
28
+ } as const;
29
+
30
+ const ALSA_COMMANDS = {
31
+ APLAY_LIST: 'aplay -l 2>/dev/null',
32
+ ARECORD_LIST: 'arecord -l 2>/dev/null',
33
+ } as const;
34
+
35
+ const PULSEAUDIO_TIMEOUTS = {
36
+ INFO_CHECK: 2000,
37
+ KILL: 2000,
38
+ START: 5000,
39
+ LIST_MODULES: 2000,
40
+ LOAD_MODULE: 3000,
41
+ LIST_DEVICES: 5000,
42
+ } as const;
43
+
44
+ /** Cached PulseAudio server socket path discovered at runtime */
45
+ let pulseAudioServerPath: string | undefined;
46
+
47
+ /**
48
+ * Parses a PulseAudio device block and extracts device metadata
49
+ *
50
+ * @param block - Raw PulseAudio device block text from `pactl list sinks` or `pactl list sources`
51
+ * @param isInput - Whether this is an input device (source) or output device (sink)
52
+ * @returns Parsed AudioDevice object or null if device should be skipped
53
+ */
54
+ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
55
+ const lines = block.split('\n');
56
+ const device: Partial<AudioDevice> = {};
57
+ const ports: AudioPort[] = [];
58
+ let inPortsSection = false;
59
+
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+ const trimmed = line.trim();
63
+
64
+ if (trimmed.startsWith('Name:')) {
65
+ device.identifier = trimmed.split('Name:')[1].trim();
66
+ } else if (trimmed.startsWith('Description:')) {
67
+ device.displayName = trimmed.split('Description:')[1].trim();
68
+ } else if (trimmed.includes('alsa.card =')) {
69
+ const cardMatch = trimmed.match(/alsa\.card\s*=\s*"(\d+)"/);
70
+ if (cardMatch) {
71
+ (device as any).cardNumber = cardMatch[1];
72
+ }
73
+ } else if (trimmed.includes('alsa.card_name =')) {
74
+ const cardNameMatch = trimmed.match(/alsa\.card_name\s*=\s*"([^"]+)"/);
75
+ if (cardNameMatch) {
76
+ (device as any).cardNameRaw = cardNameMatch[1];
77
+ }
78
+ } else if (trimmed.startsWith('Volume:')) {
79
+ const match = trimmed.match(/(\d+)%/);
80
+ if (match) device.volume = parseInt(match[1], 10);
81
+ } else if (trimmed.startsWith('Mute:')) {
82
+ device.muted = trimmed.toLowerCase().includes('yes');
83
+ } else if (trimmed.startsWith('Active Port:')) {
84
+ (device as any).activePort = trimmed.split('Active Port:')[1].trim();
85
+ console.log(`[Audio] Found active port: ${(device as any).activePort}`);
86
+ } else if (trimmed.startsWith('Ports:')) {
87
+ inPortsSection = true;
88
+ console.log(`[Audio] Entering ports section for device`);
89
+ } else if (inPortsSection) {
90
+ // Detect section boundary: next section starts with capital letter and no indentation
91
+ if (trimmed.match(/^[A-Z][a-z]+:/) && !line.match(/^\s/)) {
92
+ inPortsSection = false;
93
+ console.log(`[Audio] Exiting ports section, found ${ports.length} ports`);
94
+ } else if (trimmed.length > 0 && line.match(/^\s+\S/)) {
95
+ // Parse port line format: "analog-output-headphones: Headphones (type: Headphones, priority: 9900, availability unknown)"
96
+ const portMatch = trimmed.match(/^([^:]+):\s*(.+?)(?:\s*\((.+)\))?$/);
97
+ if (portMatch) {
98
+ const portName = portMatch[1].trim();
99
+ const portDescription = portMatch[2].trim();
100
+ const portDetails = portMatch[3];
101
+
102
+ const port: AudioPort = {
103
+ name: portName,
104
+ description: portDescription,
105
+ };
106
+
107
+ // Parse optional port metadata: type, priority, and availability
108
+ if (portDetails) {
109
+ const typeMatch = portDetails.match(/type:\s*([^,]+)/);
110
+ const priorityMatch = portDetails.match(/priority:\s*(\d+)/);
111
+ const availableMatch = portDetails.match(
112
+ /available(?::\s*(yes|no))?|not available|availability unknown/i
113
+ );
114
+
115
+ if (typeMatch) port.type = typeMatch[1].trim();
116
+ if (priorityMatch) port.priority = parseInt(priorityMatch[1], 10);
117
+
118
+ // Parse availability:
119
+ if (availableMatch) {
120
+ const availStr = availableMatch[0].toLowerCase();
121
+ if (availStr.includes('availability unknown')) {
122
+ port.availabilityStatus = AvailabilityStatus.Unknown;
123
+ } else if (availStr.includes('not available') || availStr.includes('available: no')) {
124
+ port.availabilityStatus = AvailabilityStatus.Unavailable;
125
+ } else if (availStr.includes('available: yes')) {
126
+ port.availabilityStatus = AvailabilityStatus.Available;
127
+ }
128
+ // If it just says "available" without yes/no, don't set the property
129
+ }
130
+ }
131
+
132
+ console.log(`[Audio] Parsed port: ${portName} - ${portDescription}`);
133
+ ports.push(port);
134
+ }
135
+ }
136
+ } else if (trimmed.includes('device.product.name =')) {
137
+ const match = trimmed.match(/"([^"]+)"/);
138
+ if (match) {
139
+ (device as any).productName = match[1];
140
+ }
141
+ } else if (trimmed.includes('device.form_factor =')) {
142
+ const match = trimmed.match(/"([^"]+)"/);
143
+ if (match) (device as any).formFactor = match[1];
144
+ } else if (trimmed.includes('device.bus =')) {
145
+ const match = trimmed.match(/"([^"]+)"/);
146
+ if (match) (device as any).bus = match[1];
147
+ }
148
+ }
149
+
150
+ // Filter out monitor sources (virtual recording outputs)
151
+ if (isInput && device.identifier?.includes('.monitor')) {
152
+ return null;
153
+ }
154
+
155
+ if (!device.identifier) return null;
156
+
157
+ if (ports.length > 0) {
158
+ device.availablePorts = ports;
159
+ console.log(
160
+ `[Audio] Device ${device.identifier} has ${ports.length} ports, active: ${(device as any).activePort}`
161
+ );
162
+ } else {
163
+ console.log(`[Audio] Device ${device.identifier} has NO ports detected`);
164
+ }
165
+
166
+ // Determine device availability using priority-based detection:
167
+ // 1. Check if any ports are available
168
+ // 2. If no ports, check sink/source state (RUNNING = available) or card port availability
169
+ const availablePorts = ports.filter(
170
+ p => p.availabilityStatus === AvailabilityStatus.Available
171
+ );
172
+ let deviceAvailable = availablePorts.length > 0;
173
+
174
+ // Fallback: determine availability from sink/source state or card ports when no ports detected
175
+ if (ports.length === 0 && device.identifier) {
176
+ const stateMatch = block.match(/State:\s*(\w+)/i);
177
+ if (stateMatch) {
178
+ const state = stateMatch[1].toUpperCase();
179
+ if (state === 'RUNNING') {
180
+ deviceAvailable = true;
181
+ } else {
182
+ // Check card port availability using pactl list cards
183
+ try {
184
+ const env = getPulseAudioEnv();
185
+ const cardsOutput = execSync(PULSEAUDIO_COMMANDS.LIST_CARDS, {
186
+ encoding: 'utf-8',
187
+ timeout: 2000,
188
+ stdio: 'pipe',
189
+ env,
190
+ });
191
+
192
+ const deviceMatch = device.identifier.match(/[^_]+_(\d+)_(\d+)$/);
193
+ if (deviceMatch) {
194
+ const deviceNum = deviceMatch[2];
195
+ // Map device number to HDMI port index (e.g., device 9 -> hdmi-output-2)
196
+ const portMatch = cardsOutput.match(
197
+ new RegExp(
198
+ `hdmi-output-${deviceNum === '9' ? '2' : deviceNum === '8' ? '1' : deviceNum === '7' ? '1' : '0'}[^\\n]*available`,
199
+ 'i'
200
+ )
201
+ );
202
+ if (portMatch && !portMatch[0].includes('not available')) {
203
+ deviceAvailable = true;
204
+ }
205
+ }
206
+ } catch (error) {
207
+ // Card availability check failed, device remains unavailable
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ // Extract card name from device identifier using multiple format patterns:
214
+ // Format 1: alsa_output.pci-0000_00_1f.3.analog-stereo (standard PulseAudio ALSA format)
215
+ // Format 2: bluez_sink.XX_XX_XX_XX_XX_XX (Bluetooth - no profile suffix)
216
+ // Format 3: alsa_output.usb-046d_08d9-01.analog-stereo (USB audio)
217
+ // Format 4: alsa_output_0_3 (manually created sinks)
218
+ // Generic pattern: {prefix}_{type}.{card_id}[.{profile}] or {prefix}_{type}_{card}_{device}
219
+
220
+ let cardMatch = device.identifier?.match(
221
+ /^([^_]+)_(output|input|sink|source)\.([^.]+(?:\.[^.]+)*)\.(.+)$/
222
+ );
223
+ if (cardMatch) {
224
+ const prefix = cardMatch[1];
225
+ const cardId = cardMatch[3];
226
+ (device as any).cardName = `${prefix}_card.${cardId}`;
227
+ } else {
228
+ cardMatch = device.identifier?.match(/^([^_]+)_(output|input|sink|source)\.(.+)$/);
229
+ if (cardMatch) {
230
+ const prefix = cardMatch[1];
231
+ const cardId = cardMatch[3];
232
+ (device as any).cardName = `${prefix}_card.${cardId}`;
233
+ } else {
234
+ const altMatch = device.identifier?.match(/^([^_]+)_(output|input|sink|source)_(\d+)_(\d+)$/);
235
+ if (altMatch) {
236
+ const cardNumber = altMatch[3];
237
+ try {
238
+ const env = getPulseAudioEnv();
239
+ const cardsOutput = execSync(PULSEAUDIO_COMMANDS.LIST_CARDS_SHORT, {
240
+ encoding: 'utf-8',
241
+ timeout: 2000,
242
+ stdio: 'pipe',
243
+ env,
244
+ });
245
+ const cardLine = cardsOutput.split('\n').find(line => line.includes(`card${cardNumber}`));
246
+ if (cardLine) {
247
+ const cardNameMatch = cardLine.match(/([^_\s]+_card\.[^\s]+)/);
248
+ if (cardNameMatch) {
249
+ (device as any).cardName = cardNameMatch[1];
250
+ }
251
+ }
252
+ } catch (error) {
253
+ // Card lookup failed, continue without card name
254
+ }
255
+ } else {
256
+ const cardNumber = (device as any).cardNumber;
257
+ if (cardNumber !== undefined) {
258
+ try {
259
+ const env = getPulseAudioEnv();
260
+ const cardsOutput = execSync(PULSEAUDIO_COMMANDS.LIST_CARDS_SHORT, {
261
+ encoding: 'utf-8',
262
+ timeout: 2000,
263
+ stdio: 'pipe',
264
+ env,
265
+ });
266
+ const cardLine = cardsOutput
267
+ .split('\n')
268
+ .find(line => line.includes(`card${cardNumber}`));
269
+ if (cardLine) {
270
+ const cardNameMatch = cardLine.match(/([^_\s]+_card\.[^\s]+)/);
271
+ if (cardNameMatch) {
272
+ (device as any).cardName = cardNameMatch[1];
273
+ }
274
+ }
275
+ } catch (error) {
276
+ // Card lookup failed, continue without card name
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ // Generate human-readable label incorporating device metadata
284
+ // Note: isDefault status will be set later when default devices are known
285
+ device.label = createDeviceLabel(
286
+ device,
287
+ ports,
288
+ isInput,
289
+ (device as any).formFactor,
290
+ (device as any).bus,
291
+ false,
292
+ deviceAvailable,
293
+ (device as any).activePort
294
+ );
295
+
296
+ // Remove temporary fields used only for label generation
297
+ // All metadata is consolidated in the label field
298
+ const cleanDevice: Partial<AudioDevice> = {
299
+ identifier: device.identifier,
300
+ volume: device.volume,
301
+ muted: device.muted,
302
+ availablePorts: device.availablePorts,
303
+ label: device.label,
304
+ };
305
+
306
+ return cleanDevice as AudioDevice;
307
+ }
308
+
309
+ /**
310
+ * Generates a human-readable label for an audio device
311
+ * Incorporates device metadata: formFactor, bus, isDefault, available, and activePort
312
+ *
313
+ * @param device - Partial AudioDevice object containing device properties
314
+ * @param ports - Array of available audio ports for this device
315
+ * @param isInput - Whether this is an input device (source) or output device (sink)
316
+ * @param formFactor - Device form factor (e.g., "internal", "external")
317
+ * @param bus - Device bus type (e.g., "pci", "usb")
318
+ * @param isDefault - Whether this device is the default device
319
+ * @param available - Whether the device is currently available
320
+ * @param activePort - The currently active port identifier
321
+ * @returns Human-readable label string
322
+ */
323
+ function createDeviceLabel(
324
+ device: Partial<AudioDevice>,
325
+ ports: AudioPort[],
326
+ isInput: boolean,
327
+ formFactor?: string,
328
+ bus?: string,
329
+ isDefault?: boolean,
330
+ available?: boolean,
331
+ activePort?: string
332
+ ): string {
333
+ const productName = (device as any).productName;
334
+ let baseLabel = '';
335
+
336
+ const activePortInfo = activePort ? ports.find(p => p.name === activePort) : undefined;
337
+
338
+ // Label generation priority: product name > active port > display name > identifier
339
+ if (productName) {
340
+ baseLabel = `${productName} (${isInput ? 'Microphone' : 'Speaker'})`;
341
+ } else if (activePortInfo && activePortInfo.description) {
342
+ const portDesc = activePortInfo.description;
343
+
344
+ // Map common port descriptions to user-friendly names
345
+ if (portDesc.toLowerCase().includes('headphones')) {
346
+ baseLabel = 'Headphones';
347
+ } else if (
348
+ portDesc.toLowerCase().includes('hdmi') ||
349
+ portDesc.toLowerCase().includes('displayport')
350
+ ) {
351
+ baseLabel = `HDMI Display`;
352
+ } else if (
353
+ portDesc.toLowerCase().includes('microphone') ||
354
+ portDesc.toLowerCase().includes('mic')
355
+ ) {
356
+ baseLabel = 'Microphone';
357
+ } else if (portDesc.toLowerCase().includes('line')) {
358
+ baseLabel = `Line ${isInput ? 'In' : 'Out'}`;
359
+ } else if (portDesc.toLowerCase().includes('speaker')) {
360
+ baseLabel = 'Speakers';
361
+ } else {
362
+ baseLabel = `${portDesc} (${isInput ? 'Input' : 'Output'})`;
363
+ }
364
+ } else if (device.displayName) {
365
+ baseLabel = `${device.displayName} (${isInput ? 'Input' : 'Output'})`;
366
+ } else {
367
+ baseLabel = device.identifier || 'Unknown Audio Device';
368
+ }
369
+
370
+ // Append display name if not already included
371
+ if (
372
+ device.displayName &&
373
+ baseLabel !== device.displayName &&
374
+ !baseLabel.includes(device.displayName)
375
+ ) {
376
+ baseLabel = `${device.displayName} - ${baseLabel}`;
377
+ }
378
+
379
+ // Append form factor if external device
380
+ if (formFactor && formFactor !== 'internal' && !baseLabel.includes(formFactor)) {
381
+ baseLabel = `${baseLabel} (${formFactor.charAt(0).toUpperCase() + formFactor.slice(1)})`;
382
+ }
383
+
384
+ // Append bus type if non-PCI
385
+ if (bus && bus.toLowerCase() !== 'pci' && !baseLabel.includes(bus.toUpperCase())) {
386
+ baseLabel = `${baseLabel} (${bus.toUpperCase()})`;
387
+ }
388
+
389
+ // Append status indicators
390
+ const statusParts: string[] = [];
391
+ if (isDefault) {
392
+ statusParts.push('Default');
393
+ }
394
+ if (available !== undefined) {
395
+ statusParts.push(available ? 'Available' : 'Unavailable');
396
+ }
397
+
398
+ return statusParts.length > 0 ? `${baseLabel} (${statusParts.join(', ')})` : baseLabel;
399
+ }
400
+
401
+ /**
402
+ * Discovers the PulseAudio socket path by locating the socket file
403
+ *
404
+ * @internal
405
+ * Exported for use in device-phyos audio control module
406
+ *
407
+ * @returns The socket path if found, undefined otherwise
408
+ */
409
+ export function discoverPulseAudioSocket(): string | undefined {
410
+ try {
411
+ const findOutput = execSync(
412
+ 'find /tmp -maxdepth 2 -type d -name "pulse-*" 2>/dev/null | head -1',
413
+ {
414
+ encoding: 'utf-8',
415
+ timeout: 2000,
416
+ stdio: 'pipe',
417
+ }
418
+ ).trim();
419
+
420
+ if (findOutput) {
421
+ const socketPath = `${findOutput}/native`;
422
+ try {
423
+ execSync(`test -S "${socketPath}"`, { stdio: 'pipe' });
424
+ return socketPath;
425
+ } catch {
426
+ return undefined;
427
+ }
428
+ }
429
+ } catch (error) {
430
+ // Socket discovery failed
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ /**
436
+ * Retrieves environment variables for execSync calls, including PULSE_SERVER if socket is discovered
437
+ *
438
+ * @internal
439
+ * Exported for use in device-phyos audio control module
440
+ *
441
+ * @returns Process environment with PULSE_SERVER set if socket was discovered
442
+ */
443
+ export function getPulseAudioEnv(): NodeJS.ProcessEnv {
444
+ if (!pulseAudioServerPath) {
445
+ pulseAudioServerPath = discoverPulseAudioSocket();
446
+ if (pulseAudioServerPath) {
447
+ console.log(`Discovered PulseAudio socket: ${pulseAudioServerPath}`);
448
+ }
449
+ }
450
+
451
+ const env = { ...process.env };
452
+ if (pulseAudioServerPath) {
453
+ env.PULSE_SERVER = `unix:${pulseAudioServerPath}`;
454
+ }
455
+ return env;
456
+ }
457
+
458
+ /**
459
+ * Starts the PulseAudio daemon if not already running
460
+ * Service runs as root, providing full hardware access in user mode
461
+ *
462
+ * @returns true if PulseAudio is running and accessible, false otherwise
463
+ */
464
+ function startPulseAudio(): boolean {
465
+ if (!pulseAudioServerPath) {
466
+ pulseAudioServerPath = discoverPulseAudioSocket();
467
+ if (pulseAudioServerPath) {
468
+ console.log(`Discovered existing PulseAudio socket: ${pulseAudioServerPath}`);
469
+ }
470
+ }
471
+
472
+ // Verify PulseAudio is running and accessible
473
+ try {
474
+ const env = getPulseAudioEnv();
475
+ execSync(PULSEAUDIO_COMMANDS.INFO, {
476
+ encoding: 'utf-8',
477
+ timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
478
+ stdio: 'pipe',
479
+ env,
480
+ });
481
+ console.log('PulseAudio is running and accessible');
482
+
483
+ loadAlsaDevicesIntoPulseAudio();
484
+ return true;
485
+ } catch (error) {
486
+ const errorMsg = error instanceof Error ? error.message : String(error);
487
+ console.log('PulseAudio not accessible:', errorMsg);
488
+ console.log('Attempting to start PulseAudio...');
489
+ }
490
+
491
+ // Start fresh PulseAudio instance as current user (root)
492
+ try {
493
+ execSync(PULSEAUDIO_COMMANDS.KILL, {
494
+ timeout: PULSEAUDIO_TIMEOUTS.KILL,
495
+ stdio: 'pipe',
496
+ });
497
+
498
+ execSync(PULSEAUDIO_COMMANDS.WAIT, { stdio: 'pipe' });
499
+
500
+ execSync(PULSEAUDIO_COMMANDS.START_DAEMON, {
501
+ timeout: PULSEAUDIO_TIMEOUTS.START,
502
+ stdio: 'pipe',
503
+ });
504
+
505
+ execSync(PULSEAUDIO_COMMANDS.WAIT, { stdio: 'pipe' });
506
+
507
+ pulseAudioServerPath = discoverPulseAudioSocket();
508
+ if (pulseAudioServerPath) {
509
+ console.log(`Discovered PulseAudio socket: ${pulseAudioServerPath}`);
510
+ } else {
511
+ console.log('Warning: Could not discover PulseAudio socket path');
512
+ }
513
+
514
+ const env = getPulseAudioEnv();
515
+ execSync(PULSEAUDIO_COMMANDS.INFO, {
516
+ timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
517
+ stdio: 'pipe',
518
+ env,
519
+ });
520
+
521
+ console.log('✓ PulseAudio started successfully');
522
+
523
+ loadAlsaDevicesIntoPulseAudio();
524
+
525
+ return true;
526
+ } catch (error) {
527
+ console.error(
528
+ '✗ Failed to start PulseAudio:',
529
+ error instanceof Error ? error.message : String(error)
530
+ );
531
+ console.error(' Hint: Make sure no other PulseAudio instances are running as different users');
532
+ return false;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * ALSA hardware device information
538
+ */
539
+ interface AlsaDevice {
540
+ card: number;
541
+ device: number;
542
+ name: string;
543
+ hwString: string; // e.g., "hw:0,0"
544
+ }
545
+
546
+ /**
547
+ * Parses ALSA device list output and extracts hardware device information
548
+ *
549
+ * @param aplayOutput - Raw output from `aplay -l` or `arecord -l` command
550
+ * @returns Array of parsed ALSA device objects
551
+ */
552
+ function parseAlsaDevices(aplayOutput: string): AlsaDevice[] {
553
+ const devices: AlsaDevice[] = [];
554
+
555
+ // Parse format: "card 0: PCH [HDA Intel PCH], device 0: ALC662 rev3 Analog [ALC662 rev3 Analog]"
556
+ const regex = /card (\d+): ([^,]+), device (\d+): ([^\[]+)\[([^\]]+)\]/g;
557
+ let match;
558
+
559
+ while ((match = regex.exec(aplayOutput)) !== null) {
560
+ const card = parseInt(match[1], 10);
561
+ const device = parseInt(match[3], 10);
562
+ const name = match[5].trim();
563
+
564
+ devices.push({
565
+ card,
566
+ device,
567
+ name,
568
+ hwString: `hw:${card},${device}`,
569
+ });
570
+ }
571
+
572
+ return devices;
573
+ }
574
+
575
+ /**
576
+ * Checks if a PulseAudio sink/source already exists for the specified ALSA device
577
+ *
578
+ * @param hwString - ALSA hardware string (e.g., "hw:0,0")
579
+ * @param isInput - Whether to check for source (input) or sink (output)
580
+ * @returns true if a matching sink/source exists, false otherwise
581
+ */
582
+ function checkExistingSink(hwString: string, isInput: boolean): boolean {
583
+ try {
584
+ const command = isInput ? PULSEAUDIO_COMMANDS.LIST_SOURCES : PULSEAUDIO_COMMANDS.LIST_SINKS;
585
+ const env = getPulseAudioEnv();
586
+ const output = execSync(command, {
587
+ encoding: 'utf-8',
588
+ timeout: 3000,
589
+ env,
590
+ });
591
+
592
+ const [card, device] = hwString.replace('hw:', '').split(',');
593
+ const devicePattern = new RegExp(`alsa\\.device\\s*=\\s*"${device}"`, 'i');
594
+ const cardPattern = new RegExp(`alsa\\.card\\s*=\\s*"${card}"`, 'i');
595
+
596
+ const blocks = output.split(/^(Sink|Source) #/m);
597
+ for (const block of blocks) {
598
+ if (cardPattern.test(block) && devicePattern.test(block)) {
599
+ return true;
600
+ }
601
+ }
602
+
603
+ return false;
604
+ } catch (error) {
605
+ return false;
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Creates a PulseAudio sink or source for the specified ALSA device
611
+ *
612
+ * @param alsaDevice - ALSA device information
613
+ * @param isInput - Whether to create a source (input) or sink (output)
614
+ * @returns true if creation succeeded, false otherwise
615
+ */
616
+ function createPulseAudioSink(alsaDevice: AlsaDevice, isInput: boolean): boolean {
617
+ try {
618
+ const sinkName = isInput
619
+ ? `alsa_input_${alsaDevice.card}_${alsaDevice.device}`
620
+ : `alsa_output_${alsaDevice.card}_${alsaDevice.device}`;
621
+
622
+ // Sanitize description to avoid quoting/escaping issues
623
+ const safeDescription = `${alsaDevice.name}_${alsaDevice.hwString}`.replace(
624
+ /[^a-zA-Z0-9_-]/g,
625
+ '_'
626
+ );
627
+
628
+ const command = isInput
629
+ ? PULSEAUDIO_COMMANDS.LOAD_ALSA_SOURCE(alsaDevice.hwString, sinkName, safeDescription)
630
+ : PULSEAUDIO_COMMANDS.LOAD_ALSA_SINK(alsaDevice.hwString, sinkName, safeDescription);
631
+
632
+ console.log(
633
+ ` Creating ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}: ${alsaDevice.name}`
634
+ );
635
+
636
+ const env = getPulseAudioEnv();
637
+ execSync(command, {
638
+ timeout: 5000,
639
+ stdio: 'pipe',
640
+ env,
641
+ });
642
+
643
+ return true;
644
+ } catch (error) {
645
+ console.log(
646
+ ` Failed to create ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}:`,
647
+ error instanceof Error ? error.message : String(error)
648
+ );
649
+ return false;
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Ensures PulseAudio loads all ALSA devices (including input devices)
655
+ *
656
+ * Process:
657
+ * 1. Queries ALSA hardware to find all available devices
658
+ * 2. Checks what PulseAudio has already detected
659
+ * 3. Creates missing sinks/sources dynamically
660
+ * 4. Works generically across all device types
661
+ */
662
+ function loadAlsaDevicesIntoPulseAudio(): void {
663
+ try {
664
+ console.log('Detecting ALSA hardware and ensuring PulseAudio coverage...');
665
+
666
+ // Load base ALSA card module for auto-detection
667
+ try {
668
+ const env = getPulseAudioEnv();
669
+ const modules = execSync(PULSEAUDIO_COMMANDS.LIST_MODULES, {
670
+ encoding: 'utf-8',
671
+ timeout: PULSEAUDIO_TIMEOUTS.LIST_MODULES,
672
+ env,
673
+ });
674
+
675
+ if (!modules.includes('module-alsa-card')) {
676
+ console.log(' Loading ALSA card module for auto-detection...');
677
+ execSync(PULSEAUDIO_COMMANDS.LOAD_ALSA_MODULE, {
678
+ timeout: PULSEAUDIO_TIMEOUTS.LOAD_MODULE,
679
+ stdio: 'pipe',
680
+ env,
681
+ });
682
+ }
683
+ } catch (error) {
684
+ console.log(' Note: Base ALSA module load failed (will create sinks manually)');
685
+ }
686
+
687
+ // Query ALSA for hardware devices
688
+ let outputDevices: AlsaDevice[] = [];
689
+ let inputDevices: AlsaDevice[] = [];
690
+
691
+ try {
692
+ const aplayOutput = execSync(ALSA_COMMANDS.APLAY_LIST, {
693
+ encoding: 'utf-8',
694
+ timeout: 3000,
695
+ });
696
+ outputDevices = parseAlsaDevices(aplayOutput);
697
+ console.log(` Found ${outputDevices.length} ALSA output device(s)`);
698
+ } catch (error) {
699
+ console.log(' No ALSA output devices found or aplay failed');
700
+ }
701
+
702
+ try {
703
+ const arecordOutput = execSync(ALSA_COMMANDS.ARECORD_LIST, {
704
+ encoding: 'utf-8',
705
+ timeout: 3000,
706
+ });
707
+ inputDevices = parseAlsaDevices(arecordOutput);
708
+ console.log(` Found ${inputDevices.length} ALSA input device(s)`);
709
+ } catch (error) {
710
+ console.log(' No ALSA input devices found or arecord failed');
711
+ }
712
+
713
+ // Create missing PulseAudio sinks/sources for each ALSA device
714
+ let createdCount = 0;
715
+
716
+ for (const device of outputDevices) {
717
+ if (!checkExistingSink(device.hwString, false)) {
718
+ if (createPulseAudioSink(device, false)) {
719
+ createdCount++;
720
+ }
721
+ } else {
722
+ console.log(` ✓ Sink already exists for ${device.hwString}: ${device.name}`);
723
+ }
724
+ }
725
+
726
+ for (const device of inputDevices) {
727
+ if (!checkExistingSink(device.hwString, true)) {
728
+ if (createPulseAudioSink(device, true)) {
729
+ createdCount++;
730
+ }
731
+ } else {
732
+ console.log(` ✓ Source already exists for ${device.hwString}: ${device.name}`);
733
+ }
734
+ }
735
+
736
+ if (createdCount > 0) {
737
+ console.log(`✓ Created ${createdCount} PulseAudio sink(s)/source(s) for missing hardware`);
738
+ } else {
739
+ console.log('✓ All ALSA devices already have corresponding PulseAudio sinks/sources');
740
+ }
741
+ } catch (error) {
742
+ console.log(
743
+ 'Note: Could not complete ALSA device loading:',
744
+ error instanceof Error ? error.message : String(error)
745
+ );
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Retrieves the default sink and source identifiers from PulseAudio
751
+ *
752
+ * @returns Object containing defaultSink and defaultSource identifiers, or empty object on failure
753
+ */
754
+ function getDefaultDevices(): { defaultSink?: string; defaultSource?: string } {
755
+ try {
756
+ const env = getPulseAudioEnv();
757
+ const infoOutput = execSync(PULSEAUDIO_COMMANDS.INFO, {
758
+ encoding: 'utf-8',
759
+ timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
760
+ env,
761
+ });
762
+
763
+ const defaultSinkMatch = infoOutput.match(/Default Sink: (.+)/);
764
+ const defaultSourceMatch = infoOutput.match(/Default Source: (.+)/);
765
+
766
+ return {
767
+ defaultSink: defaultSinkMatch?.[1]?.trim(),
768
+ defaultSource: defaultSourceMatch?.[1]?.trim(),
769
+ };
770
+ } catch (error) {
771
+ console.error('Failed to get default devices:', error);
772
+ return {};
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Retrieves available audio input and output devices using PulseAudio
778
+ *
779
+ * @returns AudioDevices object containing outputs and inputs arrays, or undefined on failure
780
+ */
781
+ function getAudioDevices(): AudioDevices | undefined {
782
+ if (!startPulseAudio()) {
783
+ console.error('Audio detection failed: PulseAudio unavailable');
784
+ return undefined;
785
+ }
786
+
787
+ const { defaultSink, defaultSource } = getDefaultDevices();
788
+ console.log(`Default sink: ${defaultSink}, Default source: ${defaultSource}`);
789
+
790
+ const outputs: AudioDevice[] = [];
791
+ const inputs: AudioDevice[] = [];
792
+
793
+ // Detect output devices (sinks)
794
+ try {
795
+ const env = getPulseAudioEnv();
796
+ const sinksOutput = execSync(PULSEAUDIO_COMMANDS.LIST_SINKS, {
797
+ encoding: 'utf-8',
798
+ timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
799
+ env,
800
+ });
801
+ sinksOutput
802
+ .split(/^Sink #/m)
803
+ .slice(1)
804
+ .forEach(block => {
805
+ const device = parseAudioDevice(block, false);
806
+ if (device) {
807
+ const isDefault = device.identifier === defaultSink;
808
+
809
+ if (isDefault && device.label) {
810
+ if (!device.label.includes('Default')) {
811
+ if (device.label.includes('(')) {
812
+ device.label = device.label.replace(')', ', Default)');
813
+ } else {
814
+ device.label = `${device.label} (Default)`;
815
+ }
816
+ }
817
+ }
818
+
819
+ outputs.push(device);
820
+ }
821
+ });
822
+ console.log(`Detected ${outputs.length} audio output device(s) via PulseAudio`);
823
+ } catch (error) {
824
+ console.error(
825
+ 'Failed to detect audio outputs:',
826
+ error instanceof Error ? error.message : String(error)
827
+ );
828
+ }
829
+
830
+ // Detect input devices (sources)
831
+ try {
832
+ const env = getPulseAudioEnv();
833
+ const sourcesOutput = execSync(PULSEAUDIO_COMMANDS.LIST_SOURCES, {
834
+ encoding: 'utf-8',
835
+ timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
836
+ env,
837
+ });
838
+ sourcesOutput
839
+ .split(/^Source #/m)
840
+ .slice(1)
841
+ .forEach(block => {
842
+ const device = parseAudioDevice(block, true);
843
+ if (device) {
844
+ const isDefault = device.identifier === defaultSource;
845
+
846
+ if (isDefault && device.label) {
847
+ if (!device.label.includes('Default')) {
848
+ if (device.label.includes('(')) {
849
+ device.label = device.label.replace(')', ', Default)');
850
+ } else {
851
+ device.label = `${device.label} (Default)`;
852
+ }
853
+ }
854
+ }
855
+
856
+ inputs.push(device);
857
+ }
858
+ });
859
+ console.log(`Detected ${inputs.length} audio input device(s) via PulseAudio`);
860
+ } catch (error) {
861
+ console.error(
862
+ 'Failed to detect audio inputs:',
863
+ error instanceof Error ? error.message : String(error)
864
+ );
865
+ }
866
+
867
+ if (outputs.length === 0 && inputs.length === 0) {
868
+ console.log('No audio devices detected');
869
+ return undefined;
870
+ }
871
+
872
+ return { outputs, inputs };
873
+ }
3
874
 
4
875
  export async function getSystemInformation() {
5
876
  // const now = new Date();
@@ -35,31 +906,32 @@ export async function getSystemInformation() {
35
906
  };
36
907
 
37
908
  const info = await si.get(valueObject);
38
- // console.log('getAllData3', {info});
39
909
 
40
- // Now get all network interfaces (this call reliably returns disabled interfaces as well).
910
+ // Retrieve network interfaces (includes disabled interfaces)
41
911
  let netIfaces = await si.networkInterfaces();
42
912
 
43
- // Ensure netIfaces is always an array
44
913
  if (!Array.isArray(netIfaces)) {
45
914
  netIfaces = [netIfaces];
46
915
  }
47
916
 
48
- // Map through the interfaces and assign a type if missing.
917
+ // Assign type to interfaces missing type information
49
918
  netIfaces = netIfaces.map((iface: any) => ({
50
919
  ...iface,
51
920
  type:
52
921
  iface.type ||
53
- (iface.ifaceName &&
54
- (iface.ifaceName.startsWith("en") || iface.ifaceName.startsWith("eth"))
55
- ? "wired"
56
- : "unknown"),
922
+ (iface.ifaceName && (iface.ifaceName.startsWith('en') || iface.ifaceName.startsWith('eth'))
923
+ ? 'wired'
924
+ : 'unknown'),
57
925
  }));
58
926
 
59
- // Inject the full list of network interfaces into our system info.
60
927
  info.networkInterfaces = netIfaces;
61
928
 
62
- // TODO: update so structure is aligned with what the browser version will output. Create a type.
929
+ const audioDevices = getAudioDevices();
930
+ if (audioDevices) {
931
+ info.audioDevices = audioDevices;
932
+ }
933
+
934
+ // TODO: Align structure with browser version output and create proper type definition
63
935
  const systemInfo = JSON.parse(JSON.stringify(info));
64
936
 
65
937
  return systemInfo;