feat: add SFX volume control and improve audio feedback

- Implemented separate sound effects volume control in settings.
- Updated backend to handle new `sfx_volume` parameter.
- Enhanced UI to include sound effects volume slider in admin panel.
- Integrated sound effects for various user interactions (clicks, navigation, alerts).
- Added a new global sound engine to manage audio synthesis without external files.
- Updated documentation for Docker deployment and Windows packaging.
- Refactored audio context management to ensure sound effects are available post user interaction.
This commit is contained in:
Pine
2026-08-18 07:14:13 +08:00
parent 18d28be943
commit d70d9d5fbd
22 changed files with 561 additions and 28 deletions
+2
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import Icon from './Icons';
import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds';
import { getMqttStatus } from '../utils/mqtt';
import { renderMarkdown } from '../voice/markdown.jsx';
@@ -133,6 +134,7 @@ export default function AiChatPanel() {
if (!reply) reply = pick(FALLBACK_REPLIES);
setTyping(false);
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
Sfx.notify();
executeLocalTools(tools);
resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时
}, [typing, resetIdleTimer]);
+3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import Icon from './Icons';
import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds';
/* =========================================================
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
@@ -110,6 +111,7 @@ export default function DpmOverlays() {
const alertTimer = useRef(null);
const showAlert = useCallback((params) => {
Sfx.alert();
const item = { title: params.title || '提示', content: params.content || '', id: Date.now() + Math.random() };
setAlert(item);
clearTimeout(alertTimer.current);
@@ -117,6 +119,7 @@ export default function DpmOverlays() {
}, []);
const showCard = useCallback((params) => {
Sfx.notify();
setCard({ card: params.card || 'custom', title: params.title || '', content: params.content || '', id: Date.now() + Math.random() });
}, []);
+2
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import useVisionDetection from '../voice/useVisionDetection.js';
import { Sfx } from '../utils/sounds';
/* =========================================================
全局手势识别(挂载于 ScreenLayout —— 所有大屏页面共享)
@@ -18,6 +19,7 @@ export default function GlobalVision() {
const vision = useVisionDetection({
onGesture: (g) => {
// 全部手势类型(raise/fist/both_up/point/hands_close/wave)统一分发
Sfx.success();
window.dispatchEvent(new CustomEvent('dpm:gesture', { detail: { type: g } }));
},
});
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import Icon from './Icons';
import { onMediaUiState, mediaCommand } from '../utils/mediaControl';
import { Sfx } from '../utils/sounds';
/* =========================================================
页眉媒体控制区 —— 仅媒体轮播页显示
@@ -26,13 +27,13 @@ export default function MediaHeaderControls() {
{/* 播放 / 暂停 */}
<button
className={`bd-hd-media-btn ${st.paused ? 'primary' : ''}`}
onClick={() => mediaCommand(st.paused ? 'play' : 'pause')}
onClick={() => { Sfx.click(); mediaCommand(st.paused ? 'play' : 'pause'); }}
title={st.paused ? '播放' : '暂停'}
>
<Icon name={st.paused ? 'play' : 'pause'} size={14} />
</button>
{/* 上一段 */}
<button className="bd-hd-media-btn" onClick={() => mediaCommand('prev')} title="上一段">
<button className="bd-hd-media-btn" onClick={() => { Sfx.click(); mediaCommand('prev'); }} title="上一段">
<Icon name="chevron-left" size={13} />
</button>
{/* 序号 */}
@@ -40,7 +41,7 @@ export default function MediaHeaderControls() {
<b>{st.index + 1}</b>/<em>{st.total}</em>
</span>
{/* 下一段 */}
<button className="bd-hd-media-btn" onClick={() => mediaCommand('next')} title="下一段">
<button className="bd-hd-media-btn" onClick={() => { Sfx.click(); mediaCommand('next'); }} title="下一段">
<Icon name="chevron-right" size={13} />
</button>
{/* 声音解锁 */}
+90 -2
View File
@@ -5,10 +5,13 @@ import MediaHeaderControls from './MediaHeaderControls';
import AboutDialog from './AboutDialog';
import { PAGE_ORDER, usePageIndex } from '../utils/pageNav';
import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar'
import { Sfx } from '../utils/sounds';
import { getApiBase as API_BASE } from '../config';
/* =========================================================
共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入)
状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换
· 音量面板:媒体音量 + 音效音量(写回后端设置并 MQTT 广播同步)
========================================================= */
const STATUS_BY_PATH = {
@@ -25,9 +28,66 @@ export default function PageHeader() {
const [aboutOpen, setAboutOpen] = useState(false);
const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s
const [vision, setVision] = useState({ status: 'off', cameraOn: false, gesture: null }); // 视觉识别状态
const [volOpen, setVolOpen] = useState(false);
const [mediaVol, setMediaVol] = useState(80); // 媒体音量 0-100
const [sfxVol, setSfxVol] = useState(Sfx.getVolume()); // 音效音量 0-100
const pageIdx = usePageIndex();
const gestureTimer = useRef(null);
// 启动时拉取后端设置(媒体/音效音量),并监听 MQTT settings_changed 同步
useEffect(() => {
fetch(`${API_BASE()}/api/settings`, { cache: 'no-store' })
.then((r) => r.json())
.then((s) => {
if (typeof s.volume === 'number') setMediaVol(s.volume);
if (typeof s.sfx_volume === 'number') {
setSfxVol(s.sfx_volume);
Sfx.setVolume(s.sfx_volume);
}
})
.catch(() => {});
const onSettings = (e) => {
const p = (e.detail && (e.detail.params || e.detail)) || {};
if (typeof p.volume === 'number') setMediaVol(p.volume);
if (typeof p.sfx_volume === 'number') {
setSfxVol(p.sfx_volume);
Sfx.setVolume(p.sfx_volume);
}
};
// 后端/MQTT settings_changed → 同步音量(SSE 走 api/eventsMQTT 走 dpm:media-control
window.addEventListener('dpm:mqtt-local', onSettings);
window.addEventListener('dpm:media-control', onSettings);
return () => {
window.removeEventListener('dpm:mqtt-local', onSettings);
window.removeEventListener('dpm:media-control', onSettings);
};
}, []);
// 点击面板外部 → 关闭音量面板
useEffect(() => {
if (!volOpen) return undefined;
const close = () => setVolOpen(false);
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, [volOpen]);
// 音量修改 → 写回后端(MQTT settings_changed 广播给所有大屏)
const changeVolume = (key, value) => {
const v = Math.max(0, Math.min(100, Number(value) || 0));
if (key === 'sfx_volume') {
setSfxVol(v);
Sfx.setVolume(v);
Sfx.notify();
} else {
setMediaVol(v);
}
fetch(`${API_BASE()}/api/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: v }),
}).catch(() => {});
};
useEffect(() => {
const iv = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(iv);
@@ -93,16 +153,44 @@ export default function PageHeader() {
<div className="bd-header-right">
{/* 媒体轮播页:页眉内嵌播放控制 */}
{location.pathname === '/screen' && <MediaHeaderControls />}
{/* 音量控制(媒体 + 音效) */}
<div className="bd-vol-wrap">
<button
className={`bd-vol-btn${mediaVol === 0 ? ' muted' : ''}`}
type="button"
title="音量控制"
aria-label="音量控制"
onClick={() => { setVolOpen((v) => !v); Sfx.click(); }}
>
<Icon name={mediaVol === 0 ? 'volume-off' : 'volume'} size={15} />
</button>
{volOpen && (
<div className="bd-vol-panel" onClick={(e) => e.stopPropagation()}>
<div className="bd-vol-title"><Icon name="volume" size={13} /> 媒体音量</div>
<input
type="range" min="0" max="100" value={mediaVol}
onChange={(e) => changeVolume('volume', e.target.value)}
/>
<span className="bd-vol-val">{mediaVol}%</span>
<div className="bd-vol-title"><Icon name="sparkles" size={13} /> 音效音量</div>
<input
type="range" min="0" max="100" value={sfxVol}
onChange={(e) => changeVolume('sfx_volume', e.target.value)}
/>
<span className="bd-vol-val">{sfxVol}%</span>
</div>
)}
</div>
<div className="bd-nav-keys" title="左右方向键或点击切换页面">
{left && (
<button className="bd-nav-key" onClick={() => navigate(left.path)}>
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(left.path); }}>
<Icon name="chevron-left" size={13} />
{left.label}
</button>
)}
<span className="bd-nav-dot">·</span>
{right && (
<button className="bd-nav-key" onClick={() => navigate(right.path)}>
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(right.path); }}>
{right.label}
<Icon name="chevron-right" size={13} />
</button>
+33 -15
View File
@@ -2,27 +2,45 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider";
import { API_BASE } from "./config";
import { getApiBase } from "./config";
import { invoke } from "@tauri-apps/api/core";
import "./styles/global.css";
/* =========================================================
启动引导(打包部署关键):
渲染前先从后端 GET /api/config 拉取运行时配置
(MQTT 地址/账号、API 基址),写入 window.__DPM_*__
之后 api.js / mqtt.js 均以惰性读取方式生效。
后端不可达时静默使用内置默认(Tauri 打包回退 127.0.0.1
启动引导(打包部署关键)—— 地址解析优先级
1. exe 旁 config.jsonTauri 命令读取,部署免重打包改 IP)★
2. 后端 GET /api/configMQTT 地址/账号,后端 .env 为准)
3. 内置默认(Tauri 打包回退 127.0.0.1 / 浏览器用当前主机)
结果写入 window.__DPM_*__api.js / mqtt.js 惰性读取生效
========================================================= */
async function bootstrapRuntimeConfig() {
// 1) exe 旁 config.json(最高优先):{api_base, mqtt_url, mqtt_username, mqtt_password}
try {
const res = await fetch(`${API_BASE()}/api/config`, { cache: 'no-store' });
if (!res.ok) return;
const cfg = await res.json();
// 仅覆盖 MQTT 连接信息(后端 .env 为准);API 基址以前端实际可达地址为准
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
// eslint-disable-next-line no-console
if (cfg.mqtt_url) console.info(`[bootstrap] MQTT -> ${cfg.mqtt_url}`);
const raw = await invoke("read_runtime_config");
if (raw) {
const cfg = JSON.parse(raw);
if (cfg.api_base) window.__DPM_API__ = cfg.api_base;
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
// eslint-disable-next-line no-console
if (cfg.mqtt_url) console.info(`[bootstrap] config.json MQTT -> ${cfg.mqtt_url}`);
}
} catch {
/* 浏览器开发环境或未注册命令:跳过 */
}
// 2) 后端 /api/configMQTT 地址/账号;API 基址以前端实际可达地址为准)
try {
const res = await fetch(`${getApiBase()}/api/config`, { cache: 'no-store' });
if (res.ok) {
const cfg = await res.json();
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
// eslint-disable-next-line no-console
if (cfg.mqtt_url) console.info(`[bootstrap] /api/config MQTT -> ${cfg.mqtt_url}`);
}
} catch {
/* 后端不可达:使用内置默认地址 */
}
+4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import * as api from '../utils/api';
import { getApiBase } from '../config';
import { Sfx } from '../utils/sounds';
import { API_BASE } from '../utils/api';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { enable, disable } from '@tauri-apps/plugin-autostart';
@@ -323,6 +324,9 @@ export default function MediaScreen() {
}, [reloadPlaylist]);
const applySettings = useCallback((msg) => {
if (msg.sfx_volume !== undefined) {
Sfx.setVolume(msg.sfx_volume);
}
if (msg.volume !== undefined) {
const vol = msg.volume / 100;
setVolume(vol);
+88
View File
@@ -2354,6 +2354,94 @@
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* ============ 页眉音量控制(媒体 + 音效) ============ */
.bd-vol-wrap {
position: relative;
flex-shrink: 0;
}
.bd-vol-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #4a5b76;
background: rgba(255,255,255,0.7);
border: 1px solid rgba(47,107,255,0.14);
border-radius: 9px;
cursor: pointer;
transition: all 0.2s;
}
.bd-vol-btn:hover {
color: var(--bd-blue);
border-color: rgba(47,107,255,0.4);
background: rgba(255,255,255,0.95);
}
.bd-vol-btn.muted { color: var(--bd-red); }
.bd-vol-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 60;
width: 220px;
padding: 12px 14px;
background: rgba(255,255,255,0.96);
border: 1px solid rgba(47,107,255,0.16);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(56,100,170,0.16), 0 1px 0 rgba(255,255,255,0.95) inset;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
animation: bdRotIn 0.2s ease both;
}
.bd-vol-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--bd-ink);
margin-bottom: 4px;
}
.bd-vol-title .appica-ico { color: var(--bd-blue); }
.bd-vol-panel input[type="range"] {
width: 100%;
height: 5px;
-webkit-appearance: none;
appearance: none;
border-radius: 3px;
background: rgba(47,107,255,0.14);
outline: none;
cursor: pointer;
}
.bd-vol-panel input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(47,107,255,0.4);
}
.bd-vol-panel input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--bd-blue);
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(47,107,255,0.4);
}
.bd-vol-val {
display: block;
text-align: right;
font-family: "JetBrains Mono", "Oswald", monospace;
font-size: 10.5px;
font-weight: 700;
color: var(--bd-blue);
margin: 2px 0 8px;
}
.bd-vol-val:last-child { margin-bottom: 0; }
.bd-nav-key {
display: inline-flex;
align-items: center;
+3
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Sfx } from './sounds';
/* =========================================================
页面循环导航 —— 左右方向键切换大屏页面
@@ -34,8 +35,10 @@ export function usePageNav() {
if (idx === -1) return;
const n = PAGE_ORDER.length;
if (e.key === 'ArrowRight') {
Sfx.page();
navigate(PAGE_ORDER[(idx + 1) % n].path);
} else if (e.key === 'ArrowLeft') {
Sfx.page();
navigate(PAGE_ORDER[(idx - 1 + n) % n].path);
}
};
+114
View File
@@ -0,0 +1,114 @@
/* =========================================================
全局音效引擎 —— Web Audio 合成音(无需音频资源文件)
· 独立于媒体播放音量:sfx 音量单独控制(localStorage 持久化)
· 首次用户交互后自动激活 AudioContext(浏览器自动播放策略)
· 提供:click 点击 / page 切页 / alert 通知 / success 成功 /
error 错误 / notify 提示 / tick 轻响
========================================================= */
let ctx = null; // AudioContext
let sfxGain = null; // 音效增益(接 master
const SFX_KEY = 'dpm_sfx_volume';
let sfxVolume = Number(localStorage.getItem(SFX_KEY));
if (!Number.isFinite(sfxVolume)) sfxVolume = 60; // 默认 60%
sfxVolume = Math.max(0, Math.min(100, sfxVolume));
function ensureCtx() {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
ctx = new AC();
sfxGain = ctx.createGain();
sfxGain.gain.value = sfxVolume / 100;
sfxGain.connect(ctx.destination);
}
if (ctx.state === 'suspended') {
ctx.resume().catch(() => { /* 等用户交互后再恢复 */ });
}
return ctx;
}
/**
* 合成一个音
* @param {Object} o freq 起始频率 / end 结束频率 / dur 时长s / type 波形 /
* vol 音量0-1 / delay 延迟s / attack 起音s
*/
function tone(o = {}) {
const c = ensureCtx();
if (!c) return;
const {
freq = 880, end = freq, dur = 0.12, type = 'sine',
vol = 0.4, delay = 0, attack = 0.005,
} = o;
const t0 = c.currentTime + delay;
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (end !== freq) osc.frequency.exponentialRampToValueAtTime(Math.max(20, end), t0 + dur);
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(vol, t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g);
g.connect(sfxGain);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
/** 用户交互时调用:激活/恢复 AudioContext */
export function unlockAudio() {
ensureCtx();
}
// 全局首次交互(点击/按键/触摸)即解锁 AudioContext,保证后续音效可用
if (typeof window !== 'undefined') {
const unlockOnce = () => { unlockAudio(); };
window.addEventListener('pointerdown', unlockOnce, { once: true });
window.addEventListener('keydown', unlockOnce, { once: true });
window.addEventListener('touchstart', unlockOnce, { once: true });
}
export const Sfx = {
/** 按钮点击 */
click() {
tone({ freq: 1300, dur: 0.05, type: 'triangle', vol: 0.22 });
},
/** 页面切换(上滑音) */
page() {
tone({ freq: 560, end: 920, dur: 0.16, type: 'sine', vol: 0.3 });
},
/** 通知提醒(双音 ding-ding */
alert() {
tone({ freq: 880, dur: 0.12, type: 'sine', vol: 0.38 });
tone({ freq: 1174, dur: 0.2, type: 'sine', vol: 0.38, delay: 0.14 });
},
/** 成功(上行双音) */
success() {
tone({ freq: 660, dur: 0.1, type: 'sine', vol: 0.3 });
tone({ freq: 990, dur: 0.18, type: 'sine', vol: 0.3, delay: 0.09 });
},
/** 错误(下行低音) */
error() {
tone({ freq: 320, end: 180, dur: 0.28, type: 'sawtooth', vol: 0.22 });
},
/** 轻提示(AI 回复等) */
notify() {
tone({ freq: 1046, dur: 0.07, type: 'triangle', vol: 0.18 });
tone({ freq: 1568, dur: 0.09, type: 'triangle', vol: 0.14, delay: 0.06 });
},
/** 极轻节拍(数据 tick / 播放控制) */
tick() {
tone({ freq: 1500, dur: 0.03, type: 'square', vol: 0.06 });
},
/** 设置音效音量 0-100(持久化) */
setVolume(v) {
sfxVolume = Math.max(0, Math.min(100, Number(v) || 0));
localStorage.setItem(SFX_KEY, String(sfxVolume));
if (sfxGain) sfxGain.gain.value = sfxVolume / 100;
},
getVolume() {
return sfxVolume;
},
};
export default Sfx;
+3 -1
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
import { MQTT_TOPIC_HEARTBEAT } from '../config';
import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav';
/* =========================================================
@@ -71,7 +72,7 @@ export function useMqttControl() {
switch (cmd.action) {
case 'navigate': {
const path = resolvePage(cmd.params?.page);
if (path) navigate(path);
if (path) { Sfx.page(); navigate(path); }
break;
}
case 'navigate_rel': {
@@ -93,6 +94,7 @@ export function useMqttControl() {
case 'pause':
case 'next':
case 'prev':
Sfx.tick();
case 'set_mode':
case 'play_target':
case 'minimize':