UI: голосовой ввод/STT, TTS через OpenAI, настройки инструментов + учёт недопроверенных фич
This commit is contained in:
parent
ddeaf82bfd
commit
8de4e95c6a
16
AGENTS.md
16
AGENTS.md
@ -48,3 +48,19 @@
|
||||
- `call_declined`
|
||||
- `unknown_error`
|
||||
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
|
||||
|
||||
## Недопроверенные фичи (обязательно)
|
||||
- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`.
|
||||
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
|
||||
- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`.
|
||||
- Внутри файла обязательно указывать:
|
||||
- краткое описание фичи;
|
||||
- что именно проверять;
|
||||
- ожидаемый результат;
|
||||
- статус (например: `pending`, `in_progress`, `done`).
|
||||
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
|
||||
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
|
||||
|
||||
## Коммуникация в начале нового чата
|
||||
- В начале каждого нового чата (в первом ответе пользователю) дополнительно сообщать, сколько сейчас недопроверенных фич лежит в `Dev_Docs/Pending_Features/` (без учёта `README.md`).
|
||||
- В том же первом ответе обязательно уточнять у пользователя, проверил ли он эти фичи и можно ли пометить их как завершённые (удалить соответствующие файлы).
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
# Голосовой ввод и озвучка (STT/TTS)
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Добавлен голосовой ввод в:
|
||||
- личный чат;
|
||||
- форму ответа в канале;
|
||||
- форму нового сообщения в канале;
|
||||
- форму ответа в треде.
|
||||
- Добавлен инструмент «Прочесть вслух» для текста в чате.
|
||||
- Добавлен экран `Настройки инструментов ввода`:
|
||||
- STT через OpenAI (base URL, API key, качество, модель);
|
||||
- TTS через Browser / Piper HTTP / OpenAI TTS.
|
||||
- Для TTS добавлена кнопка «Проверить озвучку».
|
||||
- Если инструмент не настроен, показывается предложение перейти в настройки.
|
||||
- Настройки сохраняются локально и сохраняются между сессиями.
|
||||
|
||||
## Как проверять
|
||||
|
||||
1. Открыть `Настройки -> Настройки инструментов ввода`.
|
||||
2. В блоке STT заполнить OpenAI API key (и при необходимости URL/модель).
|
||||
3. В блоке TTS выбрать OpenAI, заполнить API key, при необходимости модель/голос.
|
||||
4. Нажать «Проверить озвучку» и убедиться, что звук воспроизводится.
|
||||
5. Открыть чат/канал/тред, нажать кнопку `🎤`, записать голос, нажать `OK`.
|
||||
6. Убедиться, что распознанный текст подставился в поле ввода.
|
||||
7. Отправить сообщение и проверить, что оно дошло.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Голосовой ввод работает во всех указанных формах.
|
||||
- Озвучка через OpenAI TTS работает с тем же ключом, что и STT (если ключ имеет нужные права).
|
||||
- При пустых настройках показывается понятный переход в настройки.
|
||||
19
Dev_Docs/Pending_Features/README.md
Normal file
19
Dev_Docs/Pending_Features/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Недопроверенные фичи
|
||||
|
||||
Эта папка хранит список доработок, которые уже реализованы, но ещё не подтверждены ручной проверкой.
|
||||
|
||||
## Как использовать
|
||||
|
||||
1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл:
|
||||
- формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md`
|
||||
2. В файле указать:
|
||||
- что сделано;
|
||||
- как проверять;
|
||||
- ожидаемый результат;
|
||||
- текущий статус (`pending` / `in_progress` / `done`).
|
||||
3. После подтверждения работоспособности — удалить файл фичи из этой папки.
|
||||
|
||||
## Важно
|
||||
|
||||
- `README.md` не удаляется.
|
||||
- Количество недопроверенных фич = число файлов `*.md` в этой папке, кроме `README.md`.
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.45
|
||||
server.version=1.2.39
|
||||
client.version=1.2.46
|
||||
server.version=1.2.40
|
||||
|
||||
@ -48,6 +48,7 @@ import * as walletView from './pages/wallet-view.js';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js';
|
||||
import * as toolsSettingsView from './pages/tools-settings-view.js';
|
||||
import * as deviceView from './pages/device-view.js';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js';
|
||||
import * as deviceQrView from './pages/device-qr-view.js';
|
||||
@ -85,6 +86,7 @@ const routes = {
|
||||
'settings-view': settingsView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'tools-settings-view': toolsSettingsView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
'device-qr-view': deviceQrView,
|
||||
|
||||
100
shine-UI/js/components/speech-input-modal.js
Normal file
100
shine-UI/js/components/speech-input-modal.js
Normal file
@ -0,0 +1,100 @@
|
||||
import {
|
||||
createMicrophoneRecorder,
|
||||
isSpeechToTextConfigured,
|
||||
transcribeAudioBySettings,
|
||||
} from '../services/speech-tools-service.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
function formatDuration(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
||||
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
||||
const ss = String(totalSec % 60).padStart(2, '0');
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function showSttMissingConfigDialog(navigate) {
|
||||
const goSettings = window.confirm(
|
||||
'Распознавание речи не настроено. Перейти в настройки инструментов?'
|
||||
);
|
||||
if (goSettings) navigate('tools-settings-view');
|
||||
}
|
||||
|
||||
export async function openSpeechInputModal({ navigate, onTextReady }) {
|
||||
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
||||
showSttMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="speech-input-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Голосовой ввод</h3>
|
||||
<p class="meta-muted" id="speech-input-status">Идёт запись...</p>
|
||||
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
|
||||
<p class="meta-muted" id="speech-input-time">00:00</p>
|
||||
<p class="inline-error" id="speech-input-error"></p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="speech-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const statusEl = root.querySelector('#speech-input-status');
|
||||
const timeEl = root.querySelector('#speech-input-time');
|
||||
const levelEl = root.querySelector('#speech-level-fill');
|
||||
const errorEl = root.querySelector('#speech-input-error');
|
||||
const cancelBtn = root.querySelector('#speech-cancel');
|
||||
const okBtn = root.querySelector('#speech-ok');
|
||||
const recorder = createMicrophoneRecorder();
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
const setBusy = (flag) => {
|
||||
busy = !!flag;
|
||||
cancelBtn.disabled = busy;
|
||||
okBtn.disabled = busy;
|
||||
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
||||
};
|
||||
|
||||
try {
|
||||
await recorder.start(({ elapsedMs, level }) => {
|
||||
if (timeEl) timeEl.textContent = formatDuration(elapsedMs);
|
||||
if (levelEl) levelEl.style.width = `${Math.max(2, Math.round((Number(level) || 0) * 100))}%`;
|
||||
});
|
||||
} catch (error) {
|
||||
close();
|
||||
window.alert(`Не удалось получить доступ к микрофону: ${error?.message || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
recorder.cancel();
|
||||
close();
|
||||
});
|
||||
|
||||
okBtn.addEventListener('click', async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
statusEl.textContent = 'Распознаю речь...';
|
||||
try {
|
||||
const audioBlob = await recorder.stop();
|
||||
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
||||
if (typeof onTextReady === 'function') onTextReady(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
statusEl.textContent = 'Идёт запись...';
|
||||
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -10,6 +10,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
|
||||
@ -229,13 +230,16 @@ function resolveNodeText(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit }) {
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="thread-reply-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
@ -262,6 +266,15 @@ function openReplyModal({ onSubmit }) {
|
||||
};
|
||||
|
||||
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-reply-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
@ -391,6 +404,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
||||
animatePress(event.currentTarget);
|
||||
revealCounters();
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
});
|
||||
});
|
||||
@ -520,6 +534,7 @@ export function render({ navigate, route }) {
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
navigate,
|
||||
onToggleLike: async (target, action) => {
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
|
||||
@ -260,13 +261,16 @@ function openAboutChannelModal(channel) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit }) {
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="reply-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
|
||||
@ -293,6 +297,15 @@ function openReplyModal({ onSubmit }) {
|
||||
};
|
||||
|
||||
root.querySelector('#reply-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#reply-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
@ -317,7 +330,7 @@ function openReplyModal({ onSubmit }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelName, onSubmit }) {
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-message-modal">
|
||||
@ -325,6 +338,9 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
||||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||
<p class="meta-muted">${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="channel-message-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
@ -351,6 +367,15 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
||||
};
|
||||
|
||||
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#channel-message-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
|
||||
@ -624,6 +649,7 @@ function renderPostCard(post, {
|
||||
animatePress(event.currentTarget);
|
||||
revealCounters();
|
||||
openReplyModal({
|
||||
navigate,
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
@ -732,6 +758,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
animatePress(event.currentTarget);
|
||||
openAddMessageModal({
|
||||
channelName: channelData.channel.name,
|
||||
navigate,
|
||||
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import { renderHeader } from '../components/header.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
addChatMessage,
|
||||
addSystemChatMessage,
|
||||
addOutgoingPendingMessage,
|
||||
getChatMessages,
|
||||
@ -12,6 +13,8 @@ import {
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { startOutgoingCall } from '../services/call-service.js';
|
||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
|
||||
@ -200,9 +203,41 @@ export function render({ navigate, route }) {
|
||||
form.className = 'chat-input dm-chat-input';
|
||||
form.innerHTML = `
|
||||
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input">🎤</button>
|
||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-read-aloud">🔊</button>
|
||||
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
|
||||
`;
|
||||
|
||||
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
||||
const input = form.elements.message;
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(input.value || '').trim();
|
||||
input.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
|
||||
const input = form.elements.message;
|
||||
const text = String(input.value || '').trim();
|
||||
if (!text) {
|
||||
window.alert('Введите текст для озвучки.');
|
||||
return;
|
||||
}
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
const goSettings = window.confirm('Озвучка не настроена. Перейти в настройки инструментов?');
|
||||
if (goSettings) navigate('tools-settings-view');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await speakTextBySettings(text, state.entrySettings);
|
||||
} catch (error) {
|
||||
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const input = form.elements.message;
|
||||
|
||||
@ -42,12 +42,14 @@ export function render({ navigate }) {
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
|
||||
168
shine-UI/js/pages/tools-settings-view.js
Normal file
168
shine-UI/js/pages/tools-settings-view.js
Normal file
@ -0,0 +1,168 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { saveEntrySettings, state } from '../state.js';
|
||||
import { speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
|
||||
export const pageMeta = { id: 'tools-settings-view', title: 'Настройки инструментов' };
|
||||
|
||||
function optionsMarkup(options, selected) {
|
||||
return options.map((opt) => (
|
||||
`<option value="${opt.value}" ${selected === opt.value ? 'selected' : ''}>${opt.label}</option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const stt = state.entrySettings.tools?.speechToText || {};
|
||||
const tts = state.entrySettings.tools?.textToSpeech || {};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h3 class="modal-title">Распознавание речи</h3>
|
||||
<label class="field-label">Провайдер</label>
|
||||
<select class="input" id="stt-provider">
|
||||
${optionsMarkup([{ value: 'openai', label: 'OpenAI (Whisper/Transcribe)' }], String(stt.provider || 'openai'))}
|
||||
</select>
|
||||
<label class="field-label">Уровень</label>
|
||||
<select class="input" id="stt-quality">
|
||||
${optionsMarkup([
|
||||
{ value: 'easy', label: 'Easy (дешевле)' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'hard', label: 'Hard (лучше)' },
|
||||
], String(stt.quality || 'medium'))}
|
||||
</select>
|
||||
<label class="field-label">Адрес OpenAI API</label>
|
||||
<input class="input" id="stt-base-url" type="text" value="${String(stt.baseUrl || 'https://api.openai.com/v1')}" placeholder="https://api.openai.com/v1" />
|
||||
<label class="field-label">Кастомная модель (опционально)</label>
|
||||
<input class="input" id="stt-model" type="text" value="${String(stt.model || '')}" placeholder="например: gpt-4o-mini-transcribe" />
|
||||
<label class="field-label">API key</label>
|
||||
<input class="input" id="stt-api-key" type="password" value="${String(stt.apiKey || '')}" placeholder="sk-..." />
|
||||
`;
|
||||
|
||||
const card2 = document.createElement('div');
|
||||
card2.className = 'card stack';
|
||||
card2.innerHTML = `
|
||||
<h3 class="modal-title">Прочесть вслух (TTS)</h3>
|
||||
<label class="field-label">Провайдер</label>
|
||||
<select class="input" id="tts-provider">
|
||||
${optionsMarkup([
|
||||
{ value: 'browser', label: 'Браузер (SpeechSynthesis)' },
|
||||
{ value: 'piper-http', label: 'Piper (локальный HTTP)' },
|
||||
{ value: 'openai', label: 'OpenAI TTS API' },
|
||||
], String(tts.provider || 'browser'))}
|
||||
</select>
|
||||
<label class="field-label">Уровень</label>
|
||||
<select class="input" id="tts-quality">
|
||||
${optionsMarkup([
|
||||
{ value: 'easy', label: 'Easy' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'hard', label: 'Hard' },
|
||||
], String(tts.quality || 'medium'))}
|
||||
</select>
|
||||
<label class="field-label">Голос</label>
|
||||
<input class="input" id="tts-voice" type="text" value="${String(tts.voice || '')}" placeholder="например: andrey / alloy" />
|
||||
<label class="field-label">Piper HTTP адрес</label>
|
||||
<input class="input" id="tts-piper-url" type="text" value="${String(tts.piperBaseUrl || 'http://127.0.0.1:5000')}" placeholder="http://127.0.0.1:5000" />
|
||||
<label class="field-label">OpenAI/внешний API адрес</label>
|
||||
<input class="input" id="tts-external-url" type="text" value="${String(tts.externalBaseUrl || '')}" placeholder="https://api.openai.com/v1" />
|
||||
<label class="field-label">API key (для внешнего API)</label>
|
||||
<input class="input" id="tts-api-key" type="password" value="${String(tts.apiKey || '')}" placeholder="sk-..." />
|
||||
<label class="field-label">Модель (для внешнего API)</label>
|
||||
<input class="input" id="tts-model" type="text" value="${String(tts.model || '')}" placeholder="например: gpt-4o-mini-tts" />
|
||||
<label class="field-label">Тестовая фраза</label>
|
||||
<input class="input" id="tts-test-text" type="text" value="Привет! Проверка озвучки работает." />
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" type="button" id="tts-test-btn">Проверить озвучку</button>
|
||||
<button class="ghost-btn" type="button" id="piper-autofill">Загрузить и настроить (шаблон)</button>
|
||||
<button class="ghost-btn" type="button" id="piper-links">Ссылки на Piper/голоса</button>
|
||||
</div>
|
||||
<p class="meta-muted">Для офлайн-озвучки через Piper используйте локальный HTTP-обёртчик. Кнопка «шаблон» подставляет базовые значения.</p>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="ghost-btn" type="button" id="tools-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="tools-save">Сохранить</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#tools-cancel')?.addEventListener('click', () => navigate('settings-view'));
|
||||
actions.querySelector('#tools-save')?.addEventListener('click', async () => {
|
||||
const next = {
|
||||
...state.entrySettings,
|
||||
tools: {
|
||||
speechToText: {
|
||||
provider: card.querySelector('#stt-provider')?.value || 'openai',
|
||||
quality: card.querySelector('#stt-quality')?.value || 'medium',
|
||||
baseUrl: String(card.querySelector('#stt-base-url')?.value || '').trim(),
|
||||
apiKey: String(card.querySelector('#stt-api-key')?.value || '').trim(),
|
||||
model: String(card.querySelector('#stt-model')?.value || '').trim(),
|
||||
},
|
||||
textToSpeech: {
|
||||
provider: card2.querySelector('#tts-provider')?.value || 'browser',
|
||||
quality: card2.querySelector('#tts-quality')?.value || 'medium',
|
||||
voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
|
||||
piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
|
||||
externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
|
||||
apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
|
||||
model: String(card2.querySelector('#tts-model')?.value || '').trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
await saveEntrySettings(next);
|
||||
navigate('settings-view');
|
||||
});
|
||||
|
||||
card2.querySelector('#piper-autofill')?.addEventListener('click', () => {
|
||||
card2.querySelector('#tts-provider').value = 'piper-http';
|
||||
card2.querySelector('#tts-piper-url').value = 'http://127.0.0.1:5000';
|
||||
card2.querySelector('#tts-quality').value = 'medium';
|
||||
if (!String(card2.querySelector('#tts-voice').value || '').trim()) {
|
||||
card2.querySelector('#tts-voice').value = 'ru_RU-irina-medium';
|
||||
}
|
||||
});
|
||||
|
||||
card2.querySelector('#piper-links')?.addEventListener('click', () => {
|
||||
window.open('https://github.com/rhasspy/piper', '_blank', 'noopener,noreferrer');
|
||||
window.open('https://huggingface.co/rhasspy/piper-voices/tree/main', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
card2.querySelector('#tts-test-btn')?.addEventListener('click', async () => {
|
||||
const ttsProvider = card2.querySelector('#tts-provider')?.value || 'openai';
|
||||
const text = String(card2.querySelector('#tts-test-text')?.value || '').trim();
|
||||
const runtimeSettings = {
|
||||
...state.entrySettings,
|
||||
tools: {
|
||||
...state.entrySettings.tools,
|
||||
textToSpeech: {
|
||||
provider: ttsProvider,
|
||||
quality: card2.querySelector('#tts-quality')?.value || 'medium',
|
||||
voice: String(card2.querySelector('#tts-voice')?.value || '').trim(),
|
||||
piperBaseUrl: String(card2.querySelector('#tts-piper-url')?.value || '').trim(),
|
||||
externalBaseUrl: String(card2.querySelector('#tts-external-url')?.value || '').trim(),
|
||||
apiKey: String(card2.querySelector('#tts-api-key')?.value || '').trim(),
|
||||
model: String(card2.querySelector('#tts-model')?.value || '').trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await speakTextBySettings(text || 'Проверка озвучки', runtimeSettings);
|
||||
} catch (error) {
|
||||
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Настройки инструментов',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
card,
|
||||
card2,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
@ -147,6 +147,7 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'server-settings-view' ||
|
||||
pageId === 'tools-settings-view' ||
|
||||
pageId === 'device-view' ||
|
||||
pageId === 'connect-device-view' ||
|
||||
pageId === 'device-qr-view' ||
|
||||
|
||||
242
shine-UI/js/services/speech-tools-service.js
Normal file
242
shine-UI/js/services/speech-tools-service.js
Normal file
@ -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('Неизвестный провайдер озвучки.');
|
||||
}
|
||||
@ -81,6 +81,31 @@ const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com';
|
||||
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
|
||||
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
|
||||
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
|
||||
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
||||
|
||||
function normalizeToolsSettings(rawTools) {
|
||||
const source = rawTools && typeof rawTools === 'object' ? rawTools : {};
|
||||
const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {};
|
||||
const tts = source.textToSpeech && typeof source.textToSpeech === 'object' ? source.textToSpeech : {};
|
||||
return {
|
||||
speechToText: {
|
||||
provider: String(stt.provider || 'openai'),
|
||||
baseUrl: String(stt.baseUrl || DEFAULT_OPENAI_BASE_URL),
|
||||
apiKey: String(stt.apiKey || ''),
|
||||
quality: String(stt.quality || 'medium'),
|
||||
model: String(stt.model || ''),
|
||||
},
|
||||
textToSpeech: {
|
||||
provider: String(tts.provider || 'openai'),
|
||||
quality: String(tts.quality || 'medium'),
|
||||
voice: String(tts.voice || ''),
|
||||
piperBaseUrl: String(tts.piperBaseUrl || 'http://127.0.0.1:5000'),
|
||||
externalBaseUrl: String(tts.externalBaseUrl || ''),
|
||||
apiKey: String(tts.apiKey || ''),
|
||||
model: String(tts.model || ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadStoredSession() {
|
||||
try {
|
||||
@ -153,6 +178,7 @@ function persistEntrySettings(settings) {
|
||||
shineServer: String(settings?.statuses?.shineServer || 'idle'),
|
||||
arweaveServer: String(settings?.statuses?.arweaveServer || 'idle'),
|
||||
},
|
||||
tools: normalizeToolsSettings(settings?.tools),
|
||||
};
|
||||
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
@ -216,6 +242,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'),
|
||||
arweaveServer: String(storedEntrySettings?.statuses?.arweaveServer || 'idle'),
|
||||
},
|
||||
tools: normalizeToolsSettings(storedEntrySettings?.tools),
|
||||
},
|
||||
registrationDraft: {
|
||||
flowType: '',
|
||||
@ -630,6 +657,7 @@ export async function saveEntrySettings(nextSettings) {
|
||||
...state.entrySettings.statuses,
|
||||
...(nextSettings.statuses || {}),
|
||||
},
|
||||
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
|
||||
};
|
||||
persistEntrySettings(state.entrySettings);
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
|
||||
@ -3381,6 +3381,28 @@ textarea.input {
|
||||
|
||||
.dm-chat-input {
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.dm-voice-btn {
|
||||
min-width: 42px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.voice-level-wrap {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.voice-level-fill {
|
||||
width: 2%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: rgba(212, 175, 55, 0.95);
|
||||
transition: width 80ms linear;
|
||||
}
|
||||
|
||||
.dm-screen .input,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user