278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
/*
|
|
* Veskslop daemon — web UI preferences.
|
|
*
|
|
* Stored next to the token file, not in the puppet state, so the web UI can
|
|
* read and write them while the Discord client is disconnected.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
|
|
export interface TtsPreferences {
|
|
provider: "local" | "espeak" | "command" | "api" | null;
|
|
language: string | null;
|
|
voice: string | null;
|
|
/** Speech-rate multiplier, 1.0 is the engine's natural rate. Null uses the engine default. */
|
|
speed: number | null;
|
|
/** Pitch offset in semitones, 0 is the engine's natural pitch. Null uses the engine default. */
|
|
pitch: number | null;
|
|
/** Flextalk Expression profile value, 0 to 100. Null keeps the engine's registry default. */
|
|
expression: number | null;
|
|
/** Flextalk profile overrides (only the keys the user changed). */
|
|
profile: Record<string, number>;
|
|
/** Five saved profile slots; null means empty. `name` labels the slot. */
|
|
profilePresets: Array<Record<string, number | string> | null>;
|
|
/** High-pass filter Hz, 40-400. Null uses the engine default (40). */
|
|
highpass: number | null;
|
|
/** Mono echo amount, 0-100. Null or 0 disables. */
|
|
echo: number | null;
|
|
/** Synthesize each word and merge with silence gaps (Flextalk only). */
|
|
words: boolean;
|
|
/** Silence between words in milliseconds, 0-500. */
|
|
wordGapMs: number | null;
|
|
}
|
|
|
|
const PROFILE_RANGES: Record<string, [number, number]> = {
|
|
pitch: [0, 255],
|
|
rate: [0, 100],
|
|
volume: [0, 65535],
|
|
expression: [0, 100],
|
|
prominences: [0, 100],
|
|
head_size: [0, 100],
|
|
lowering: [0, 100]
|
|
};
|
|
|
|
function normalizeProfile(value: unknown): Record<string, number> {
|
|
const profile: Record<string, number> = {};
|
|
if (!value || typeof value !== "object") return profile;
|
|
|
|
for (const [key, range] of Object.entries(PROFILE_RANGES)) {
|
|
const raw = (value as Record<string, unknown>)[key];
|
|
if (isUnset(raw)) continue;
|
|
const number = Number(raw);
|
|
if (!Number.isFinite(number) || number < range[0] || number > range[1]) continue;
|
|
profile[key] = Math.round(number);
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
function normalizePreset(value: unknown): Record<string, number | string> | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
|
|
const preset: Record<string, number | string> = normalizeProfile(value);
|
|
const name = (value as Record<string, unknown>).name;
|
|
if (typeof name === "string" && name.trim()) preset.name = name.trim().slice(0, 40);
|
|
return preset;
|
|
}
|
|
|
|
export interface FavouritesPreferences {
|
|
users: string[];
|
|
channels: string[];
|
|
}
|
|
|
|
export interface AudioPreferences {
|
|
/** Duck the TTS while someone else speaks. */
|
|
duck: boolean;
|
|
}
|
|
|
|
export interface WebPreferences {
|
|
/** null means every guild is visible. An empty array hides all. */
|
|
visibleGuilds: string[] | null;
|
|
tts: TtsPreferences;
|
|
audio: AudioPreferences;
|
|
favourites: FavouritesPreferences;
|
|
}
|
|
|
|
const DEFAULTS: WebPreferences = {
|
|
visibleGuilds: null,
|
|
tts: {
|
|
provider: null,
|
|
language: null,
|
|
voice: null,
|
|
speed: null,
|
|
pitch: null,
|
|
expression: null,
|
|
profile: {},
|
|
profilePresets: [null, null, null, null, null],
|
|
highpass: null,
|
|
echo: null,
|
|
words: false,
|
|
wordGapMs: null
|
|
},
|
|
audio: { duck: true },
|
|
favourites: { users: [], channels: [] }
|
|
};
|
|
|
|
export interface PreferencesOptions {
|
|
/** The old state file that may still hold preferences. */
|
|
legacyFile?: string;
|
|
}
|
|
|
|
function normalizeProvider(value: unknown): TtsPreferences["provider"] {
|
|
return value === "api" || value === "local" || value === "espeak" || value === "command" ? value : null;
|
|
}
|
|
|
|
function isUnset(value: unknown): boolean {
|
|
return value === null || value === undefined || value === "";
|
|
}
|
|
|
|
function normalizeSpeed(value: unknown): number | null {
|
|
if (isUnset(value)) return null;
|
|
const speed = Number(value);
|
|
if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) return null;
|
|
return Math.round(speed * 100) / 100;
|
|
}
|
|
|
|
function normalizePitch(value: unknown): number | null {
|
|
if (isUnset(value)) return null;
|
|
const pitch = Number(value);
|
|
if (!Number.isFinite(pitch) || pitch < -12 || pitch > 12) return null;
|
|
return Math.round(pitch * 10) / 10;
|
|
}
|
|
|
|
function normalizeExpression(value: unknown): number | null {
|
|
if (isUnset(value)) return null;
|
|
const expression = Number(value);
|
|
if (!Number.isFinite(expression) || expression < 0 || expression > 100) return null;
|
|
return Math.round(expression);
|
|
}
|
|
|
|
function normalizeRange(value: unknown, min: number, max: number): number | null {
|
|
if (isUnset(value)) return null;
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number) || number < min || number > max) return null;
|
|
return Math.round(number);
|
|
}
|
|
|
|
export class PreferencesStore {
|
|
private preferences: WebPreferences = structuredClone(DEFAULTS);
|
|
|
|
constructor(
|
|
private readonly file = process.env.VESKSLOP_PREFERENCES ?? ".veskslop-preferences.json",
|
|
options: PreferencesOptions = {}
|
|
) {
|
|
try {
|
|
if (existsSync(this.file)) {
|
|
this.preferences = this.normalize(JSON.parse(readFileSync(this.file, "utf8")));
|
|
} else {
|
|
this.migrate(options.legacyFile);
|
|
}
|
|
} catch (error) {
|
|
console.warn("[preferences] could not read", this.file, (error as Error).message);
|
|
}
|
|
}
|
|
|
|
get(): WebPreferences {
|
|
return structuredClone(this.preferences);
|
|
}
|
|
|
|
update(patch: Partial<WebPreferences>): WebPreferences {
|
|
if (patch.visibleGuilds !== undefined) {
|
|
this.preferences.visibleGuilds = Array.isArray(patch.visibleGuilds)
|
|
? [...new Set(patch.visibleGuilds.map(String))]
|
|
: null;
|
|
}
|
|
|
|
if (patch.tts !== undefined) {
|
|
this.preferences.tts = {
|
|
provider: normalizeProvider(patch.tts?.provider),
|
|
language: patch.tts?.language ? String(patch.tts.language).slice(0, 16) : null,
|
|
voice: patch.tts?.voice ? String(patch.tts.voice).slice(0, 64) : null,
|
|
speed: normalizeSpeed(patch.tts?.speed),
|
|
pitch: normalizePitch(patch.tts?.pitch),
|
|
expression: normalizeExpression(patch.tts?.expression),
|
|
profile: normalizeProfile(patch.tts?.profile),
|
|
profilePresets: Array.isArray(patch.tts?.profilePresets)
|
|
? Array.from({ length: 5 }, (_, index) => normalizePreset(patch.tts?.profilePresets?.[index]))
|
|
: this.preferences.tts.profilePresets,
|
|
highpass: normalizeRange(patch.tts?.highpass, 40, 400),
|
|
echo: normalizeRange(patch.tts?.echo, 0, 100),
|
|
words: patch.tts?.words === true,
|
|
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500)
|
|
};
|
|
}
|
|
|
|
if (patch.audio !== undefined) {
|
|
this.preferences.audio = {
|
|
duck: patch.audio?.duck !== false
|
|
};
|
|
}
|
|
|
|
if (patch.favourites !== undefined) {
|
|
this.preferences.favourites = {
|
|
users: Array.isArray(patch.favourites?.users)
|
|
? [...new Set(patch.favourites.users.map(String))].slice(0, 100)
|
|
: [],
|
|
channels: Array.isArray(patch.favourites?.channels)
|
|
? [...new Set(patch.favourites.channels.map(String))].slice(0, 100)
|
|
: []
|
|
};
|
|
}
|
|
|
|
this.save();
|
|
return this.get();
|
|
}
|
|
|
|
private migrate(legacyFile?: string) {
|
|
if (!legacyFile || !existsSync(legacyFile)) return;
|
|
|
|
try {
|
|
const legacy = JSON.parse(readFileSync(legacyFile, "utf8"));
|
|
if (legacy?.preferences) {
|
|
this.preferences = this.normalize(legacy.preferences);
|
|
this.save();
|
|
console.log("[preferences] migrated from", legacyFile);
|
|
}
|
|
} catch {
|
|
// nothing to migrate
|
|
}
|
|
}
|
|
|
|
private normalize(raw: any): WebPreferences {
|
|
const provider = raw?.tts?.provider;
|
|
|
|
return {
|
|
visibleGuilds: Array.isArray(raw?.visibleGuilds)
|
|
? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))]
|
|
: null,
|
|
tts: {
|
|
provider: normalizeProvider(provider),
|
|
language: raw?.tts?.language ? String(raw.tts.language).slice(0, 16) : null,
|
|
voice: raw?.tts?.voice ? String(raw.tts.voice).slice(0, 64) : null,
|
|
speed: normalizeSpeed(raw?.tts?.speed),
|
|
pitch: normalizePitch(raw?.tts?.pitch),
|
|
expression: normalizeExpression(raw?.tts?.expression),
|
|
profile: normalizeProfile(raw?.tts?.profile),
|
|
profilePresets: Array.isArray(raw?.tts?.profilePresets)
|
|
? Array.from({ length: 5 }, (_, index) => normalizePreset(raw.tts.profilePresets[index]))
|
|
: [null, null, null, null, null],
|
|
highpass: normalizeRange(raw?.tts?.highpass, 40, 400),
|
|
echo: normalizeRange(raw?.tts?.echo, 0, 100),
|
|
words: raw?.tts?.words === true,
|
|
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500)
|
|
},
|
|
audio: {
|
|
duck: raw?.audio?.duck !== false
|
|
},
|
|
favourites: {
|
|
users: Array.isArray(raw?.favourites?.users)
|
|
? [...new Set<string>(raw.favourites.users.map((id: unknown) => String(id)))].slice(0, 100)
|
|
: [],
|
|
channels: Array.isArray(raw?.favourites?.channels)
|
|
? [...new Set<string>(raw.favourites.channels.map((id: unknown) => String(id)))].slice(0, 100)
|
|
: []
|
|
}
|
|
};
|
|
}
|
|
|
|
private save() {
|
|
try {
|
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
writeFileSync(this.file, JSON.stringify(this.preferences, null, 4));
|
|
} catch (error) {
|
|
console.error("[preferences] write failed:", error);
|
|
}
|
|
}
|
|
}
|