@phystack/hub-device 4.5.2-dev → 4.5.4-dev

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,6 +1,6 @@
1
1
  import si from 'systeminformation';
2
2
  import { execSync } from 'child_process';
3
- import { AudioDevice, AudioDevices, AudioPort } from '../types/twin.types';
3
+ import { AudioDevice, AudioDevices, AudioPort, AudioProfile } from '../types/twin.types';
4
4
 
5
5
  // ============================================================================
6
6
  // PULSEAUDIO COMMANDS
@@ -14,6 +14,7 @@ const PULSEAUDIO_COMMANDS = {
14
14
  LOAD_ALSA_MODULE: 'pactl load-module module-alsa-card',
15
15
  LIST_SINKS: 'pactl list sinks',
16
16
  LIST_SOURCES: 'pactl list sources',
17
+ LIST_CARDS: 'pactl list cards',
17
18
  LOAD_ALSA_SINK: (hwDevice: string, sinkName: string, description: string) =>
18
19
  `pactl load-module module-alsa-sink device=${hwDevice} sink_name=${sinkName} sink_properties="device.description=${description}"`,
19
20
  LOAD_ALSA_SOURCE: (hwDevice: string, sourceName: string, description: string) =>
@@ -29,6 +30,9 @@ const PULSEAUDIO_TIMEOUTS = {
29
30
  LIST_DEVICES: 5000,
30
31
  } as const;
31
32
 
33
+ // Store discovered PulseAudio server socket path
34
+ let pulseAudioServerPath: string | undefined;
35
+
32
36
  /**
33
37
  * Build audio device description
34
38
  */
@@ -70,6 +74,18 @@ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
70
74
  device.identifier = trimmed.split('Name:')[1].trim();
71
75
  } else if (trimmed.startsWith('Description:')) {
72
76
  device.displayName = trimmed.split('Description:')[1].trim();
77
+ } else if (trimmed.includes('alsa.card =')) {
78
+ // Extract card number for later card name construction
79
+ const cardMatch = trimmed.match(/alsa\.card\s*=\s*"(\d+)"/);
80
+ if (cardMatch) {
81
+ (device as any).cardNumber = cardMatch[1];
82
+ }
83
+ } else if (trimmed.includes('alsa.card_name =')) {
84
+ // Extract card name
85
+ const cardNameMatch = trimmed.match(/alsa\.card_name\s*=\s*"([^"]+)"/);
86
+ if (cardNameMatch) {
87
+ (device as any).cardNameRaw = cardNameMatch[1];
88
+ }
73
89
  } else if (trimmed.startsWith('Volume:')) {
74
90
  const match = trimmed.match(/(\d+)%/);
75
91
  if (match) device.volume = parseInt(match[1], 10);
@@ -104,11 +120,25 @@ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
104
120
  if (portDetails) {
105
121
  const typeMatch = portDetails.match(/type:\s*([^,]+)/);
106
122
  const priorityMatch = portDetails.match(/priority:\s*(\d+)/);
107
- const availableMatch = portDetails.match(/available|availability unknown/);
123
+ // Parse availability: "available", "available: yes", "not available", "availability unknown"
124
+ const availableMatch = portDetails.match(
125
+ /available(?::\s*(yes|no))?|not available|availability unknown/i
126
+ );
108
127
 
109
128
  if (typeMatch) port.type = typeMatch[1].trim();
110
129
  if (priorityMatch) port.priority = parseInt(priorityMatch[1], 10);
111
- if (availableMatch) port.available = availableMatch[0] === 'available';
130
+
131
+ // Determine availability: true if "available" or "available: yes", false otherwise
132
+ if (availableMatch) {
133
+ const availStr = availableMatch[0].toLowerCase();
134
+ if (availStr.includes('not available') || availStr.includes('availability unknown')) {
135
+ port.available = false;
136
+ } else if (availStr.includes('available: no')) {
137
+ port.available = false;
138
+ } else {
139
+ port.available = true; // "available" or "available: yes"
140
+ }
141
+ }
112
142
  }
113
143
 
114
144
  console.log(`[Audio] Parsed port: ${portName} - ${portDescription}`);
@@ -124,6 +154,12 @@ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
124
154
  } else if (trimmed.includes('device.vendor.name =')) {
125
155
  const match = trimmed.match(/"([^"]+)"/);
