/* * 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; /** Five saved profile slots; null means empty. `name` labels the slot. */ profilePresets: Array | 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 = { 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 { const profile: Record = {}; if (!value || typeof value !== "object") return profile; for (const [key, range] of Object.entries(PROFILE_RANGES)) { const raw = (value as Record)[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 | null { if (!value || typeof value !== "object") return null; const preset: Record = normalizeProfile(value); const name = (value as Record).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 { 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(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(raw.favourites.users.map((id: unknown) => String(id)))].slice(0, 100) : [], channels: Array.isArray(raw?.favourites?.channels) ? [...new Set(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); } } }