Files
Veskslop-t/poc/src/preferences.ts
T

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);
}
}
}