@phystack/hub-device 4.5.1-dev → 4.5.3-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,49 @@ 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: "alsa_output.pci-0000_00_1f.3.analog-stereo" or "alsa_input.pci-0000_00_1f.3.analog-stereo"
190
+ const cardMatch = device.identifier?.match(/alsa_(output|input)\.([^.]+)\.(.+)/);
191
+ if (cardMatch) {
192
+ device.cardName = `alsa_card.${cardMatch[2]}`;
193
+ const profileType = isInput ? 'input' : 'output';
194
+ device.cardProfile = `${profileType}:${cardMatch[3]}`;
195
+ } else {
196
+ // Fallback: try to construct card name from card number if available
197
+ const cardNumber = (device as any).cardNumber;
198
+ if (cardNumber !== undefined) {
199
+ // Try to find card name from pactl list cards
200
+ try {
201
+ const env = getPulseAudioEnv();
202
+ const cardsOutput = execSync('pactl list cards short', {
203
+ encoding: 'utf-8',
204
+ timeout: 2000,
205
+ stdio: 'pipe',
206
+ env,
207
+ });
208
+ const cardLine = cardsOutput.split('\n').find(line => line.includes(`card${cardNumber}`));
209
+ if (cardLine) {
210
+ const cardNameMatch = cardLine.match(/alsa_card\.([^\s]+)/);
211
+ if (cardNameMatch) {
212
+ device.cardName = `alsa_card.${cardNameMatch[1]}`;
213
+ }
214
+ }
215
+ } catch (error) {
216
+ // Card lookup failed, continue without it
217
+ }
218
+ }
143
219
  }
144
220
 
145
221
  // Build rich description
@@ -153,20 +229,153 @@ function parseAudioDevice(block: string, isInput: boolean): AudioDevice | null {
153
229
  );
154
230
  }
155
231
 
232
+ // Create human-readable label based on active port and product name
233
+ device.label = createDeviceLabel(device, ports, isInput);
234
+
156
235
  return device as AudioDevice;
157
236
  }
158
237
 