126
156
  if (match && !device.description) device.description = match[1];
157
+ } else if (trimmed.includes('device.product.name =')) {
158
+ // Extract product name (e.g., "LG ULTRAFINE" for HDMI displays)
159
+ const match = trimmed.match(/"([^"]+)"/);
160
+ if (match) {
161
+ (device as any).productName = match[1];
162
+ }
127
163
  }
128
164
  }
129
165
 
@@ -137,9 +173,115 @@ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
137
173
  // Add ports if any were found
138
174
  if (ports.length > 0) {
139
175
  device.availablePorts = ports;
140
- console.log(`[Audio] Device ${device.identifier} has ${ports.length} ports, active: ${device.activePort}`);
176
+ console.log(
177
+ `[Audio] Device ${device.identifier} has ${ports.length} ports, active: ${device.activePort}`
178
+ );
179
+
180
+ // Determine overall device availability (true if at least one port is available)
181
+ const availablePorts = ports.filter(p => p.available === true);
182
+ device.available = availablePorts.length > 0;
141
183
  } else {
142
184
  console.log(`[Audio] Device ${device.identifier} has NO ports detected`);
185
+ device.available = false;
186
+ }
187
+
188
+ // Extract card information from device identifier
189
+ // Format 1: "alsa_output.pci-0000_00_1f.3.analog-stereo" (standard PulseAudio ALSA format)
190
+ // Format 2: "bluez_sink.XX_XX_XX_XX_XX_XX" (Bluetooth - no profile suffix)
191
+ // Format 3: "alsa_output.usb-046d_08d9-01.analog-stereo" (USB audio)
192
+ // Format 4: "alsa_output_0_3" (manually created sinks)
193
+ //
194
+ // Generic pattern: {prefix}_{type}.{card_id}[.{profile}] or {prefix}_{type}_{card}_{device}
195
+ // Where prefix can be: alsa, bluez, or other PulseAudio modules
196
+ // Note: Some devices (like Bluetooth) may not have a profile suffix
197
+
198
+ // Try standard format with profile: {prefix}_{type}.{card_id}.{profile}
199
+ let cardMatch = device.identifier?.match(
200
+ /^([^_]+)_(output|input|sink|source)\.([^.]+(?:\.[^.]+)*)\.(.+)$/
201
+ );
202
+ if (cardMatch) {
203
+ const prefix = cardMatch[1]; // e.g., "alsa", "bluez"
204
+ const cardId = cardMatch[3]; // e.g., "pci-0000_00_1f.3", "usb-046d_08d9-01", "XX_XX_XX_XX_XX_XX"
205
+ const profile = cardMatch[4]; // e.g., "analog-stereo"
206
+
207
+ // Map device type to profile type
208
+ const deviceType = cardMatch[2]; // "output", "input", "sink", "source"
209
+ const profileType = deviceType === 'input' || deviceType === 'source' ? 'input' : 'output';
210
+
211
+ // Construct card name: prefix + "_card." + card_id
212
+ // e.g., "alsa_card.pci-0000_00_1f.3", "bluez_card.XX_XX_XX_XX_XX_XX"
213
+ device.cardName = `${prefix}_card.${cardId}`;
214
+ device.cardProfile = `${profileType}:${profile}`;
215
+ } else {
216
+ // Try format without profile: {prefix}_{type}.{card_id} (e.g., Bluetooth)
217
+ cardMatch = device.identifier?.match(/^([^_]+)_(output|input|sink|source)\.(.+)$/);
218
+ if (cardMatch) {
219
+ const prefix = cardMatch[1]; // e.g., "bluez"
220
+ const cardId = cardMatch[3]; // e.g., "XX_XX_XX_XX_XX_XX"
221
+
222
+ // Construct card name: prefix + "_card." + card_id
223
+ device.cardName = `${prefix}_card.${cardId}`;
224
+ // No profile for these devices (Bluetooth devices don't have profiles in the identifier)
225
+ // Profile will be determined from card information if available
226
+ } else {
227
+ // Fallback: Try to extract card number from identifier format "alsa_output_0_3"
228
+ const altMatch = device.identifier?.match(/^([^_]+)_(output|input|sink|source)_(\d+)_(\d+)$/);
229
+ if (altMatch) {
230
+ const cardNumber = altMatch[3];
231
+ // Try to find card name from pactl list cards (supports any card type)
232
+ try {
233
+ const env = getPulseAudioEnv();
234
+ const cardsOutput = execSync('pactl list cards short', {
235
+ encoding: 'utf-8',
236
+ timeout: 2000,
237
+ stdio: 'pipe',
238
+ env,
239
+ });
240
+ const cardLine = cardsOutput.split('\n').find(line => line.includes(`card${cardNumber}`));
241
+ if (cardLine) {
242
+ // Match any card name format: {prefix}_card.{identifier}
243
+ // e.g., "alsa_card.pci-0000_00_1f.3", "bluez_card.XX_XX_XX_XX_XX_XX"
244
+ const cardNameMatch = cardLine.match(/([^_\s]+_card\.[^\s]+)/);
245
+ if (cardNameMatch) {
246
+ device.cardName = cardNameMatch[1];
247
+ }
248
+ }
249
+ } catch (error) {
250
+ // Card lookup failed, continue without it
251
+ }
252
+ } else {
253
+ // Final fallback: use card number from device properties
254
+ const cardNumber = (device as any).cardNumber;
255
+ if (cardNumber !== undefined) {
256
+ try {
257
+ const env = getPulseAudioEnv();
258
+ const cardsOutput = execSync('pactl list cards short', {
259
+ encoding: 'utf-8',
260
+ timeout: 2000,
261
+ stdio: 'pipe',
262
+ env,
263
+ });
264
+ const cardLine = cardsOutput
265
+ .split('\n')
266
+ .find(line => line.includes(`card${cardNumber}`));
267
+ if (cardLine) {
268
+ // Match any card name format: {prefix}_card.{identifier}
269
+ const cardNameMatch = cardLine.match(/([^_\s]+_card\.[^\s]+)/);
270
+ if (cardNameMatch) {
271
+ device.cardName = cardNameMatch[1];
272
+ }
273
+ }
274
+ } catch (error) {
275
+ // Card lookup failed, continue without it
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ // Always set available flag - if no ports, device is still available if it exists
283
+ if (device.available === undefined) {
284
+ device.available = true; // Default to available if we can't determine
143
285
  }
144
286
 
145
287
  // Build rich description
@@ -153,20 +295,153 @@ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
153
295
  );
154
296
  }
155
297
 
298
+ // Create human-readable label based on active port and product name
299
+ device.label = createDeviceLabel(device, ports, isInput);
300
+
156
301
  return device as AudioDevice;
157
302
  }
158
303
 
304
+ /**
305
+ * Create a human-readable label for an audio device based on ports and product info
306
+ */
307
+ function createDeviceLabel(
308
+ device: Partial<AudioDevice>,
309
+ ports: AudioPort[],
310
+ isInput: boolean
311
+ ): string {
312
+ const productName = (device as any).productName;
313
+ const activePort = device.activePort;
314
+
315
+ // Find the active port in the ports list
316
+ const activePortInfo = ports.find(p => p.name === activePort);
317
+
318
+ // Priority 1: Use product name if available (e.g., "LG ULTRAFINE")
319
+ if (productName) {
320
+ return `${productName} (${isInput ? 'Microphone' : 'Speaker'})`;
321
+ }
322
+
323
+ // Priority 2: Use active port description if available
324
+ if (activePortInfo && activePortInfo.description) {
325
+ const portDesc = activePortInfo.description;
326
+
327
+ // Map common port descriptions to user-friendly names
328
+ if (portDesc.toLowerCase().includes('headphones')) {
329
+ return 'Headphones';
330
+ } else if (
331
+ portDesc.toLowerCase().includes('hdmi') ||
332
+ portDesc.toLowerCase().includes('displayport')
333
+ ) {
334
+ return `HDMI Display (${portDesc})`;
335
+ } else if (
336
+ portDesc.toLowerCase().includes('microphone') ||
337
+ portDesc.toLowerCase().includes('mic')
338
+ ) {
339
+ return 'Microphone';
340
+ } else if (portDesc.toLowerCase().includes('line')) {
341
+ return `Line ${isInput ? 'In' : 'Out'}`;
342
+ } else if (portDesc.toLowerCase().includes('speaker')) {
343
+ return 'Speakers';
344
+ } else {
345
+ // Use port description with device type
346
+ return `${portDesc} (${isInput ? 'Input' : 'Output'})`;
347
+ }
348
+ }
349
+
350
+ // Priority 3: Use card profile information
351
+ if (device.cardProfile) {
352
+ if (device.cardProfile.includes('analog-stereo')) {
353
+ return isInput ? 'Analog Microphone' : 'Analog Speakers';
354
+ } else if (device.cardProfile.includes('hdmi-stereo')) {
355
+ return 'HDMI Audio';
356
+ } else if (device.cardProfile.includes('hdmi-surround')) {
357
+ return 'HDMI Surround Audio';
358
+ }
359
+ }
360
+
361
+ // Priority 4: Use display name with device type
362
+ if (device.displayName) {
363
+ return `${device.displayName} (${isInput ? 'Input' : 'Output'})`;
364
+ }
365
+
366
+ // Fallback: Use identifier
367
+ return device.identifier || 'Unknown Audio Device';
368
+ }
369
+
370
+ /**
371
+ * Discover PulseAudio socket path by finding the socket file
372
+ * @internal
373
+ * Exported for use in device-phyos audio control
374
+ */
375
+ export function discoverPulseAudioSocket(): string | undefined {
376
+ try {
377
+ // Find PulseAudio socket directories in /tmp
378
+ const findOutput = execSync(
379
+ 'find /tmp -maxdepth 2 -type d -name "pulse-*" 2>/dev/null | head -1',
380
+ {
381
+ encoding: 'utf-8',
382
+ timeout: 2000,
383
+ stdio: 'pipe',
384
+ }
385
+ ).trim();
386
+
387
+ if (findOutput) {
388
+ const socketPath = `${findOutput}/native`;
389
+ // Verify the socket file exists
390
+ try {
391
+ execSync(`test -S "${socketPath}"`, { stdio: 'pipe' });
392
+ return socketPath;
393
+ } catch {
394
+ return undefined;
395
+ }
396
+ }
397
+ } catch (error) {
398
+ // Socket discovery failed, will try without it
399
+ }
400
+ return undefined;
401
+ }
402
+
403
+ /**
404
+ * Get environment variables for execSync calls to include PULSE_SERVER if discovered
405
+ * @internal
406
+ * Exported for use in device-phyos audio control
407
+ */
408
+ export function getPulseAudioEnv(): NodeJS.ProcessEnv {
409
+ // Try to discover socket if not already discovered
410
+ if (!pulseAudioServerPath) {
411
+ pulseAudioServerPath = discoverPulseAudioSocket();
412
+ if (pulseAudioServerPath) {
413
+ console.log(`Discovered PulseAudio socket: ${pulseAudioServerPath}`);
414
+ }
415
+ }
416
+
417
+ const env = { ...process.env };
418
+ if (pulseAudioServerPath) {
419
+ env.PULSE_SERVER = `unix:${pulseAudioServerPath}`;
420
+ }
421
+ return env;
422
+ }
423
+
159
424
  /**
160
425
  * Start PulseAudio daemon if not running
161
426
  * Service runs as root, so user mode has full hardware access
162
427
  */
163
428
  function startPulseAudio(): boolean {
429
+ // If we don't have the socket path yet, try to discover it
430
+ if (!pulseAudioServerPath) {
431
+ pulseAudioServerPath = discoverPulseAudioSocket();
432
+ if (pulseAudioServerPath) {
433
+ console.log(`Discovered existing PulseAudio socket: ${pulseAudioServerPath}`);
434
+ }
435
+ }
436
+
164
437
  // Check if already running and accessible
165
438
  try {
439
+ const env = getPulseAudioEnv();
166
440
  execSync(PULSEAUDIO_COMMANDS.INFO, {
167
441
  encoding: 'utf-8',
168
442
  timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
169
443
  stdio: 'pipe',
444
+ env,
170
445
  });
171
446
  console.log('PulseAudio is running and accessible');
172
447
 
@@ -200,10 +475,20 @@ function startPulseAudio(): boolean {
200
475
  // Wait for startup
201
476
  execSync(PULSEAUDIO_COMMANDS.WAIT, { stdio: 'pipe' });
202
477
 
478
+ // Discover the socket path
479
+ pulseAudioServerPath = discoverPulseAudioSocket();
480
+ if (pulseAudioServerPath) {
481
+ console.log(`Discovered PulseAudio socket: ${pulseAudioServerPath}`);
482
+ } else {
483
+ console.log('Warning: Could not discover PulseAudio socket path');
484
+ }
485
+
203
486
  // Verify it's running
487
+ const env = getPulseAudioEnv();
204
488
  execSync(PULSEAUDIO_COMMANDS.INFO, {
205
489
  timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
206
490
  stdio: 'pipe',
491
+ env,
207
492
  });
208
493
 
209
494
  console.log('✓ PulseAudio started successfully');
@@ -261,9 +546,11 @@ function parseAlsaDevices(aplayOutput: string): AlsaDevice[] {
261
546
  function checkExistingSink(hwString: string, isInput: boolean): boolean {
262
547
  try {
263
548
  const command = isInput ? PULSEAUDIO_COMMANDS.LIST_SOURCES : PULSEAUDIO_COMMANDS.LIST_SINKS;
549
+ const env = getPulseAudioEnv();
264
550
  const output = execSync(command, {
265
551
  encoding: 'utf-8',
266
552
  timeout: 3000,
553
+ env,
267
554
  });
268
555
 
269
556
  // Check if any sink/source references this ALSA device
@@ -299,23 +586,32 @@ function createPulseAudioSink(alsaDevice: AlsaDevice, isInput: boolean): boolean
299
586
 
300
587
  // Simplify description to avoid quoting/escaping issues with PulseAudio
301
588
  // Replace spaces and special chars with underscores
302
- const safeDescription = `${alsaDevice.name}_${alsaDevice.hwString}`.replace(/[^a-zA-Z0-9_-]/g, '_');
589
+ const safeDescription = `${alsaDevice.name}_${alsaDevice.hwString}`.replace(
590
+ /[^a-zA-Z0-9_-]/g,
591
+ '_'
592
+ );
303
593
 
304
594
  const command = isInput
305
595
  ? PULSEAUDIO_COMMANDS.LOAD_ALSA_SOURCE(alsaDevice.hwString, sinkName, safeDescription)
306
596
  : PULSEAUDIO_COMMANDS.LOAD_ALSA_SINK(alsaDevice.hwString, sinkName, safeDescription);
307
597
 
308
- console.log(` Creating ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}: ${alsaDevice.name}`);
598
+ console.log(
599
+ ` Creating ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}: ${alsaDevice.name}`
600
+ );
309
601
 
602
+ const env = getPulseAudioEnv();
310
603
  execSync(command, {
311
604
  timeout: 5000,
312
605
  stdio: 'pipe',
606
+ env,
313
607
  });
314
608
 
315
609
  return true;
316
610
  } catch (error) {
317
- console.log(` Failed to create ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}:`,
318
- error instanceof Error ? error.message : String(error));
611
+ console.log(
612
+ ` Failed to create ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}:`,
613
+ error instanceof Error ? error.message : String(error)
614
+ );
319
615
  return false;
320
616
  }
321
617
  }
@@ -334,9 +630,11 @@ function loadAlsaDevicesIntoPulseAudio(): void {
334
630
 
335
631
  // Step 1: Load the base ALSA card module (for auto-detection)
336
632
  try {
633
+ const env = getPulseAudioEnv();
337
634
  const modules = execSync(PULSEAUDIO_COMMANDS.LIST_MODULES, {
338
635
  encoding: 'utf-8',
339
636
  timeout: PULSEAUDIO_TIMEOUTS.LIST_MODULES,
637
+ env,
340
638
  });
341
639
 
342
640
  if (!modules.includes('module-alsa-card')) {
@@ -344,6 +642,7 @@ function loadAlsaDevicesIntoPulseAudio(): void {
344
642
  execSync(PULSEAUDIO_COMMANDS.LOAD_ALSA_MODULE, {
345
643
  timeout: PULSEAUDIO_TIMEOUTS.LOAD_MODULE,
346
644
  stdio: 'pipe',
645
+ env,
347
646
  });
348
647
  }
349
648
  } catch (error) {
@@ -404,7 +703,6 @@ function loadAlsaDevicesIntoPulseAudio(): void {
404
703
  } else {
405
704
  console.log('✓ All ALSA devices already have corresponding PulseAudio sinks/sources');
406
705
  }
407
-
408
706
  } catch (error) {
409
707
  console.log(
410
708
  'Note: Could not complete ALSA device loading:',
@@ -418,9 +716,11 @@ function loadAlsaDevicesIntoPulseAudio(): void {
418
716
  */
419
717
  function getDefaultDevices(): { defaultSink?: string; defaultSource?: string } {
420
718
  try {
719
+ const env = getPulseAudioEnv();
421
720
  const infoOutput = execSync(PULSEAUDIO_COMMANDS.INFO, {
422
721
  encoding: 'utf-8',
423
722
  timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
723
+ env,
424
724
  });
425
725
 
426
726
  const defaultSinkMatch = infoOutput.match(/Default Sink: (.+)/);
@@ -436,6 +736,111 @@ function getDefaultDevices(): { defaultSink?: string; defaultSource?: string } {
436
736
  }
437
737
  }
438
738
 
739
+ /**
740
+ * Parse card profiles from pactl list cards output
741
+ */
742
+ function parseCardProfiles(cardBlock: string): AudioProfile[] {
743
+ const profiles: AudioProfile[] = [];
744
+ const lines = cardBlock.split('\n');
745
+ let inProfilesSection = false;
746
+
747
+ // Extract port mappings from Ports section
748
+ const portProfileMap: Map<string, string[]> = new Map();
749
+ let inPortsSection = false;
750
+ let currentPortName = '';
751
+
752
+ for (const line of lines) {
753
+ const trimmed = line.trim();
754
+
755
+ // Parse Ports section to map ports to profiles
756
+ if (trimmed.startsWith('Ports:')) {
757
+ inPortsSection = true;
758
+ continue;
759
+ }
760
+
761
+ if (inPortsSection && trimmed.startsWith('Part of profile(s):')) {
762
+ const profileList = trimmed.replace('Part of profile(s):', '').trim();
763
+ if (currentPortName) {
764
+ portProfileMap.set(
765
+ currentPortName,
766
+ profileList.split(',').map(p => p.trim())
767
+ );
768
+ }
769
+ continue;
770
+ }
771
+
772
+ if (inPortsSection) {
773
+ if (trimmed.match(/^[a-z]+-[a-z]+-[^:]+:/)) {
774
+ currentPortName = trimmed.split(':')[0].trim();
775
+ } else if (trimmed.startsWith('Active Profile:') || trimmed.match(/^[A-Z]/)) {
776
+ inPortsSection = false;
777
+ }
778
+ }
779
+
780
+ // Parse Profiles section
781
+ if (trimmed.startsWith('Profiles:')) {
782
+ inProfilesSection = true;
783
+ continue;
784
+ }
785
+
786
+ if (inProfilesSection) {
787
+ if (trimmed.startsWith('Active Profile:') || trimmed.startsWith('Ports:')) {
788
+ inProfilesSection = false;
789
+ continue;
790
+ }
791
+
792
+ // Parse profile line: "output:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 39268, available: yes)"
793
+ const profileMatch = trimmed.match(/^([^:]+):\s*(.+?)\s*\([^)]+available:\s*(yes|no)\)/);
794
+ if (profileMatch) {
795
+ const profileName = profileMatch[1].trim();
796
+ const description = profileMatch[2].trim();
797
+ const available = profileMatch[3].trim() === 'yes';
798
+
799
+ // Find ports that belong to this profile
800
+ const ports: string[] = [];
801
+ for (const [portName, profileList] of portProfileMap.entries()) {
802
+ if (profileList.includes(profileName)) {
803
+ ports.push(portName);
804
+ }
805
+ }
806
+
807
+ profiles.push({
808
+ profile: profileName,
809
+ available,
810
+ description,
811
+ ports,
812
+ });
813
+ }
814
+ }
815
+ }
816
+
817
+ return profiles;
818
+ }
819
+
820
+ /**
821
+ * Get card profiles for a specific card
822
+ */
823
+ function getCardProfiles(cardName: string): AudioProfile[] {
824
+ try {
825
+ const env = getPulseAudioEnv();
826
+ const cardsOutput = execSync(PULSEAUDIO_COMMANDS.LIST_CARDS, {
827
+ encoding: 'utf-8',
828
+ timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
829
+ env,
830
+ });
831
+
832
+ const cardBlocks = cardsOutput.split(/^Card #/m);
833
+ for (const block of cardBlocks.slice(1)) {
834
+ if (block.includes(`Name: ${cardName}`)) {
835
+ return parseCardProfiles(block);
836
+ }
837
+ }
838
+ } catch (error) {
839
+ console.log(`Failed to get profiles for card ${cardName}:`, error);
840
+ }
841
+ return [];
842
+ }
843
+
439
844
  /**
440
845
  * Get available audio input/output devices using PulseAudio
441
846
  */
@@ -451,12 +856,15 @@ function getAudioDevices(): AudioDevices | undefined {
451
856
 
452
857
  const outputs: AudioDevice[] = [];
453
858
  const inputs: AudioDevice[] = [];
859
+ const cardProfilesCache: Map<string, AudioProfile[]> = new Map();
454
860
 
455
861
  // Detect output devices (sinks)
456
862
  try {
863
+ const env = getPulseAudioEnv();
457
864
  const sinksOutput = execSync(PULSEAUDIO_COMMANDS.LIST_SINKS, {
458
865
  encoding: 'utf-8',
459
866
  timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
867
+ env,
460
868
  });
461
869
  sinksOutput
462
870
  .split(/^Sink #/m)
@@ -466,6 +874,15 @@ function getAudioDevices(): AudioDevices | undefined {
466
874
  if (device) {
467
875
  // Mark as default if it matches the default sink
468
876
  device.isDefault = device.identifier === defaultSink;
877
+
878
+ // Add available profiles if cardName is available
879
+ if (device.cardName && !cardProfilesCache.has(device.cardName)) {
880
+ cardProfilesCache.set(device.cardName, getCardProfiles(device.cardName));
881
+ }
882
+ if (device.cardName) {
883
+ device.availableProfiles = cardProfilesCache.get(device.cardName) || [];
884
+ }
885
+
469
886
  outputs.push(device);
470
887
  }
471
888
  });
@@ -479,9 +896,11 @@ function getAudioDevices(): AudioDevices | undefined {
479
896
 
480
897
  // Detect input devices (sources)
481
898
  try {
899
+ const env = getPulseAudioEnv();
482
900
  const sourcesOutput = execSync(PULSEAUDIO_COMMANDS.LIST_SOURCES, {
483
901
  encoding: 'utf-8',
484
902
  timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
903
+ env,
485
904
  });
486
905
  sourcesOutput
487
906
  .split(/^Source #/m)
@@ -491,6 +910,15 @@ function getAudioDevices(): AudioDevices | undefined {
491
910
  if (device) {
492
911
  // Mark as default if it matches the default source
493
912
  device.isDefault = device.identifier === defaultSource;
913
+
914
+ // Add available profiles if cardName is available
915
+ if (device.cardName && !cardProfilesCache.has(device.cardName)) {
916
+ cardProfilesCache.set(device.cardName, getCardProfiles(device.cardName));
917
+ }
918
+ if (device.cardName) {
919
+ device.availableProfiles = cardProfilesCache.get(device.cardName) || [];
920
+ }
921
+
494
922
  inputs.push(device);
495
923
  }
496
924
  });
@@ -337,6 +337,13 @@ export interface AudioPort {
337
337
  available?: boolean; // Whether port is available
338
338
  }
339
339
 
340
+ export interface AudioProfile {
341
+ profile: string; // Profile name (e.g., "output:hdmi-stereo")
342
+ available: boolean; // Whether profile is available
343
+ description: string; // Profile description (e.g., "Digital Stereo (HDMI) Output")
344
+ ports: string[]; // Ports available in this profile (e.g., ["hdmi-output-0"])
345
+ }
346
+
340
347
  export interface AudioDevice {
341
348
  identifier: string;
342
349
  displayName: string;
@@ -348,6 +355,11 @@ export interface AudioDevice {
348
355
  isDefault?: boolean; // Whether this is the system default device
349
356
  activePort?: string; // Currently active port (e.g., "analog-output-headphones")
350
357
  availablePorts?: AudioPort[]; // Available ports for this device
358
+ cardName?: string; // Card name (e.g., "alsa_card.pci-0000_00_1f.3")
359
+ cardProfile?: string; // Card profile (e.g., "output:analog-stereo")
360
+ label?: string; // Human-readable label for the device (e.g., "Headphones", "HDMI Display")
361
+ available?: boolean; // Overall device availability (true if at least one port is available)
362
+ availableProfiles?: AudioProfile[]; // All available profiles for this card
351
363
  }
352
364
 
353
365
  export interface AudioDevices {