feat(radio): replace robot TTS with Piper Neural voices & add instant voice preview

- Integrate local Piper ONNX neural TTS engine (Dmitri, Irina, Denis, Ruslan)
- Add GET /api/radio/preview-voice endpoint with cached pre-rendered samples
- Add '▶ Прослушать голос' button in Web UI for 1-click preview before generating
- Add automated filter to strip LLM refusals and fall back to clean radio phrasing
This commit is contained in:
Antigravity committed 2026-10-12 16:58:59 +03:00
1 parent e5afeb06cc
commit 4ed8e0c71a
7 files changed
+183 -24

No files matched your search

+32 -1
View File
@@ -501,6 +501,7 @@ document.addEventListener("DOMContentLoaded", () => {
// --- AI Radio Host Controller (Phase 3) ---
const radioGenerateBtn = document.getElementById("radioGenerateBtn");
const radioVoiceSelect = document.getElementById("radioVoiceSelect");
const radioVoicePreviewBtn = document.getElementById("radioVoicePreviewBtn");
const radioStatusLabel = document.getElementById("radioStatusLabel");
const radioClipsCountLabel = document.getElementById("radioClipsCountLabel");
const radioSegmentsContainer = document.getElementById("radioSegmentsContainer");
@@ -508,10 +509,29 @@ document.addEventListener("DOMContentLoaded", () => {
fetchRadioStatus();
// Voice sample preview playback
if (radioVoicePreviewBtn) {
radioVoicePreviewBtn.addEventListener("click", () => {
const selectedVoice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium";
const audioUrl = `api/radio/preview-voice?voice=${encodeURIComponent(selectedVoice)}`;
const selectedOption = (radioVoiceSelect && radioVoiceSelect.options[radioVoiceSelect.selectedIndex])
? radioVoiceSelect.options[radioVoiceSelect.selectedIndex].text
: "Голос ведущего";
playPreviewTrack(
audioUrl,
`Сэмпл: ${selectedOption}`,
"AI Радио (Предпрослушивание)",
"",
"",
audioUrl
);
});
}
if (radioGenerateBtn) {
radioGenerateBtn.addEventListener("click", async () => {
radioGenerateBtn.disabled = true;
const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru-RU-DmitryNeural";
const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium";
if (radioStatusLabel) {
radioStatusLabel.textContent = "🎙️ Ведущий пишет реплики и сводит эфир...";
radioStatusLabel.className = "badge badge-llm";
@@ -560,6 +580,17 @@ document.addEventListener("DOMContentLoaded", () => {
if (radioClipsCountLabel) {
radioClipsCountLabel.textContent = `${data.speech_clips_count || 0} записей`;
}
// Populate voice selector dynamically
if (radioVoiceSelect && data.available_voices && data.available_voices.length > 0) {
const currentVal = radioVoiceSelect.value || data.default_voice;
radioVoiceSelect.innerHTML = data.available_voices.map(v => `
<option value="${escapeHtml(v.id)}" ${v.id === currentVal ? "selected" : ""}>
🎙️ ${escapeHtml(v.name)}
</option>
`).join("");
}
if (radioStatusLabel) {
if (data.playlist_ready && data.speech_clips_count > 0) {
radioStatusLabel.textContent = "🟢 Эфир в ротации";