238
+ /**
239
+ * Create a human-readable label for an audio device based on ports and product info
240
+ */
241
+ function createDeviceLabel(
242
+ device: Partial<AudioDevice>,
243
+ ports: AudioPort[],
244
+ isInput: boolean
245
+ ): string {
246
+ const productName = (device as any).productName;
247
+ const activePort = device.activePort;
248
+
249
+ // Find the active port in the ports list
250
+ const activePortInfo = ports.find(p => p.name === activePort);
251
+
252
+ // Priority 1: Use product name if available (e.g., "LG ULTRAFINE")
253
+ if (productName) {
254
+ return `${productName} (${isInput ? 'Microphone' : 'Speaker'})`;
255
+ }
256
+
257
+ // Priority 2: Use active port description if available
258
+ if (activePortInfo && activePortInfo.description) {
259
+ const portDesc = activePortInfo.description;
260
+
261
+ // Map common port descriptions to user-friendly names
262
+ if (portDesc.toLowerCase().includes('headphones')) {
263
+ return 'Headphones';
264
+ } else if (
265
+ portDesc.toLowerCase().includes('hdmi') ||
266
+ portDesc.toLowerCase().includes('displayport')
267
+ ) {
268
+ return `HDMI Display (${portDesc})`;
269
+ } else if (
270
+ portDesc.toLowerCase().includes('microphone') ||
271
+ portDesc.toLowerCase().includes('mic')
272
+ ) {
273
+ return 'Microphone';
274
+ } else if (portDesc.toLowerCase().includes('line')) {
275
+ return `Line ${isInput ? 'In' : 'Out'}`;
276
+ } else if (portDesc.toLowerCase().includes('speaker')) {
277
+ return 'Speakers';
278
+ } else {
279
+ // Use port description with device type
280
+ return `${portDesc} (${isInput ? 'Input' : 'Output'})`;
281
+ }
282
+ }
283
+
284
+ // Priority 3: Use card profile information
285
+ if (device.cardProfile) {
286
+ if (device.cardProfile.includes('analog-stereo')) {
287
+ return isInput ? 'Analog Microphone' : 'Analog Speakers';
288
+ } else if (device.cardProfile.includes('hdmi-stereo')) {
289
+ return 'HDMI Audio';
290
+ } else if (device.cardProfile.includes('hdmi-surround')) {
291
+ return 'HDMI Surround Audio';
292
+ }
293
+ }
294
+
295
+ // Priority 4: Use display name with device type
296
+ if (device.displayName) {
297
+ return `${device.displayName} (${isInput ? 'Input' : 'Output'})`;
298
+ }
299
+
300
+ // Fallback: Use identifier
301
+ return device.identifier || 'Unknown Audio Device';
302
+ }
303
+
304
+ /**
305
+ * Discover PulseAudio socket path by finding the socket file
306
+ * @internal
307
+ * Exported for use in device-phyos audio control
308
+ */
309
+ export function discoverPulseAudioSocket(): string | undefined {
310
+ try {
311
+ // Find PulseAudio socket directories in /tmp
312
+ const findOutput = execSync(
313
+ 'find /tmp -maxdepth 2 -type d -name "pulse-*" 2>/dev/null | head -1',
314
+ {
315
+ encoding: 'utf-8',
316
+ timeout: 2000,
317
+ stdio: 'pipe',
318
+ }
319
+ ).trim();
320
+
321
+ if (findOutput) {
322
+ const socketPath = `${findOutput}/native`;
323
+ // Verify the socket file exists
324
+ try {
325
+ execSync(`test -S "${socketPath}"`, { stdio: 'pipe' });
326
+ return socketPath;
327
+ } catch {
328
+ return undefined;
329
+ }
330
+ }
331
+ } catch (error) {
332
+ // Socket discovery failed, will try without it
333
+ }
334
+ return undefined;
335
+ }
336
+
337
+ /**
338
+ * Get environment variables for execSync calls to include PULSE_SERVER if discovered
339
+ * @internal
340
+ * Exported for use in device-phyos audio control
341
+ */
342
+ export function getPulseAudioEnv(): NodeJS.ProcessEnv {
343
+ // Try to discover socket if not already discovered
344
+ if (!pulseAudioServerPath) {
345
+ pulseAudioServerPath = discoverPulseAudioSocket();
346
+ if (pulseAudioServerPath) {
347
+ console.log(`Discovered PulseAudio socket: ${pulseAudioServerPath}`);
348
+ }
349
+ }
350
+
351
+ const env = { ...process.env };
352
+ if (pulseAudioServerPath) {
353
+ env.PULSE_SERVER = `unix:${pulseAudioServerPath}`;
354
+ }
355
+ return env;
356
+ }
357
+
159
358
  /**
160
359
  * Start PulseAudio daemon if not running
161
360
  * Service runs as root, so user mode has full hardware access
162
361
  */
