Add TTS volume, a browser preview, and the split-audio plan
This commit is contained in:
+41
-1
@@ -2029,9 +2029,11 @@ async function warmEngine() {
|
||||
function fillSpeechSliders(tts) {
|
||||
const speed = typeof tts?.speed === "number" ? tts.speed : 1;
|
||||
const pitch = typeof tts?.pitch === "number" ? tts.pitch : 0;
|
||||
const volume = typeof tts?.volume === "number" ? tts.volume : 100;
|
||||
|
||||
$("#tts-speed").value = Number.isFinite(speed) ? speed : 1;
|
||||
$("#tts-pitch").value = Number.isFinite(pitch) ? pitch : 0;
|
||||
$("#tts-volume").value = Number.isFinite(volume) ? volume : 100;
|
||||
syncSpeechSliderLabels();
|
||||
}
|
||||
|
||||
@@ -2042,6 +2044,7 @@ function syncSpeechSliderLabels() {
|
||||
|
||||
$("#tts-speed-value").textContent = `${speed.toFixed(2)}x`;
|
||||
$("#tts-pitch-value").textContent = pitchSlider.disabled ? "n/a" : `${pitch > 0 ? "+" : ""}${pitch} st`;
|
||||
$("#tts-volume-value").textContent = `${Number($("#tts-volume").value)}%`;
|
||||
|
||||
const expressionSlider = $("#tts-expression");
|
||||
if (expressionSlider) {
|
||||
@@ -2177,6 +2180,7 @@ function effectOptions() {
|
||||
profile: profileFromControls(),
|
||||
highpass: Number($("#tts-highpass").value),
|
||||
echo: Number($("#tts-echo").value),
|
||||
volume: Number($("#tts-volume").value),
|
||||
words: $("#tts-words").checked,
|
||||
wordGapMs: Number($("#tts-word-gap").value),
|
||||
// VOX and the Half-Life profile are part of the `vox`/`hev` voices now.
|
||||
@@ -2463,6 +2467,40 @@ async function speakText() {
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSpeech() {
|
||||
const text = $("#tts-input").value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const button = $("#tts-preview");
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const provider = $("#tts-provider").value;
|
||||
const speed = Number($("#tts-speed").value);
|
||||
const pitch = Number($("#tts-pitch").value);
|
||||
const expressionEl = $("#tts-expression");
|
||||
const expression =
|
||||
expressionEl.dataset.dirty === "1" ? Number(expressionEl.value) : state.speech.expression ?? undefined;
|
||||
|
||||
const result = await VK.api.post("/api/audio/preview", {
|
||||
text,
|
||||
provider,
|
||||
speed,
|
||||
pitch,
|
||||
...(typeof expression === "number" ? { expression } : {}),
|
||||
...effectOptions()
|
||||
});
|
||||
|
||||
const audio = new Audio(result.url);
|
||||
audio.volume = Math.min(1, Math.max(0, Number($("#tts-volume").value) / 100));
|
||||
await audio.play();
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transcript */
|
||||
|
||||
function transcriptLine(segment) {
|
||||
@@ -2880,11 +2918,12 @@ function init() {
|
||||
});
|
||||
$("#tts-expression").addEventListener("change", () => void saveSpeech());
|
||||
buildProfileSliders();
|
||||
for (const id of ["tts-highpass", "tts-echo", "tts-word-gap"]) {
|
||||
for (const id of ["tts-highpass", "tts-echo", "tts-word-gap", "tts-volume"]) {
|
||||
$(`#${id}`).addEventListener("input", () => {
|
||||
$("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`;
|
||||
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
|
||||
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
|
||||
$("#tts-volume-value").textContent = `${Number($("#tts-volume").value)}%`;
|
||||
});
|
||||
$(`#${id}`).addEventListener("change", () => void saveSpeech());
|
||||
}
|
||||
@@ -3065,6 +3104,7 @@ function init() {
|
||||
$("#tts-close").addEventListener("click", () => {
|
||||
$("#tts-panel").hidden = true;
|
||||
});
|
||||
$("#tts-preview").addEventListener("click", () => void previewSpeech());
|
||||
$("#tts-speak").addEventListener("click", () => void speakText());
|
||||
$("#tts-stop").addEventListener("click", async () => {
|
||||
try {
|
||||
|
||||
@@ -170,6 +170,11 @@
|
||||
<input id="tts-pitch" type="range" min="-12" max="12" step="1" value="0" />
|
||||
<span id="tts-pitch-value" class="vk-mono vk-dim">0 st</span>
|
||||
</label>
|
||||
<label class="vk-tts-slider" title="Playback volume for speech, 100% is the level as synthesized">
|
||||
<span class="vk-micro-label">volume</span>
|
||||
<input id="tts-volume" type="range" min="0" max="200" step="5" value="100" />
|
||||
<span id="tts-volume-value" class="vk-mono vk-dim">100%</span>
|
||||
</label>
|
||||
<button id="profile-open" class="vk-chip vk-chip-off" type="button" title="Flextalk voice profile">
|
||||
voice profile…
|
||||
</button>
|
||||
@@ -179,6 +184,7 @@
|
||||
<div class="vk-tts-meta">
|
||||
<span class="vk-mono vk-dim" id="tts-status">idle</span>
|
||||
<button id="tts-stop" class="btn btn-secondary vk-small-btn" type="button">Stop</button>
|
||||
<button id="tts-preview" class="btn btn-secondary vk-small-btn" type="button" title="Play this line in the browser, not in the call">Preview</button>
|
||||
<button id="tts-speak" class="btn btn-primary vk-small-btn" type="button">Speak</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,7 @@ export class AudioService {
|
||||
* grows. The wrapper touches `<file>.ready` after the first word, which is the reliable
|
||||
* signal; a size threshold and a timeout cover providers that do not mark readiness.
|
||||
*/
|
||||
private async playWhenReady(file: string): Promise<void> {
|
||||
private async playWhenReady(file: string, volume?: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
try {
|
||||
if (statSync(`${file}.ready`).size >= 0) break;
|
||||
@@ -172,7 +172,7 @@ export class AudioService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.manager.play(file);
|
||||
await this.manager.play(file, volume);
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(`${file}.ready`);
|
||||
@@ -182,6 +182,23 @@ export class AudioService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The TTS output directory, for serving preview files. */
|
||||
get ttsDirectory(): string {
|
||||
return this.tts.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a line for browser playback. This never touches the call:
|
||||
* no queue, no ducking, no virtual microphone, no voice-processing bypass.
|
||||
*/
|
||||
async preview(text: string, options: TtsOptions = {}): Promise<string> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error("text is required");
|
||||
if (trimmed.length > 1000) throw new Error("text is too long (max 1000 characters)");
|
||||
|
||||
return this.tts.synthesize(trimmed, { ...options, stream: false, onOutputPath: undefined });
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.queue.length = 0;
|
||||
this.manager.stopPlayback();
|
||||
@@ -402,7 +419,7 @@ export class AudioService {
|
||||
options.onOutputPath = path => {
|
||||
streaming = (async () => {
|
||||
await this.applyDuck(this.shouldDuck());
|
||||
await this.playWhenReady(path);
|
||||
await this.playWhenReady(path, item.options.volume);
|
||||
})();
|
||||
};
|
||||
}
|
||||
@@ -413,7 +430,7 @@ export class AudioService {
|
||||
await pending;
|
||||
} else {
|
||||
await this.applyDuck(this.shouldDuck());
|
||||
await this.manager.play(file);
|
||||
await this.manager.play(file, item.options.volume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[audio] speak failed:", (error as Error).message);
|
||||
|
||||
+12
-2
@@ -276,7 +276,7 @@ export class AudioManager {
|
||||
this.modules = [];
|
||||
}
|
||||
|
||||
async play(file: string): Promise<void> {
|
||||
async play(file: string, volume?: number): Promise<void> {
|
||||
if (!this.available) throw new AudioUnavailableError(this.error ?? "audio is not available");
|
||||
|
||||
const binary = process.env.VESKSLOP_FFPLAY ?? "ffplay";
|
||||
@@ -284,7 +284,17 @@ export class AudioManager {
|
||||
// Lead-in silence: give Discord's voice pipeline a moment to settle
|
||||
// before the first syllable, so the attack is not swallowed.
|
||||
const args = ["-nodisp", "-autoexit", "-loglevel", "error"];
|
||||
if (LEAD_MS > 0) args.push("-af", `adelay=${LEAD_MS}:all=1`);
|
||||
const filters: string[] = [];
|
||||
if (LEAD_MS > 0) filters.push(`adelay=${LEAD_MS}:all=1`);
|
||||
|
||||
// Volume percent, 0-200. 100 keeps the level as synthesized. ffplay's own
|
||||
// -volume stops at 100, so use the volume filter and its dB scale.
|
||||
if (typeof volume === "number" && Number.isFinite(volume) && volume !== 100) {
|
||||
const gain = volume > 0 ? `${(20 * Math.log10(volume / 100)).toFixed(2)}dB` : "0";
|
||||
filters.push(`volume=${gain}`);
|
||||
}
|
||||
|
||||
if (filters.length) args.push("-af", filters.join(","));
|
||||
args.push(file);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface TtsPreferences {
|
||||
provider: "local" | "espeak" | "command" | "api" | null;
|
||||
language: string | null;
|
||||
voice: string | null;
|
||||
/** Playback volume percent, 0-200. Null uses the natural level. */
|
||||
volume: number | 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. */
|
||||
@@ -176,6 +178,7 @@ const DEFAULTS: WebPreferences = {
|
||||
provider: null,
|
||||
language: null,
|
||||
voice: null,
|
||||
volume: null,
|
||||
speed: null,
|
||||
pitch: null,
|
||||
expression: null,
|
||||
@@ -416,6 +419,7 @@ export class PreferencesStore {
|
||||
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,
|
||||
volume: normalizeRange(patch.tts?.volume, 0, 200),
|
||||
speed: normalizeSpeed(patch.tts?.speed),
|
||||
pitch: normalizePitch(patch.tts?.pitch),
|
||||
expression: normalizeExpression(patch.tts?.expression),
|
||||
@@ -500,6 +504,7 @@ export class PreferencesStore {
|
||||
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,
|
||||
volume: normalizeRange(raw?.tts?.volume, 0, 200),
|
||||
speed: normalizeSpeed(raw?.tts?.speed),
|
||||
pitch: normalizePitch(raw?.tts?.pitch),
|
||||
expression: normalizeExpression(raw?.tts?.expression),
|
||||
|
||||
@@ -18,6 +18,8 @@ export type TtsProvider = "local" | "espeak" | "command" | "api";
|
||||
export interface TtsOptions {
|
||||
provider?: TtsProvider;
|
||||
voice?: string;
|
||||
/** Playback volume percent, 0-200. The daemon applies it at play time. */
|
||||
volume?: number;
|
||||
/** Speech-rate multiplier; the engine maps it to its own units. */
|
||||
speed?: number;
|
||||
/** Pitch offset in semitones; the engine maps it to its own units. */
|
||||
|
||||
+104
-66
@@ -14,7 +14,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { Readable } from "node:stream";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { dirname } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import QRCode from "qrcode";
|
||||
import { WebSocketServer } from "ws";
|
||||
@@ -25,11 +25,11 @@ import { CoreError, type CoreClient } from "./core-client.js";
|
||||
import type { CoreProcess } from "./core-process.js";
|
||||
import { MediaLimitError, type MediaService } from "./media.js";
|
||||
import { dedupe, getQueryCount, paced, pacedSend, pacedVoice } from "./pacing.js";
|
||||
import type { PreferencesStore, WebPreferences } from "./preferences.js";
|
||||
import type { PreferencesStore, TtsPreferences, WebPreferences } from "./preferences.js";
|
||||
import type { Puppet } from "./puppet.js";
|
||||
import { ensureTls } from "./tls.js";
|
||||
import type { TranscriptStore } from "./transcript.js";
|
||||
import type { TtsProvider, TtsVoice } from "./tts.js";
|
||||
import type { TtsOptions, TtsProvider, TtsVoice } from "./tts.js";
|
||||
|
||||
const PINNED_LANGUAGES = ["en", "sv", "de", "da", "nb", "no", "ru", "chr"];
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
@@ -231,6 +231,77 @@ function parseMediaFilters(query: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Turn a speak/preview request body into TTS options. Missing fields fall back
|
||||
* to the stored preferences.
|
||||
*/
|
||||
function buildSpeechOptions(body: any, tts: TtsPreferences, voice: string | undefined): TtsOptions {
|
||||
const requested = body?.provider;
|
||||
const provider = ["api", "local", "espeak", "command"].includes(requested)
|
||||
? requested
|
||||
: tts.provider;
|
||||
// Number(null) is 0: null, undefined, and "" all mean "unset".
|
||||
const optional = (value: unknown): number | undefined => {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
};
|
||||
|
||||
const speed = optional(body?.speed) ?? tts.speed ?? undefined;
|
||||
const pitch = optional(body?.pitch) ?? tts.pitch ?? undefined;
|
||||
const expression = optional(body?.expression) ?? tts.expression ?? undefined;
|
||||
const profile = body?.profile && typeof body.profile === "object" ? body.profile : tts.profile;
|
||||
const highpass = optional(body?.highpass) ?? tts.highpass ?? undefined;
|
||||
const echo = optional(body?.echo) ?? tts.echo ?? undefined;
|
||||
const words = body?.words !== undefined ? body.words === true : tts.words;
|
||||
const wordGapMs = optional(body?.wordGapMs) ?? tts.wordGapMs ?? undefined;
|
||||
// The Half-Life VOX voice carries its prosody with it.
|
||||
const vox =
|
||||
(body?.vox !== undefined ? body.vox === true : tts.vox) || voice === "vox";
|
||||
const voxClean = body?.voxClean !== undefined ? body.voxClean === true : tts.voxClean;
|
||||
const preset =
|
||||
body?.preset === "vox" || body?.preset === "hev"
|
||||
? body.preset
|
||||
: body?.preset === null || body?.preset === ""
|
||||
? null
|
||||
: tts.preset;
|
||||
const echoDelayMs = optional(body?.echoDelayMs) ?? tts.echoDelayMs ?? undefined;
|
||||
const echoLevel = optional(body?.echoLevel) ?? tts.echoLevel ?? undefined;
|
||||
const crunch =
|
||||
["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(body?.crunch ?? ""))
|
||||
? (String(body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush")
|
||||
: body?.crunch === null || body?.crunch === "" || body?.crunch === "off"
|
||||
? undefined
|
||||
: tts.crunch ?? undefined;
|
||||
const crunchBits = optional(body?.crunchBits) ?? tts.crunchBits ?? undefined;
|
||||
const crunchRate = optional(body?.crunchRate) ?? tts.crunchRate ?? undefined;
|
||||
const streamWords =
|
||||
body?.streamWords !== undefined ? body.streamWords !== false : tts.streamWords;
|
||||
|
||||
return {
|
||||
provider: provider ?? undefined,
|
||||
voice,
|
||||
...(Number.isFinite(speed) ? { speed } : {}),
|
||||
...(Number.isFinite(pitch) ? { pitch } : {}),
|
||||
...(Number.isFinite(expression) ? { expression } : {}),
|
||||
...(profile && Object.keys(profile).length ? { profile } : {}),
|
||||
...(Number.isFinite(highpass) ? { highpass } : {}),
|
||||
...(Number.isFinite(echo) ? { echo } : {}),
|
||||
...(words ? { words: true } : {}),
|
||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
||||
...(vox ? { vox: true } : {}),
|
||||
...(voxClean ? { voxClean: true } : {}),
|
||||
...(preset ? { preset } : {}),
|
||||
...(Number.isFinite(echoDelayMs) ? { echoDelayMs } : {}),
|
||||
...(Number.isFinite(echoLevel) ? { echoLevel } : {}),
|
||||
...(crunch ? { crunch } : {}),
|
||||
...(Number.isFinite(crunchBits) ? { crunchBits } : {}),
|
||||
...(Number.isFinite(crunchRate) ? { crunchRate } : {}),
|
||||
stream: streamWords && words === true
|
||||
} as TtsOptions;
|
||||
}
|
||||
|
||||
export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "64kb" }));
|
||||
@@ -508,6 +579,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
provider: tts?.provider ?? null,
|
||||
language: tts?.language ?? null,
|
||||
voice: tts?.voice ?? null,
|
||||
volume: tts?.volume ?? null,
|
||||
speed: tts?.speed ?? null,
|
||||
pitch: tts?.pitch ?? null,
|
||||
expression: tts?.expression ?? null,
|
||||
@@ -794,80 +866,46 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
const text = String(req.body?.text ?? "");
|
||||
const prefs = deps.preferences.get();
|
||||
|
||||
const requested = req.body?.provider;
|
||||
const provider = ["api", "local", "espeak", "command"].includes(requested)
|
||||
? requested
|
||||
: prefs.tts.provider;
|
||||
const voice = req.body?.voice ? String(req.body.voice) : prefs.tts.voice ?? undefined;
|
||||
// Number(null) is 0: null, undefined, and "" all mean "unset".
|
||||
const optional = (value: unknown): number | undefined => {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
};
|
||||
const options = buildSpeechOptions(req.body ?? {}, prefs.tts, voice);
|
||||
|
||||
const speed = optional(req.body?.speed) ?? prefs.tts.speed ?? undefined;
|
||||
const pitch = optional(req.body?.pitch) ?? prefs.tts.pitch ?? undefined;
|
||||
const expression = optional(req.body?.expression) ?? prefs.tts.expression ?? undefined;
|
||||
const profile = req.body?.profile && typeof req.body.profile === "object" ? req.body.profile : prefs.tts.profile;
|
||||
const highpass = optional(req.body?.highpass) ?? prefs.tts.highpass ?? undefined;
|
||||
const echo = optional(req.body?.echo) ?? prefs.tts.echo ?? undefined;
|
||||
const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
|
||||
const wordGapMs = optional(req.body?.wordGapMs) ?? prefs.tts.wordGapMs ?? undefined;
|
||||
// The Half-Life VOX voice carries its prosody with it.
|
||||
const vox =
|
||||
(req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox) || voice === "vox";
|
||||
const voxClean = req.body?.voxClean !== undefined ? req.body.voxClean === true : prefs.tts.voxClean;
|
||||
const preset =
|
||||
req.body?.preset === "vox" || req.body?.preset === "hev"
|
||||
? req.body.preset
|
||||
: req.body?.preset === null || req.body?.preset === ""
|
||||
? null
|
||||
: prefs.tts.preset;
|
||||
const echoDelayMs = optional(req.body?.echoDelayMs) ?? prefs.tts.echoDelayMs ?? undefined;
|
||||
const echoLevel = optional(req.body?.echoLevel) ?? prefs.tts.echoLevel ?? undefined;
|
||||
const crunch =
|
||||
["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? ""))
|
||||
? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush")
|
||||
: req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off"
|
||||
? undefined
|
||||
: prefs.tts.crunch ?? undefined;
|
||||
const crunchBits = optional(req.body?.crunchBits) ?? prefs.tts.crunchBits ?? undefined;
|
||||
const crunchRate = optional(req.body?.crunchRate) ?? prefs.tts.crunchRate ?? undefined;
|
||||
const streamWords =
|
||||
req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords;
|
||||
|
||||
const result = await deps.audio.speak(text, {
|
||||
provider: provider ?? undefined,
|
||||
voice,
|
||||
...(Number.isFinite(speed) ? { speed } : {}),
|
||||
...(Number.isFinite(pitch) ? { pitch } : {}),
|
||||
...(Number.isFinite(expression) ? { expression } : {}),
|
||||
...(profile && Object.keys(profile).length ? { profile } : {}),
|
||||
...(Number.isFinite(highpass) ? { highpass } : {}),
|
||||
...(Number.isFinite(echo) ? { echo } : {}),
|
||||
...(words ? { words: true } : {}),
|
||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
||||
...(vox ? { vox: true } : {}),
|
||||
...(voxClean ? { voxClean: true } : {}),
|
||||
...(preset ? { preset } : {}),
|
||||
...(Number.isFinite(echoDelayMs) ? { echoDelayMs } : {}),
|
||||
...(Number.isFinite(echoLevel) ? { echoLevel } : {}),
|
||||
...(crunch ? { crunch } : {}),
|
||||
...(Number.isFinite(crunchBits) ? { crunchBits } : {}),
|
||||
...(Number.isFinite(crunchRate) ? { crunchRate } : {}),
|
||||
stream: streamWords && words === true
|
||||
});
|
||||
const result = await deps.audio.speak(text, options);
|
||||
deps.getPuppet()?.appendAction({
|
||||
type: "audio",
|
||||
action: "speak",
|
||||
provider: provider ?? deps.audio.status().tts.defaultProvider,
|
||||
provider: options.provider ?? deps.audio.status().tts.defaultProvider,
|
||||
chars: text.trim().length
|
||||
});
|
||||
res.json({ ok: true, result });
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/audio/preview",
|
||||
wrap(async (req, res) => {
|
||||
const text = String(req.body?.text ?? "").trim();
|
||||
const prefs = deps.preferences.get();
|
||||
const voice = req.body?.voice ? String(req.body.voice) : prefs.tts.voice ?? undefined;
|
||||
const file = await deps.audio.preview(text, buildSpeechOptions(req.body ?? {}, prefs.tts, voice));
|
||||
|
||||
res.json({ ok: true, result: { url: `/api/audio/tts/${encodeURIComponent(basename(file))}` } });
|
||||
})
|
||||
);
|
||||
|
||||
// Preview files only: the name pattern blocks path traversal.
|
||||
app.get(
|
||||
"/api/audio/tts/:name",
|
||||
wrap(async (req, res) => {
|
||||
const name = String(req.params.name);
|
||||
if (!/^tts-[0-9]+-[0-9]+\.wav$/.test(name)) throw new HttpError(400, "invalid audio name");
|
||||
|
||||
const file = join(deps.audio.ttsDirectory, name);
|
||||
if (!existsSync(file)) throw new HttpError(404, "audio not found");
|
||||
|
||||
res.type("audio/wav").sendFile(file);
|
||||
})
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/audio/stop",
|
||||
wrap(async (_req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user