mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
gui: speakeruse 支持声卡选择(设备通道可配输出设备)
- playDeviceAudio 按 gui-prefs.deviceBridge.audio.device 用 aplay -D <dev> 播放 - 新增 audio:list IPC(aplay -L 枚举) + preload 暴露 audio.list - renderer 设备通道新增 speakeruse 声卡下拉, 保存到 prefs - 排除 hw: 裸设备(mono->stereo 需转换), 只留 default/pipewire/pulse/plughw - 修复 USB 声卡无声音: mono WAV 播到仅立体声设备失败, 改用 plughw 自动转换 已用 mock 网关 + 源码直跑验证: voice -> via plughw:CARD=Audio,DEV=0 播放成功
This commit is contained in:
@ -813,6 +813,51 @@ let deviceBridgeAddr = ""; // 设备桥网关地址
|
|||||||
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
||||||
let speechAccum = null;
|
let speechAccum = null;
|
||||||
|
|
||||||
|
// 读取设备音频输出配置(gui-prefs.deviceBridge.audio)
|
||||||
|
function getAudioConfig() {
|
||||||
|
try {
|
||||||
|
const prefs = loadGuiPrefs();
|
||||||
|
const db = prefs.deviceBridge || {};
|
||||||
|
const au = db.audio || {};
|
||||||
|
return { device: au.device || "default" };
|
||||||
|
} catch (e) {
|
||||||
|
return { device: "default" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 枚举本机可用音频输出设备(aplay -L 解析:设备名顶格、描述缩进)
|
||||||
|
function listAudioDevices() {
|
||||||
|
const porcp = require("child_process");
|
||||||
|
const out = [];
|
||||||
|
const SKIP_PREFIXES = ["lavrate", "samplerate", "speex", "jack", "oss",
|
||||||
|
"upmix", "vdownmix", "speexrate"];
|
||||||
|
try {
|
||||||
|
const r = porcp.spawnSync("aplay", ["-L"], { encoding: "utf8" });
|
||||||
|
const lines = ((r.stdout || "") + "\n").split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const ln = lines[i];
|
||||||
|
if (!ln || ln[0] === " " || ln[0] === "\t") continue; // 跳过缩进的描述行
|
||||||
|
const name = ln.trim();
|
||||||
|
if (!name || /^(Default PCM|List of|Plug PCM)/.test(name)) continue;
|
||||||
|
if (SKIP_PREFIXES.some((s) => name === s)) continue; // 插件类,非实体设备
|
||||||
|
// 只保留可实际输出的设备类型;纯 hw: 裸设备不支持自动通道转换
|
||||||
|
// (mono→stereo)易失败,改用 plughw:(带转换)。pipewire/pulse/sysdefault 亦可。
|
||||||
|
const useful = name === "default" || name === "pipewire" ||
|
||||||
|
name === "pulse" || name === "sysdefault" ||
|
||||||
|
/^plughw:/.test(name) || /^sysdefault:/.test(name);
|
||||||
|
if (!useful) continue;
|
||||||
|
let desc = "";
|
||||||
|
// 下一行可能是描述(缩进)
|
||||||
|
if (lines[i + 1] && (lines[i + 1][0] === " " || lines[i + 1][0] === "\t"))
|
||||||
|
desc = lines[i + 1].trim();
|
||||||
|
out.push({ name, desc });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (!out.find((d) => d.name === "default"))
|
||||||
|
out.unshift({ name: "default", desc: "系统默认设备" });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// 播放设备收到的音频(由 cmd_speech_end 触发,二进制已聚合)
|
// 播放设备收到的音频(由 cmd_speech_end 触发,二进制已聚合)
|
||||||
function playDeviceAudio(audioBuf, mime, reqId) {
|
function playDeviceAudio(audioBuf, mime, reqId) {
|
||||||
try {
|
try {
|
||||||
@ -820,6 +865,7 @@ function playDeviceAudio(audioBuf, mime, reqId) {
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const porcp = require("child_process");
|
const porcp = require("child_process");
|
||||||
|
const au = getAudioConfig();
|
||||||
const ext =
|
const ext =
|
||||||
(mime || "").indexOf("mp3") === -1
|
(mime || "").indexOf("mp3") === -1
|
||||||
? (mime || "").indexOf("ogg") === -1
|
? (mime || "").indexOf("ogg") === -1
|
||||||
@ -834,7 +880,8 @@ function playDeviceAudio(audioBuf, mime, reqId) {
|
|||||||
if (platform === "linux") {
|
if (platform === "linux") {
|
||||||
if (porcp.spawnSync("which", ["aplay"]).status === 0) {
|
if (porcp.spawnSync("which", ["aplay"]).status === 0) {
|
||||||
cmd = "aplay";
|
cmd = "aplay";
|
||||||
args = [tmp];
|
// 支持用户选择的输出设备(默认 default)
|
||||||
|
args = au.device && au.device !== "default" ? ["-D", au.device, tmp] : [tmp];
|
||||||
} else if (porcp.spawnSync("which", ["paplay"]).status === 0) {
|
} else if (porcp.spawnSync("which", ["paplay"]).status === 0) {
|
||||||
cmd = "paplay";
|
cmd = "paplay";
|
||||||
args = [tmp];
|
args = [tmp];
|
||||||
@ -864,7 +911,7 @@ function playDeviceAudio(audioBuf, mime, reqId) {
|
|||||||
baseResult(
|
baseResult(
|
||||||
reqId || "",
|
reqId || "",
|
||||||
"ok",
|
"ok",
|
||||||
"audio played: " + audioBuf.length + " bytes",
|
"audio played: " + audioBuf.length + " bytes via " + (au.device || "default"),
|
||||||
"",
|
"",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -2144,6 +2191,7 @@ function loadGuiPrefs() {
|
|||||||
screensueDuration: db.screensueDuration === undefined || db.screensueDuration === null || db.screensueDuration === "" ? "5" : String(db.screensueDuration),
|
screensueDuration: db.screensueDuration === undefined || db.screensueDuration === null || db.screensueDuration === "" ? "5" : String(db.screensueDuration),
|
||||||
exec: db.exec || {},
|
exec: db.exec || {},
|
||||||
authSchedule: db.authSchedule || {},
|
authSchedule: db.authSchedule || {},
|
||||||
|
audio: db.audio || {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -2160,6 +2208,7 @@ function loadGuiPrefs() {
|
|||||||
screensueDuration: "5",
|
screensueDuration: "5",
|
||||||
exec: {},
|
exec: {},
|
||||||
authSchedule: {},
|
authSchedule: {},
|
||||||
|
audio: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -2210,6 +2259,15 @@ ipcMain.handle("displays:list", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IPC:本机音频输出设备列表(供设备通道选择声卡)
|
||||||
|
ipcMain.handle("audio:list", () => {
|
||||||
|
try {
|
||||||
|
return listAudioDevices();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// IPC:本机设备桥状态(启用+网关+token+连接状态)
|
// IPC:本机设备桥状态(启用+网关+token+连接状态)
|
||||||
ipcMain.handle("device-bridge:get", () => {
|
ipcMain.handle("device-bridge:get", () => {
|
||||||
const p = loadGuiPrefs();
|
const p = loadGuiPrefs();
|
||||||
@ -2224,6 +2282,7 @@ ipcMain.handle("device-bridge:get", () => {
|
|||||||
screensueDisplay: db.screensueDisplay || "0",
|
screensueDisplay: db.screensueDisplay || "0",
|
||||||
exec: db.exec || {},
|
exec: db.exec || {},
|
||||||
authSchedule: db.authSchedule || {},
|
authSchedule: db.authSchedule || {},
|
||||||
|
audio: db.audio || {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2253,6 +2312,7 @@ ipcMain.handle("device-bridge:set", (_, cfg) => {
|
|||||||
screensueDisplay: db.screensueDisplay || "0",
|
screensueDisplay: db.screensueDisplay || "0",
|
||||||
exec: db.exec || {},
|
exec: db.exec || {},
|
||||||
authSchedule: db.authSchedule || {},
|
authSchedule: db.authSchedule || {},
|
||||||
|
audio: db.audio || {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -48,4 +48,7 @@ contextBridge.exposeInMainWorld("homeagent", {
|
|||||||
displays: {
|
displays: {
|
||||||
list: () => ipcRenderer.invoke("displays:list"),
|
list: () => ipcRenderer.invoke("displays:list"),
|
||||||
},
|
},
|
||||||
|
audio: {
|
||||||
|
list: () => ipcRenderer.invoke("audio:list"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -659,6 +659,12 @@ async function refreshAll() {
|
|||||||
state.displays = (await window.homeagent.displays.list()) || [];
|
state.displays = (await window.homeagent.displays.list()) || [];
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
// 本机音频输出设备列表(speakeruse 声卡选择用)
|
||||||
|
try {
|
||||||
|
if (window.homeagent && window.homeagent.audio) {
|
||||||
|
state.audioDevices = (await window.homeagent.audio.list()) || [];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
// 本机设备桥身份:设备桥由 gui-prefs 驱动,独立于当前连接类型
|
// 本机设备桥身份:设备桥由 gui-prefs 驱动,独立于当前连接类型
|
||||||
if (window.homeagent && window.homeagent.deviceBridge) {
|
if (window.homeagent && window.homeagent.deviceBridge) {
|
||||||
var dbinfo = await window.homeagent.deviceBridge.get();
|
var dbinfo = await window.homeagent.deviceBridge.get();
|
||||||
@ -4967,6 +4973,20 @@ function renderDevices() {
|
|||||||
"</option>",
|
"</option>",
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
var audioDev = dbc.audio && dbc.audio.device ? dbc.audio.device : "default";
|
||||||
|
var audioOpts = (state.audioDevices || [])
|
||||||
|
.map(
|
||||||
|
(a) =>
|
||||||
|
'<option value="' +
|
||||||
|
escHtml(a.name) +
|
||||||
|
'"' +
|
||||||
|
(a.name === audioDev ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
escHtml(a.name) +
|
||||||
|
(a.desc ? " — " + escHtml(String(a.desc).slice(0, 32)) : "") +
|
||||||
|
"</option>",
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
selfHtml +=
|
selfHtml +=
|
||||||
'<div class="kv-row"><span class="key">' +
|
'<div class="kv-row"><span class="key">' +
|
||||||
__("设备通道", "Device Channel") +
|
__("设备通道", "Device Channel") +
|
||||||
@ -5002,6 +5022,13 @@ function renderDevices() {
|
|||||||
"</div>" +
|
"</div>" +
|
||||||
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
|
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
|
||||||
"<label style='font-size:13px'>" +
|
"<label style='font-size:13px'>" +
|
||||||
|
__("speakeruse 声卡", "speakeruse audio out") +
|
||||||
|
'</label><select id="dev-bridge-audio" style="font-size:13px;padding:3px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary);min-width:160px">' +
|
||||||
|
(audioOpts || '<option value="default">default</option>') +
|
||||||
|
"</select>" +
|
||||||
|
"</div>" +
|
||||||
|
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
|
||||||
|
"<label style='font-size:13px'>" +
|
||||||
__("cmdrun 目录", "cmdrun cwd") +
|
__("cmdrun 目录", "cmdrun cwd") +
|
||||||
'</label><input id="dev-bridge-cwd" value="' +
|
'</label><input id="dev-bridge-cwd" value="' +
|
||||||
escHtml(dbExec.cwd || "") +
|
escHtml(dbExec.cwd || "") +
|
||||||
@ -5153,6 +5180,8 @@ async function saveBridgeChannel() {
|
|||||||
var dv = parseInt(dur.value, 10);
|
var dv = parseInt(dur.value, 10);
|
||||||
cfg.screensueDuration = Number.isFinite(dv) && dv >= 0 ? String(dv) : "5";
|
cfg.screensueDuration = Number.isFinite(dv) && dv >= 0 ? String(dv) : "5";
|
||||||
}
|
}
|
||||||
|
var audioSel = document.getElementById("dev-bridge-audio");
|
||||||
|
if (audioSel) cfg.audio = { device: audioSel.value || "default" };
|
||||||
var cwd = document.getElementById("dev-bridge-cwd");
|
var cwd = document.getElementById("dev-bridge-cwd");
|
||||||
var sandbox = document.getElementById("dev-bridge-sandbox");
|
var sandbox = document.getElementById("dev-bridge-sandbox");
|
||||||
var boxdir = document.getElementById("dev-bridge-boxdir");
|
var boxdir = document.getElementById("dev-bridge-boxdir");
|
||||||
|
|||||||
Reference in New Issue
Block a user