@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/sysinfo/node.d.ts +2 -0
- package/dist/sysinfo/node.d.ts.map +1 -1
- package/dist/sysinfo/node.js +271 -3
- package/dist/sysinfo/node.js.map +1 -1
- package/dist/types/twin.types.d.ts +11 -0
- package/dist/types/twin.types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +1 -1
- package/src/sysinfo/node.ts +371 -9
- package/src/types/twin.types.ts +12 -0
package/src/sysinfo/node.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
318
|
-
|
|
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
|
});
|
package/src/types/twin.types.ts
CHANGED
|
@@ -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 {
|