Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls
This commit is contained in:
+145
-6
@@ -9,9 +9,61 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export interface TtsPreferences {
|
||||
provider: "local" | "api" | null;
|
||||
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 {
|
||||
@@ -19,16 +71,36 @@ export interface FavouritesPreferences {
|
||||
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 },
|
||||
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: [] }
|
||||
};
|
||||
|
||||
@@ -37,6 +109,42 @@ export interface PreferencesOptions {
|
||||
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);
|
||||
|
||||
@@ -68,9 +176,26 @@ export class PreferencesStore {
|
||||
|
||||
if (patch.tts !== undefined) {
|
||||
this.preferences.tts = {
|
||||
provider: patch.tts?.provider === "api" ? "api" : patch.tts?.provider === "local" ? "local" : null,
|
||||
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
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,9 +237,23 @@ export class PreferencesStore {
|
||||
? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))]
|
||||
: null,
|
||||
tts: {
|
||||
provider: provider === "api" || provider === "local" ? provider : null,
|
||||
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
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user