tts: hotfix qwen 503s and primer noise (5 stale defaults, AudioContext primer, normalizeModelVoice)

This commit is contained in:
russell@unturf.com 2026-05-24 10:16:37 -04:00
parent 9e36a1b6c2
commit 29c400bdb4
No known key found for this signature in database
3 changed files with 50 additions and 24 deletions

View file

@ -17,7 +17,7 @@
import { initializeChunkFiveFont } from "./ui-themes.js";
import { getUIText } from "./ui-translations.js";
import { handleTTS, downloadAudio, generateTitleForTTS, getTTSMode, setTTSMode, primeAudioPlayback } from "./tts.js";
import { handleTTS, downloadAudio, generateTitleForTTS, getTTSMode, setTTSMode, primeAudioPlayback, normalizeModelVoice } from "./tts.js";
import { VOICES_API_URL } from "./config.js";
// Function to fetch and populate voice dropdown
@ -356,14 +356,15 @@ export function openTTSModal() {
const speed = parseFloat(speedSlider.value);
// Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria")
// Normalize legacy/qwen entries so stale vault data doesn't 503.
let model = 'tts-1-f5';
let voice = selectedValue;
if (selectedValue.includes(':')) {
const parts = selectedValue.split(':');
model = parts[0];
voice = parts[1];
}
({ model, voice } = normalizeModelVoice(model, voice));
try {
const currentMode = getTTSMode();

View file

@ -327,20 +327,42 @@ async function fetchTTSChunk(chunkText, voice, model) {
}
}
// Silent 1-frame MP3 (data URI). Used to "prime" audio output inside a user
// gesture handler so iOS Safari unlocks audio playback before async work runs.
// Without this, audio.play() called after `await` rejects with NotAllowedError.
const SILENT_MP3_DATA_URI =
"data:audio/mp3;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsuY29tIC8gTGFTb25vdGhlcXVlLm9yZwBURU5DAAAAHQAAA1N3aXRjaCBQbHVzIMKpIE5DSCBTb2Z0d2FyZQBUSVQyAAAABgAAAzIyMzUAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAAAAD/80DEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//MUZAAAAAGkAAAAAAAAA0gAAAAAVVVV";
// Legacy voice names from the deprecated Qwen-only era. Anyone with these in
// their vault would otherwise hit 503 since the backend routes "tts-1" → qwen
// and qwen-tts is not installed in the F5 sidecar.
const LEGACY_OPENAI_VOICES = new Set([
"alloy", "echo", "fable", "onyx", "nova", "shimmer",
"amber", "breeze", "coral", "dawn", "ember", "frost",
"glow", "haze", "jade", "kite", "lark", "mist", "nectar",
]);
// Call inside a user-gesture handler before doing any async work, so iOS Safari
// unlocks audio output for subsequent .play() calls after `await`.
// Normalize a model+voice pair: remap legacy / unknown models to F5, and
// remap legacy OpenAI voice names to F5's "aria" default.
export function normalizeModelVoice(model, voice) {
const isQwen = !model || model === "tts-1" || model.includes("qwen");
const normModel = isQwen ? "tts-1-f5" : model;
const normVoice = LEGACY_OPENAI_VOICES.has(voice) ? "aria" : voice;
return { model: normModel, voice: normVoice };
}
// Call inside a user-gesture handler before doing any async work to unlock iOS
// Safari audio output for subsequent .play() calls after `await`. Uses an
// AudioContext + zero-gain silent buffer (NOT a "silent" MP3 data URI, which
// can contain encoder artifacts the user hears as a pop/buzz before playback).
let _audioCtx = null;
export function primeAudioPlayback() {
try {
const a = new Audio(SILENT_MP3_DATA_URI);
a.volume = 0;
const p = a.play();
if (p && p.catch) p.catch(() => {});
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
if (!_audioCtx) _audioCtx = new Ctx();
if (_audioCtx.state === "suspended") _audioCtx.resume();
const buffer = _audioCtx.createBuffer(1, 1, _audioCtx.sampleRate || 22050);
const source = _audioCtx.createBufferSource();
const gain = _audioCtx.createGain();
gain.gain.value = 0;
source.buffer = buffer;
source.connect(gain).connect(_audioCtx.destination);
source.start(0);
} catch (e) {
console.warn("primeAudioPlayback failed:", e);
}

View file

@ -18,6 +18,7 @@
import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
import hljs from "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/es/highlight.min.js";
import { setSystemMessageAppend, isTTSEnabled } from "./config.js";
import { normalizeModelVoice } from "./tts.js";
import { extractWebpageContent, fitPageContent, buildPageAnalysisPrompt, parsePageAnalysis, buildAnalysisContext } from "./content.js";
import { computePageIntelligence, formatPageIntelligence } from "./page-intelligence.js";
import { loadSiteJourney, recordPageVisit, formatJourneyContext, savePageSummary, getRelevantPageSummaries, saveConversationSummary, saveSiteLinks } from "./storage.js";
@ -54,15 +55,17 @@ async function ensureCryptoJS() {
// Get USE_CUSTOM_STYLING from window or default
const _USE_CUSTOM_STYLING = window.UNCLOSEAI_CUSTOM_STYLING !== false;
// Helper function to parse model:voice format
// Helper function to parse model:voice format. Normalizes legacy/qwen models
// and legacy OpenAI voice names so stale vault entries don't hit 503.
function parseVoiceSelection(selectedValue) {
// Check if format is "model:voice" or legacy "voice"
let model = "tts-1-f5";
let voice = selectedValue;
if (selectedValue.includes(":")) {
const [model, voice] = selectedValue.split(":");
return { model, voice };
const [m, v] = selectedValue.split(":");
model = m;
voice = v;
}
// Legacy format - assume tts-1-f5 model
return { model: "tts-1-f5", voice: selectedValue };
return normalizeModelVoice(model, voice);
}
// Import required functions dynamically to avoid circular dependencies
@ -1498,7 +1501,7 @@ async function openUncloseaiEmbeddedModalNew() {
userTtsBtn.onclick = async () => {
const modalVoiceSelect = document.getElementById("hermes-voice-select");
const selectedVoice = modalVoiceSelect?.value || "tts-1:alloy";
const selectedVoice = modalVoiceSelect?.value || "tts-1-f5:aria";
// If audio is already playing, pause it
if (userAudio && !userAudio.paused) {
@ -1692,7 +1695,7 @@ async function openUncloseaiEmbeddedModalNew() {
const modalVoiceSelect = document.getElementById(
"hermes-voice-select",
);
const selectedVoice = modalVoiceSelect?.value || "tts-1:alloy";
const selectedVoice = modalVoiceSelect?.value || "tts-1-f5:aria";
// If audio is already playing, pause it
if (currentAudio && !currentAudio.paused) {
@ -2167,7 +2170,7 @@ You have complete knowledge of this page content and can reference any details,
const modalVoiceSelect = document.getElementById(
"hermes-voice-select",
);
const selectedVoice = modalVoiceSelect?.value || "tts-1:alloy";
const selectedVoice = modalVoiceSelect?.value || "tts-1-f5:aria";
// If audio is already playing, pause it
if (historicalUserAudio && !historicalUserAudio.paused) {
@ -2331,7 +2334,7 @@ You have complete knowledge of this page content and can reference any details,
const modalVoiceSelect = document.getElementById(
"hermes-voice-select",
);
const selectedVoice = modalVoiceSelect?.value || "tts-1:alloy";
const selectedVoice = modalVoiceSelect?.value || "tts-1-f5:aria";
// If audio is already playing, pause it
if (historicalAudio && !historicalAudio.paused) {
@ -2800,7 +2803,7 @@ You have complete knowledge of this page content and can reference any details,
const modalVoiceSelect = document.getElementById(
"hermes-voice-select",
);
const selectedVoice = modalVoiceSelect?.value || "tts-1:alloy";
const selectedVoice = modalVoiceSelect?.value || "tts-1-f5:aria";
// If audio is already playing, pause it
if (introAudio && !introAudio.paused) {