diff --git a/app/mongo_models.py b/app/mongo_models.py index b40d4e7..d988b18 100644 --- a/app/mongo_models.py +++ b/app/mongo_models.py @@ -158,4 +158,48 @@ class Logs(MongoModel): lineno = fields.IntegerField() class StorageMeta(MongoModel): - used_size = fields.IntegerField() \ No newline at end of file + used_size = fields.IntegerField() + + +class Questions(MongoModel): + session_id = fields.CharField() + text = fields.CharField() + order = fields.IntegerField(blank=True, default=0) + created_at = fields.DateTimeField(default=datetime.now(timezone.utc)) + attributes = fields.DictField(blank=True, default={}) + generation_metadata = fields.DictField(blank=True, default={}) + +class InterviewAvatars(MongoModel): + session_id = fields.CharField() + file_id = fields.ObjectIdField() + + created_at = fields.DateTimeField(default=datetime.now(timezone.utc)) + last_update = fields.DateTimeField(default=datetime.now(timezone.utc)) + + def save(self): + self.last_update = datetime.now(timezone.utc) + return super().save() + +class InterviewRecording(MongoModel): + session_id = fields.CharField() + audio_file_id = fields.ObjectIdField() + + duration = fields.FloatField(blank=True) + + # [{question_id, order, start, end}] + question_segments = fields.ListField( + fields.DictField(), + blank=True, + default=[] + ) + + status = fields.CharField(default="recorded") + + created_at = fields.DateTimeField(default=datetime.now(timezone.utc)) + last_update = fields.DateTimeField(default=datetime.now(timezone.utc)) + + metadata = fields.DictField(blank=True, default={}) + + def save(self): + self.last_update = datetime.now(timezone.utc) + return super().save() diff --git a/app/mongo_odm.py b/app/mongo_odm.py index d61a6a5..c8b7fe1 100644 --- a/app/mongo_odm.py +++ b/app/mongo_odm.py @@ -23,7 +23,7 @@ RecognizedAudioToProcess, RecognizedPresentationsToProcess, Sessions, TaskAttempts, TaskAttemptsToPassBack, Tasks, - Trainings, TrainingsToProcess, StorageMeta) + Trainings, TrainingsToProcess, StorageMeta, InterviewAvatars, Questions, InterviewRecording) from app.status import (AudioStatus, PassBackStatus, PresentationStatus, TrainingStatus) from app.utils import remove_blank_and_none @@ -119,6 +119,28 @@ def recalculate_used_storage_data(self): total_size += file_doc['length'] self.set_used_storage_size(total_size) logger.info(f"Storage size recalculated: {total_size/BYTES_PER_MB:.2f} MB") + + def delete_file(self, file_id): + try: + oid = ObjectId(file_id) + except InvalidId as e: + logger.warning('Invalid file_id = {}: {}.'.format(file_id, e)) + return + + db = _get_db() + file_doc = db.fs.files.find_one({'_id': oid}) + if not file_doc: + logger.warning('No file doc for file_id = {}.'.format(file_id)) + return + + length = file_doc.get('length', 0) + try: + self.storage.delete(oid) + except (NoFile, ValidationError) as e: + logger.warning('Error deleting file_id = {}: {}.'.format(file_id, e)) + return + + self.update_storage_size(-length) class TrainingsDBManager: @@ -929,3 +951,131 @@ def get_criterion_pack_by_name(self, name): def get_all_criterion_packs(self): return CriterionPack.objects.all().order_by([("name", pymongo.ASCENDING)]) + +class QuestionsDBManager: + + def add_question(self, session_id: str, text: str): + question = Questions(session_id=session_id, text=text) + return question.save() + + def get_questions_by_session(self, session_id: str): + return Questions.objects.raw({"session_id": session_id}) + + def remove_question(self, question_id): + return Questions.objects.get({"_id": question_id}).delete() + + def get_all(self): + return Questions.objects.all() + +class InterviewAvatarsDBManager: + def __new__(cls): + if not hasattr(cls, 'init_done'): + cls.instance = super(InterviewAvatarsDBManager, cls).__new__(cls) + connect(Config.c.mongodb.url + Config.c.mongodb.database_name) + cls.init_done = True + return cls.instance + + def get_by_session_id(self, session_id: str): + try: + return InterviewAvatars.objects.get({'session_id': session_id}) + except InterviewAvatars.DoesNotExist: + return None + + def add_or_update_avatar(self, session_id: str, file_obj, filename: str | None = None): + storage = DBManager() + if filename is None: + filename = str(uuid.uuid4()) + avatar_file_id = storage.add_file(file_obj, filename) + avatar = self.get_by_session_id(session_id) + if avatar is None: + avatar = InterviewAvatars(session_id=session_id, file_id=avatar_file_id) + else: + try: + storage.delete_file(avatar.file_id) + except Exception: + logger.warning('Failed to delete old avatar file for session_id = {}.'.format(session_id)) + avatar.file_id = avatar_file_id + + saved = avatar.save() + logger.info('Avatar saved for session_id = {}, file_id = {}.'.format(session_id, avatar_file_id)) + return saved + + def get_avatar_record(self, session_id: str) -> Union[InterviewAvatars, None]: + return self.get_by_session_id(session_id) + + def get_avatar_file(self, session_id: str): + avatar = self.get_by_session_id(session_id) + if avatar is None: + logger.info('No avatar for session_id = {}.'.format(session_id)) + return None + + storage = DBManager() + return storage.get_file(avatar.file_id) + + def delete_avatar(self, session_id: str): + avatar = self.get_by_session_id(session_id) + if avatar is None: + return + + storage = DBManager() + try: + storage.delete_file(avatar.file_id) + except Exception as e: + logger.warning('Error deleting avatar file for session_id = {}: {}.'.format(session_id, e)) + + avatar.delete() + logger.info('Avatar deleted for session_id = {}.'.format(session_id)) + +class InterviewRecordingDBManager: + def __new__(cls): + if not hasattr(cls, 'init_done'): + cls.instance = super().__new__(cls) + connect(Config.c.mongodb.url + Config.c.mongodb.database_name) + cls.init_done = True + return cls.instance + + def create( + self, + session_id: str, + file_obj, + duration: float | None = None, + filename: str | None = None, + metadata: dict | None = None, + ): + storage = DBManager() + file_id = storage.add_file(file_obj, filename) + + rec = InterviewRecording( + session_id=session_id, + audio_file_id=file_id, + duration=duration, + metadata=metadata or {}, + ).save() + + return rec + + def append_segment( + self, + session_id: str, + question_id, + order: int, + start: float, + end: float, + ): + InterviewRecording.objects.model._mongometa.collection.update_one( + {"session_id": session_id}, + {"$push": { + "question_segments": { + "question_id": question_id, + "order": order, + "start": start, + "end": end, + } + }} + ) + + def get_by_session(self, session_id: str): + try: + return InterviewRecording.objects.get({"session_id": session_id}) + except InterviewRecording.DoesNotExist: + return None diff --git a/app/routes/interview.py b/app/routes/interview.py new file mode 100644 index 0000000..b0bf838 --- /dev/null +++ b/app/routes/interview.py @@ -0,0 +1,171 @@ +from flask import Blueprint, render_template, session, request, Response +import json +from flask import jsonify +from app.mongo_odm import DBManager +from datetime import datetime, timezone +from app.root_logger import get_root_logger +from app.lti_session_passback.auth_checkers import check_auth +from app.mongo_odm import InterviewAvatarsDBManager, QuestionsDBManager +from app.mongo_models import InterviewRecording + +routes_interview = Blueprint('routes_interview', __name__) +logger = get_root_logger() + + +@routes_interview.route('/interview/', methods=['GET']) +def interview_page(): + user_session = check_auth() + if not user_session: + return "User session not found", 404 + + session_id = session.get('session_id') + if not session_id: + return "Session id not found", 404 + + avatar_record = InterviewAvatarsDBManager().get_avatar_record(session_id) + has_avatar = avatar_record is not None + logger.info("session_id" + session_id) + questions = QuestionsDBManager().get_questions_by_session(session_id) + logger.info(f"Questions count: {len(list(questions))}") + + return render_template( + 'interview.html', + has_avatar=has_avatar, + session_id=session_id, + questions=list(questions) + ), 200 + + +def _partial_response_file(grid_out): + file_size = getattr(grid_out, 'length', None) + if file_size is None: + grid_out.seek(0, 2) # SEEK_END + file_size = grid_out.tell() + grid_out.seek(0) + + content_type = getattr(grid_out, 'content_type', None) or 'video/mp4' + + range_header = request.headers.get('Range', None) + if not range_header: + def full_stream(): + chunk_size = 8192 + grid_out.seek(0) + while True: + chunk = grid_out.read(chunk_size) + if not chunk: + break + yield chunk + + headers = { + 'Content-Length': str(file_size), + 'Accept-Ranges': 'bytes', + 'Content-Type': content_type, + } + return Response(full_stream(), status=200, headers=headers) + + try: + byte_range = range_header.strip().split('=')[1] + start_str, end_str = byte_range.split('-') + except Exception: + return Response(status=416) + + try: + start = int(start_str) if start_str else 0 + except ValueError: + start = 0 + + try: + end = int(end_str) if end_str else file_size - 1 + except ValueError: + end = file_size - 1 + + if end >= file_size: + end = file_size - 1 + if start > end: + return Response(status=416) + + length = end - start + 1 + + def stream_range(): + chunk_size = 8192 + grid_out.seek(start) + remaining = length + while remaining > 0: + size = chunk_size if remaining >= chunk_size else remaining + data = grid_out.read(size) + if not data: + break + remaining -= len(data) + yield data + + headers = { + 'Content-Range': f'bytes {start}-{end}/{file_size}', + 'Accept-Ranges': 'bytes', + 'Content-Length': str(length), + 'Content-Type': content_type, + } + return Response(stream_range(), status=206, headers=headers) + + +@routes_interview.route('/avatar_video') +def avatar_video(): + user_session = check_auth() + if not user_session: + return '', 404 + + session_id = session.get('session_id') + if not session_id: + return '', 404 + + grid_out = InterviewAvatarsDBManager().get_avatar_file(session_id) + if grid_out is None: + return '', 404 + + return _partial_response_file(grid_out) + +@routes_interview.route("/api/interview/recording", methods=["POST"]) +def save_interview_recording(): + """ + multipart/form-data: + - audio: Blob + - session_id: str + - segments: JSON string + - duration: float (optional) + """ + logger.info("Interview record saving started") + + audio_file = request.files.get("audio") + session_id = request.form.get("session_id") + segments_raw = request.form.get("segments") + duration = request.form.get("duration") + + if not audio_file or not session_id or not segments_raw: + return jsonify({"error": "audio, session_id and segments are required"}), 400 + + try: + segments = json.loads(segments_raw) + except Exception: + return jsonify({"error": "segments must be valid JSON"}), 400 + + storage = DBManager() + audio_file_id = storage.add_file( + audio_file, + filename=f"interview_{session_id}.webm" + ) + + recording = InterviewRecording( + session_id=session_id, + audio_file_id=audio_file_id, + duration=float(duration) if duration else None, + question_segments=segments, + status="recorded", + created_at=datetime.now(timezone.utc), + ).save() + + logger.info(f"Interview recording saved {session_id} {segments}") + + return jsonify({ + "recording_id": str(recording.pk), + "audio_file_id": str(audio_file_id), + "segments_count": len(segments), + }), 201 \ No newline at end of file diff --git a/app/routes/lti.py b/app/routes/lti.py index 26f8fc2..ee27d34 100644 --- a/app/routes/lti.py +++ b/app/routes/lti.py @@ -6,49 +6,76 @@ from app.lti_session_passback.lti_module import utils from app.lti_session_passback.lti_module.check_request import check_request -from app.mongo_odm import ConsumersDBManager, PresentationFilesDBManager, SessionsDBManager, TasksDBManager -from app.utils import ALLOWED_EXTENSIONS, DEFAULT_EXTENSION, check_argument_is_convertible_to_object_id +from app.mongo_odm import ( + ConsumersDBManager, + PresentationFilesDBManager, + SessionsDBManager, + TasksDBManager, +) +from app.utils import ( + ALLOWED_EXTENSIONS, + DEFAULT_EXTENSION, + check_argument_is_convertible_to_object_id, +) routes_lti = Blueprint('routes_lti', __name__) logger = get_root_logger() - @routes_lti.route('/lti', methods=['POST']) def lti(): """ - Route that is an entry point for LTI. + Route that is an entry point for LTI (тренировки). - :return: Redirects to training_greeting page, or + :return: Redirects to training_greeting or interview page, or an empty dictionary with 404 HTTP return code if access was denied. """ + params = request.form consumer_key = params.get('oauth_consumer_key', '') consumer_secret = ConsumersDBManager().get_secret(consumer_key) + request_info = dict( headers=dict(request.headers), data=params, url=request.url, - secret=consumer_secret + secret=consumer_secret, ) if not check_request(request_info): return {}, 404 + full_name = utils.get_person_name(params) username = utils.get_username(params) custom_params = utils.get_custom_params(params) + + mode = custom_params.get('mode', 'training') + task_id = custom_params.get('task_id', '') task_description = custom_params.get('task_description', '') attempt_count = int(custom_params.get('attempt_count', 1)) required_points = float(custom_params.get('required_points', 0)) - criteria_pack_id = CriteriaPackFactory().get_criteria_pack(custom_params.get('criteria_pack_id', '')).name + criteria_pack_id = CriteriaPackFactory().get_criteria_pack( + custom_params.get('criteria_pack_id', '') + ).name presentation_id = custom_params.get('presentation_id') feedback_evaluator_id = int(custom_params.get('feedback_evaluator_id', 1)) role = utils.get_role(params) params_for_passback = utils.extract_passback_params(params) - pres_formats = list(set(custom_params.get('formats', '').split(',')) & ALLOWED_EXTENSIONS) or [DEFAULT_EXTENSION] + pres_formats = ( + list(set(custom_params.get('formats', '').split(',')) & ALLOWED_EXTENSIONS) + or [DEFAULT_EXTENSION] + ) + + SessionsDBManager().add_session( + username, + consumer_key, + task_id, + params_for_passback, + role, + pres_formats, + ) - SessionsDBManager().add_session(username, consumer_key, task_id, params_for_passback, role, pres_formats) session['session_id'] = username session['task_id'] = task_id session['consumer_key'] = consumer_key @@ -62,6 +89,15 @@ def lti(): if not PresentationFilesDBManager().get_presentation_file(presentation_id): presentation_id = None - TasksDBManager().add_task_if_absent(task_id, task_description, attempt_count, required_points, criteria_pack_id, presentation_id) + TasksDBManager().add_task_if_absent( + task_id, + task_description, + attempt_count, + required_points, + criteria_pack_id, + presentation_id, + ) + if mode == 'interview': + return redirect(url_for('routes_interview.interview_page')) return redirect(url_for('routes_trainings.view_training_greeting')) diff --git a/app/static/css/interview.css b/app/static/css/interview.css new file mode 100644 index 0000000..9a864e4 --- /dev/null +++ b/app/static/css/interview.css @@ -0,0 +1,291 @@ +.interview-container { + position: relative; + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: 50px; +} + +.placeholder-photo { + width: 320px; + height: 240px; + margin: 0 auto; + background-color: #000; + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.avatar-stream { + width: 100%; + height: 100%; + object-fit: cover; + background: #000; + display: block; +} + +.avatar-placeholder { + width: 100%; + height: 100%; + background: #000; + border-radius: 12px; +} + +.mic-icon { + font-size: 2.5rem; + color: #007bff; +} + +.mic-circle-wrapper { + width: 60px; + height: 60px; + background-color: #007bff; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + margin: 15px auto 0 auto; + cursor: pointer; + transition: background-color 0.2s, transform 0.2s; +} + +.mic-circle-wrapper:hover { + background-color: #0056b3; + transform: scale(1.1); +} + +.mic-circle-wrapper i { + color: white; + font-size: 1.5rem; +} + +#start-btn { + display: block; + margin: 15px auto 0 auto; + padding: 15px 40px; + font-size: 1.3rem; + border-radius: 12px; + background-color: #363773; + border: none; + color: white; + font-weight: bold; + transition: background-color 0.2s; +} + +#start-btn:hover { + background-color: #218838; +} + +.stop-btn { + position: fixed; + bottom: 20px; + left: 20px; + padding: 15px 30px; + font-size: 1.2rem; + border-radius: 12px; + background-color: #9F0F0F; + border: none; + color: white; + font-weight: bold; + transition: background-color 0.2s; +} + +.stop-btn:hover { + background-color: #c82333; +} + +.timer-section { + position: absolute; + top: 50px; + right: 50px; + text-align: left; + max-width: 350px; + font-family: Arial, sans-serif; +} + +#timer { + font-size: 2.5rem; + font-weight: bold; + color: #343a40; +} + +#question-number { + font-size: 2.2rem; + font-weight: bold; +} + +#question-total { + font-size: 2.2rem; + font-weight: bold; +} + +#question-text { + font-size: 2.2rem; + margin-top: 5px; +} + +.controls-center { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + margin-top: 16px; +} + +.btn-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.interview-btn { + border: none; + border-radius: 14px; + padding: 14px 34px; + font-size: 1.25rem; + font-weight: 800; + letter-spacing: 0.2px; + cursor: pointer; + min-width: 240px; + text-align: center; + + box-shadow: 0 10px 22px rgba(0,0,0,0.18); + transform: translateY(0); + transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease, opacity 0.15s ease; +} + +.interview-btn:hover { + transform: translateY(-1px); + box-shadow: 0 14px 26px rgba(0,0,0,0.22); + filter: brightness(1.03); +} + +.interview-btn:active { + transform: translateY(1px); + box-shadow: 0 8px 16px rgba(0,0,0,0.16); +} + +.interview-btn:disabled { + cursor: not-allowed; + opacity: 0.65; + box-shadow: none; +} + +/* ===== Цвета под разные действия ===== */ +.interview-btn-main { + background: linear-gradient(135deg, #2e7dff, #6a5cff); + color: #fff; +} + +.interview-btn-end { + background: linear-gradient(135deg, #ffb020, #ff7a18); + color: #1f1f1f; +} + +.interview-btn-next { + background: linear-gradient(135deg, #1fcf7a, #19a866); + color: #fff; +} + +.mic-circle-wrapper { + width: 66px; + height: 66px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + margin: 0; + cursor: default; + transition: transform 0.2s, opacity 0.2s; +} + +.mic-circle-wrapper i { + font-size: 1.7rem; +} + +@media (max-width: 520px) { + .btn-row { + flex-direction: column; + gap: 10px; + } + .interview-btn { + min-width: 280px; + width: 90%; + max-width: 340px; + } +} + +.recordings-panel{ + position: fixed; + right: 16px; + bottom: 16px; + width: 360px; + max-width: calc(100vw - 32px); + + max-height: 45vh; + display: flex; + flex-direction: column; + + background: rgba(255,255,255,0.96); + border: 1px solid rgba(0,0,0,0.15); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.18); + z-index: 9999; + + overflow: hidden; +} + +.recordings-title{ + padding: 10px 12px; + font-weight: 600; + border-bottom: 1px solid rgba(0,0,0,0.10); + background: rgba(0,0,0,0.03); +} + +.recordings-list{ + padding: 10px 12px; + overflow-y: auto; +} + +.recordings-list .border.rounded{ + margin-top: 10px !important; + padding: 10px !important; +} + +@media (max-width: 520px){ + .recordings-panel{ + right: 10px; + left: 10px; + width: auto; + bottom: 10px; + max-height: 50vh; + } +} + +#mic-indicator.speaking { + opacity: 1 !important; + box-shadow: 0 0 0 6px rgba(0, 200, 0, 0.20), 0 0 18px rgba(0, 200, 0, 0.55); + transform: scale(1.06); + transition: transform 80ms linear, box-shadow 120ms linear, opacity 120ms linear; +} + + +#mic-indicator { + transition: transform 120ms linear, box-shadow 120ms linear, opacity 120ms linear; +} + +.headphones-hint-fixed { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 1.2rem; + line-height: 1.2; + color: #2f2f2f; + border-radius: 10px; + box-shadow: 0 4px 14px rgba(0,0,0,0.15); +} diff --git a/app/static/js/interview.js b/app/static/js/interview.js new file mode 100644 index 0000000..65f9eaa --- /dev/null +++ b/app/static/js/interview.js @@ -0,0 +1,350 @@ +const CONFIG = { + TOTAL_LIMIT_SEC: 180, + VAD_THRESHOLD: 0.03, + VAD_HANG_MS: 250, + TIMER_TICK_MS: 1000, +}; + +const QUESTIONS = window.INTERVIEW_DATA?.questions || []; +const SESSION_ID = window.INTERVIEW_DATA?.sessionId || null; + +const timerSection = document.querySelector(".timer-section"); +const timerElement = document.getElementById("timer"); + +const mainBtn = document.getElementById("main-btn"); +const nextBtn = document.getElementById("next-btn"); +const stopBtn = document.getElementById("stop-btn"); + +const questionNumberEl = document.getElementById("question-number"); +const questionTotalEl = document.getElementById("question-total"); +const questionTextEl = document.getElementById("question-text"); + +const statusEl = document.getElementById("status"); +const micIndicator = document.getElementById("mic-indicator"); +const recordingsEl = document.getElementById("recordings"); + + +let state = "idle"; +let questionIndex = 0; + +let sessionStartTs = null; +let currentAnswerStartTs = null; +let questionSegments = []; + +let mediaStream = null; +let micArmed = false; +let micSpeaking = false; + +function applyMicIndicator() { + if (!micIndicator) return; + if (!micArmed) { + micIndicator.style.opacity = "0.35"; + micIndicator.classList.remove("speaking"); + return; + } + micIndicator.style.opacity = micSpeaking ? "1" : "0.35"; + micIndicator.classList.toggle("speaking", micSpeaking); +} + +function setMic(active) { + micArmed = !!active; + if (!micArmed) micSpeaking = false; + applyMicIndicator(); +} + +function setMicSpeaking(active) { + micSpeaking = !!active; + applyMicIndicator(); +} + +let remainingSec = 0; +let timer = null; + +function formatMMSS(totalSec) { + const safe = Math.max(0, totalSec); + const mins = Math.floor(safe / 60); + const secs = safe % 60; + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; +} + +function updateTimer() { + if (timerElement) timerElement.textContent = formatMMSS(remainingSec); +} + +function startGlobalCountdown() { + if (timer) clearInterval(timer); + + remainingSec = CONFIG.TOTAL_LIMIT_SEC; + updateTimer(); + + timer = setInterval(() => { + remainingSec--; + updateTimer(); + + if (remainingSec <= 0) { + clearInterval(timer); + timer = null; + remainingSec = 0; + updateTimer(); + finishInterviewByTimeout(); + } + }, CONFIG.TIMER_TICK_MS); +} + +function stopTimer() { + if (timer) clearInterval(timer); + timer = null; +} + +function hideInterviewUI() { + if (timerSection) timerSection.style.display = "none"; +} + +function showInterviewUI() { + if (timerSection) timerSection.style.display = "block"; +} + +function setStatus(text) { + if (statusEl) statusEl.textContent = text || ""; +} + +function setButtons({ mainText, mainEnabled, showNext }) { + if (mainBtn) { + mainBtn.textContent = mainText; + mainBtn.disabled = !mainEnabled; + } + if (nextBtn) nextBtn.style.display = showNext ? "inline-block" : "none"; +} + +function renderQuestion() { + const q = QUESTIONS[questionIndex]; + if (!q) return; + + if (questionNumberEl) questionNumberEl.textContent = String(questionIndex + 1); + if (questionTextEl) questionTextEl.textContent = q.text || ""; + + if (nextBtn) { + nextBtn.textContent = + questionIndex === QUESTIONS.length - 1 ? "Закончить" : "Следующий вопрос"; + } +} + +function speak(text) { + return new Promise((resolve) => { + if (!window.speechSynthesis) return resolve(); + window.speechSynthesis.cancel(); + const utter = new SpeechSynthesisUtterance(text); + utter.lang = "ru-RU"; + utter.onend = resolve; + utter.onerror = resolve; + window.speechSynthesis.speak(utter); + }); +} + +let sessionRecorder = null; +let fullSessionChunks = []; + +function pickSupportedMimeType() { + const candidates = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/ogg;codecs=opus", + "audio/ogg", + ]; + for (const t of candidates) { + if (window.MediaRecorder && MediaRecorder.isTypeSupported(t)) return t; + } + return null; +} + +async function ensureMic() { + if (mediaStream) return mediaStream; + mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + return mediaStream; +} + +async function startSessionRecording() { + await ensureMic(); + fullSessionChunks = []; + sessionStartTs = performance.now(); + questionSegments = []; + + const mimeType = pickSupportedMimeType(); + sessionRecorder = mimeType + ? new MediaRecorder(mediaStream, { mimeType }) + : new MediaRecorder(mediaStream); + + sessionRecorder.ondataavailable = (e) => { + if (e.data && e.data.size > 0) fullSessionChunks.push(e.data); + }; + + sessionRecorder.start(); + + startMicIndicator(); +} + +function stopSessionRecording() { + if (!sessionRecorder) return; + + sessionRecorder.onstop = () => { + const blob = new Blob(fullSessionChunks, { type: "audio/webm" }); + addFullSessionRecording(blob); + sendSessionToBackend(blob); + }; + + sessionRecorder.stop(); +} + +let vadInterval = null; +function startMicIndicator() { + if (!mediaStream) return; + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + const audioCtx = new AC(); + const source = audioCtx.createMediaStreamSource(mediaStream); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 1024; + source.connect(analyser); + const data = new Uint8Array(analyser.fftSize); + + setMic(true); + + vadInterval = setInterval(() => { + analyser.getByteTimeDomainData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) { + const x = (data[i] - 128) / 128; + sum += x * x; + } + const rms = Math.sqrt(sum / data.length); + setMicSpeaking(rms > CONFIG.VAD_THRESHOLD); + }, 50); +} + +function stopMicIndicator() { + if (vadInterval) clearInterval(vadInterval); + vadInterval = null; + setMicSpeaking(false); + setMic(false); +} + +function closeCurrentAnswer() { + if (currentAnswerStartTs == null) return; + const now = performance.now(); + const q = QUESTIONS[questionIndex]; + questionSegments.push({ + question_id: q?.id || q?._id || null, + order: questionIndex, + start: (currentAnswerStartTs - sessionStartTs) / 1000, + end: (now - sessionStartTs) / 1000, + }); + currentAnswerStartTs = null; +} + +async function startInterview() { + showInterviewUI(); + recordingsEl.innerHTML = ""; + questionIndex = 0; + state = "running"; + + if (questionTotalEl) questionTotalEl.textContent = QUESTIONS.length; + + renderQuestion(); + setStatus("Интервью началось"); + + setButtons({ mainText: "Озвучить вопрос", mainEnabled: true, showNext: false }); + + await startSessionRecording(); + startGlobalCountdown(); +} + +async function askQuestion() { + const q = QUESTIONS[questionIndex]; + if (!q) return; + + setStatus("Озвучиваю вопрос…"); + setButtons({ mainText: "Озвучивается…", mainEnabled: false, showNext: false }); + + await speak(q.text); + + currentAnswerStartTs = performance.now(); + + setStatus("Говорите ответ"); + setButtons({ mainText: "Озвучить вопрос", mainEnabled: false, showNext: true }); +} + +function nextQuestion() { + closeCurrentAnswer(); + if (questionIndex < QUESTIONS.length - 1) { + questionIndex++; + renderQuestion(); + askQuestion(); + return; + } + finishInterview(); +} + +function finishInterview() { + closeCurrentAnswer(); + state = "finished"; + stopTimer(); + stopSessionRecording(); + stopMicIndicator(); + + hideInterviewUI(); + setStatus("Интервью завершено"); + + setButtons({ mainText: "Начать заново", mainEnabled: true, showNext: false }); +} + +function finishInterviewByTimeout() { + setStatus("Время интервью вышло"); + finishInterview(); +} + +function addFullSessionRecording(blob) { + const url = URL.createObjectURL(blob); + const card = document.createElement("div"); + card.className = "mt-3 p-3 border rounded"; + card.innerHTML = ` + Запись всей тренировки + + + Скачать запись + + `; + recordingsEl.appendChild(card); +} + +async function sendSessionToBackend(blob) { + if (!SESSION_ID) return; + const form = new FormData(); + form.append("audio", blob); + form.append("session_id", SESSION_ID); + form.append("segments", JSON.stringify(questionSegments)); + + try { + const resp = await fetch("/api/interview/recording", { method: "POST", body: form }); + if (!resp.ok) console.warn("Ошибка отправки записи:", resp.status); + } catch (err) { + console.error("Ошибка при fetch:", err); + } +} + + +mainBtn?.addEventListener("click", async () => { + if (state === "idle" || state === "finished") { + await startInterview(); + return; + } + if (state === "running") { + await askQuestion(); + } +}); + +nextBtn?.addEventListener("click", nextQuestion); +stopBtn?.addEventListener("click", finishInterview); + + +hideInterviewUI(); +setStatus("Нажмите «Начать», чтобы начать интервью"); diff --git a/app/templates/interview.html b/app/templates/interview.html new file mode 100644 index 0000000..be3a6d9 --- /dev/null +++ b/app/templates/interview.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block header %} + + +{% endblock %} +{% block content %} +
+
+
+ Рекомендуется отвечать на вопросы в наушниках +
+
+ {% if has_avatar %} + + {% else %} +
+ {% endif %} +
+ +
+ +
+
+ + + +
+ +
+ +
+
+
+ +
+

00:00

+

Вопрос 1 из

+

+
+ + + +
+
Записи
+
+
+ + +
+{% endblock %} diff --git a/app/web_speech_trainer.py b/app/web_speech_trainer.py index eeaee66..dea2d7a 100644 --- a/app/web_speech_trainer.py +++ b/app/web_speech_trainer.py @@ -32,6 +32,7 @@ from app.routes.task_attempts import routes_task_attempts from app.routes.version import routes_version from app.routes.capacity import routes_capacity +from app.routes.interview import routes_interview from app.status import PassBackStatus, TrainingStatus from app.training_manager import TrainingManager from app.utils import ALLOWED_EXTENSIONS, DEFAULT_EXTENSION @@ -57,6 +58,7 @@ app.register_blueprint(routes_task_attempts) app.register_blueprint(routes_version) app.register_blueprint(routes_capacity) +app.register_blueprint(routes_interview) logger = get_root_logger(service_name='web')