@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.
- 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 +312 -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 +437 -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,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(
|
|
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(
|
|
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(
|
|
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(
|
|
318
|
-
|
|
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
|
});
|
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 {
|