Update project dependencies and configuration: Added ECharts for data visualization, integrated Terser for minification, and adjusted Vite configuration for production. Updated app title and ConnectScreen content to reflect new version. Enhanced EQPage with headphone model loading and frequency response chart rendering.

This commit is contained in:
yangy
2026-04-09 17:00:44 +08:00
parent 502f708336
commit 53f24d5bb4
33 changed files with 3522 additions and 102 deletions
+191 -8
View File
@@ -13,11 +13,19 @@
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones } from "lucide-react";
import { useLocation } from "wouter";
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import BottomNav from "@/components/BottomNav";
import { toast } from "sonner";
import { navigateToSelect } from "./SelectPage";
import { decodeCustomBase64 } from "@/lib/luxsinApi";
import * as echarts from "echarts";
import {
getSectionsMatrix,
visualizeResponse,
getChartOps,
getFilterType,
} from "@/lib/peqAudio";
/* ── iOS Toggle ── */
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -210,6 +218,7 @@ export default function EQPage() {
const [selectedBand, setSelectedBand] = useState(3);
const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0);
const [headphoneModels, setHeadphoneModels] = useState<string[]>([]);
const [peqItems, setPeqItems] = useState<Array<{ name: string; brand?: string; model?: string; filters: any[] }>>([]);
const [abMode, setAbMode] = useState<"A" | "B">("A");
const [autoGain, setAutoGain] = useState(false);
@@ -220,56 +229,230 @@ export default function EQPage() {
}
}, [deviceState?.peqSelect]);
// 加载耳机列表
// 获取耳机原始曲线
const getModelCurve = async (brand: string, name: string) => {
try {
const resp = await fetch(
`//api.luxsin.com.cn/audio/modelCurve?brand=${encodeURIComponent(brand)}&name=${encodeURIComponent(name)}`
);
const data = await resp.text();
// 使用自定义 Base64 解码
const decoded = decodeCustomBase64(data);
const parsed = JSON.parse(decoded);
console.log('型号的原始数据:', parsed);
if (parsed.hasOwnProperty('fr')) {
return parsed.fr;
}
return null;
} catch (error) {
console.error("Failed to get model curve:", error);
return null;
}
};
// 加载耳机列表并初始化曲线
useEffect(() => {
async function loadHeadphones() {
if (isDemoMode || !api) {
// 演示模式使用默认列表
setHeadphoneModels([
const defaultModels = [
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
]);
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
return;
}
try {
const peqState = await api.getPeqState();
if (peqState.peq && peqState.peq.length > 0) {
setHeadphoneModels(peqState.peq.map(h => h.name));
setPeqItems(peqState.peq);
// 初始化当前选中的耳机曲线
const currentPeq = peqState.peq[peqState.peqSelect || 0];
if (currentPeq) {
// 解析 filters(可能是 JSON 字符串或数组)
let filters: any[] = [];
if (typeof currentPeq.filters === 'string') {
try {
filters = JSON.parse(currentPeq.filters);
} catch (error) {
console.error("Failed to parse filters:", error);
filters = [];
}
} else if (Array.isArray(currentPeq.filters)) {
filters = currentPeq.filters;
}
console.log("Raw filters from API:", filters);
// 修复 filters 数据(兼容不同的字段名)
const fixedFilters = filters.map((f: any) => {
// 兼容不同的字段名:fc, freq, frequency
const freq = f.fc || f.freq || f.frequency || 1000;
return {
freq: Number(freq),
gain: Number(f.gain || 0),
q: Number(f.q || 1),
type: f.type || 'PEAK',
enabled: f.enabled !== undefined ? f.enabled : true,
};
});
console.log("Fixed filters:", fixedFilters);
// 更新 bands 状态
if (fixedFilters && fixedFilters.length > 0) {
setBands(fixedFilters);
}
// 如果有 brand 和 model,获取原始曲线
let raw: number[] | null = null;
if ((currentPeq as any).brand && (currentPeq as any).model) {
try {
const modelCurve = await getModelCurve((currentPeq as any).brand, (currentPeq as any).model);
if (modelCurve && 'raw' in modelCurve) {
raw = modelCurve.raw;
}
} catch (error) {
console.warn("Failed to load model curve:", error);
}
}
// 渲染图表(使用修复后的 fixedFilters
// 使用 setTimeout 确保 DOM 已经渲染
setTimeout(() => {
renderCharts(fixedFilters.length > 0 ? fixedFilters : bands, raw, false);
}, 100);
console.log("Initialized PEQ:", currentPeq.name, "Filters count:", fixedFilters.length, "Raw curve:", raw ? "loaded" : "not available");
}
} else {
// 如果没有数据,使用默认列表
setHeadphoneModels([
const defaultModels = [
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
]);
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
}
} catch (error) {
console.error("Failed to load headphone models:", error);
// 出错时使用默认列表
setHeadphoneModels([
const defaultModels = [
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
]);
];
setHeadphoneModels(defaultModels);
setPeqItems(defaultModels.map(name => ({ name, filters: [] })));
}
}
loadHeadphones();
}, [api, isDemoMode]);
const chartRef = useRef<echarts.ECharts | null>(null);
const band = bands[selectedBand];
// Render frequency response chart using ECharts
const renderCharts = (peqFilters: typeof bands, raw: number[] | null, changeParam: boolean) => {
const list: any[] = [];
const fs = 48000;
// Calculate coefficient matrix for each filter
peqFilters.forEach((item, index) => {
const filterType = getFilterType(item.type);
console.log(`Filter ${index}: type="${item.type}" -> mapped to ${filterType}, freq=${item.freq}, gain=${item.gain}, q=${item.q}`);
const coeff = getSectionsMatrix(
item.gain,
item.freq,
item.q,
filterType,
false,
fs
);
if (coeff) {
list.push(coeff);
} else {
console.error(`Filter ${index} returned null/undefined coeff!`);
}
});
// Get frequency response data
const dataSet = visualizeResponse(list, fs);
const ops = getChartOps(dataSet, 20, -20, '#FFED00');
// Get or create chart instance
const chartDom = document.getElementById('freq-chart');
if (!chartDom) return;
if (!chartRef.current) {
chartRef.current = echarts.init(chartDom);
}
const myChart = chartRef.current;
// Handle changeParam (get raw data from existing chart)
if (changeParam && myChart) {
const option = myChart.getOption();
if (option.series && option.series.length > 1) {
raw = (option.series[1] as any).data;
}
}
// Clear and rebuild chart
myChart.clear();
// Add Raw and Equalized curves if raw data is available
if (raw) {
ops.series.push({
name: 'Raw',
data: raw,
type: 'line',
showSymbol: false,
lineStyle: {
color: '#ffffff',
},
});
// Calculate equalized curve
const equalizedRaw = dataSet[1].map((value, index) => value + (raw as any)[index]);
ops.series.push({
name: 'Equalized',
data: equalizedRaw,
type: 'line',
showSymbol: false,
lineStyle: {
color: '#23d2fe',
width: 6,
opacity: 0.7,
},
});
}
myChart.setOption(ops, true);
};
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b));
// Re-render chart when band changes
if (peqItems.length > 0 && headphoneIdx < peqItems.length) {
const currentPeq = peqItems[headphoneIdx];
renderCharts(bands, null, false);
}
};
const totalGain = bands.reduce((sum, b) => sum + (b.enabled ? b.gain : 0), 0);
+51 -9
View File
@@ -24,6 +24,7 @@ import {
ChevronDown,
Zap,
Wifi,
Bluetooth,
} from "lucide-react";
import { useLocation } from "wouter";
import { useRef, useState, useEffect, useCallback } from "react";
@@ -110,17 +111,13 @@ export default function Home() {
const vuLabel = `VU表${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0;
// Slider fill %
const fillPct = localVol;
// Slider fill % (0-200 → 0-100%)
const fillPct = (localVol / 200) * 100;
return (
<div className="min-h-screen bg-black flex flex-col">
{/* ── Top bar ── */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<button className="flex items-center gap-1.5 text-white">
<span className="text-[18px] font-bold tracking-tight">Luxsin X9</span>
<ChevronDown size={16} className="text-white/50" />
</button>
<div className="flex items-center justify-end px-4 pt-4 pb-2">
<button onClick={() => setLocation("/system")}
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.6)" }}>
@@ -195,9 +192,13 @@ export default function Home() {
</button>
<div className="flex-1 relative flex items-center">
{/* Track fill */}
{/* Track fill - 根据按钮位置调整 */}
<div className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{ width: `${fillPct}%`, background: "#00FFF6", boxShadow: "0 0 8px rgba(0,255,246,0.5)" }} />
style={{
width: `${fillPct}%`,
background: "#00FFF6",
boxShadow: "0 0 8px rgba(0,255,246,0.5)"
}} />
<input
type="range" min={0} max={200} value={localVol}
className="cyan-slider relative z-10"
@@ -224,6 +225,47 @@ export default function Home() {
{volToDB(localVol)}
</span>
</div>
{/* Bluetooth info */}
{ds.bt_status !== 0 && (
<div className="mt-3 px-3 py-3 text-center">
<div className="flex items-center justify-center gap-1.5 mb-1">
<Bluetooth size={14} className="text-[#00FFF6]" />
<span className="text-[11px] text-white/50">{ds.bt_srcname}</span>
</div>
{ds.bt_title && (
<p className="text-[13px] text-white font-medium truncate mb-2">
{ds.bt_title}
{ds.bt_artist && <span className="text-white/50"> - {ds.bt_artist}</span>}
</p>
)}
{/* Playback controls */}
<div className="flex items-center justify-center gap-4 mt-2">
<button
onClick={() => updateSetting({ bt_next: 0 })}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
<img src="/player/skip-forward.png" alt="上一首" className="w-5 h-5" />
</button>
<button
onClick={() => updateSetting({ bt_play: 1 })}
className="w-12 h-12 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "transparent", border: "1px solid rgba(0,255,246,0.5)", boxShadow: "0 0 12px rgba(0,255,246,0.4)" }}>
<img
src={ds.bt_status === 1 ? "/player/play.png" : "/player/pause.png"}
alt={ds.bt_status === 1 ? "播放" : "暂停"}
className="w-6 h-6"
/>
</button>
<button
onClick={() => updateSetting({ bt_next: 1 })}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{ background: "rgba(0,255,246,0.15)", border: "1px solid rgba(0,255,246,0.3)" }}>
<img src="/player/skip-back.png" alt="下一首" className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
{/* ── 4 quick-action circle buttons ── */}
+5 -40
View File
@@ -13,48 +13,14 @@ import { useLocation } from "wouter";
interface VUStyle {
index: number;
name: string;
description: string;
preview: string;
}
const VU_STYLES: VUStyle[] = [
{
index: 0,
name: "经典复古",
description: "琥珀色指针表盘,经典模拟风格",
preview: "/vu/vu1.png",
},
{
index: 1,
name: "现代平面",
description: "LED 电平条,简洁现代风格",
preview: "/vu/vu2.png",
},
{
index: 2,
name: "数字显示",
description: "数字电平显示,精准直观",
preview: "/vu/vu3.png",
},
{
index: 3,
name: "条形图",
description: "垂直条形图,专业风格",
preview: "/vu/vu4.png",
},
{
index: 4,
name: "渐变光谱",
description: "彩虹渐变光谱,炫酷动感",
preview: "/vu/vu5.png",
},
{
index: 5,
name: "频谱分析",
description: "实时频谱分析,专业监测",
preview: "/vu/vu6.png",
},
];
const VU_STYLES: VUStyle[] = Array.from({ length: 16 }, (_, i) => ({
index: i,
name: `VU ${i + 1}`,
preview: `/vu/vu${i + 1}.png`,
}));
export default function VUPage() {
const [, setLocation] = useLocation();
@@ -112,7 +78,6 @@ export default function VUPage() {
<p className={cn("text-[15px] font-medium", isActive ? "text-[#00FFF6]" : "text-white/60")}>
{style.name}
</p>
<p className="text-[12px] text-white/35 mt-0.5">{style.description}</p>
</div>
</div>
);