听力补偿新增左右耳曲线
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import {
|
||||||
|
HEARING_FREQ_LABELS,
|
||||||
|
buildHearingSplineSeries,
|
||||||
|
} from "@/lib/hearingCurve";
|
||||||
|
|
||||||
|
const LEFT_COLOR = "rgb(0, 255, 246)";
|
||||||
|
const RIGHT_COLOR = "#FF5A36";
|
||||||
|
|
||||||
|
type HearingCompensationChartProps = {
|
||||||
|
left: number[];
|
||||||
|
right: number[];
|
||||||
|
leftLabel?: string;
|
||||||
|
rightLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HearingCompensationChart({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
leftLabel = "Left",
|
||||||
|
rightLabel = "Right",
|
||||||
|
className,
|
||||||
|
}: HearingCompensationChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<echarts.ECharts | null>(null);
|
||||||
|
|
||||||
|
const leftSeries = useMemo(() => buildHearingSplineSeries(left), [left]);
|
||||||
|
const rightSeries = useMemo(() => buildHearingSplineSeries(right), [right]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const option: echarts.EChartsOption = {
|
||||||
|
animation: false,
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
top: 8,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: leftLabel,
|
||||||
|
icon: "circle",
|
||||||
|
textStyle: { color: LEFT_COLOR },
|
||||||
|
itemStyle: { color: LEFT_COLOR, borderColor: LEFT_COLOR },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: rightLabel,
|
||||||
|
icon: "circle",
|
||||||
|
textStyle: { color: RIGHT_COLOR },
|
||||||
|
itemStyle: { color: RIGHT_COLOR, borderColor: RIGHT_COLOR },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
textStyle: { fontSize: 13 },
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 48,
|
||||||
|
right: 36,
|
||||||
|
top: 48,
|
||||||
|
bottom: 28,
|
||||||
|
},
|
||||||
|
graphic: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
right: 10,
|
||||||
|
bottom: 6,
|
||||||
|
style: {
|
||||||
|
text: "Hz",
|
||||||
|
fill: "#999",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
z: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xAxis: {
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: HEARING_FREQ_LABELS.length - 1,
|
||||||
|
interval: 1,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => {
|
||||||
|
const idx = Math.round(value);
|
||||||
|
if (Math.abs(value - idx) > 1e-6) return "";
|
||||||
|
return HEARING_FREQ_LABELS[idx] ?? "";
|
||||||
|
},
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#999",
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: { type: "solid", color: "#3A4348" },
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: { color: "#3A4348" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
min: -80,
|
||||||
|
max: 0,
|
||||||
|
interval: 10,
|
||||||
|
name: "dB",
|
||||||
|
nameLocation: "end",
|
||||||
|
nameGap: 8,
|
||||||
|
nameTextStyle: {
|
||||||
|
color: "#999",
|
||||||
|
fontSize: 14,
|
||||||
|
align: "right",
|
||||||
|
padding: [0, 8, 0, 0],
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#999",
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: { type: "solid", color: "#3A4348" },
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: { color: "#3A4348" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: leftLabel,
|
||||||
|
type: "line",
|
||||||
|
data: leftSeries,
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: false,
|
||||||
|
lineStyle: { color: LEFT_COLOR, width: 2 },
|
||||||
|
itemStyle: { color: LEFT_COLOR },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: rightLabel,
|
||||||
|
type: "line",
|
||||||
|
data: rightSeries,
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: false,
|
||||||
|
lineStyle: { color: RIGHT_COLOR, width: 2 },
|
||||||
|
itemStyle: { color: RIGHT_COLOR },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!chartRef.current) {
|
||||||
|
chartRef.current = echarts.init(el, undefined, { renderer: "canvas" });
|
||||||
|
}
|
||||||
|
chartRef.current.setOption(option, true);
|
||||||
|
|
||||||
|
const onResize = () => chartRef.current?.resize();
|
||||||
|
const ro = new ResizeObserver(onResize);
|
||||||
|
ro.observe(el);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [leftSeries, rightSeries, leftLabel, rightLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.dispose();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={
|
||||||
|
className
|
||||||
|
?? "w-full h-[min(42vw,200px)] min-h-[140px] md:h-[260px] lg:h-[300px] rounded-lg bg-black/30"
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/** Hearing compensation curve helpers: map levels → dB and cubic-spline smooth. */
|
||||||
|
|
||||||
|
export const HEARING_FREQ_LABELS = ["125", "250", "500", "1k", "2k", "4k", "8k"] as const;
|
||||||
|
|
||||||
|
/** Y = n + (-70). Values outside [-80, 0] are kept as-is (axis still -80…0). */
|
||||||
|
export function mapHearingLevelToDb(n: number): number {
|
||||||
|
return n - 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natural cubic spline for monotonically spaced `xs`.
|
||||||
|
* Returns an interpolator defined on [xs[0], xs[n-1]] (extrapolates flat at ends).
|
||||||
|
*/
|
||||||
|
export function createNaturalCubicSpline(
|
||||||
|
xs: number[],
|
||||||
|
ys: number[],
|
||||||
|
): (x: number) => number {
|
||||||
|
const n = xs.length;
|
||||||
|
if (n === 0) return () => 0;
|
||||||
|
if (n === 1) return () => ys[0]!;
|
||||||
|
if (n === 2) {
|
||||||
|
const x0 = xs[0]!;
|
||||||
|
const x1 = xs[1]!;
|
||||||
|
const y0 = ys[0]!;
|
||||||
|
const y1 = ys[1]!;
|
||||||
|
return (x: number) => {
|
||||||
|
if (x1 === x0) return y0;
|
||||||
|
const t = (x - x0) / (x1 - x0);
|
||||||
|
return y0 + t * (y1 - y0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = new Array<number>(n - 1);
|
||||||
|
for (let i = 0; i < n - 1; i++) h[i] = xs[i + 1]! - xs[i]!;
|
||||||
|
|
||||||
|
const alpha = new Array<number>(n).fill(0);
|
||||||
|
for (let i = 1; i < n - 1; i++) {
|
||||||
|
alpha[i] =
|
||||||
|
(3 / h[i]!) * (ys[i + 1]! - ys[i]!) -
|
||||||
|
(3 / h[i - 1]!) * (ys[i]! - ys[i - 1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const l = new Array<number>(n).fill(1);
|
||||||
|
const mu = new Array<number>(n).fill(0);
|
||||||
|
const z = new Array<number>(n).fill(0);
|
||||||
|
|
||||||
|
for (let i = 1; i < n - 1; i++) {
|
||||||
|
l[i] = 2 * (xs[i + 1]! - xs[i - 1]!) - h[i - 1]! * mu[i - 1]!;
|
||||||
|
mu[i] = h[i]! / l[i]!;
|
||||||
|
z[i] = (alpha[i]! - h[i - 1]! * z[i - 1]!) / l[i]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = new Array<number>(n).fill(0);
|
||||||
|
const b = new Array<number>(n - 1).fill(0);
|
||||||
|
const d = new Array<number>(n - 1).fill(0);
|
||||||
|
|
||||||
|
for (let j = n - 2; j >= 0; j--) {
|
||||||
|
c[j] = z[j]! - mu[j]! * c[j + 1]!;
|
||||||
|
b[j] =
|
||||||
|
(ys[j + 1]! - ys[j]!) / h[j]! -
|
||||||
|
(h[j]! * (c[j + 1]! + 2 * c[j]!)) / 3;
|
||||||
|
d[j] = (c[j + 1]! - c[j]!) / (3 * h[j]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (x: number) => {
|
||||||
|
let i = 0;
|
||||||
|
if (x <= xs[0]!) {
|
||||||
|
i = 0;
|
||||||
|
} else if (x >= xs[n - 1]!) {
|
||||||
|
i = n - 2;
|
||||||
|
} else {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = n - 2;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (x < xs[mid]!) {
|
||||||
|
hi = mid - 1;
|
||||||
|
} else if (x > xs[mid + 1]!) {
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
i = mid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (x > xs[i + 1]!) i = Math.min(n - 2, lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = x - xs[i]!;
|
||||||
|
return ys[i]! + b[i]! * dx + c[i]! * dx * dx + d[i]! * dx * dx * dx;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build densely sampled [xIndex, dB] pairs for a smooth ECharts line.
|
||||||
|
* X uses equal spacing 0…6 matching HEARING_FREQ_LABELS.
|
||||||
|
*/
|
||||||
|
export function buildHearingSplineSeries(
|
||||||
|
levels: number[],
|
||||||
|
samplesPerSegment = 16,
|
||||||
|
): [number, number][] {
|
||||||
|
const xs = levels.map((_, i) => i);
|
||||||
|
const ys = levels.map(mapHearingLevelToDb);
|
||||||
|
const spline = createNaturalCubicSpline(xs, ys);
|
||||||
|
const maxX = xs[xs.length - 1] ?? 0;
|
||||||
|
const steps = Math.max(1, maxX * samplesPerSegment);
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
const x = (s / steps) * maxX;
|
||||||
|
points.push([x, spline(x)]);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
@@ -113,7 +113,9 @@
|
|||||||
},
|
},
|
||||||
"hearing": {
|
"hearing": {
|
||||||
"label": "Hörkompensation",
|
"label": "Hörkompensation",
|
||||||
"emptyProfile": "Kein Profil verfügbar"
|
"emptyProfile": "Kein Profil verfügbar",
|
||||||
|
"left": "Links",
|
||||||
|
"right": "Rechts"
|
||||||
},
|
},
|
||||||
"subwoofer": {
|
"subwoofer": {
|
||||||
"label": "Subwoofer",
|
"label": "Subwoofer",
|
||||||
|
|||||||
@@ -113,7 +113,9 @@
|
|||||||
},
|
},
|
||||||
"hearing": {
|
"hearing": {
|
||||||
"label": "Hearing compensation",
|
"label": "Hearing compensation",
|
||||||
"emptyProfile": "No profile available"
|
"emptyProfile": "No profile available",
|
||||||
|
"left": "Left",
|
||||||
|
"right": "Right"
|
||||||
},
|
},
|
||||||
"subwoofer": {
|
"subwoofer": {
|
||||||
"label": "Subwoofer",
|
"label": "Subwoofer",
|
||||||
|
|||||||
@@ -113,7 +113,9 @@
|
|||||||
},
|
},
|
||||||
"hearing": {
|
"hearing": {
|
||||||
"label": "Compensación auditiva",
|
"label": "Compensación auditiva",
|
||||||
"emptyProfile": "No hay perfil disponible"
|
"emptyProfile": "No hay perfil disponible",
|
||||||
|
"left": "Izquierda",
|
||||||
|
"right": "Derecha"
|
||||||
},
|
},
|
||||||
"subwoofer": {
|
"subwoofer": {
|
||||||
"label": "Subwoofer",
|
"label": "Subwoofer",
|
||||||
|
|||||||
@@ -113,7 +113,9 @@
|
|||||||
},
|
},
|
||||||
"hearing": {
|
"hearing": {
|
||||||
"label": "Compensation auditive",
|
"label": "Compensation auditive",
|
||||||
"emptyProfile": "Aucun profil disponible"
|
"emptyProfile": "Aucun profil disponible",
|
||||||
|
"left": "Gauche",
|
||||||
|
"right": "Droite"
|
||||||
},
|
},
|
||||||
"subwoofer": {
|
"subwoofer": {
|
||||||
"label": "Caisson de basses",
|
"label": "Caisson de basses",
|
||||||
|
|||||||
@@ -106,7 +106,9 @@
|
|||||||
},
|
},
|
||||||
"hearing": {
|
"hearing": {
|
||||||
"label": "聽力補償",
|
"label": "聽力補償",
|
||||||
"emptyProfile": "無可用配置"
|
"emptyProfile": "無可用配置",
|
||||||
|
"left": "左耳",
|
||||||
|
"right": "右耳"
|
||||||
},
|
},
|
||||||
"subwoofer": {
|
"subwoofer": {
|
||||||
"label": "低音炮輸出",
|
"label": "低音炮輸出",
|
||||||
|
|||||||
@@ -106,7 +106,9 @@
|
|||||||
},
|
},
|
||||||
"hearing": {
|
"hearing": {
|
||||||
"label": "听力补偿",
|
"label": "听力补偿",
|
||||||
"emptyProfile": "无可用配置"
|
"emptyProfile": "无可用配置",
|
||||||
|
"left": "左耳",
|
||||||
|
"right": "右耳"
|
||||||
},
|
},
|
||||||
"subwoofer": {
|
"subwoofer": {
|
||||||
"label": "低音炮输出",
|
"label": "低音炮输出",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useLocation } from "wouter";
|
|||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
import SubwooferLpfChart from "@/components/SubwooferLpfChart";
|
import SubwooferLpfChart from "@/components/SubwooferLpfChart";
|
||||||
|
import HearingCompensationChart from "@/components/HearingCompensationChart";
|
||||||
import { FeatureGate } from "@/components/FeatureGate";
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { navigateToSelect } from "./SelectPage";
|
import { navigateToSelect } from "./SelectPage";
|
||||||
import { parseFirmwareVersion, parseHearingData } from "@/lib/luxsinApi";
|
import { parseFirmwareVersion, parseHearingData } from "@/lib/luxsinApi";
|
||||||
@@ -914,6 +915,15 @@ export default function EffectsPage() {
|
|||||||
<span className="text-[14px] text-white/45">{hearingDisplay}</span>
|
<span className="text-[14px] text-white/45">{hearingDisplay}</span>
|
||||||
<ChevronRight size={16} className="ios-chevron" />
|
<ChevronRight size={16} className="ios-chevron" />
|
||||||
</button>
|
</button>
|
||||||
|
{hearingOn && hearingProfiles[hearingIdx] && (
|
||||||
|
<HearingCompensationChart
|
||||||
|
className="mt-3 w-full h-[min(42vw,200px)] min-h-[140px] md:h-[260px] lg:h-[300px] rounded-lg bg-black/30"
|
||||||
|
left={hearingProfiles[hearingIdx].l}
|
||||||
|
right={hearingProfiles[hearingIdx].r}
|
||||||
|
leftLabel={effectText.hearing?.left ?? "Left"}
|
||||||
|
rightLabel={effectText.hearing?.right ?? "Right"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
|
|||||||
Reference in New Issue
Block a user