diff --git a/README.md b/README.md index 9e73e88..30023b0 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node | | GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d | | GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it | +| GET | `/api/admin/platform` | admin | this host facts: hostname, kernel, uptime, load, memory, IPs | | GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models | | POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) | | GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index dd61c4c..b6be4a3 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -82,10 +82,18 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`, copy button and dependency-free syntax highlighting (`lib/highlight.tsx` — regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched). - Admin Center tabs are deep-linkable (`/admin`, `/admin/models`, - `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves + `/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx` (live Hugging Face GGUF search + one-click `hf.co/…` pull to a node). + Stats tab: `StatsSection.tsx` (node/window pickers, bar/area/line dock, SVG + tooltips, platform host cards). +- Accent color (`lib/accent.ts`) derives the 50–950 ramp from one hex and + also repaints the SVG favicon (data URL) so the tab icon matches the dress. +- Voice (`lib/voice.ts`, no dependency): `Chat.tsx` composer mic with live + waveform + speech-recognition transcript (editable before send), per-bubble + TTS playback with glow, `Settings → Voice` prefs (voice/lang/speed/pitch, + localStorage), admin-editable preview lines. ## Theming diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 94e9054..5df0f6d 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -84,14 +84,16 @@ Notes: ## Admin Center (`/admin`) & self-registration The sidebar **Admin Center** button (admin only) opens the `/admin` page with -six deep-linkable tabs (`/admin` = accounts, `/admin/models`, `/admin/access`, -`/admin/reports`, `/admin/features`, `/admin/mail`): **Accounts** (same -manager as before), **Access** (access-request wishlist triage, see below), -**Reports**, **Models** (node pool, per-model policy, Ollama library + HF -catalog), **Features** (server-side switches), **Mail** (SMTP credentials -viewer + connectivity tester: `GET /api/admin/mail`, -`POST /api/admin/mail/verify`, `POST /api/admin/mail/test`, admin Bearer -only — plus the password-recovery rate limits below). +seven deep-linkable tabs (`/admin` = accounts, `/admin/models`, +`/admin/stats`, `/admin/access`, `/admin/reports`, `/admin/features`, +`/admin/mail`): **Accounts** (same manager as before), **Access** +(access-request wishlist triage, see below), **Reports**, **Models** (node +pool, per-model policy, Ollama library + HF catalog), **Stats** (pool load +charts + platform host facts, see below), **Features** (server-side +switches + voice-preview lines), **Mail** (SMTP credentials viewer + +connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`, +`POST /api/admin/mail/test`, admin Bearer only — plus the password-recovery +rate limits below). - `registration_enabled` (default off): when on, the login page shows a working **Register** button — anyone can create an account (key shown once) @@ -173,6 +175,34 @@ invalidate the model list cache; `listModels` merges tags across nodes. `GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`, `GET /api/admin/ollama/probe?host=…`. +## Telemetry sub-page (`/admin/stats`) + platform facts + +The **Stats** tab is the pool observatory: node picker, window picker +(1h / 6h / 24h / 7d / 30d), chart-type dock (bar / area / line SVG charts +with hover tooltips: requests, errors, avg latency, tokens), totals tiles, +and a latency underlay. Below, **platform host facts** for this server — +hostname, kernel, arch/CPU, app + system uptime, load average, memory bar, +runtime, network interfaces/IPs (`GET /api/admin/platform`, admin only). +Disk meters are intentionally absent (models live on the Ollama hosts). + +## Voice: dictation + playback + +- **Composer mic** (browser speech recognition — Chrome/Edge): records with + a live gradient waveform, timer and live transcript; **Stop** drops the + transcript into the composer, editable before sending; **Discard** throws + it away. Unsupported browsers get a disabled mic with an explanatory + tooltip. No audio leaves the browser (no server STT endpoint yet — + whisper.cpp via `WHISPER_BIN` is the documented next step). +- **Message playback**: every assistant bubble has a Listen button + (speechSynthesis, Siri-style breathing glow while speaking, click again to + stop; playback stops on send / new message / conversation switch). +- **Settings → Voice** (per browser, localStorage): language (auto-detect + per message via `detectLang`, or forced), voice picker grouped by locale, + speed, pitch, and one-tap previews of the admin-set lines. +- **Admin Center → Features → Voice previews**: the default playback lines + (`voice_previews` setting, 1–6 lines; empty list resets to the built-ins), + also served to users via `GET /api/tools`. + ## Personal providers (BYOK) Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini` diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 4a84bf8..0cfd029 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Plus, Send, ShieldAlert, SlidersHorizontal, Trash2, TrendingUp, Users } from "lucide-react"; -import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts"; +import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus, type VoicePreview } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { navigate } from "../lib/route.ts"; import { AdminPanel } from "./AdminPanel.tsx"; @@ -10,10 +10,10 @@ import { ModelsPanel } from "./ModelsPanel.tsx"; import { StatsSection } from "./StatsSection.tsx"; import { Toasts } from "./Toasts.tsx"; import { pushToast } from "../lib/toasts.ts"; -import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; +import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Picker, Spinner, Switch } from "./ui.tsx"; import { cn } from "../lib/cn.ts"; import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts"; -import { useT } from "../lib/i18n.ts"; +import { useT, type StringKey } from "../lib/i18n.ts"; type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail"; @@ -337,7 +337,7 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) { /** Admin voice-preview lines (Settings → Voice for users). Empty = reset. */ function VoicePreviewsCard() { const { t } = useT(); - const [lines, setLines] = useState([]); + const [lines, setLines] = useState([]); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); @@ -351,7 +351,7 @@ function VoicePreviewsCard() { .catch(() => setLoaded(true)); }, []); - async function save(next: string[]) { + async function save(next: VoicePreview[]) { setBusy(true); setMsg(null); try { @@ -365,10 +365,12 @@ function VoicePreviewsCard() { } } - function commit(i: number, v: string) { - const trimmed = v.trim(); - if (trimmed === "") { - // Emptied line: drop the row locally (server needs 1+ lines). + /** Commit one line after editing (empty text drops the row). */ + function commit(i: number) { + const line = lines[i]; + if (!line) return; + const text = line.text.trim(); + if (text === "") { const next = lines.filter((_, j) => j !== i); if (next.length === 0) { setMsg({ ok: false, text: t("vfeat.invalid") }); @@ -378,7 +380,11 @@ function VoicePreviewsCard() { void save(next); return; } - void save(lines.map((l, j) => (j === i ? trimmed : l))); + void save(lines.map((l, j) => (j === i ? { ...l, text } : l))); + } + + function patch(i: number, p: Partial) { + setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...p } : l))); } async function refreshLines() { @@ -396,36 +402,84 @@ function VoicePreviewsCard() {

