v4.1.12: 보안 취약점 수정 — RCE, 경로탈출 2건, IDOR, XSS(스튜디오/언어 앱)
- [CRITICAL] image_edit/imagegen 도구의 숫자 파라미터(x,y,width,height,degrees,opacity,quality,seed,guidance_scale,num_frames,fps)가 검증 없이 Python 소스에 직접 문자열 삽입되어 원격 코드 실행 가능했음. toFiniteNumber()로 전 지점 강제 숫자 변환 — 실제 페이로드로 라이브 검증 완료 - [HIGH] imageStyleTransformTool과 /api/imagegen/save-result에 경로 탈출 방어(isPathInsideDir) 누락 — 다른 사용자 워크스페이스/임의 파일 접근 가능했음. 둘 다 수정 후 실제 ../ 페이로드로 차단 확인 - [HIGH] 언어 앱 진행상황 저장 API가 URL의 :userId를 그대로 신뢰해 다른 사용자 진행상황을 읽고 덮어쓸 수 있었음(IDOR). 세션 기반으로 수정, 실제 다른 사용자명으로 테스트해 본인 워크스페이스에만 저장됨을 확인 - [MEDIUM] 언어 앱 플래시카드·단어장 렌더링(LLM 생성 콘텐츠)이 이스케이프 없이 innerHTML에 삽입되던 XSS 경로 수정(escHtml 추가) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smallclaw",
|
"name": "smallclaw",
|
||||||
"version": "4.1.11",
|
"version": "4.1.12",
|
||||||
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
|
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -104,9 +104,14 @@ export function registerLanguageRoutes(app: express.Application): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// NOTE: the :userId route param is intentionally ignored for workspace resolution —
|
||||||
|
// progress always belongs to the authenticated session, never an arbitrary URL param,
|
||||||
|
// otherwise any logged-in user could read/overwrite any other user's progress file.
|
||||||
app.get('/api/language/progress/:userId', (req: express.Request, res: express.Response) => {
|
app.get('/api/language/progress/:userId', (req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
const workspace = getWorkspacePath(String(req.params.userId));
|
const sessionUser = (req as any).user;
|
||||||
|
if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
|
||||||
|
const workspace = getWorkspacePath(sessionUser.username);
|
||||||
const progressPath = path.join(workspace, 'language', 'progress.json');
|
const progressPath = path.join(workspace, 'language', 'progress.json');
|
||||||
if (!fs.existsSync(progressPath)) { res.json({ success: true, progress: null }); return; }
|
if (!fs.existsSync(progressPath)) { res.json({ success: true, progress: null }); return; }
|
||||||
res.json({ success: true, progress: JSON.parse(fs.readFileSync(progressPath, 'utf-8')) });
|
res.json({ success: true, progress: JSON.parse(fs.readFileSync(progressPath, 'utf-8')) });
|
||||||
@@ -117,9 +122,11 @@ export function registerLanguageRoutes(app: express.Application): void {
|
|||||||
|
|
||||||
app.post('/api/language/progress/:userId', (req: express.Request, res: express.Response) => {
|
app.post('/api/language/progress/:userId', (req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
|
const sessionUser = (req as any).user;
|
||||||
|
if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
if (!body || typeof body !== 'object') { res.status(400).json({ success: false, error: 'body required' }); return; }
|
if (!body || typeof body !== 'object') { res.status(400).json({ success: false, error: 'body required' }); return; }
|
||||||
const workspace = getWorkspacePath(String(req.params.userId));
|
const workspace = getWorkspacePath(sessionUser.username);
|
||||||
const langDir = path.join(workspace, 'language');
|
const langDir = path.join(workspace, 'language');
|
||||||
fs.mkdirSync(langDir, { recursive: true });
|
fs.mkdirSync(langDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(langDir, 'progress.json'), JSON.stringify(body, null, 2), 'utf-8');
|
fs.writeFileSync(path.join(langDir, 'progress.json'), JSON.stringify(body, null, 2), 'utf-8');
|
||||||
@@ -129,6 +136,41 @@ export function registerLanguageRoutes(app: express.Application): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/language/interpret — 실시간 한국어↔우즈베크어 통역 (언어 자동 판별)
|
||||||
|
app.post('/api/language/interpret', async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const text = String(req.body?.text || '').trim().slice(0, 500);
|
||||||
|
if (!text) { res.status(400).json({ success: false, error: 'text required' }); return; }
|
||||||
|
|
||||||
|
const ollama = getOllamaClient();
|
||||||
|
const prompt = `다음 텍스트가 한국어인지 우즈베크어인지 판별하고, 반대 언어로 번역하세요.
|
||||||
|
텍스트: "${text}"
|
||||||
|
JSON으로만 응답하세요 (코드 블록 없이):
|
||||||
|
{"source_lang":"ko 또는 uz","translated":"번역문","romanization":"번역문이 우즈베크어면 한글 발음 표기, 한국어면 빈 문자열"}
|
||||||
|
우즈베크어는 1993년 이후 표준 라틴 문자(oʻ, gʻ 포함)를 사용하세요. 자연스럽고 간결하게 번역하세요.`;
|
||||||
|
|
||||||
|
const result = await ollama.chatWithThinking(
|
||||||
|
[{ role: 'user' as const, content: prompt }],
|
||||||
|
'executor',
|
||||||
|
{ num_predict: 400, temperature: 0.2, think: false }
|
||||||
|
);
|
||||||
|
const raw = String(result.message?.content || '').trim();
|
||||||
|
let parsed: any;
|
||||||
|
try { parsed = extractJson(raw, 'object'); } catch { parsed = null; }
|
||||||
|
if (!parsed?.translated) {
|
||||||
|
res.json({ success: false, error: '번역 실패', raw }); return;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
source_lang: parsed.source_lang === 'uz' ? 'uz' : 'ko',
|
||||||
|
translated: String(parsed.translated),
|
||||||
|
romanization: String(parsed.romanization || ''),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, error: String(err?.message || err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/language/tts — Uzbek TTS via edge-tts
|
// POST /api/language/tts — Uzbek TTS via edge-tts
|
||||||
app.post('/api/language/tts', async (req: express.Request, res: express.Response) => {
|
app.post('/api/language/tts', async (req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Express, Request, Response } from 'express';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js';
|
import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js';
|
||||||
import { imageEditTool } from '../../tools/image.js';
|
import { imageEditTool, isPathInsideDir } from '../../tools/image.js';
|
||||||
|
|
||||||
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||||
const VIDEO_EXTS = new Set(['.mp4']);
|
const VIDEO_EXTS = new Set(['.mp4']);
|
||||||
@@ -232,6 +232,7 @@ export function registerImagegenRoutes(app: Express): void {
|
|||||||
if (!srcRel) return res.status(400).json({ error: 'path is required' });
|
if (!srcRel) return res.status(400).json({ error: 'path is required' });
|
||||||
|
|
||||||
const srcFull = path.join(user.workspace, srcRel);
|
const srcFull = path.join(user.workspace, srcRel);
|
||||||
|
if (!isPathInsideDir(user.workspace, srcFull)) return res.status(403).json({ error: 'Access denied: path escapes workspace' });
|
||||||
if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' });
|
if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' });
|
||||||
|
|
||||||
console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`);
|
console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`);
|
||||||
|
|||||||
+20
-9
@@ -4,7 +4,7 @@ import fs from 'fs';
|
|||||||
import { ToolResult } from '../types.js';
|
import { ToolResult } from '../types.js';
|
||||||
import { getWorkspacePath } from '../config/paths.js';
|
import { getWorkspacePath } from '../config/paths.js';
|
||||||
|
|
||||||
function isPathInsideDir(base: string, target: string): boolean {
|
export function isPathInsideDir(base: string, target: string): boolean {
|
||||||
const resolvedBase = path.resolve(base);
|
const resolvedBase = path.resolve(base);
|
||||||
const resolvedTarget = path.resolve(target);
|
const resolvedTarget = path.resolve(target);
|
||||||
if (resolvedBase === resolvedTarget) return true;
|
if (resolvedBase === resolvedTarget) return true;
|
||||||
@@ -12,6 +12,17 @@ function isPathInsideDir(base: string, target: string): boolean {
|
|||||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EDIT_SCRIPT interpolates these directly into Python source (e.g. `int(${params.x})`) run
|
||||||
|
// via `python3 -c <script>` — an unvalidated string like `0)); import os; os.system(...); x=(0`
|
||||||
|
// breaks out of the int()/float() call and executes as arbitrary Python. Every numeric field
|
||||||
|
// that reaches that template MUST be coerced through this first; non-finite input silently
|
||||||
|
// falls back to the default rather than erroring, since these are cosmetic edit params, not
|
||||||
|
// something worth failing the whole request over.
|
||||||
|
export function toFiniteNumber(value: any, fallback: number): number {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif']);
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif']);
|
||||||
|
|
||||||
function runPython(script: string, timeoutMs = 60_000): Promise<any> {
|
function runPython(script: string, timeoutMs = 60_000): Promise<any> {
|
||||||
@@ -717,18 +728,18 @@ export const imageEditTool = {
|
|||||||
src: resolved,
|
src: resolved,
|
||||||
dst: outPath,
|
dst: outPath,
|
||||||
operation,
|
operation,
|
||||||
x: args?.x ?? 0,
|
x: toFiniteNumber(args?.x, 0),
|
||||||
y: args?.y ?? 0,
|
y: toFiniteNumber(args?.y, 0),
|
||||||
width: args?.width ?? 0,
|
width: toFiniteNumber(args?.width, 0),
|
||||||
height: args?.height ?? 0,
|
height: toFiniteNumber(args?.height, 0),
|
||||||
keep_aspect: args?.keep_aspect ?? false,
|
keep_aspect: args?.keep_aspect ?? false,
|
||||||
degrees: args?.degrees ?? 0,
|
degrees: toFiniteNumber(args?.degrees, 0),
|
||||||
direction: args?.direction ?? 'horizontal',
|
direction: args?.direction ?? 'horizontal',
|
||||||
value: args?.value ?? 1.0,
|
value: toFiniteNumber(args?.value, 1.0),
|
||||||
text: args?.text ?? '',
|
text: args?.text ?? '',
|
||||||
position: args?.position ?? 'bottom-right',
|
position: args?.position ?? 'bottom-right',
|
||||||
opacity: args?.opacity ?? 0.5,
|
opacity: toFiniteNumber(args?.opacity, 0.5),
|
||||||
quality: args?.quality ?? 92,
|
quality: toFiniteNumber(args?.quality, 92),
|
||||||
style: args?.style ?? 'painting',
|
style: args?.style ?? 'painting',
|
||||||
preset: args?.preset ?? 'warm',
|
preset: args?.preset ?? 'warm',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from 'path';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { ToolResult } from '../types.js';
|
import { ToolResult } from '../types.js';
|
||||||
import { getWorkspacePath } from '../config/paths.js';
|
import { getWorkspacePath } from '../config/paths.js';
|
||||||
import { buildImageMarkdown } from './image.js';
|
import { buildImageMarkdown, toFiniteNumber, isPathInsideDir } from './image.js';
|
||||||
import { getOllamaConfig } from './web.js';
|
import { getOllamaConfig } from './web.js';
|
||||||
|
|
||||||
// SDXL/LTX-Video's text encoders (CLIP/T5) are trained overwhelmingly on English
|
// SDXL/LTX-Video's text encoders (CLIP/T5) are trained overwhelmingly on English
|
||||||
@@ -334,6 +334,7 @@ export const imageStyleTransformTool = {
|
|||||||
|
|
||||||
const workspacePath = getWorkspacePath(args);
|
const workspacePath = getWorkspacePath(args);
|
||||||
const srcPath = path.isAbsolute(imageArg) ? imageArg : path.resolve(workspacePath, imageArg);
|
const srcPath = path.isAbsolute(imageArg) ? imageArg : path.resolve(workspacePath, imageArg);
|
||||||
|
if (!isPathInsideDir(workspacePath, srcPath)) return { success: false, error: 'Access denied: path escapes workspace' };
|
||||||
if (!fs.existsSync(srcPath)) return { success: false, error: `Source image not found: ${imageArg}` };
|
if (!fs.existsSync(srcPath)) return { success: false, error: `Source image not found: ${imageArg}` };
|
||||||
|
|
||||||
let outPath = String(args?.output || '').trim();
|
let outPath = String(args?.output || '').trim();
|
||||||
@@ -349,8 +350,8 @@ export const imageStyleTransformTool = {
|
|||||||
negative_prompt: preset.negative,
|
negative_prompt: preset.negative,
|
||||||
strength: Math.min(1, Math.max(0.05, args?.strength ?? preset.strength)),
|
strength: Math.min(1, Math.max(0.05, args?.strength ?? preset.strength)),
|
||||||
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
||||||
guidance_scale: args?.guidance_scale ?? preset.guidance_scale,
|
guidance_scale: toFiniteNumber(args?.guidance_scale, preset.guidance_scale),
|
||||||
seed: args?.seed,
|
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
|
||||||
face_lock: args?.face_lock ?? true,
|
face_lock: args?.face_lock ?? true,
|
||||||
max_side: 1024,
|
max_side: 1024,
|
||||||
dst: outPath,
|
dst: outPath,
|
||||||
@@ -438,7 +439,7 @@ export const imageGenerateTool = {
|
|||||||
prompt: translatedPrompt || prompt,
|
prompt: translatedPrompt || prompt,
|
||||||
width, height,
|
width, height,
|
||||||
steps: Math.min(8, Math.max(1, args?.steps ?? 4)),
|
steps: Math.min(8, Math.max(1, args?.steps ?? 4)),
|
||||||
seed: args?.seed,
|
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
|
||||||
dst: outPath,
|
dst: outPath,
|
||||||
};
|
};
|
||||||
result = await runVenvPython(FLUX_SCRIPT(params), 180_000);
|
result = await runVenvPython(FLUX_SCRIPT(params), 180_000);
|
||||||
@@ -448,8 +449,8 @@ export const imageGenerateTool = {
|
|||||||
negative_prompt: translatedNegative || negativePromptRaw,
|
negative_prompt: translatedNegative || negativePromptRaw,
|
||||||
width, height,
|
width, height,
|
||||||
steps: Math.min(50, Math.max(15, args?.steps ?? 30)),
|
steps: Math.min(50, Math.max(15, args?.steps ?? 30)),
|
||||||
guidance_scale: args?.guidance_scale ?? 7.0,
|
guidance_scale: toFiniteNumber(args?.guidance_scale, 7.0),
|
||||||
seed: args?.seed,
|
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
|
||||||
dst: outPath,
|
dst: outPath,
|
||||||
};
|
};
|
||||||
result = await runVenvPython(SDXL_SCRIPT(params), 180_000);
|
result = await runVenvPython(SDXL_SCRIPT(params), 180_000);
|
||||||
@@ -566,8 +567,8 @@ export const videoGenerateTool = {
|
|||||||
negative_prompt: translatedNegative || negativePromptRaw,
|
negative_prompt: translatedNegative || negativePromptRaw,
|
||||||
width: Math.round((args?.width ?? 704) / 32) * 32,
|
width: Math.round((args?.width ?? 704) / 32) * 32,
|
||||||
height: Math.round((args?.height ?? 480) / 32) * 32,
|
height: Math.round((args?.height ?? 480) / 32) * 32,
|
||||||
num_frames: args?.num_frames ?? 65,
|
num_frames: toFiniteNumber(args?.num_frames, 65),
|
||||||
fps: args?.fps ?? 24,
|
fps: toFiniteNumber(args?.fps, 24),
|
||||||
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
||||||
guidance_scale: Math.min(10, Math.max(1, args?.guidance_scale ?? 3.0)),
|
guidance_scale: Math.min(10, Math.max(1, args?.guidance_scale ?? 3.0)),
|
||||||
dst: outPath,
|
dst: outPath,
|
||||||
|
|||||||
+335
-14
@@ -29,6 +29,43 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
|
|||||||
/* ── Layout ── */
|
/* ── Layout ── */
|
||||||
.lg-layout { flex: 1; display: flex; min-height: 0; }
|
.lg-layout { flex: 1; display: flex; min-height: 0; }
|
||||||
|
|
||||||
|
/* ── Interpreter panel (right dock) ── */
|
||||||
|
.lg-interp { width: 320px; flex-shrink: 0; display: none; flex-direction: column; border-left: 1px solid var(--line); background: var(--panel); min-height: 0; }
|
||||||
|
.lg-interp.open { display: flex; }
|
||||||
|
.lg-interp-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--panel-2); flex-shrink: 0; }
|
||||||
|
.lg-interp-head b { font-size: 13px; flex: 1; }
|
||||||
|
.lg-interp-close { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 2px 6px; }
|
||||||
|
.lg-interp-close:hover { color: var(--text); }
|
||||||
|
.lg-interp-msgs { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.lg-interp-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 12px; line-height: 1.6; }
|
||||||
|
.im-bubble { max-width: 88%; border-radius: 12px; padding: 8px 11px; font-size: 13px; line-height: 1.5; }
|
||||||
|
.im-user { align-self: flex-end; background: #166534; color: #fff; border-bottom-right-radius: 3px; }
|
||||||
|
.im-reply { align-self: flex-start; background: var(--panel-2); border: 1px solid var(--line); border-bottom-left-radius: 3px; }
|
||||||
|
.im-reply .im-rom { font-size: 11px; color: var(--muted); font-style: italic; margin-top: 3px; }
|
||||||
|
.im-reply .im-lang { font-size: 10px; color: #22c55e; font-weight: 700; margin-bottom: 3px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.im-reply .im-tts { margin-left: 6px; }
|
||||||
|
.im-loading { align-self: flex-start; color: var(--muted); font-size: 12px; padding: 4px 0; }
|
||||||
|
.lg-interp-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
|
||||||
|
.lg-interp-input-row textarea { flex: 1; resize: none; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 13px; color: var(--text); outline: none; font-family: inherit; height: 38px; max-height: 90px; }
|
||||||
|
.lg-interp-input-row textarea:focus { border-color: var(--brand); }
|
||||||
|
.lg-interp-send { background: #166534; border: 1px solid #22c55e; color: #fff; border-radius: 8px; padding: 0 14px; font-size: 13px; font-weight: 700; cursor: pointer; flex-shrink: 0; }
|
||||||
|
.lg-interp-send:hover { filter: brightness(1.15); }
|
||||||
|
.lg-interp-send:disabled { opacity: .5; cursor: default; }
|
||||||
|
.lg-interp-toggle-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 7px; padding: 5px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit; transition: .15s; white-space: nowrap; }
|
||||||
|
.lg-interp-toggle-btn:hover, .lg-interp-toggle-btn.active { border-color: #22c55e; color: #22c55e; }
|
||||||
|
.lg-mic-btn { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; width: 38px; height: 38px; flex-shrink: 0; font-size: 15px; cursor: pointer; }
|
||||||
|
.lg-mic-btn:hover { border-color: #22c55e; }
|
||||||
|
.lg-mic-btn.recording { border-color: #ef4444; color: #ef4444; animation: lg-mic-pulse .8s infinite alternate; background: rgba(239,68,68,.08); }
|
||||||
|
@keyframes lg-mic-pulse { from { opacity: .6; } to { opacity: 1; } }
|
||||||
|
.lg-call-btn { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 2px 6px; }
|
||||||
|
.lg-call-btn:hover { color: #22c55e; }
|
||||||
|
.lg-call-btn.active { color: #ef4444; animation: lg-mic-pulse .8s infinite alternate; }
|
||||||
|
.lg-call-status { font-size: 11px; color: #22c55e; padding: 0 12px 8px; min-height: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
@media(max-width:1024px){
|
||||||
|
.lg-interp { position: fixed; top: 0; right: 0; bottom: 0; z-index: 700; width: 88vw; max-width: 360px; box-shadow: -4px 0 24px rgba(0,0,0,.45); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Sidebar ── */
|
/* ── Sidebar ── */
|
||||||
.lg-sidebar { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); min-height: 0; }
|
.lg-sidebar { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); min-height: 0; }
|
||||||
.csb-tab-bar { display: flex; border-bottom: 2px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
|
.csb-tab-bar { display: flex; border-bottom: 2px solid var(--line); flex-shrink: 0; background: var(--panel-2); }
|
||||||
@@ -252,10 +289,11 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
|
|||||||
<button class="lg-theme-btn" onclick="setTheme('light')">☀️ 라이트</button>
|
<button class="lg-theme-btn" onclick="setTheme('light')">☀️ 라이트</button>
|
||||||
<button class="lg-theme-btn" onclick="setTheme('warm')">🌿 웜</button>
|
<button class="lg-theme-btn" onclick="setTheme('warm')">🌿 웜</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="lg-interp-toggle-btn" id="interp-toggle-btn" onclick="toggleInterpreter()">🗣️ 통역사</button>
|
||||||
<a href="/" style="font-size:12px;color:var(--muted);text-decoration:none;margin-left:4px">← 메인</a>
|
<a href="/" style="font-size:12px;color:var(--muted);text-decoration:none;margin-left:4px">← 메인</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mob-backdrop" onclick="mobCloseFilter()"></div>
|
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
|
||||||
|
|
||||||
<div id="mob-tabbar" style="display:none">
|
<div id="mob-tabbar" style="display:none">
|
||||||
<button class="mob-tab" id="mobtab-flash" onclick="mobNav('flash')">
|
<button class="mob-tab" id="mobtab-flash" onclick="mobNav('flash')">
|
||||||
@@ -484,9 +522,33 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /lg-main -->
|
</div><!-- /lg-main -->
|
||||||
|
|
||||||
|
<!-- Interpreter panel -->
|
||||||
|
<div class="lg-interp" id="lg-interp">
|
||||||
|
<div class="lg-interp-head">
|
||||||
|
<b>🗣️ 실시간 통역사</b>
|
||||||
|
<span style="font-size:11px;color:var(--muted)">한국어 ⇄ 우즈베크어</span>
|
||||||
|
<button class="lg-call-btn" id="lg-call-btn" onclick="toggleCall()" title="통역 통화 시작/종료">📞</button>
|
||||||
|
<button class="lg-interp-close" onclick="toggleInterpreter(false)" title="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lg-call-status" id="lg-call-status"></div>
|
||||||
|
<div class="lg-interp-msgs" id="lg-interp-msgs">
|
||||||
|
<div class="lg-interp-empty" id="lg-interp-empty">문장을 입력하거나 🎤/📞로 말하면 한국어↔우즈베크어를 자동으로 판별해서 통역해드려요.<br>예: "화장실이 어디예요?" 또는 "Rahmat"</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg-interp-input-row">
|
||||||
|
<button class="lg-mic-btn" id="lg-mic-btn" onclick="toggleMicInput()" title="음성으로 입력">🎤</button>
|
||||||
|
<textarea id="lg-interp-input" placeholder="한국어 또는 우즈베크어로 입력..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInterpret();}"></textarea>
|
||||||
|
<button class="lg-interp-send" id="lg-interp-send-btn" onclick="sendInterpret()">전송</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div><!-- /lg-layout -->
|
</div><!-- /lg-layout -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Vocab/flashcard/quiz content can come from generate-flashcards (LLM output) — never trust
|
||||||
|
// it as HTML. Escape before every innerHTML insertion of model- or user-derived text.
|
||||||
|
function escHtml(s){
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
// ── Mobile ────────────────────────────────────────────────────────────────────
|
// ── Mobile ────────────────────────────────────────────────────────────────────
|
||||||
function mobCloseFilter(){
|
function mobCloseFilter(){
|
||||||
document.querySelector('.lg-sidebar').classList.remove('mob-open');
|
document.querySelector('.lg-sidebar').classList.remove('mob-open');
|
||||||
@@ -496,6 +558,10 @@ function mobOpenFilter(){
|
|||||||
document.querySelector('.lg-sidebar').classList.add('mob-open');
|
document.querySelector('.lg-sidebar').classList.add('mob-open');
|
||||||
document.getElementById('mob-backdrop').classList.add('show');
|
document.getElementById('mob-backdrop').classList.add('show');
|
||||||
}
|
}
|
||||||
|
function mobCloseAll(){
|
||||||
|
mobCloseFilter();
|
||||||
|
toggleInterpreter(false);
|
||||||
|
}
|
||||||
function mobNav(nav){
|
function mobNav(nav){
|
||||||
mobCloseFilter();
|
mobCloseFilter();
|
||||||
setNav(nav);
|
setNav(nav);
|
||||||
@@ -703,8 +769,9 @@ function saveProgress() {
|
|||||||
known: [...STATE.known],
|
known: [...STATE.known],
|
||||||
lessonsDone: STATE.lessonsDone,
|
lessonsDone: STATE.lessonsDone,
|
||||||
}));
|
}));
|
||||||
// also sync to server (fire-and-forget)
|
// also sync to server (fire-and-forget). The URL segment is a leftover route param —
|
||||||
fetch('/api/language/progress/papa', {
|
// the server derives the actual user from the session cookie, not this value.
|
||||||
|
fetch('/api/language/progress/session', {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ known: [...STATE.known], lessonsDone: STATE.lessonsDone })
|
body: JSON.stringify({ known: [...STATE.known], lessonsDone: STATE.lessonsDone })
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
@@ -779,13 +846,13 @@ function renderGrid() {
|
|||||||
<div class="tile-stage" onclick="this.querySelector('.tile-inner').classList.toggle('flipped')">
|
<div class="tile-stage" onclick="this.querySelector('.tile-inner').classList.toggle('flipped')">
|
||||||
<div class="tile-inner">
|
<div class="tile-inner">
|
||||||
<div class="tile-face tile-front">
|
<div class="tile-face tile-front">
|
||||||
<span class="tile-cat">${CAT_LABELS[v.cat]||''}</span>
|
<span class="tile-cat">${escHtml(CAT_LABELS[v.cat]||'')}</span>
|
||||||
<div class="tile-uz">${v.uz}</div>
|
<div class="tile-uz">${escHtml(v.uz)}</div>
|
||||||
${showRom?`<div class="tile-rom">[${v.rom}]</div>`:''}
|
${showRom?`<div class="tile-rom">[${escHtml(v.rom)}]</div>`:''}
|
||||||
<button class="tts-btn tile-tts-btn" data-uz="${v.uz.replace(/"/g,'"')}" onclick="event.stopPropagation();speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
|
<button class="tts-btn tile-tts-btn" data-uz="${escHtml(v.uz)}" onclick="event.stopPropagation();speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile-face tile-back">
|
<div class="tile-face tile-back">
|
||||||
<div class="tile-ko">${v.ko}</div>
|
<div class="tile-ko">${escHtml(v.ko)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -909,15 +976,15 @@ function renderVocabTable() {
|
|||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:6px">
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
<div>
|
<div>
|
||||||
<div class="vocab-uz">${v.uz}</div>
|
<div class="vocab-uz">${escHtml(v.uz)}</div>
|
||||||
${showRom?`<div class="vocab-rom">[${v.rom}]</div>`:''}
|
${showRom?`<div class="vocab-rom">[${escHtml(v.rom)}]</div>`:''}
|
||||||
</div>
|
</div>
|
||||||
<button class="tts-btn" data-uz="${v.uz.replace(/"/g,'"')}" onclick="speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
|
<button class="tts-btn" data-uz="${escHtml(v.uz)}" onclick="speak(this.dataset.uz,this)" title="발음 듣기">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
${showRom?`<td style="color:var(--muted);font-size:12px;font-style:italic">[${v.rom}]</td>`:''}
|
${showRom?`<td style="color:var(--muted);font-size:12px;font-style:italic">[${escHtml(v.rom)}]</td>`:''}
|
||||||
<td>${v.ko}</td>
|
<td>${escHtml(v.ko)}</td>
|
||||||
<td><span class="vocab-cat">${CAT_LABELS[v.cat]||v.cat}</span></td>
|
<td><span class="vocab-cat">${escHtml(CAT_LABELS[v.cat]||v.cat)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -1208,6 +1275,260 @@ function speakCurrent() {
|
|||||||
if (v) speak(v.uz, document.getElementById('card-tts-btn'));
|
if (v) speak(v.uz, document.getElementById('card-tts-btn'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// INTERPRETER (real-time ko ⇄ uz chat panel + voice/call mode)
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
let _interpBusy = false;
|
||||||
|
function toggleInterpreter(force) {
|
||||||
|
const panel = document.getElementById('lg-interp');
|
||||||
|
const btn = document.getElementById('interp-toggle-btn');
|
||||||
|
const open = typeof force === 'boolean' ? force : !panel.classList.contains('open');
|
||||||
|
panel.classList.toggle('open', open);
|
||||||
|
btn.classList.toggle('active', open);
|
||||||
|
if (window.innerWidth <= 1024) {
|
||||||
|
document.getElementById('mob-backdrop').classList.toggle('show', open);
|
||||||
|
}
|
||||||
|
if (open) setTimeout(() => document.getElementById('lg-interp-input')?.focus(), 50);
|
||||||
|
}
|
||||||
|
function interpAppend(html) {
|
||||||
|
const msgs = document.getElementById('lg-interp-msgs');
|
||||||
|
const empty = document.getElementById('lg-interp-empty');
|
||||||
|
if (empty) empty.remove();
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
const node = wrap.firstElementChild;
|
||||||
|
msgs.appendChild(node);
|
||||||
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls /api/language/interpret, renders the reply bubble (with TTS button for
|
||||||
|
// either language), and returns {translated, targetLang} for callers (call mode)
|
||||||
|
// that need to auto-play the result. Returns null on failure.
|
||||||
|
async function interpretAndRender(text) {
|
||||||
|
const loading = interpAppend(`<div class="im-loading">💬 통역 중...</div>`);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/language/interpret', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
loading.remove();
|
||||||
|
if (!d.success) {
|
||||||
|
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">통역 실패: ${(d.error||'').toString().slice(0,120)}</div>`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetLang = d.source_lang === 'ko' ? 'uz' : 'ko';
|
||||||
|
const langLabel = targetLang === 'uz' ? '🇺🇿 우즈베크어' : '🇰🇷 한국어';
|
||||||
|
const translated = String(d.translated || '');
|
||||||
|
const rom = String(d.romanization || '').replace(/</g,'<');
|
||||||
|
const node = interpAppend(`<div class="im-bubble im-reply">
|
||||||
|
<div class="im-lang">${langLabel}</div>
|
||||||
|
<div>${translated.replace(/</g,'<')}<button class="tts-btn im-tts" title="발음 듣기">🔊</button></div>
|
||||||
|
${rom ? `<div class="im-rom">[${rom}]</div>` : ''}
|
||||||
|
</div>`);
|
||||||
|
const ttsBtn = node.querySelector('.im-tts');
|
||||||
|
if (ttsBtn) ttsBtn.onclick = () => speakLang(translated, targetLang, ttsBtn);
|
||||||
|
return { translated, targetLang };
|
||||||
|
} catch (e) {
|
||||||
|
loading.remove();
|
||||||
|
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">네트워크 오류: ${String(e.message||e).slice(0,120)}</div>`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInterpret() {
|
||||||
|
if (_interpBusy) return;
|
||||||
|
const inp = document.getElementById('lg-interp-input');
|
||||||
|
const text = inp.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
inp.value = '';
|
||||||
|
inp.style.height = '38px';
|
||||||
|
interpAppend(`<div class="im-bubble im-user">${text.replace(/</g,'<')}</div>`);
|
||||||
|
|
||||||
|
_interpBusy = true;
|
||||||
|
document.getElementById('lg-interp-send-btn').disabled = true;
|
||||||
|
try { await interpretAndRender(text); }
|
||||||
|
finally {
|
||||||
|
_interpBusy = false;
|
||||||
|
document.getElementById('lg-interp-send-btn').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// speakLang: TTS for either language. uz → /api/language/tts (edge-tts uz voices),
|
||||||
|
// ko → /api/voice/tts (configured Korean engine, currently OmniVoice GPU).
|
||||||
|
// Returns a Promise that resolves when playback finishes (used by call mode to
|
||||||
|
// know when it's safe to start listening again).
|
||||||
|
let _ttsLangAudio = null;
|
||||||
|
function speakLang(text, lang, btn) {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
if (!text) { resolve(); return; }
|
||||||
|
if (_ttsLangAudio) { try { _ttsLangAudio.pause(); } catch {} _ttsLangAudio = null; }
|
||||||
|
if (btn) btn.classList.add('playing');
|
||||||
|
try {
|
||||||
|
const url = lang === 'uz' ? '/api/language/tts' : '/api/voice/tts';
|
||||||
|
const body = lang === 'uz' ? { text, gender: _ttsGender } : { text };
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
if (!res.ok) throw new Error('TTS 실패');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objUrl = URL.createObjectURL(blob);
|
||||||
|
_ttsLangAudio = new Audio(objUrl);
|
||||||
|
_ttsLangAudio.onended = () => { if (btn) btn.classList.remove('playing'); URL.revokeObjectURL(objUrl); resolve(); };
|
||||||
|
_ttsLangAudio.onerror = () => { if (btn) btn.classList.remove('playing'); resolve(); };
|
||||||
|
_ttsLangAudio.play();
|
||||||
|
} catch (e) {
|
||||||
|
if (btn) btn.classList.remove('playing');
|
||||||
|
console.error('speakLang error:', e);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-shot mic input (fills the textarea, doesn't auto-send) ──
|
||||||
|
let _micRecorder = null, _micStream = null, _micChunks = [];
|
||||||
|
async function toggleMicInput() {
|
||||||
|
const btn = document.getElementById('lg-mic-btn');
|
||||||
|
if (_micRecorder && _micRecorder.state === 'recording') {
|
||||||
|
_micRecorder.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_micStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
||||||
|
} catch (e) {
|
||||||
|
alert('마이크 권한이 필요합니다: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_micChunks = [];
|
||||||
|
_micRecorder = new MediaRecorder(_micStream, { mimeType: 'audio/webm' });
|
||||||
|
_micRecorder.ondataavailable = e => { if (e.data.size > 0) _micChunks.push(e.data); };
|
||||||
|
_micRecorder.onstop = async () => {
|
||||||
|
btn.classList.remove('recording');
|
||||||
|
try { _micStream.getTracks().forEach(t => t.stop()); } catch {}
|
||||||
|
const blob = new Blob(_micChunks, { type: 'audio/webm' });
|
||||||
|
if (blob.size < 1000) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/voice/stt', { method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: blob });
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.success && d.text) {
|
||||||
|
const inp = document.getElementById('lg-interp-input');
|
||||||
|
inp.value = (inp.value ? inp.value + ' ' : '') + d.text;
|
||||||
|
inp.focus();
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('STT error:', e); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
};
|
||||||
|
_micRecorder.start();
|
||||||
|
btn.classList.add('recording');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Call mode: continuous listen → STT → translate → speak → repeat ──
|
||||||
|
const CALL_RMS_ON = 0.020, CALL_RMS_OFF = 0.012;
|
||||||
|
const CALL_SPEECH_CONFIRM_FRAMES = 6, CALL_SILENCE_HANGOVER_MS = 900;
|
||||||
|
let callActive = false, callWakeLock = null, callMicStream = null, callAudioCtx = null, callAnalyser = null;
|
||||||
|
let callRecorder = null, callChunks = [], callState = 'idle', callSpeechFrames = 0, callSilenceMs = 0, callVadRaf = null;
|
||||||
|
const _callVadData = new Uint8Array(512);
|
||||||
|
|
||||||
|
async function callAcquireWakeLock() {
|
||||||
|
if (!('wakeLock' in navigator)) return;
|
||||||
|
try { callWakeLock = await navigator.wakeLock.request('screen'); callWakeLock.addEventListener('release', () => { callWakeLock = null; }); } catch {}
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (callActive && callWakeLock === null && document.visibilityState === 'visible') callAcquireWakeLock();
|
||||||
|
});
|
||||||
|
function setCallStatus(text) { const el = document.getElementById('lg-call-status'); if (el) el.textContent = text || ''; }
|
||||||
|
|
||||||
|
function toggleCall() { if (callActive) hangupCall(); else startCall(); }
|
||||||
|
|
||||||
|
async function startCall() {
|
||||||
|
try {
|
||||||
|
callMicStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
||||||
|
} catch (e) {
|
||||||
|
alert('마이크 권한이 필요합니다: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callActive = true;
|
||||||
|
callAcquireWakeLock();
|
||||||
|
toggleInterpreter(true);
|
||||||
|
document.getElementById('lg-call-btn').classList.add('active');
|
||||||
|
document.getElementById('lg-call-btn').textContent = '📵';
|
||||||
|
callAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const source = callAudioCtx.createMediaStreamSource(callMicStream);
|
||||||
|
callAnalyser = callAudioCtx.createAnalyser();
|
||||||
|
callAnalyser.fftSize = 512;
|
||||||
|
source.connect(callAnalyser);
|
||||||
|
beginCallListening();
|
||||||
|
}
|
||||||
|
function hangupCall() {
|
||||||
|
callActive = false;
|
||||||
|
if (callVadRaf) cancelAnimationFrame(callVadRaf);
|
||||||
|
callVadRaf = null;
|
||||||
|
try { callRecorder && callRecorder.state !== 'inactive' && callRecorder.stop(); } catch {}
|
||||||
|
try { callMicStream && callMicStream.getTracks().forEach(t => t.stop()); } catch {}
|
||||||
|
try { callAudioCtx && callAudioCtx.close(); } catch {}
|
||||||
|
try { callWakeLock && callWakeLock.release(); } catch {}
|
||||||
|
callWakeLock = null; callMicStream = null; callAudioCtx = null; callAnalyser = null; callRecorder = null;
|
||||||
|
callState = 'idle';
|
||||||
|
setCallStatus('');
|
||||||
|
const btn = document.getElementById('lg-call-btn');
|
||||||
|
if (btn) { btn.classList.remove('active'); btn.textContent = '📞'; }
|
||||||
|
}
|
||||||
|
function beginCallListening() {
|
||||||
|
if (!callActive) return;
|
||||||
|
callState = 'listening';
|
||||||
|
callSpeechFrames = 0; callSilenceMs = 0;
|
||||||
|
setCallStatus('🎙️ 듣는 중...');
|
||||||
|
callChunks = [];
|
||||||
|
callRecorder = new MediaRecorder(callMicStream, { mimeType: 'audio/webm' });
|
||||||
|
callRecorder.ondataavailable = e => { if (e.data.size > 0) callChunks.push(e.data); };
|
||||||
|
callRecorder.onstop = onCallRecordingStop;
|
||||||
|
callRecorder.start();
|
||||||
|
runCallVadLoop();
|
||||||
|
}
|
||||||
|
function runCallVadLoop() {
|
||||||
|
if (!callActive || (callState !== 'listening' && callState !== 'speaking')) return;
|
||||||
|
callAnalyser.getByteTimeDomainData(_callVadData);
|
||||||
|
let sumSq = 0;
|
||||||
|
for (let i = 0; i < _callVadData.length; i++) { const v = (_callVadData[i] - 128) / 128; sumSq += v * v; }
|
||||||
|
const rms = Math.sqrt(sumSq / _callVadData.length);
|
||||||
|
const FRAME_MS = 16;
|
||||||
|
if (callState === 'listening') {
|
||||||
|
if (rms > CALL_RMS_ON) { callSpeechFrames++; if (callSpeechFrames > CALL_SPEECH_CONFIRM_FRAMES) { callState = 'speaking'; setCallStatus('🗣️ 말하는 중...'); } }
|
||||||
|
else callSpeechFrames = 0;
|
||||||
|
} else if (callState === 'speaking') {
|
||||||
|
if (rms < CALL_RMS_OFF) { callSilenceMs += FRAME_MS; if (callSilenceMs > CALL_SILENCE_HANGOVER_MS) { stopCallListeningAndProcess(); return; } }
|
||||||
|
else callSilenceMs = 0;
|
||||||
|
}
|
||||||
|
callVadRaf = requestAnimationFrame(runCallVadLoop);
|
||||||
|
}
|
||||||
|
function stopCallListeningAndProcess() {
|
||||||
|
if (callState === 'processing') return;
|
||||||
|
callState = 'processing';
|
||||||
|
setCallStatus('💬 통역 중...');
|
||||||
|
try { callRecorder.stop(); } catch {}
|
||||||
|
}
|
||||||
|
async function onCallRecordingStop() {
|
||||||
|
if (!callActive) return;
|
||||||
|
const blob = new Blob(callChunks, { type: 'audio/webm' });
|
||||||
|
if (blob.size < 2000) { beginCallListening(); return; }
|
||||||
|
try {
|
||||||
|
const sttRes = await fetch('/api/voice/stt', { method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: blob });
|
||||||
|
const sttData = await sttRes.json();
|
||||||
|
const text = (sttData.text || '').trim();
|
||||||
|
if (!text) { beginCallListening(); return; }
|
||||||
|
interpAppend(`<div class="im-bubble im-user">🎙️ ${text.replace(/</g,'<')}</div>`);
|
||||||
|
const result = await interpretAndRender(text);
|
||||||
|
if (result && callActive) {
|
||||||
|
setCallStatus('🔊 재생 중...');
|
||||||
|
await speakLang(result.translated, result.targetLang, null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
interpAppend(`<div class="im-bubble im-reply" style="color:#f87171">오류: ${String(e.message || e).slice(0,120)}</div>`);
|
||||||
|
} finally {
|
||||||
|
if (callActive) beginCallListening();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// STATS
|
// STATS
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user