SHA256
UI: голосовой ввод/STT, TTS через OpenAI, настройки инструментов + учёт недопроверенных фич
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
const OPENAI_MODELS_BY_QUALITY = {
|
||||
easy: 'whisper-1',
|
||||
medium: 'gpt-4o-mini-transcribe',
|
||||
hard: 'gpt-4o-transcribe',
|
||||
};
|
||||
|
||||
const PIPER_LENGTH_SCALE_BY_QUALITY = {
|
||||
easy: '1.15',
|
||||
medium: '1.0',
|
||||
hard: '0.9',
|
||||
};
|
||||
|
||||
function normalizeOpenAiBaseUrl(url) {
|
||||
const value = String(url || '').trim();
|
||||
if (!value) return 'https://api.openai.com/v1';
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveSttModel(config) {
|
||||
const quality = String(config?.quality || 'medium').toLowerCase();
|
||||
const customModel = String(config?.model || '').trim();
|
||||
return customModel || OPENAI_MODELS_BY_QUALITY[quality] || OPENAI_MODELS_BY_QUALITY.medium;
|
||||
}
|
||||
|
||||
export function isSpeechToTextConfigured(entrySettings) {
|
||||
const cfg = entrySettings?.tools?.speechToText || {};
|
||||
if (String(cfg.provider || 'openai') !== 'openai') return false;
|
||||
return !!String(cfg.apiKey || '').trim();
|
||||
}
|
||||
|
||||
export function isTextToSpeechConfigured(entrySettings) {
|
||||
const cfg = entrySettings?.tools?.textToSpeech || {};
|
||||
const provider = String(cfg.provider || 'browser');
|
||||
if (provider === 'browser') return true;
|
||||
if (provider === 'piper-http') return !!String(cfg.piperBaseUrl || '').trim();
|
||||
if (provider === 'openai') return !!String(cfg.apiKey || '').trim();
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function transcribeAudioBySettings(audioBlob, entrySettings) {
|
||||
const cfg = entrySettings?.tools?.speechToText || {};
|
||||
const provider = String(cfg.provider || 'openai');
|
||||
if (provider !== 'openai') {
|
||||
throw new Error('Поддерживается только провайдер OpenAI для распознавания.');
|
||||
}
|
||||
|
||||
const apiKey = String(cfg.apiKey || '').trim();
|
||||
if (!apiKey) throw new Error('Не заполнен OpenAI API key.');
|
||||
|
||||
const model = resolveSttModel(cfg);
|
||||
const baseUrl = normalizeOpenAiBaseUrl(cfg.baseUrl);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('model', model);
|
||||
form.append('language', 'ru');
|
||||
form.append('response_format', 'json');
|
||||
form.append('file', audioBlob, 'voice-input.webm');
|
||||
|
||||
const response = await fetch(`${baseUrl}/audio/transcriptions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Ошибка STT API (${response.status}): ${body || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const text = String(payload?.text || '').trim();
|
||||
if (!text) throw new Error('Пустой ответ распознавания.');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function createMicrophoneRecorder() {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
let stream = null;
|
||||
let recorder = null;
|
||||
let startedAtMs = 0;
|
||||
let chunks = [];
|
||||
let timerId = 0;
|
||||
let level = 0;
|
||||
let analyser = null;
|
||||
let rafId = 0;
|
||||
|
||||
async function start(onTick) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||
startedAtMs = Date.now();
|
||||
chunks = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event?.data?.size > 0) chunks.push(event.data);
|
||||
};
|
||||
recorder.start(250);
|
||||
|
||||
timerId = window.setInterval(() => {
|
||||
if (typeof onTick === 'function') {
|
||||
onTick({
|
||||
elapsedMs: Date.now() - startedAtMs,
|
||||
level,
|
||||
});
|
||||
}
|
||||
}, 120);
|
||||
|
||||
if (Ctx) {
|
||||
const audioCtx = new Ctx();
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
const read = () => {
|
||||
if (!analyser) return;
|
||||
analyser.getByteFrequencyData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 1) sum += data[i];
|
||||
level = data.length > 0 ? Math.max(0, Math.min(1, (sum / data.length) / 255)) : 0;
|
||||
rafId = window.requestAnimationFrame(read);
|
||||
};
|
||||
read();
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!recorder) return null;
|
||||
const blob = await new Promise((resolve) => {
|
||||
recorder.onstop = () => resolve(new Blob(chunks, { type: 'audio/webm' }));
|
||||
recorder.stop();
|
||||
});
|
||||
cleanup();
|
||||
return blob;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
try {
|
||||
recorder?.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (timerId) window.clearInterval(timerId);
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
timerId = 0;
|
||||
rafId = 0;
|
||||
analyser = null;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
stream = null;
|
||||
recorder = null;
|
||||
}
|
||||
|
||||
return { start, stop, cancel };
|
||||
}
|
||||
|
||||
export async function speakTextBySettings(text, entrySettings) {
|
||||
const value = String(text || '').trim();
|
||||
if (!value) return;
|
||||
|
||||
const cfg = entrySettings?.tools?.textToSpeech || {};
|
||||
const provider = String(cfg.provider || 'browser');
|
||||
|
||||
if (provider === 'browser') {
|
||||
const utt = new SpeechSynthesisUtterance(value);
|
||||
utt.lang = 'ru-RU';
|
||||
const selected = String(cfg.voice || '').trim();
|
||||
if (selected) {
|
||||
const voice = window.speechSynthesis.getVoices().find((v) => v.name === selected);
|
||||
if (voice) utt.voice = voice;
|
||||
}
|
||||
window.speechSynthesis.speak(utt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'piper-http') {
|
||||
const baseUrl = String(cfg.piperBaseUrl || '').trim().replace(/\/+$/, '');
|
||||
if (!baseUrl) throw new Error('Не указан адрес Piper HTTP.');
|
||||
const quality = String(cfg.quality || 'medium').toLowerCase();
|
||||
const voice = String(cfg.voice || '').trim();
|
||||
const lengthScale = PIPER_LENGTH_SCALE_BY_QUALITY[quality] || PIPER_LENGTH_SCALE_BY_QUALITY.medium;
|
||||
const resp = await fetch(`${baseUrl}/api/tts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: value,
|
||||
voice,
|
||||
quality,
|
||||
length_scale: lengthScale,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Piper HTTP недоступен (${resp.status}).`);
|
||||
const blob = await resp.blob();
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio(audioUrl);
|
||||
await audio.play();
|
||||
window.setTimeout(() => URL.revokeObjectURL(audioUrl), 30000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'openai') {
|
||||
const apiKey = String(cfg.apiKey || '').trim();
|
||||
if (!apiKey) throw new Error('Не заполнен API key для OpenAI TTS.');
|
||||
const model = String(cfg.model || '').trim() || 'gpt-4o-mini-tts';
|
||||
const baseUrl = normalizeOpenAiBaseUrl(cfg.externalBaseUrl || cfg.baseUrl || 'https://api.openai.com/v1');
|
||||
const voice = String(cfg.voice || '').trim() || 'alloy';
|
||||
const resp = await fetch(`${baseUrl}/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
voice,
|
||||
input: value,
|
||||
format: 'mp3',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`OpenAI TTS недоступен (${resp.status}).`);
|
||||
const blob = await resp.blob();
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio(audioUrl);
|
||||
await audio.play();
|
||||
window.setTimeout(() => URL.revokeObjectURL(audioUrl), 30000);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Неизвестный провайдер озвучки.');
|
||||
}
|
||||
Reference in New Issue
Block a user