{t("vfeat.title")}

{t("vfeat.desc")}

-
    +
      {lines.map((line, i) => ( -
    • - - setLines((prev) => prev.map((l, j) => (j === i ? e.target.value : l)))} - onBlur={(e) => { if (e.target.value !== line) commit(i, e.target.value); }} - onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} - /> - - {line} - +
    • +
      + + patch(i, { text: e.target.value })} + onBlur={() => commit(i)} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + /> + + +
      +
      + + + { + patch(i, { timbre: v as VoicePreview["timbre"] }); + // Persist timbre/rate/pitch tweaks live (text commits on blur). + const next = lines.map((l, j) => (j === i ? { ...l, timbre: v as VoicePreview["timbre"] } : l)); + if (next[i]!.text.trim() !== "") void save(next); + }} + align="left" + options={(["masculine", "feminine", "any"] as const).map((v) => ({ value: v, label: t(`voice.timbre.${v}` as StringKey) }))} + /> + + + + +
    • ))}
    {lines.length < 6 && ( - )} @@ -440,6 +494,12 @@ function VoicePreviewsCard() { )}
    ); + + function commitSliders(i: number) { + const line = lines[i]; + if (!line || line.text.trim() === "") return; + void save(lines.map((l, j) => (j === i ? { ...l } : l))); + } } type SmtpInfo = { diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index e9102ad..8654354 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react"; -import { api, type UserProvider } from "../lib/api.ts"; +import { api, type UserProvider, type VoicePreview } from "../lib/api.ts"; import { pushToast } from "../lib/toasts.ts"; import { cn } from "../lib/cn.ts"; import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts"; -import { detectLang, getVoicePrefs, listTtsVoices, setVoicePrefs, speak, stopSpeak, ttsSupported, type VoicePrefs } from "../lib/voice.ts"; +import { detectLang, getVoicePrefs, listTtsVoices, pickVoice, setVoicePrefs, speakPreview, stopSpeak, ttsSupported, type VoicePrefs } from "../lib/voice.ts"; import { FEATURES, setFeature, useFeatures } from "../lib/features.ts"; import type { Conversation, FilePreview, User } from "../lib/types.ts"; import { useT, type StringKey } from "../lib/i18n.ts"; @@ -875,7 +875,7 @@ function VoiceSection() { const { t } = useT(); const [prefs, setPrefs] = useState(() => getVoicePrefs()); const [voices, setVoices] = useState([]); - const [previews, setPreviews] = useState([]); + const [previews, setPreviews] = useState([]); const [playing, setPlaying] = useState(null); useEffect(() => { @@ -891,17 +891,34 @@ function VoiceSection() { setVoicePrefs(next); } - function preview(i: number, text: string) { + function preview(i: number, line: VoicePreview) { if (playing === i) { stopSpeak(); setPlaying(null); return; } - const ok = speak(text, prefs, voices, { + // Each line plays with ITS OWN character (timbre/rate/pitch/lang) — + // never the global playback prefs. + const pick = speakPreview(line.text, line, voices, { onend: () => setPlaying(null), onerror: () => setPlaying(null), }); - if (ok) setPlaying(i); + if (pick || ttsSupported()) setPlaying(i); + } + + /** Adopt a preview's character as the global playback voice. */ + function adoptVoice(line: VoicePreview) { + const lang = line.lang === "auto" ? detectLang(line.text) : line.lang; + const pick = pickVoice(voices, { lang, timbre: line.timbre }); + const next: VoicePrefs = { + voiceURI: pick?.voiceURI ?? prefs.voiceURI, + rate: line.rate, + pitch: line.pitch, + lang, + }; + setPrefs(next); + setVoicePrefs(next); + pushToast(t("voice.adopted"), { icon: "check" }); } const supported = ttsSupported(); @@ -980,10 +997,22 @@ function VoiceSection() { > {playing === i ? : } - {line} - - {prefs.lang === "auto" ? detectLang(line) : prefs.lang} · {prefs.rate.toFixed(2)}× + + {line.text} + + {t(`voice.timbre.${line.timbre}` as StringKey)} · {(line.lang === "auto" ? detectLang(line.text) : line.lang).toUpperCase()} · {line.rate.toFixed(2)}× / {line.pitch.toFixed(2)}× + + ))}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 46950e0..76d5ba3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,6 +68,14 @@ export interface OllamaHostModel { modifiedAt: string | null; } +export interface VoicePreview { + text: string; + lang: string; + rate: number; + pitch: number; + timbre: "masculine" | "feminine" | "any"; +} + export interface PlatformInfo { hostname: string; kernel: string; @@ -93,7 +101,7 @@ export interface AdminSettings { ollamaDefaultWeight: number; recoveryLimitMax: number; recoveryLimitWindowMin: number; - voicePreviews: string[]; + voicePreviews: VoicePreview[]; } export interface AdminSettingsPatch { @@ -106,7 +114,7 @@ export interface AdminSettingsPatch { ollamaDefaultWeight?: number; recoveryLimitMax?: number; recoveryLimitWindowMin?: number; - voicePreviews?: string[]; + voicePreviews?: VoicePreview[]; } export interface OllamaNode { @@ -432,7 +440,7 @@ export const api = { deleteProvider: (provider: string) => req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }), - tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: string[] }>("/api/tools"), + tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"), /** Send an image to the platform OCR tool. Returns editable text (never the raw image). */ ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index a69382b..7156374 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -597,6 +597,14 @@ const en = { "vfeat.line": "Line {n}", "vfeat.remove": "Remove line {n}", "vfeat.invalid": "1–6 non-empty lines, 500 chars max.", + "voice.timbre.masculine": "Masculine", + "voice.timbre.feminine": "Feminine", + "voice.timbre.any": "Any", + "voice.adopt": "Use this voice", + "voice.adopted": "Playback voice updated", + "vfeat.timbre": "Timbre", + "vfeat.rate": "Speed", + "vfeat.pitch": "Pitch", "msg.copy": "Copy message", @@ -1281,6 +1289,14 @@ const es: Dict = { "vfeat.line": "Line {n}", "vfeat.remove": "Remove line {n}", "vfeat.invalid": "1–6 non-empty lines, 500 chars max.", + "voice.timbre.masculine": "Masculine", + "voice.timbre.feminine": "Feminine", + "voice.timbre.any": "Any", + "voice.adopt": "Use this voice", + "voice.adopted": "Playback voice updated", + "vfeat.timbre": "Timbre", + "vfeat.rate": "Speed", + "vfeat.pitch": "Pitch", "msg.copy": "Copiar mensaje", @@ -1960,6 +1976,14 @@ const fr: Dict = { "vfeat.line": "Line {n}", "vfeat.remove": "Remove line {n}", "vfeat.invalid": "1–6 non-empty lines, 500 chars max.", + "voice.timbre.masculine": "Masculine", + "voice.timbre.feminine": "Feminine", + "voice.timbre.any": "Any", + "voice.adopt": "Use this voice", + "voice.adopted": "Playback voice updated", + "vfeat.timbre": "Timbre", + "vfeat.rate": "Speed", + "vfeat.pitch": "Pitch", "msg.copy": "Copier le message", @@ -2627,6 +2651,14 @@ const ru: Dict = { "vfeat.line": "Line {n}", "vfeat.remove": "Remove line {n}", "vfeat.invalid": "1–6 non-empty lines, 500 chars max.", + "voice.timbre.masculine": "Masculine", + "voice.timbre.feminine": "Feminine", + "voice.timbre.any": "Any", + "voice.adopt": "Use this voice", + "voice.adopted": "Playback voice updated", + "vfeat.timbre": "Timbre", + "vfeat.rate": "Speed", + "vfeat.pitch": "Pitch", "msg.copy": "Скопировать сообщение", @@ -3299,6 +3331,14 @@ const it: Dict = { "vfeat.line": "Line {n}", "vfeat.remove": "Remove line {n}", "vfeat.invalid": "1–6 non-empty lines, 500 chars max.", + "voice.timbre.masculine": "Masculine", + "voice.timbre.feminine": "Feminine", + "voice.timbre.any": "Any", + "voice.adopt": "Use this voice", + "voice.adopted": "Playback voice updated", + "vfeat.timbre": "Timbre", + "vfeat.rate": "Speed", + "vfeat.pitch": "Pitch", "msg.copy": "Copia messaggio", @@ -3962,6 +4002,14 @@ const pt: Dict = { "vfeat.line": "Line {n}", "vfeat.remove": "Remove line {n}", "vfeat.invalid": "1–6 non-empty lines, 500 chars max.", + "voice.timbre.masculine": "Masculine", + "voice.timbre.feminine": "Feminine", + "voice.timbre.any": "Any", + "voice.adopt": "Use this voice", + "voice.adopted": "Playback voice updated", + "vfeat.timbre": "Timbre", + "vfeat.rate": "Speed", + "vfeat.pitch": "Pitch", "msg.copy": "Copiar mensagem", diff --git a/frontend/src/lib/voice.ts b/frontend/src/lib/voice.ts index a47c52c..03cff0b 100644 --- a/frontend/src/lib/voice.ts +++ b/frontend/src/lib/voice.ts @@ -81,6 +81,48 @@ export interface SpeakOpts { onerror?: (msg: string) => void; } +export type VoiceTimbre = "masculine" | "feminine" | "any"; + +export interface PreviewVoice { + lang: string; + rate: number; + pitch: number; + timbre: VoiceTimbre; +} + +// Name fragments hinting a masculine / feminine voice (Chrome, Edge, +// Safari and Android voices carry such markers; absence falls back to +// any same-language voice — never an error). +const MASCULINE_HINTS = ["male", "homme", "masculin", "man", "garcon", "garçon", "david", "daniel", "thomas", "paul", "pierre", "jean", "marco", "jorge", "diego", "alex "]; +const FEMININE_HINTS = ["female", "femme", "féminin", "feminin", "woman", "girl", "fille", "samantha", "marie", "sophie", "anna", "alice", "amelie", "amélie", "camille", "léa", "lea", "chloe", "chloé", "zira", "eva"]; + +/** + * Pick a voice for a language + timbre character. Explicit URI wins, then + * same-language voices matching the timbre hints, then any same-language + * voice, then the engine default. + */ +export function pickVoice( + voices: SpeechSynthesisVoice[], + opts: { lang: string; timbre?: VoiceTimbre; voiceURI?: string | null }, +): SpeechSynthesisVoice | null { + if (opts.voiceURI) { + const exact = voices.find((v) => v.voiceURI === opts.voiceURI); + if (exact) return exact; + } + const lang = opts.lang.toLowerCase(); + const sameLang = voices.filter((v) => v.lang.toLowerCase().startsWith(lang)); + const pool = sameLang.length > 0 ? sameLang : voices; + if (opts.timbre === "masculine" || opts.timbre === "feminine") { + const hints = opts.timbre === "masculine" ? MASCULINE_HINTS : FEMININE_HINTS; + const match = pool.find((v) => { + const n = `${v.name} ${v.voiceURI}`.toLowerCase(); + return hints.some((h) => n.includes(h)); + }); + if (match) return match; + } + return pool.find((v) => v.default) ?? pool[0] ?? null; +} + /** Speak one message; cancels any current playback first. Returns false when unsupported. */ export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVoice[], opts: SpeakOpts = {}): boolean { if (!ttsSupported()) return false; @@ -95,10 +137,7 @@ export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVo utter.lang = lang; utter.rate = prefs.rate; utter.pitch = prefs.pitch; - const pick = voices.find((v) => prefs.voiceURI && v.voiceURI === prefs.voiceURI) - ?? voices.find((v) => v.lang.toLowerCase().startsWith(lang.toLowerCase())) - ?? voices.find((v) => v.default) - ?? voices[0]; + const pick = pickVoice(voices, { lang, voiceURI: prefs.voiceURI }); if (pick) utter.voice = pick; utter.onstart = () => opts.onstart?.(); utter.onend = () => opts.onend?.(); @@ -119,6 +158,42 @@ export function stopSpeak(): void { } } +/** + * Play a preview line with ITS OWN voice character (timbre + rate + pitch), + * never the user's global playback prefs — previews must sound different + * from each other. Returns the resolved voice (null when unsupported) so + * callers can offer "use this voice" adoption. + */ +export function speakPreview( + text: string, + preview: PreviewVoice, + voices: SpeechSynthesisVoice[], + opts: SpeakOpts = {}, +): SpeechSynthesisVoice | null { + if (!ttsSupported()) return null; + const synth = window.speechSynthesis; + try { + synth.cancel(); + } catch { + // ignore + } + const lang = preview.lang === "auto" ? detectLang(text) : preview.lang; + const utter = new SpeechSynthesisUtterance(text.slice(0, 2000)); + utter.lang = lang; + utter.rate = preview.rate; + utter.pitch = preview.pitch; + const pick = pickVoice(voices, { lang, timbre: preview.timbre }); + if (pick) utter.voice = pick; + utter.onend = () => opts.onend?.(); + utter.onerror = () => opts.onerror?.("error"); + try { + synth.speak(utter); + } catch { + return null; + } + return pick; +} + // --- speech recognition (STT) --- export interface Recognizer { diff --git a/src/index.ts b/src/index.ts index 25b37c7..fb74ca7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,33 +224,62 @@ async function probeOllamaHost(host: string): Promise<{ } /** - * Default TTS voice-preview lines (admin-editable, resettable). Short, - * varied lines that show off grave/hesitant, grave/serious, cute feminine - * and medium feminine deliveries. + * Voice-preview lines (admin-editable, resettable). Each line carries its + * OWN voice character — timbre/rate/pitch/lang — so previews sound + * different from each other instead of sharing the user's playback voice: + * deep-hesitant masculine, deep-serious masculine, cute feminine, + * medium feminine. */ -const DEFAULT_VOICE_PREVIEWS = [ - "Salut, euhm, comment je peux t'aider aujourd'hui ?", - "5 sur 5 je recois ! Que veux-tu ?", - "Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?", - "Hey, que puis-je faire pour toi ?", +export interface VoicePreview { + text: string; + lang: string; + rate: number; + pitch: number; + timbre: "masculine" | "feminine" | "any"; +} + +const DEFAULT_VOICE_PREVIEWS: VoicePreview[] = [ + { text: "Salut, euhm, comment je peux t'aider aujourd'hui ?", lang: "fr", rate: 0.85, pitch: 0.75, timbre: "masculine" }, + { text: "5 sur 5 je recois ! Que veux-tu ?", lang: "fr", rate: 1, pitch: 0.8, timbre: "masculine" }, + { text: "Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?", lang: "fr", rate: 1.05, pitch: 1.35, timbre: "feminine" }, + { text: "Hey, que puis-je faire pour toi ?", lang: "fr", rate: 1, pitch: 1.1, timbre: "feminine" }, ]; -async function getVoicePreviews(): Promise { +function sanitizePreviewLine(v: unknown): VoicePreview | null { + // Legacy plain-string lines (first version) still play, neutrally. + if (typeof v === "string") { + const text = v.trim(); + if (text.length === 0 || text.length > 500) return null; + return { text, lang: "auto", rate: 1, pitch: 1, timbre: "any" }; + } + if (!v || typeof v !== "object") return null; + const o = v as Record; + if (typeof o.text !== "string") return null; + const text = o.text.trim(); + if (text.length === 0 || text.length > 500) return null; + const lang = typeof o.lang === "string" && o.lang.length > 0 && o.lang.length <= 12 ? o.lang : "auto"; + const rate = typeof o.rate === "number" && o.rate >= 0.5 && o.rate <= 2 ? o.rate : 1; + const pitch = typeof o.pitch === "number" && o.pitch >= 0.5 && o.pitch <= 2 ? o.pitch : 1; + const timbre = o.timbre === "masculine" || o.timbre === "feminine" ? o.timbre : "any"; + return { text, lang, rate, pitch, timbre }; +} + +async function getVoicePreviews(): Promise { try { const raw = await getSetting("voice_previews", ""); - if (!raw) return [...DEFAULT_VOICE_PREVIEWS]; + if (!raw) return DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p })); const arr = JSON.parse(raw) as unknown; - if (!Array.isArray(arr)) return [...DEFAULT_VOICE_PREVIEWS]; - const clean = arr.filter((s): s is string => typeof s === "string").map((s) => s.trim()).filter((s) => s.length > 0 && s.length <= 500).slice(0, 6); - return clean.length > 0 ? clean : [...DEFAULT_VOICE_PREVIEWS]; + if (!Array.isArray(arr)) return DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p })); + const clean = arr.map(sanitizePreviewLine).filter((p): p is VoicePreview => p !== null).slice(0, 6); + return clean.length > 0 ? clean : DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p })); } catch { - return [...DEFAULT_VOICE_PREVIEWS]; + return DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p })); } } -function sanitizeVoicePreviews(v: unknown): string[] | null { +function sanitizeVoicePreviews(v: unknown): VoicePreview[] | null { if (!Array.isArray(v) || v.length === 0 || v.length > 6) return null; - const clean = v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0 && s.length <= 500); + const clean = v.map(sanitizePreviewLine).filter((p): p is VoicePreview => p !== null); return clean.length > 0 ? clean.slice(0, 6) : null; } @@ -1067,7 +1096,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ await setSetting("voice_previews", ""); } else { const clean = sanitizeVoicePreviews(body.voicePreviews); - if (!clean) return json({ error: "voicePreviews must be 1-6 non-empty strings (max 500 chars each), or [] to reset" }, 400); + if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?}, or [] to reset" }, 400); await setSetting("voice_previews", JSON.stringify(clean)); } }