// ============================================================ // LUXSIN X9 API — Client Library // Custom Base64 decode + HTTP API wrappers // ============================================================ const ALPHABET_CUSTOM = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/"; const ALPHABET_STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; // ============================================================ // Volume Conversion: dB (-100 to 0) <-> Device Value (0 to 200) // ============================================================ export function dbToVolume(db: number): number { // Formula: volume = 2 * db + 200 // e.g., -56dB -> 2 * (-56) + 200 = 88 return Math.round(2 * db + 200); } export function volumeToDb(vol: number): number { // Formula: db = (vol - 200) / 2 // e.g., 88 -> (88 - 200) / 2 = -56dB return (vol - 200) / 2; } export function decodeCustomBase64(encoded: string): string { let translated = ""; for (let i = 0; i < encoded.length; i++) { const char = encoded.charAt(i); const index = ALPHABET_CUSTOM.indexOf(char); translated += index !== -1 ? ALPHABET_STANDARD.charAt(index) : char; } const decoded = atob(translated); const bytes = new Uint8Array(decoded.length); for (let i = 0; i < decoded.length; i++) { bytes[i] = decoded.charCodeAt(i); } return new TextDecoder("utf-8").decode(bytes); } export function encodeCustomBase64(data: string): string { // btoa only supports Latin1; convert UTF-8 bytes to a binary string first. const bytes = new TextEncoder().encode(data); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } const standard = btoa(binary); let translated = ""; for (let i = 0; i < standard.length; i++) { const char = standard.charAt(i); const index = ALPHABET_STANDARD.indexOf(char); translated += index !== -1 ? ALPHABET_CUSTOM.charAt(index) : char; } return translated; } // ============================================================ // Device State Type // ============================================================ export interface DeviceState { device: string; version: string; mac: string; language: number; volume: number; soundStep: number; input: number; output: number; audioFormat: string; pcm: number; hdmimutepolar: number; hdmiType: number; vu: number; vuSensor: number; vu_count: number; screenLight: number; knob_breathlight: number; buttonLight: number; buttonShort: number; screenOff: number; sleep: number; autoHome: number; bootSound: number; dsp_enable: number; audio_enable: number; peqEnable: number; peqSelect: number; balance: number; xlr: number; dacGain: number; dacArc: number; dacImpedance: number; dacVolumeDirect: number; analogGain: number; effect_enable: number; effect_value: number; width_enable: number; width_value: number; scene_enable: number; scene_value: number; crossfeed_enable: number; crossfeed_value: number; subwoofer_enable: number; subwoofer_value: number; subwoofer_rate: number; subwoofer_gain: number; loudness_enable: number; loudness_bass_gain: number; loudness_treble_gain: number; loudness_threshold_gain: number; bt_status: number; bt_srcname: string; bt_title: string; bt_artist: string; msgCount: number; } export interface PeqFilter { fc: number; gain: number; q: number; type: number; } export interface PeqState { filters: PeqFilter[]; peqSelect?: number; peq?: Array<{ name: string; filters?: PeqFilter[] | string; autoPre?: number; preamp?: number; canDel?: number; brand?: string; model?: string; }>; } /** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */ export interface PeqChangePayload { peqChange: { name: string; filters: PeqFilter[]; autoPre?: number; preamp?: number; canDel?: number; brand?: string; model?: string; target?: string; form?: string; }; } // ============================================================ // Input/Output Labels // ============================================================ export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA"]; export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"]; export const LANGUAGE_LABELS = ["English", "繁體中文", "简体中文"]; export const SCREEN_LIGHT_LABELS = ["Bright", "Medium", "Dark"]; export const KNOB_LIGHT_LABELS = ["Off", "Bright", "Medium", "Dark"]; export const SCREEN_OFF_LABELS = ["Off", "30s", "1 min", "3 min", "5 min"]; export const SLEEP_LABELS = ["Off", "1 min", "5 min", "10 min"]; export const AUTO_HOME_LABELS = ["Off", "20s", "40s", "60s"]; export const FILTER_TYPE_LABELS = ["Low Pass", "High Pass", "Band Pass", "Notch", "Peak", "Low Shelf", "High Shelf", "All Pass"]; export const DAC_ARC_LABELS = ["ARC", "eARC"]; export const BT_STATUS_LABELS = ["Disconnected", "Playing", "Paused"]; // ============================================================ // API Class // ============================================================ export class LuxsinAPI { private baseUrl: string; constructor(ip: string) { this.baseUrl = `http://${ip}`; } async getDeviceState(): Promise { const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`); const text = await response.text(); const decoded = decodeCustomBase64(text.trim()); return JSON.parse(decoded) as DeviceState; } async getMsgCount(): Promise { const response = await fetch(`${this.baseUrl}/msgCount`); const text = await response.text(); return parseInt(text.trim(), 10); } async setSetting(params: Record): Promise { const query = Object.entries(params) .map(([k, v]) => `${k}=${v}`) .join("&"); await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`); } async getPeqState(): Promise { const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`); const text = await response.text(); const decoded = decodeCustomBase64(text.trim()); return JSON.parse(decoded) as PeqState; } async setPeqFilters(filters: PeqFilter[]): Promise { const payload = JSON.stringify({ peqChange: { filters } }); const encoded = encodeCustomBase64(payload); await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${encodeURIComponent(encoded)}`, }); } /** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */ async upgradePeqChange(body: PeqChangePayload): Promise { const payload = JSON.stringify(body); const encoded = encodeCustomBase64(payload); await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${encodeURIComponent(encoded)}`, }); } /** Remove one or more headphone PEQ profiles. */ async removePeq(names: string[]): Promise { const payload = JSON.stringify({ peqRemove: names }); const encoded = encodeCustomBase64(payload); await fetch(`${this.baseUrl}/dev/info.cgi`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: `json=${encodeURIComponent(encoded)}`, }); } setVolume(volume: number) { return this.setSetting({ volume }); } setInput(input: number) { return this.setSetting({ input }); } setOutput(output: number) { return this.setSetting({ output }); } setBalance(balance: number) { return this.setSetting({ balance }); } setLanguage(language: number) { return this.setSetting({ language }); } setDspEnable(enable: boolean) { return this.setSetting({ dsp_enable: enable ? 0 : 1 }); } setAudioEnable(enable: boolean) { return this.setSetting({ audio_enable: enable ? 1 : 0 }); } setPeqEnable(enable: boolean) { return this.setSetting({ peqEnable: enable ? 1 : 0 }); } setPeqSelect(index: number) { return this.setSetting({ peqSelect: index }); } setVu(vu: number) { return this.setSetting({ vu }); } setScreenLight(level: number) { return this.setSetting({ screenLight: level }); } setKnobLight(level: number) { return this.setSetting({ knob_breathlight: level }); } setButtonLight(on: boolean) { return this.setSetting({ buttonLight: on ? 0 : 1 }); } setScreenOff(timer: number) { return this.setSetting({ screenOff: timer }); } setSleep(timer: number) { return this.setSetting({ sleep: timer }); } setAutoHome(timer: number) { return this.setSetting({ autoHome: timer }); } setLoudnessEnable(enable: boolean) { return this.setSetting({ loudness_enable: enable ? 1 : 0 }); } setSubwooferEnable(enable: boolean) { return this.setSetting({ subwoofer_enable: enable ? 1 : 0 }); } btPlayPause() { return this.setSetting({ bt_play: 1 }); } btNext() { return this.setSetting({ bt_next: 1 }); } btPrev() { return this.setSetting({ bt_next: 0 }); } setEffectEnable(enable: boolean) { return this.setSetting({ effect_enable: enable ? 1 : 0 }); } setWidthEnable(enable: boolean) { return this.setSetting({ width_enable: enable ? 1 : 0 }); } setSceneEnable(enable: boolean) { return this.setSetting({ scene_enable: enable ? 1 : 0 }); } setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); } setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); } setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); } setBootSound(on: boolean) { return this.setSetting({ bootSound: on ? 1 : 0 }); } } // ============================================================ // Mock data for demo/offline mode // ============================================================ export const MOCK_DEVICE_STATE: DeviceState = { device: "Luxsin-X9", version: "1.2.3", mac: "AA:BB:CC:DD:EE:FF", language: 0, volume: 75, soundStep: 2, input: 0, output: 0, audioFormat: "PCM 44.1 KHz", pcm: 1, hdmimutepolar: 0, hdmiType: 0, vu: 0, vuSensor: 0, vu_count: 5, screenLight: 0, knob_breathlight: 1, buttonLight: 0, buttonShort: 0, screenOff: 2, sleep: 0, autoHome: 1, bootSound: 1, dsp_enable: 0, audio_enable: 1, peqEnable: 1, peqSelect: 0, balance: 0, xlr: 0, dacGain: 0, dacArc: 0, dacImpedance: 0, dacVolumeDirect: 0, analogGain: 0, effect_enable: 0, effect_value: 50, width_enable: 0, width_value: 50, scene_enable: 0, scene_value: 0, crossfeed_enable: 0, crossfeed_value: 0, subwoofer_enable: 0, subwoofer_value: 80, subwoofer_rate: 0, subwoofer_gain: 0, loudness_enable: 0, loudness_bass_gain: 3, loudness_treble_gain: 2, loudness_threshold_gain: 60, bt_status: 1, bt_srcname: "iPhone 15 Pro", bt_title: "Bohemian Rhapsody", bt_artist: "Queen", msgCount: 42, }; export const MOCK_PEQ_STATE: PeqState = { filters: [ { fc: 80, gain: 3.0, q: 0.7, type: 5 }, { fc: 250, gain: -1.5, q: 1.4, type: 4 }, { fc: 1000, gain: 0.0, q: 1.0, type: 4 }, { fc: 4000, gain: 2.0, q: 1.4, type: 4 }, { fc: 12000, gain: 1.5, q: 0.7, type: 6 }, ], }; // ============================================================ // Luxsin cloud audio catalog (brands / models) // Dev: proxied via Vite (`/luxsin-audio-api` → `/audio`) to avoid Origin-based 403. // ============================================================ export interface LuxsinAudioBrand { id: number; name: string; } export interface LuxsinAudioModel { id: number; name: string; form?: string; } export interface LuxsinAudioModelListItem { brandName: string; modelName: string; form?: string; source?: string; } export function buildLuxsinAudioUrl(resourcePath: string): string { const path = resourcePath.replace(/^\//, ""); if (import.meta.env.DEV) { return `/luxsin-audio-api/${path}`; } return `https://api.luxsin.com.cn/audio/${path}`; } /** Fetches `getBrand`; body is custom Base64 text, decodes to JSON array of `{ id, name }`. */ export async function fetchLuxsinAudioBrands(): Promise { const res = await fetch(buildLuxsinAudioUrl("getBrand")); if (!res.ok) { throw new Error(`getBrand HTTP ${res.status}`); } const body = (await res.text()).trim(); const json = decodeCustomBase64(body); const data = JSON.parse(json) as unknown; if (!Array.isArray(data)) { throw new Error("getBrand: response is not an array"); } return data .map((row) => { if (!row || typeof row !== "object") return null; const r = row as { id?: unknown; name?: unknown }; const name = typeof r.name === "string" ? r.name : ""; if (!name) return null; const id = typeof r.id === "number" ? r.id : Number(r.id); return { id: Number.isFinite(id) ? id : 0, name }; }) .filter((b): b is LuxsinAudioBrand => b !== null); } /** Fetches `getModel?brandName=...`; body is custom Base64 text, decodes to JSON array of model rows. */ export async function fetchLuxsinAudioModels(brandName: string): Promise { const brand = brandName.trim(); if (!brand) return []; const res = await fetch(buildLuxsinAudioUrl(`getModel?brandName=${encodeURIComponent(brand)}`)); if (!res.ok) { throw new Error(`getModel HTTP ${res.status}`); } const body = (await res.text()).trim(); const json = decodeCustomBase64(body); const data = JSON.parse(json) as unknown; if (!Array.isArray(data)) { throw new Error("getModel: response is not an array"); } return data .map((row) => { if (!row || typeof row !== "object") return null; const r = row as { id?: unknown; name?: unknown; form?: unknown }; const name = typeof r.name === "string" ? r.name : ""; if (!name) return null; const id = typeof r.id === "number" ? r.id : Number(r.id); const form = typeof r.form === "string" ? r.form : undefined; return { id: Number.isFinite(id) ? id : 0, name, ...(form ? { form } : {}), }; }) .filter((m): m is LuxsinAudioModel => m !== null); } /** Fetches `modelList?key=...&count=...`; body is custom Base64 text, decodes to brand+model rows. */ export async function fetchLuxsinAudioModelList( key: string, count = 1000, ): Promise { const q = key.trim(); if (!q) return []; const safeCount = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1000; const res = await fetch( buildLuxsinAudioUrl(`modelList?key=${encodeURIComponent(q)}&count=${safeCount}`), ); if (!res.ok) { throw new Error(`modelList HTTP ${res.status}`); } const body = (await res.text()).trim(); const json = decodeCustomBase64(body); const data = JSON.parse(json) as unknown; if (!Array.isArray(data)) { throw new Error("modelList: response is not an array"); } return data .map((row): LuxsinAudioModelListItem | null => { if (!row || typeof row !== "object") return null; const r = row as { brand_name?: unknown; name?: unknown; form?: unknown; source?: unknown; }; const brandName = typeof r.brand_name === "string" ? r.brand_name : ""; const modelName = typeof r.name === "string" ? r.name : ""; if (!brandName || !modelName) return null; const form = typeof r.form === "string" ? r.form : undefined; const source = typeof r.source === "string" ? r.source : undefined; return { brandName, modelName, ...(form ? { form } : {}), ...(source ? { source } : {}), }; }) .filter((item): item is LuxsinAudioModelListItem => item !== null); } /** Fetches `getCurve` and returns decoded text payload. */ export async function fetchLuxsinAudioCurve( brand: string, name: string, target: string, ): Promise { const brandName = encodeURIComponent(brand.trim()); const modelName = encodeURIComponent(name.trim()); const targetName = encodeURIComponent(target.trim()); const res = await fetch( buildLuxsinAudioUrl(`getCurve?brand=${brandName}&name=${modelName}&target=${targetName}`), ); if (!res.ok) { throw new Error(`getCurve HTTP ${res.status}`); } const body = (await res.text()).trim(); return decodeCustomBase64(body); }