163
362
  function startPulseAudio(): boolean {
363
+ // If we don't have the socket path yet, try to discover it
364
+ if (!pulseAudioServerPath) {
365
+ pulseAudioServerPath = discoverPulseAudioSocket();
366
+ if (pulseAudioServerPath) {
367
+ console.log(`Discovered existing PulseAudio socket: ${pulseAudioServerPath}`);
368
+ }
369
+ }
370
+
164
371
  // Check if already running and accessible
165
372
  try {
373
+ const env = getPulseAudioEnv();
166
374
  execSync(PULSEAUDIO_COMMANDS.INFO, {
167
375
  encoding: 'utf-8',
168
376
  timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
169
377
  stdio: 'pipe',
378
+ env,
170
379
  });
171
380
  console.log('PulseAudio is running and accessible');
172
381
 
@@ -200,10 +409,20 @@ function startPulseAudio(): boolean {
200
409
  // Wait for startup
201
410
  execSync(PULSEAUDIO_COMMANDS.WAIT, { stdio: 'pipe' });
202
411
 
412
+ // Discover the socket path
413
+ pulseAudioServerPath = discoverPulseAudioSocket();
414
+ if (pulseAudioServerPath) {
415
+ console.log(`Discovered PulseAudio socket: ${pulseAudioServerPath}`);
416
+ } else {
417
+ console.log('Warning: Could not discover PulseAudio socket path');
418
+ }
419
+
203
420
  // Verify it's running
421
+ const env = getPulseAudioEnv();
204
422
  execSync(PULSEAUDIO_COMMANDS.INFO, {
205
423
  timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
206
424
  stdio: 'pipe',
425
+ env,
207
426
  });
208
427
 
209
428
  console.log('✓ PulseAudio started successfully');
@@ -261,9 +480,11 @@ function parseAlsaDevices(aplayOutput: string): AlsaDevice[] {
261
480
  function checkExistingSink(hwString: string, isInput: boolean): boolean {
262
481
  try {
263
482
  const command = isInput ? PULSEAUDIO_COMMANDS.LIST_SOURCES : PULSEAUDIO_COMMANDS.LIST_SINKS;
483
+ const env = getPulseAudioEnv();
264
484
  const output = execSync(command, {
265
485
  encoding: 'utf-8',
266
486
  timeout: 3000,
487
+ env,
267
488
  });
268
489
 
269
490
  // Check if any sink/source references this ALSA device
@@ -299,23 +520,32 @@ function createPulseAudioSink(alsaDevice: AlsaDevice, isInput: boolean): boolean
299
520
 
300
521
  // Simplify description to avoid quoting/escaping issues with PulseAudio
301
522
  // Replace spaces and special chars with underscores
302
- const safeDescription = `${alsaDevice.name}_${alsaDevice.hwString}`.replace(/[^a-zA-Z0-9_-]/g, '_');
523
+ const safeDescription = `${alsaDevice.name}_${alsaDevice.hwString}`.replace(
524
+ /[^a-zA-Z0-9_-]/g,
525
+ '_'
526
+ );
303
527
 
304
528
  const command = isInput
305
529
  ? PULSEAUDIO_COMMANDS.LOAD_ALSA_SOURCE(alsaDevice.hwString, sinkName, safeDescription)
306
530
  : PULSEAUDIO_COMMANDS.LOAD_ALSA_SINK(alsaDevice.hwString, sinkName, safeDescription);
307
531
 
308
- console.log(` Creating ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}: ${alsaDevice.name}`);
532
+ console.log(
533
+ ` Creating ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}: ${alsaDevice.name}`
534
+ );
309
535
 
536
+ const env = getPulseAudioEnv();
310
537
  execSync(command, {
311
538
  timeout: 5000,
312
539
  stdio: 'pipe',
540
+ env,
313
541
  });
314
542
 
315
543
  return true;
316
544
  } catch (error) {
317
- console.log(` Failed to create ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}:`,
318
- error instanceof Error ? error.message : String(error));
545
+ console.log(
546
+ ` Failed to create ${isInput ? 'source' : 'sink'} for ${alsaDevice.hwString}:`,
547
+ error instanceof Error ? error.message : String(error)
548
+ );
319
549
  return false;
320
550
  }
321
551
  }
@@ -334,9 +564,11 @@ function loadAlsaDevicesIntoPulseAudio(): void {
334
564
 
335
565
  // Step 1: Load the base ALSA card module (for auto-detection)
336
566
  try {
567
+ const env = getPulseAudioEnv();
337
568
  const modules = execSync(PULSEAUDIO_COMMANDS.LIST_MODULES, {
338
569
  encoding: 'utf-8',
339
570
  timeout: PULSEAUDIO_TIMEOUTS.LIST_MODULES,
571
+ env,
340
572
  });
341
573
 
342
574
  if (!modules.includes('module-alsa-card')) {
@@ -344,6 +576,7 @@ function loadAlsaDevicesIntoPulseAudio(): void {
344
576
  execSync(PULSEAUDIO_COMMANDS.LOAD_ALSA_MODULE, {
345
577
  timeout: PULSEAUDIO_TIMEOUTS.LOAD_MODULE,
346
578
  stdio: 'pipe',
579
+ env,
347
580
  });
348
581
  }
349
582
  } catch (error) {
@@ -404,7 +637,6 @@ function loadAlsaDevicesIntoPulseAudio(): void {
404
637
  } else {
405
638
  console.log('✓ All ALSA devices already have corresponding PulseAudio sinks/sources');
406
639
  }
407
-
408
640
  } catch (error) {
409
641
  console.log(
410
642
  'Note: Could not complete ALSA device loading:',
@@ -418,9 +650,11 @@ function loadAlsaDevicesIntoPulseAudio(): void {
418
650
  */
419
651
  function getDefaultDevices(): { defaultSink?: string; defaultSource?: string } {
420
652
  try {
653
+ const env = getPulseAudioEnv();
421
654
  const infoOutput = execSync(PULSEAUDIO_COMMANDS.INFO, {
422
655
  encoding: 'utf-8',
423
656
  timeout: PULSEAUDIO_TIMEOUTS.INFO_CHECK,
657
+ env,
424
658
  });
425
659
 
426
660
  const defaultSinkMatch = infoOutput.match(/Default Sink: (.+)/);
@@ -436,6 +670,111 @@ function getDefaultDevices(): { defaultSink?: string; defaultSource?: string } {
436
670
  }
437
671
  }
438
672
 
673
+ /**
674
+ * Parse card profiles from pactl list cards output
675
+ */
676
+ function parseCardProfiles(cardBlock: string): AudioProfile[] {
677
+ const profiles: AudioProfile[] = [];
678
+ const lines = cardBlock.split('\n');
679
+ let inProfilesSection = false;
680
+
681
+ // Extract port mappings from Ports section
682
+ const portProfileMap: Map<string, string[]> = new Map();
683
+ let inPortsSection = false;
684
+ let currentPortName = '';
685
+
686
+ for (const line of lines) {
687
+ const trimmed = line.trim();
688
+
689
+ // Parse Ports section to map ports to profiles
690
+ if (trimmed.startsWith('Ports:')) {
691
+ inPortsSection = true;
692
+ continue;
693
+ }
694
+
695
+ if (inPortsSection && trimmed.startsWith('Part of profile(s):')) {
696
+ const profileList = trimmed.replace('Part of profile(s):', '').trim();
697
+ if (currentPortName) {
698
+ portProfileMap.set(
699
+ currentPortName,
700
+ profileList.split(',').map(p => p.trim())
701
+ );
702
+ }
703
+ continue;
704
+ }
705
+
706
+ if (inPortsSection) {
707
+ if (trimmed.match(/^[a-z]+-[a-z]+-[^:]+:/)) {
708
+ currentPortName = trimmed.split(':')[0].trim();
709
+ } else if (trimmed.startsWith('Active Profile:') || trimmed.match(/^[A-Z]/)) {
710
+ inPortsSection = false;
711
+ }
712
+ }
713
+
714
+ // Parse Profiles section
715
+ if (trimmed.startsWith('Profiles:')) {
716
+ inProfilesSection = true;
717
+ continue;
718
+ }
719
+
720
+ if (inProfilesSection) {
721
+ if (trimmed.startsWith('Active Profile:') || trimmed.startsWith('Ports:')) {
722
+ inProfilesSection = false;
723
+ continue;
724
+ }
725
+
726
+ // Parse profile line: "output:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 39268, available: yes)"
727
+ const profileMatch = trimmed.match(/^([^:]+):\s*(.+?)\s*\([^)]+available:\s*(yes|no)\)/);
728
+ if (profileMatch) {
729
+ const profileName = profileMatch[1].trim();
730
+ const description = profileMatch[2].trim();
731
+ const available = profileMatch[3].trim() === 'yes';
732
+
733
+ // Find ports that belong to this profile
734
+ const ports: string[] = [];
735
+ for (const [portName, profileList] of portProfileMap.entries()) {
736
+ if (profileList.includes(profileName)) {
737
+ ports.push(portName);
738
+ }
739
+ }
740
+
741
+ profiles.push({
742
+ profile: profileName,
743
+ available,
744
+ description,
745
+ ports,
746
+ });
747
+ }
748
+ }
749
+ }
750
+
751
+ return profiles;
752
+ }
753
+
754
+ /**
755
+ * Get card profiles for a specific card
756
+ */
757
+ function getCardProfiles(cardName: string): AudioProfile[] {
758
+ try {
759
+ const env = getPulseAudioEnv();
760
+ const cardsOutput = execSync(PULSEAUDIO_COMMANDS.LIST_CARDS, {
761
+ encoding: 'utf-8',
762
+ timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
763
+ env,
764
+ });
765
+
766
+ const cardBlocks = cardsOutput.split(/^Card #/m);
767
+ for (const block of cardBlocks.slice(1)) {
768
+ if (block.includes(`Name: ${cardName}`)) {
769
+ return parseCardProfiles(block);
770
+ }
771
+ }
772
+ } catch (error) {
773
+ console.log(`Failed to get profiles for card ${cardName}:`, error);
774
+ }
775
+ return [];
776
+ }
777
+
439
778
  /**
440
779
  * Get available audio input/output devices using PulseAudio
441
780
  */
@@ -451,12 +790,15 @@ function getAudioDevices(): AudioDevices | undefined {
451
790
 
452
791
  const outputs: AudioDevice[] = [];
453
792
  const inputs: AudioDevice[] = [];
793
+ const cardProfilesCache: Map<string, AudioProfile[]> = new Map();
454
794
 
455
795
  // Detect output devices (sinks)
456
796
  try {
797
+ const env = getPulseAudioEnv();
457
798
  const sinksOutput = execSync(PULSEAUDIO_COMMANDS.LIST_SINKS, {
458
799
  encoding: 'utf-8',
459
800
  timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
801
+ env,
460
802
  });
461
803
  sinksOutput
462
804
  .split(/^Sink #/m)
@@ -466,6 +808,15 @@ function getAudioDevices(): AudioDevices | undefined {
466
808
  if (device) {
467
809
  // Mark as default if it matches the default sink
468
810
  device.isDefault = device.identifier === defaultSink;
811
+
812
+ // Add available profiles if cardName is available
813
+ if (device.cardName && !cardProfilesCache.has(device.cardName)) {
814
+ cardProfilesCache.set(device.cardName, getCardProfiles(device.cardName));
815
+ }
816
+ if (device.cardName) {
817
+ device.availableProfiles = cardProfilesCache.get(device.cardName) || [];
818
+ }
819
+
469
820
  outputs.push(device);
470
821
  }
471
822
  });
@@ -479,9 +830,11 @@ function getAudioDevices(): AudioDevices | undefined {
479
830
 
480
831
  // Detect input devices (sources)
481
832
  try {
833
+ const env = getPulseAudioEnv();
482
834
  const sourcesOutput = execSync(PULSEAUDIO_COMMANDS.LIST_SOURCES, {
483
835
  encoding: 'utf-8',
484
836
  timeout: PULSEAUDIO_TIMEOUTS.LIST_DEVICES,
837
+ env,
485
838
  });
486
839
  sourcesOutput
487
840
  .split(/^Source #/m)
@@ -491,6 +844,15 @@ function getAudioDevices(): AudioDevices | undefined {
491
844
  if (device) {
492
845
  // Mark as default if it matches the default source
493
846
  device.isDefault = device.identifier === defaultSource;
847
+
848
+ // Add available profiles if cardName is available
849
+ if (device.cardName && !cardProfilesCache.has(device.cardName)) {
850
+ cardProfilesCache.set(device.cardName, getCardProfiles(device.cardName));
851
+ }
852
+ if (device.cardName) {
853
+ device.availableProfiles = cardProfilesCache.get(device.cardName) || [];
854
+ }
855
+
494
856
  inputs.push(device);
495
857
  }
496
858
  });
@@ -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 {