From 9c13c218984e5da3357621abb68b0e132573be91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 22 May 2026 18:39:13 +0400 Subject: [PATCH] =?UTF-8?q?v64:=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=E2=80=94=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=20=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=20=D0=BD=D0=B0=20css/=20js/=20build.sh=20dist/=20(781=E2=86=92?= =?UTF-8?q?43=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20index.html)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.sh | 21 ++ css/style.css | 16 + deploy_lang.sh | 7 +- dist/index.html | 770 +++++++++++++++++++++++++++++++++++++++++++++++ index.html | 753 +-------------------------------------------- js/audio.js | 252 ++++++++++++++++ js/compare.js | 119 ++++++++ js/main.js | 120 ++++++++ js/storage.js | 102 +++++++ js/syllables.js | 56 ++++ js/transcribe.js | 47 +++ 11 files changed, 1516 insertions(+), 747 deletions(-) create mode 100755 build.sh create mode 100644 css/style.css create mode 100644 dist/index.html create mode 100644 js/audio.js create mode 100644 js/compare.js create mode 100644 js/main.js create mode 100644 js/storage.js create mode 100644 js/syllables.js create mode 100644 js/transcribe.js diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..a284678 --- /dev/null +++ b/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Склеивает CSS/JS в единый index.html для деплоя (ConfigMap — один файл).""" +import os + +BASE = os.path.dirname(os.path.abspath(__file__)) +html = open(os.path.join(BASE, 'index.html')).read() + +css = open(os.path.join(BASE, 'css/style.css')).read() +html = html.replace('', + f'') + +for jsf in ['storage.js', 'syllables.js', 'transcribe.js', 'audio.js', 'compare.js', 'main.js']: + js = open(os.path.join(BASE, 'js', jsf)).read() + html = html.replace(f'', + f'') + +os.makedirs(os.path.join(BASE, 'dist'), exist_ok=True) +with open(os.path.join(BASE, 'dist/index.html'), 'w') as f: + f.write(html) + +print(f'dist/index.html ready ({len(html)} bytes)') diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..fd1a01f --- /dev/null +++ b/css/style.css @@ -0,0 +1,16 @@ +/* Lyngvo — Итальянский тренажёр произношения */ +body { background: #f8f8fa; font-family: sans-serif; } +#app { max-width:900px; margin:20px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:24px 32px; } +textarea { width:100%; font-size:1.2em; border-radius:6px; border:1px solid #ccc; padding:8px; } +button { font-size:1em; padding:8px 18px; border-radius:6px; border:none; background:#2a7cff; color:#fff; cursor:pointer; } +button:disabled { background:#ccc; cursor:not-allowed; } +#score { font-size:1.5em; margin:12px 0; } +#diff { font-size:1.2em; line-height:1.8; } +#history { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; } +#history span { font-size:0.85em; background:#e8e8f0; padding:3px 10px; border-radius:12px; cursor:pointer; } +#history span:hover { background:#d0d0e0; } +.phonetics { font-size:0.85em; color:#666; margin-top:6px; display:flex; gap:16px; flex-wrap:wrap; } +.recItem { display:flex; align-items:center; gap:8px; padding:4px 8px; background:#f0f0f5; border-radius:8px; margin:4px 0; } +.recItem button { font-size:0.8em; padding:4px 10px; } +.syl-chip { cursor:pointer; padding:2px 4px; border-radius:4px; transition:0.15s; } +.syl-chip:hover { background:#e0e0ff; } diff --git a/deploy_lang.sh b/deploy_lang.sh index 28efabc..d7330f7 100755 --- a/deploy_lang.sh +++ b/deploy_lang.sh @@ -1,9 +1,12 @@ #!/bin/bash set -e -# Вклеиваем Groq API ключ в index.html +# Собираем dist/index.html (CSS + JS встроены) +~/lang/.venv/bin/python3 ~/lang/build.sh + +# Вклеиваем Groq API ключ в dist/index.html KEY=$(cat ~/lang/token.txt) -cp ~/lang/index.html /tmp/lyngvo_index.html +cp ~/lang/dist/index.html /tmp/lyngvo_index.html sed -i "s|const GROQ_API_KEY = '';|const GROQ_API_KEY = '${KEY}';|" /tmp/lyngvo_index.html # Синхронизация всей папки lang на ВМ diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..6f16cc5 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,770 @@ + + + + + + + + Lyngvo — Итальянский тренажёр произношения + + + + +
+

🇮🇹 Lyngvo тренажёр итальянского произношения · для Google Chrome

+
+ 1. Введите фразу  →  2. ▶ Эталон (прослушайте)  →  3. 🎙 Записать (произнесите)  →  4. 📊 Сравнить +
+ +
+
+ + + + + +
+
+ +
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/index.html b/index.html index b8f5b4b..1751fea 100644 --- a/index.html +++ b/index.html @@ -7,21 +7,7 @@ Lyngvo — Итальянский тренажёр произношения - +
@@ -47,735 +33,12 @@
- + + + + + + - + \ No newline at end of file diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..f773d64 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,252 @@ +// ---------- Аудио: запись, TTS, фонетический анализ ---------- + +// ---------- Индикатор микрофона ---------- +function startMicMeter(stream) { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const src = ctx.createMediaStreamSource(stream); + micAnalyser = ctx.createAnalyser(); + micAnalyser.fftSize = 256; + src.connect(micAnalyser); + const bar = document.getElementById('micBar'); + const fill = document.getElementById('micFill'); + bar.style.display = 'block'; + fill.style.width = '0%'; + const data = new Uint8Array(micAnalyser.frequencyBinCount); + function tick() { + if (!micAnalyser) return; + micAnalyser.getByteFrequencyData(data); + const max = Math.max(...data); + const pct = Math.min(100, Math.round(max / 255 * 100)); + fill.style.width = pct + '%'; + fill.style.background = pct > 70 ? '#ff5722' : pct > 30 ? '#ff9800' : '#4caf50'; + micAnimId = requestAnimationFrame(tick); + } + tick(); + } catch(e) { console.warn('Mic meter:', e); } +} + +function stopMicMeter() { + if (micAnimId) cancelAnimationFrame(micAnimId); + micAnimId = null; + micAnalyser = null; + document.getElementById('micBar').style.display = 'none'; +} + +// ---------- TTS ---------- +function playTTS(text) { + return new Promise((resolve) => { + const utter = new SpeechSynthesisUtterance(text); + utter.lang = 'it-IT'; + utter.rate = 0.9; + const voices = speechSynthesis.getVoices(); + const it = voices.find(v => v.lang.startsWith('it')); + if (it) utter.voice = it; + utter.onend = resolve; + utter.onerror = resolve; + speechSynthesis.cancel(); + speechSynthesis.speak(utter); + }); +} + +// ---------- Запись ---------- +async function startRecording() { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + startMicMeter(stream); + audioChunks = []; + let mimeType = 'audio/webm;codecs=opus'; + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'audio/webm'; + if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''; + } + const opts = mimeType ? { mimeType } : {}; + mediaRecorder = new MediaRecorder(stream, opts); + mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; + mediaRecorder.onstop = () => { + stopMicMeter(); + recordDuration = (Date.now() - recordStartTime) / 1000; + userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' }); + stream.getTracks().forEach(t => t.stop()); + enable('btnCompare', true); + enable('btnStereo', true); + enable('btnPlayUser', true); + const btn = document.getElementById('btnRecord'); + btn.textContent = '🎙 Записать'; + btn.disabled = false; + isRecording = false; + setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB'); + if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration); + else { + const t = document.getElementById('inputText').value.trim(); + if (t) saveRecording(userAudioBlob, t, recordDuration); + } + }; + mediaRecorder.start(100); + recordStartTime = Date.now(); + setStatus('Идёт запись...'); +} + +function stopRecording() { + if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); +} + +// ---------- Воспроизведение ---------- +async function playRec(id) { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readonly'); + const store = tx.objectStore('recs'); + const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); }); + if (rec?.blob) { + const url = URL.createObjectURL(rec.blob); + const audio = new Audio(url); + await new Promise((resolve, reject) => { + audio.onended = () => { URL.revokeObjectURL(url); resolve(); }; + audio.onerror = () => { URL.revokeObjectURL(url); reject(); }; + audio.play(); + }); + const tr = await doCompare(rec.blob, rec.word, rec.duration); + if (tr) saveTranscription(id, tr); + } + } catch(e) { /* тихо */ } +} + +async function playStereo(ttsText, userBlob) { + const ctx = new AudioContext(); + const userURL = URL.createObjectURL(userBlob); + const userAudio = new Audio(userURL); + const userSource = ctx.createMediaElementSource(userAudio); + const userPan = ctx.createStereoPanner(); + userPan.pan.value = 1; + userSource.connect(userPan).connect(ctx.destination); + await playTTS(ttsText); + userAudio.play(); + setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос'); +} + +// ---------- Фонетический анализ ---------- +function phoneticAnalysis(blob) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100); + const buf = await ctx.decodeAudioData(e.target.result); + const data = buf.getChannelData(0); + const sr = buf.sampleRate; + const len = data.length; + const frameSize = Math.floor(sr * 0.025); + const step = Math.floor(frameSize / 2); + const numFrames = Math.max(1, Math.floor((len - frameSize) / step)); + + const rmsArr = new Float32Array(numFrames); + const zcrArr = new Float32Array(numFrames); + for (let fi = 0; fi < numFrames; fi++) { + const s = fi * step; + let rmsSum = 0, zcr = 0; + for (let i = s; i < s + frameSize; i++) { + rmsSum += data[i] * data[i]; + if (i > s && (data[i] >= 0) !== (data[i - 1] >= 0)) zcr++; + } + rmsArr[fi] = Math.sqrt(rmsSum / frameSize); + zcrArr[fi] = zcr / frameSize * sr; + } + + let meanRms = 0; + for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi]; + meanRms /= numFrames; + const energyThresh = Math.max(0.005, meanRms * 0.4); + + const pitches = []; + let voicedCount = 0; + for (let fi = 0; fi < numFrames; fi++) { + if (rmsArr[fi] > energyThresh) { + voicedCount++; + const p = pitchFrame(data, fi * step, frameSize, sr); + if (p > 70 && p < 450) pitches.push(p); + } + } + const voiceRatio = voicedCount / numFrames; + + let pitchHz = 0, pitchStability = 0; + if (pitches.length >= 5) { + const sorted = [...pitches].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + const mad = pitches.map(p => Math.abs(p - median)).sort((a, b) => a - b)[Math.floor(pitches.length / 2)]; + const relMad = (mad / median) * 100; + pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5))); + pitchHz = Math.round(median); + } + + const vzArr = []; + for (let fi = 0; fi < numFrames; fi++) { + if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]); + } + let articulationScore = 40; + if (vzArr.length > 0) { + const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length; + articulationScore = Math.round(Math.max(0, Math.min(100, + mzc < 150 ? mzc / 1.5 : + mzc < 500 ? 60 + (mzc - 150) / 350 * 20 : + mzc < 1800 ? 80 + (mzc - 500) / 1300 * 18 : + mzc < 3000 ? 98 - (mzc - 1800) / 1200 * 40 : + Math.max(0, 58 - (mzc - 3000) / 1000 * 20) + ))); + } + + const endStart = Math.floor(numFrames * 0.78); + let endVoiced = 0; + const endTotal = numFrames - endStart; + for (let fi = endStart; fi < numFrames; fi++) { + if (rmsArr[fi] > energyThresh) endVoiced++; + } + const endingScore = endTotal > 0 ? Math.round(Math.min(100, (endVoiced / endTotal) * 125)) : 50; + + const clarityScore = Math.round( + voiceRatio < 0.05 ? 0 : + voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 : + voiceRatio < 0.65 ? 50 + (voiceRatio - 0.35) / 0.30 * 45 : + voiceRatio < 0.82 ? 95 - (voiceRatio - 0.65) / 0.17 * 15 : + Math.max(40, 80 - (voiceRatio - 0.82) / 0.18 * 40) + ); + + const hasSpeech = voiceRatio > 0.08; + const phonScore = hasSpeech ? Math.round( + pitchStability * 0.30 + + articulationScore * 0.25 + + clarityScore * 0.25 + + endingScore * 0.20 + ) : 0; + + resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore }); + } catch (err) { + resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 }); + } + }; + reader.readAsArrayBuffer(blob); + }); +} + +function pitchFrame(data, start, len, sr) { + const n = len; + let e0 = 0; + for (let i = 0; i < n; i++) e0 += data[start + i] * data[start + i]; + if (e0 < 1e-8) return 0; + const minLag = Math.floor(sr / 400); + const maxLag = Math.min(n - 1, Math.floor(sr / 70)); + let bestR = 0, bestLag = 0; + for (let lag = minLag; lag <= maxLag; lag++) { + let num = 0, e1 = 0, e2 = 0; + const m = n - lag; + for (let i = 0; i < m; i++) { + const a = data[start + i], b = data[start + i + lag]; + num += a * b; + e1 += a * a; + e2 += b * b; + } + const den = Math.sqrt(e1 * e2); + const r = den > 0 ? num / den : 0; + if (r > bestR) { bestR = r; bestLag = lag; } + } + return bestR > 0.30 ? sr / bestLag : 0; +} diff --git a/js/compare.js b/js/compare.js new file mode 100644 index 0000000..21c8db4 --- /dev/null +++ b/js/compare.js @@ -0,0 +1,119 @@ +// ---------- Сравнение текстов (Левенштейн) и doCompare ---------- + +function normalize(text) { + return text.toLowerCase().replace(/[.,!?;:«»"'’]/g, '').trim(); +} + +function levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({length: m+1}, (_,i) => [i]); + for (let j=0; j<=n; j++) dp[0][j] = j; + for (let i=1; i<=m; i++) + for (let j=1; j<=n; j++) + dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]); + return dp[m][n]; +} + +function wordSimilarity(a, b) { + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + return 1 - levenshtein(a, b) / maxLen; +} + +function compareTexts(original, transcribed) { + const a = normalize(original).split(/\s+/); + const b = normalize(transcribed).split(/\s+/); + let totalScore = 0; + const result = a.map(word => { + let best = 0, bestMatch = ''; + for (const t of b) { + const sim = wordSimilarity(word, t); + if (sim > best) { best = sim; bestMatch = t; } + } + totalScore += best; + const quality = best >= 0.8 ? 'ok' : best >= 0.5 ? 'near' : 'bad'; + return { word, quality, bestMatch, score: Math.round(best*100) }; + }); + const score = Math.round((totalScore / a.length) * 100); + return { score, words: result }; +} + +function renderDiff(words) { + return words.map(({ word, quality, bestMatch, score }) => { + const color = quality === 'ok' ? 'green' : quality === 'near' ? '#e6a800' : 'red'; + const tip = quality !== 'ok' ? ` → ${bestMatch} (${score}%)` : ''; + return `${word}${tip}`; + }).join(' '); +} + +async function doCompare(blob, originalText, duration) { + if (isAnalyzing) return ''; + isAnalyzing = true; + const startTime = Date.now(); + const timerId = setInterval(() => { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + setStatus('Анализ... ' + elapsed + 'с'); + }, 150); + let whisperResult, phon; + try { + const phonTimeout = new Promise(resolve => + setTimeout(() => resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 }), 15000) + ); + [whisperResult, phon] = await Promise.all([ + transcribe(blob), + Promise.race([phoneticAnalysis(blob), phonTimeout]) + ]); + } catch(e) { + clearInterval(timerId); + isAnalyzing = false; + setStatus('Ошибка анализа.'); + return ''; + } + clearInterval(timerId); + + const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴'; + let recHTML = '', diffHTML = ''; + + if (whisperResult.text) { + const _transcribed = whisperResult.text; + const { score, words } = compareTexts(originalText, whisperResult.text); + const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴'; + recHTML = recEmoji + ' Распознание: ' + score + '%  |  🗣 ' + whisperResult.text + '  |  '; + diffHTML = + '
' + + '📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' + + '
' + + renderDiff(words); + } else { + recHTML = ''; + diffHTML = ''; + } + + document.getElementById('score').innerHTML = + recHTML + phonEmoji + ' Произношение: ' + phon.phonScore + '%'; + + document.getElementById('diff').innerHTML = diffHTML + + '
' + + '🎵 Тон: ' + phon.pitchHz + ' Hz' + + '📏 Стабильность: ' + phon.pitchStability + '%' + + '🗣 Артикуляция: ' + phon.voiceClarity + '%' + + '
'; + + // Слоги — кликабельные + _currentBlob = blob; + _currentSylBuf = null; + const sy = syllabifyIT(originalText); + if (sy && sy.includes('-')) { + const syls = sy.split('-'); + const sylHTML = '
' + + syls.map((s, i) => + '' + s + '' + ).join('·') + + '
'; + document.getElementById('diff').innerHTML += sylHTML; + } + + setStatus('Готово.'); + isAnalyzing = false; + return whisperResult.text || ''; +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..36a7422 --- /dev/null +++ b/js/main.js @@ -0,0 +1,120 @@ +// ---------- Глобальные переменные и инициализация ---------- +const GROQ_API_KEY = ''; +const VERSION = 'v64'; + +let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = ''; +let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null; +let recordStartTime = 0, recordDuration = 0; + +function setStatus(msg) { document.getElementById('status').textContent = msg; } +function enable(id, val) { document.getElementById(id).disabled = !val; } + +// ---------- Инициализация ---------- +document.getElementById('ver').textContent = VERSION; +renderHistory(); +renderRecHistory(); + +// ---------- Кнопка Запись ---------- +document.getElementById('btnRecord').onclick = async function () { + const btn = this; + if (isRecording) { + btn.disabled = true; + stopRecording(); + return; + } + try { + isRecording = true; + btn.textContent = '■ Стоп'; + await startRecording(); + } catch (err) { + let msg = '⛔ '; + if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Нажмите на замочек 🔒 в адресной строке → Микрофон → Разрешить.'; + else if (err.name === 'NotFoundError') msg += 'Микрофон не найден. Подключите микрофон.'; + else if (err.name === 'NotReadableError') msg += 'Микрофон занят другим приложением.'; + else msg += 'Ошибка: ' + (err.message || err.name); + setStatus(msg); + btn.textContent = '🎙 Записать'; + isRecording = false; + } +}; + +// ---------- Воспроизведение своей записи ---------- +document.getElementById('btnPlayUser').onclick = async () => { + if (!userAudioBlob) return setStatus('Нет записи!'); + if (userAudioBlob.size === 0) return setStatus('Запись пустая!'); + const url = URL.createObjectURL(userAudioBlob); + const audio = new Audio(url); + audio.volume = 1.0; + audio.onended = () => { URL.revokeObjectURL(url); setStatus('Готово.'); }; + audio.onerror = () => { URL.revokeObjectURL(url); setStatus('Ошибка воспроизведения.'); }; + setStatus('🔊 Ваша запись...'); + try { + await audio.play(); + } catch (err) { + URL.revokeObjectURL(url); + setStatus('Ошибка воспроизведения — нажмите ещё раз.'); + return; + } + const original = document.getElementById('inputText').value.trim(); + if (original && userAudioBlob) { + await doCompare(userAudioBlob, original, recordDuration); + } +}; + +// ---------- Кнопки ---------- +document.getElementById('btnTTS').onclick = async () => { + ttsText = document.getElementById('inputText').value.trim(); + if (!ttsText) return setStatus('Введите текст!'); + saveToHistory(ttsText); + translateToRussian(ttsText); + setStatus('🔊 Эталон...'); + await playTTS(ttsText); + setStatus('Готово.'); +}; + +document.getElementById('btnCompare').onclick = async () => { + if (!userAudioBlob) return setStatus('Нет записи!'); + const original = document.getElementById('inputText').value.trim(); + await doCompare(userAudioBlob, original, recordDuration); +}; + +document.getElementById('btnStereo').onclick = async () => { + const text = document.getElementById('inputText').value.trim(); + if (!text) return setStatus('Введите текст для эталона!'); + if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.'); + await playStereo(text, userAudioBlob); + const original = document.getElementById('inputText').value.trim(); + if (original && userAudioBlob) { + await doCompare(userAudioBlob, original, recordDuration); + } +}; + +// ---------- История: compareRec / stereoRec ---------- +async function compareRec(id) { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readonly'); + const store = tx.objectStore('recs'); + const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); }); + if (rec?.blob) { + document.getElementById('inputText').value = rec.word; + const tr = await doCompare(rec.blob, rec.word, rec.duration); + if (tr) saveTranscription(id, tr); + } + } catch(e) { setStatus('Ошибка загрузки записи.'); } +} + +async function stereoRec(id) { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readonly'); + const store = tx.objectStore('recs'); + const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); }); + if (rec?.blob) { + document.getElementById('inputText').value = rec.word; + await playStereo(rec.word, rec.blob); + const tr = await doCompare(rec.blob, rec.word, rec.duration); + if (tr) saveTranscription(id, tr); + } + } catch(e) { setStatus('Ошибка загрузки записи.'); } +} diff --git a/js/storage.js b/js/storage.js new file mode 100644 index 0000000..39d3f72 --- /dev/null +++ b/js/storage.js @@ -0,0 +1,102 @@ +// ---------- Хранилище (IndexedDB + localStorage) ---------- + +function openRecDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open('lyngvo_recs', 1); + req.onupgradeneeded = () => { req.result.createObjectStore('recs', { keyPath: 'id', autoIncrement: true }); }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function saveTranscription(id, text) { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readwrite'); + const store = tx.objectStore('recs'); + const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); }); + if (rec) { rec.transcription = text; store.put(rec); } + await new Promise(r => { tx.oncomplete = r; }); + renderRecHistory(); + } catch(e) { /* тихо */ } +} + +async function saveRecording(blob, word, duration) { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readwrite'); + const store = tx.objectStore('recs'); + store.add({ word, duration, blob, ts: Date.now() }); + const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); }); + if (all.length > 10) { + const toDelete = all.sort((a,b) => a.ts - b.ts).slice(0, all.length - 10); + for (const rec of toDelete) store.delete(rec.id); + } + await new Promise(r => { tx.oncomplete = r; }); + renderRecHistory(); + } catch(e) { /* тихо */ } +} + +async function loadRecordings() { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readonly'); + const store = tx.objectStore('recs'); + const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); }); + return all.sort((a,b) => b.ts - a.ts); + } catch(e) { return []; } +} + +async function renderRecHistory() { + const recs = await loadRecordings(); + const div = document.getElementById('recHistory'); + if (!recs.length) { div.innerHTML = ''; return; } + div.innerHTML = '
📼 История записей (выберите для стерео):
' + + recs.map(r => { + const d = new Date(r.ts); + const time = d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'}); + return '
' + + '' + r.word + '' + + '' + time + ' · ' + r.duration.toFixed(1) + 'с' + + '' + + '' + + '' + + (r.transcription ? '' + syllabifyPhrase(r.transcription) + '' : '') + + '' + + '
'; + }).join(''); +} + +async function deleteRec(id) { + try { + const db = await openRecDB(); + const tx = db.transaction('recs', 'readwrite'); + tx.objectStore('recs').delete(id); + await new Promise(r => { tx.oncomplete = r; }); + renderRecHistory(); + } catch(e) { /* тихо */ } +} + +// ---------- История фраз (localStorage) ---------- +const HIST_KEY = 'lyngvo_phrases'; +function loadHistory() { + try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; } + catch { return []; } +} +function saveToHistory(text) { + let hist = loadHistory().filter(t => t !== text); + hist.unshift(text); + hist = hist.slice(0, 5); + localStorage.setItem(HIST_KEY, JSON.stringify(hist)); + renderHistory(); +} +function renderHistory() { + const hist = loadHistory(); + const div = document.getElementById('history'); + if (!hist.length) { div.innerHTML = ''; return; } + div.innerHTML = hist.map(t => + '' + t + '' + ).join(''); +} diff --git a/js/syllables.js b/js/syllables.js new file mode 100644 index 0000000..9aa3849 --- /dev/null +++ b/js/syllables.js @@ -0,0 +1,56 @@ +// ---------- Слоги ---------- + +function syllabifyIT(w) { + const isV = c => 'aeiouàèéìíòóùú'.includes(c.toLowerCase()); + const onset = s => /^(str|scr|spr|spl|bl|br|cl|cr|dr|fl|fr|gl|gn|gr|pl|pr|sc|sk|sl|sm|sn|sp|sq|sr|st|sv|tr|ch|gh)/i.test(s); + let out = [], syl = '', i = 0; + while (i < w.length) { + syl += w[i]; + if (isV(w[i])) { + let j = i + 1, cons = ''; + while (j < w.length && !isV(w[j])) cons += w[j++]; + if (j < w.length && cons.length > 0) { + const k = cons.length === 1 ? 0 : onset(cons.slice(1)) ? 1 : Math.floor(cons.length / 2); + syl += cons.slice(0, k); + out.push(syl); syl = ''; + i += k; + } + } + i++; + } + if (syl) out.push(syl); + return out.join('-'); +} + +function syllabifyPhrase(text) { + return text.split(/\s+/).map(syllabifyIT).join(' '); +} + +// ---------- AudioContext для проигрывания слогов ---------- +let _audioCtx = null; +function getAudioCtx() { + if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (_audioCtx.state === 'suspended') _audioCtx.resume(); + return _audioCtx; +} + +let _currentBlob = null; +let _currentSylBuf = null; + +async function playSyllable(sylIdx, total) { + if (!_currentBlob) return; + const ctx = getAudioCtx(); + try { + if (!_currentSylBuf) { + const ab = await _currentBlob.arrayBuffer(); + _currentSylBuf = await ctx.decodeAudioData(ab); + } + const dur = _currentSylBuf.duration; + const start = (dur / total) * sylIdx; + const len = dur / total; + const src = ctx.createBufferSource(); + src.buffer = _currentSylBuf; + src.connect(ctx.destination); + src.start(0, start, len); + } catch(e) { console.log('playSyllable ERR', e); } +} diff --git a/js/transcribe.js b/js/transcribe.js new file mode 100644 index 0000000..d5b2e33 --- /dev/null +++ b/js/transcribe.js @@ -0,0 +1,47 @@ +// ---------- Groq API: транскрипция и перевод ---------- + +async function transcribe(blob) { + if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; + const tStart = performance.now(); + + const form = new FormData(); + form.append('file', blob, 'audio.webm'); + form.append('model', 'whisper-large-v3'); + form.append('language', 'it'); + + console.log('[HTTP] blob ' + (blob.size/1024).toFixed(1) + 'KB → multipart POST'); + + try { + const r = await fetch('https://proxy.kube5s.ru/openai/v1/audio/transcriptions', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY }, + body: form + }); + const data = await r.json(); + console.log('[HTTP] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)')); + return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null }; + } catch(e) { + console.log('[HTTP] ERR', e); + return { text: '', error: 'network' }; + } +} + +async function translateToRussian(text) { + if (!GROQ_API_KEY) return; + try { + const res = await fetch('https://proxy.kube5s.ru/openai/v1/chat/completions', { + method: 'POST', + headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }], + max_tokens: 50, + temperature: 0 + }) + }); + const data = await res.json(); + if (data.choices?.[0]?.message?.content) { + document.getElementById('translation').textContent = '🇷🇺 ' + data.choices[0].message.content; + } + } catch(e) { /* тихо */ } +}