MAJOR ARCHITECTURE UPDATE - C++ Style Interface Enforcement 🔒 **Strict Interface System**: - Created DRSExerciseInterface (10 required methods) - Created ProgressSystemInterface (17 required methods) - Updated ImplementationValidator with 3-phase validation - Red screen errors for missing implementations 📚 **11/11 Exercise Modules Implemented**: ✅ VocabularyModule - Local flashcard validation ✅ TextAnalysisModule - AI text comprehension ✅ GrammarAnalysisModule - AI grammar correction ✅ TranslationModule - AI translation validation ✅ OpenResponseModule - AI open-ended responses ✅ PhraseModule - Phrase comprehension ✅ AudioModule - Audio listening exercises ✅ ImageModule - Visual comprehension ✅ GrammarModule - Grammar exercises ✅ TextModule - Reading comprehension ✅ WordDiscoveryModule - Vocabulary introduction 🎯 **Required Methods (All Modules)**: - Lifecycle: init(), render(), destroy() - Exercise: validate(), getResults(), handleUserInput() - Progress: markCompleted(), getProgress() - Metadata: getExerciseType(), getExerciseConfig() 📋 **Documentation**: - Updated CLAUDE.md with complete interface hierarchy - Created DRS_IMPLEMENTATION_PLAN.md (roadmap) - Documented enforcement rules and patterns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1949 lines
69 KiB
JavaScript
1949 lines
69 KiB
JavaScript
/**
|
|
* AudioModule - Listening comprehension exercises with AI validation
|
|
* Handles audio passages with listening questions and pronunciation practice
|
|
*/
|
|
|
|
import DRSExerciseInterface from '../interfaces/DRSExerciseInterface.js';
|
|
|
|
class AudioModule extends DRSExerciseInterface {
|
|
constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) {
|
|
super('AudioModule');
|
|
|
|
// Validate dependencies
|
|
if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) {
|
|
throw new Error('AudioModule requires all service dependencies');
|
|
}
|
|
|
|
this.orchestrator = orchestrator;
|
|
this.llmValidator = llmValidator;
|
|
this.prerequisiteEngine = prerequisiteEngine;
|
|
this.contextMemory = contextMemory;
|
|
|
|
// Module state
|
|
this.initialized = false;
|
|
this.container = null;
|
|
this.currentExerciseData = null;
|
|
this.currentAudio = null;
|
|
this.currentQuestion = null;
|
|
this.questionIndex = 0;
|
|
this.questionResults = [];
|
|
this.validationInProgress = false;
|
|
this.lastValidationResult = null;
|
|
this.aiAvailable = false;
|
|
this.audioElement = null;
|
|
this.audioProgress = 0;
|
|
this.playCount = 0;
|
|
|
|
// Configuration
|
|
this.config = {
|
|
requiredProvider: 'openai', // Prefer OpenAI for audio analysis
|
|
model: 'gpt-4o-mini',
|
|
temperature: 0.2,
|
|
maxTokens: 800,
|
|
timeout: 45000, // Longer timeout for complex audio analysis
|
|
questionsPerAudio: 3, // Default number of questions per audio
|
|
maxPlaybacks: 5, // Maximum playbacks before penalty
|
|
showTranscriptAfter: 3, // Show transcript after N playbacks
|
|
allowReplay: true // Allow replaying the audio
|
|
};
|
|
|
|
// Languages configuration
|
|
this.languages = {
|
|
userLanguage: 'English',
|
|
targetLanguage: 'French'
|
|
};
|
|
|
|
// Bind methods
|
|
this._handleUserInput = this._handleUserInput.bind(this);
|
|
this._handleNextQuestion = this._handleNextQuestion.bind(this);
|
|
this._handleRetry = this._handleRetry.bind(this);
|
|
this._handlePlayAudio = this._handlePlayAudio.bind(this);
|
|
this._handleAudioProgress = this._handleAudioProgress.bind(this);
|
|
this._handleAudioEnded = this._handleAudioEnded.bind(this);
|
|
}
|
|
|
|
async init() {
|
|
if (this.initialized) return;
|
|
|
|
console.log('🎧 Initializing AudioModule...');
|
|
|
|
// Test AI connectivity - recommended for audio comprehension
|
|
try {
|
|
const testResult = await this.llmValidator.testConnectivity();
|
|
if (testResult.success) {
|
|
console.log(`✅ AI connectivity verified for audio analysis (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`);
|
|
this.aiAvailable = true;
|
|
} else {
|
|
console.warn('⚠️ AI connection failed - audio comprehension will be limited:', testResult.error);
|
|
this.aiAvailable = false;
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ AI connectivity test failed - using basic audio analysis:', error.message);
|
|
this.aiAvailable = false;
|
|
}
|
|
|
|
this.initialized = true;
|
|
console.log(`✅ AudioModule initialized (AI: ${this.aiAvailable ? 'available for deep analysis' : 'limited - basic analysis only'})`);
|
|
}
|
|
|
|
/**
|
|
* Check if module can run with current prerequisites
|
|
* @param {Array} prerequisites - List of learned vocabulary/concepts
|
|
* @param {Object} chapterContent - Full chapter content
|
|
* @returns {boolean} - True if module can run
|
|
*/
|
|
canRun(prerequisites, chapterContent) {
|
|
// Check if there are audio files and if prerequisites allow them
|
|
const audios = chapterContent?.audios || [];
|
|
if (audios.length === 0) return false;
|
|
|
|
// Find audio files that can be unlocked with current prerequisites
|
|
const availableAudios = audios.filter(audio => {
|
|
const unlockStatus = this.prerequisiteEngine.canUnlock('audio', audio);
|
|
return unlockStatus.canUnlock;
|
|
});
|
|
|
|
return availableAudios.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Present exercise UI and content
|
|
* @param {HTMLElement} container - DOM container to render into
|
|
* @param {Object} exerciseData - Specific exercise data to present
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async present(container, exerciseData) {
|
|
if (!this.initialized) {
|
|
throw new Error('AudioModule must be initialized before use');
|
|
}
|
|
|
|
this.container = container;
|
|
this.currentExerciseData = exerciseData;
|
|
this.currentAudio = exerciseData.audio;
|
|
this.questionIndex = 0;
|
|
this.questionResults = [];
|
|
this.validationInProgress = false;
|
|
this.lastValidationResult = null;
|
|
this.audioProgress = 0;
|
|
this.playCount = 0;
|
|
|
|
// Detect languages from chapter content
|
|
this._detectLanguages(exerciseData);
|
|
|
|
// Generate or extract questions
|
|
this.questions = await this._prepareQuestions(this.currentAudio);
|
|
|
|
console.log(`🎧 Presenting audio comprehension: "${this.currentAudio.title || 'Listening Exercise'}" (${this.questions.length} questions)`);
|
|
|
|
// Render initial UI
|
|
await this._renderAudioExercise();
|
|
|
|
// Start with audio listening phase
|
|
this._showAudioListening();
|
|
}
|
|
|
|
/**
|
|
* Validate user input with AI for deep audio comprehension
|
|
* @param {string} userInput - User's response
|
|
* @param {Object} context - Exercise context
|
|
* @returns {Promise<ValidationResult>} - Validation result with score and feedback
|
|
*/
|
|
async validate(userInput, context) {
|
|
if (!userInput || !userInput.trim()) {
|
|
throw new Error('Please provide an answer');
|
|
}
|
|
|
|
if (!this.currentAudio || !this.currentQuestion) {
|
|
throw new Error('No audio or question loaded for validation');
|
|
}
|
|
|
|
console.log(`🎧 Validating audio comprehension answer for question ${this.questionIndex + 1}`);
|
|
|
|
// Build comprehensive prompt for audio comprehension
|
|
const prompt = this._buildAudioComprehensionPrompt(userInput);
|
|
|
|
try {
|
|
// Use AI validation with structured response
|
|
const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, {
|
|
preferredProvider: this.config.requiredProvider,
|
|
temperature: this.config.temperature,
|
|
maxTokens: this.config.maxTokens,
|
|
timeout: this.config.timeout,
|
|
systemPrompt: `You are an expert listening comprehension evaluator. Focus on understanding audio content and critical listening skills. ALWAYS respond in the exact format: [answer]yes/no [explanation]your detailed analysis here`
|
|
});
|
|
|
|
// Parse structured response
|
|
const parsedResult = this._parseStructuredResponse(aiResponse);
|
|
|
|
// Apply playback penalty if too many replays
|
|
if (this.playCount > this.config.maxPlaybacks) {
|
|
parsedResult.score = Math.max(parsedResult.score - 10, 40);
|
|
parsedResult.feedback += ` (Note: Score reduced due to excessive playbacks - practice active listening on first attempts)`;
|
|
}
|
|
|
|
// Record interaction in context memory
|
|
this.contextMemory.recordInteraction({
|
|
type: 'audio',
|
|
subtype: 'comprehension',
|
|
content: {
|
|
audio: this.currentAudio,
|
|
question: this.currentQuestion,
|
|
audioTitle: this.currentAudio.title || 'Listening Exercise',
|
|
audioDuration: this.currentAudio.duration || 0,
|
|
playCount: this.playCount
|
|
},
|
|
userResponse: userInput.trim(),
|
|
validation: parsedResult,
|
|
context: {
|
|
languages: this.languages,
|
|
questionIndex: this.questionIndex,
|
|
totalQuestions: this.questions.length,
|
|
playbacks: this.playCount
|
|
}
|
|
});
|
|
|
|
return parsedResult;
|
|
|
|
} catch (error) {
|
|
console.error('❌ AI audio comprehension validation failed:', error);
|
|
|
|
// Fallback to basic keyword analysis if AI fails
|
|
if (!this.aiAvailable) {
|
|
return this._performBasicAudioValidation(userInput);
|
|
}
|
|
|
|
throw new Error(`Audio comprehension validation failed: ${error.message}. Please check your answer and try again.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current progress data
|
|
* @returns {ProgressData} - Progress information for this module
|
|
*/
|
|
getProgress() {
|
|
const totalQuestions = this.questions ? this.questions.length : 0;
|
|
const completedQuestions = this.questionResults.length;
|
|
const correctAnswers = this.questionResults.filter(result => result.correct).length;
|
|
|
|
return {
|
|
type: 'audio',
|
|
audioTitle: this.currentAudio?.title || 'Listening Exercise',
|
|
totalQuestions,
|
|
completedQuestions,
|
|
correctAnswers,
|
|
currentQuestionIndex: this.questionIndex,
|
|
questionResults: this.questionResults,
|
|
progressPercentage: totalQuestions > 0 ? Math.round((completedQuestions / totalQuestions) * 100) : 0,
|
|
comprehensionRate: completedQuestions > 0 ? Math.round((correctAnswers / completedQuestions) * 100) : 0,
|
|
playbackCount: this.playCount,
|
|
aiAnalysisAvailable: this.aiAvailable
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clean up and prepare for unloading
|
|
*/
|
|
cleanup() {
|
|
console.log('🧹 Cleaning up AudioModule...');
|
|
|
|
// Stop audio playback
|
|
if (this.audioElement) {
|
|
this.audioElement.pause();
|
|
this.audioElement.currentTime = 0;
|
|
}
|
|
|
|
// Remove event listeners
|
|
if (this.container) {
|
|
this.container.innerHTML = '';
|
|
}
|
|
|
|
// Reset state
|
|
this.container = null;
|
|
this.currentExerciseData = null;
|
|
this.currentAudio = null;
|
|
this.currentQuestion = null;
|
|
this.questionIndex = 0;
|
|
this.questionResults = [];
|
|
this.questions = null;
|
|
this.validationInProgress = false;
|
|
this.lastValidationResult = null;
|
|
this.audioElement = null;
|
|
this.audioProgress = 0;
|
|
this.playCount = 0;
|
|
|
|
console.log('✅ AudioModule cleaned up');
|
|
}
|
|
|
|
/**
|
|
* Get module metadata
|
|
* @returns {Object} - Module information
|
|
*/
|
|
getMetadata() {
|
|
return {
|
|
name: 'AudioModule',
|
|
type: 'audio',
|
|
version: '1.0.0',
|
|
description: 'Listening comprehension exercises with AI-powered audio analysis',
|
|
capabilities: ['audio_comprehension', 'active_listening', 'pronunciation_feedback', 'ai_feedback'],
|
|
aiRequired: false, // Can work without AI but limited
|
|
config: this.config
|
|
};
|
|
}
|
|
|
|
// Private Methods
|
|
|
|
/**
|
|
* Detect languages from exercise data
|
|
* @private
|
|
*/
|
|
_detectLanguages(exerciseData) {
|
|
const chapterContent = this.currentExerciseData?.chapterContent;
|
|
|
|
if (chapterContent?.metadata?.userLanguage) {
|
|
this.languages.userLanguage = chapterContent.metadata.userLanguage;
|
|
}
|
|
|
|
if (chapterContent?.metadata?.targetLanguage) {
|
|
this.languages.targetLanguage = chapterContent.metadata.targetLanguage;
|
|
}
|
|
|
|
console.log(`🌍 Audio languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`);
|
|
}
|
|
|
|
/**
|
|
* Prepare questions for the audio
|
|
* @private
|
|
*/
|
|
async _prepareQuestions(audio) {
|
|
// If audio already has questions, use them
|
|
if (audio.questions && audio.questions.length > 0) {
|
|
return audio.questions.map((q, index) => ({
|
|
id: `q${index + 1}`,
|
|
question: q.question || q.text || q,
|
|
type: q.type || 'open',
|
|
expectedAnswer: q.answer || q.expectedAnswer,
|
|
keywords: q.keywords || [],
|
|
difficulty: q.difficulty || 'medium',
|
|
requiresTranscript: q.requiresTranscript || false
|
|
}));
|
|
}
|
|
|
|
// Generate default listening comprehension questions
|
|
const defaultQuestions = [
|
|
{
|
|
id: 'main_content',
|
|
question: `What is the main topic or content of this audio?`,
|
|
type: 'open',
|
|
keywords: ['main', 'topic', 'about', 'content'],
|
|
difficulty: 'medium',
|
|
requiresTranscript: false
|
|
},
|
|
{
|
|
id: 'specific_details',
|
|
question: `What specific details or information did you hear?`,
|
|
type: 'open',
|
|
keywords: ['details', 'specific', 'information', 'mentioned'],
|
|
difficulty: 'easy',
|
|
requiresTranscript: false
|
|
},
|
|
{
|
|
id: 'comprehension',
|
|
question: `What can you understand or infer from the speaker's tone and context?`,
|
|
type: 'open',
|
|
keywords: ['tone', 'context', 'infer', 'understand', 'meaning'],
|
|
difficulty: 'hard',
|
|
requiresTranscript: false
|
|
}
|
|
];
|
|
|
|
// Limit to configured number of questions
|
|
return defaultQuestions.slice(0, this.config.questionsPerAudio);
|
|
}
|
|
|
|
/**
|
|
* Build comprehensive prompt for audio comprehension validation
|
|
* @private
|
|
*/
|
|
_buildAudioComprehensionPrompt(userAnswer) {
|
|
const audioTitle = this.currentAudio.title || 'Listening Exercise';
|
|
const audioTranscript = this.currentAudio.transcript || '';
|
|
const audioDuration = this.currentAudio.duration || 'Unknown';
|
|
|
|
return `You are evaluating listening comprehension for a language learning exercise.
|
|
|
|
CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your detailed analysis here
|
|
|
|
AUDIO CONTENT:
|
|
Title: "${audioTitle}"
|
|
Duration: ${audioDuration}
|
|
${audioTranscript ? `Transcript: "${audioTranscript}"` : 'Transcript: Not available'}
|
|
|
|
QUESTION: ${this.currentQuestion.question}
|
|
|
|
STUDENT RESPONSE: "${userAnswer}"
|
|
|
|
EVALUATION CONTEXT:
|
|
- Exercise Type: Listening comprehension
|
|
- Languages: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}
|
|
- Question Type: ${this.currentQuestion.type}
|
|
- Question Difficulty: ${this.currentQuestion.difficulty}
|
|
- Question ${this.questionIndex + 1} of ${this.questions.length}
|
|
- Audio Playbacks: ${this.playCount}
|
|
|
|
EVALUATION CRITERIA:
|
|
- [answer]yes if the student demonstrates understanding of the audio content in relation to the question
|
|
- [answer]no if the response shows lack of comprehension or is unrelated to the audio
|
|
- Focus on LISTENING COMPREHENSION and UNDERSTANDING, not perfect language
|
|
- Accept different interpretations if they show understanding of the audio
|
|
- Consider that students may hear different details or focus on different aspects
|
|
- Reward active listening and attention to audio-specific elements (tone, emphasis, pronunciation)
|
|
|
|
[explanation] should provide:
|
|
1. What the student understood correctly from the audio
|
|
2. What they might have missed or misunderstood
|
|
3. Encouragement and specific improvement suggestions for listening skills
|
|
4. Connection to broader audio meaning and context
|
|
5. Tips for better listening comprehension
|
|
|
|
Format: [answer]yes/no [explanation]your comprehensive educational feedback here`;
|
|
}
|
|
|
|
/**
|
|
* Parse structured AI response for audio comprehension
|
|
* @private
|
|
*/
|
|
_parseStructuredResponse(aiResponse) {
|
|
try {
|
|
let responseText = '';
|
|
|
|
// Extract text from AI response
|
|
if (typeof aiResponse === 'string') {
|
|
responseText = aiResponse;
|
|
} else if (aiResponse.content) {
|
|
responseText = aiResponse.content;
|
|
} else if (aiResponse.text) {
|
|
responseText = aiResponse.text;
|
|
} else {
|
|
responseText = JSON.stringify(aiResponse);
|
|
}
|
|
|
|
console.log('🔍 Parsing AI audio comprehension response:', responseText.substring(0, 150) + '...');
|
|
|
|
// Extract [answer] - case insensitive
|
|
const answerMatch = responseText.match(/\[answer\](yes|no)/i);
|
|
if (!answerMatch) {
|
|
throw new Error('AI response missing [answer] format');
|
|
}
|
|
|
|
// Extract [explanation] - multiline support
|
|
const explanationMatch = responseText.match(/\[explanation\](.+)/s);
|
|
if (!explanationMatch) {
|
|
throw new Error('AI response missing [explanation] format');
|
|
}
|
|
|
|
const isCorrect = answerMatch[1].toLowerCase() === 'yes';
|
|
const explanation = explanationMatch[1].trim();
|
|
|
|
// Higher scores for audio comprehension to encourage listening
|
|
const result = {
|
|
score: isCorrect ? 85 : 55, // Slightly lower than text due to listening difficulty
|
|
correct: isCorrect,
|
|
feedback: explanation,
|
|
answer: answerMatch[1].toLowerCase(),
|
|
explanation: explanation,
|
|
timestamp: new Date().toISOString(),
|
|
provider: this.config.requiredProvider,
|
|
model: this.config.model,
|
|
cached: false,
|
|
formatValid: true,
|
|
audioComprehension: true
|
|
};
|
|
|
|
console.log(`✅ AI audio comprehension parsed: ${result.answer} - Score: ${result.score}`);
|
|
return result;
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to parse AI audio comprehension response:', error);
|
|
console.error('Raw response:', aiResponse);
|
|
|
|
throw new Error(`AI response format invalid: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform basic audio validation when AI is unavailable
|
|
* @private
|
|
*/
|
|
_performBasicAudioValidation(userAnswer) {
|
|
console.log('🔍 Performing basic audio validation (AI unavailable)');
|
|
|
|
const answerLength = userAnswer.trim().length;
|
|
const hasKeywords = this.currentQuestion.keywords?.some(keyword =>
|
|
userAnswer.toLowerCase().includes(keyword.toLowerCase())
|
|
);
|
|
|
|
// Basic scoring based on answer length and keyword presence
|
|
let score = 35; // Lower base score for audio (harder without AI)
|
|
|
|
if (answerLength > 15) score += 15; // Substantial answer
|
|
if (answerLength > 40) score += 10; // Detailed answer
|
|
if (hasKeywords) score += 20; // Contains relevant keywords
|
|
if (answerLength > 80) score += 10; // Very detailed
|
|
if (this.playCount <= 2) score += 10; // Bonus for fewer playbacks
|
|
|
|
const isCorrect = score >= 65;
|
|
|
|
return {
|
|
score: Math.min(score, 100),
|
|
correct: isCorrect,
|
|
feedback: isCorrect
|
|
? "Good listening comprehension demonstrated! Your answer shows understanding of the audio content."
|
|
: "Your answer could be more detailed. Try to listen for specific information and include more details from what you heard.",
|
|
timestamp: new Date().toISOString(),
|
|
provider: 'basic_audio_analysis',
|
|
model: 'keyword_length_analysis',
|
|
cached: false,
|
|
mockGenerated: true,
|
|
audioComprehension: true
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render the audio exercise interface
|
|
* @private
|
|
*/
|
|
async _renderAudioExercise() {
|
|
if (!this.container || !this.currentAudio) return;
|
|
|
|
const audioTitle = this.currentAudio.title || 'Listening Exercise';
|
|
const audioDuration = this.currentAudio.duration ? `${this.currentAudio.duration}s` : 'Unknown';
|
|
const audioUrl = this.currentAudio.url || this.currentAudio.src || '';
|
|
|
|
this.container.innerHTML = `
|
|
<div class="audio-exercise">
|
|
<div class="exercise-header">
|
|
<h2>🎧 Listening Comprehension</h2>
|
|
<div class="audio-info">
|
|
<span class="audio-meta">
|
|
${this.questions?.length || 0} questions • ${audioDuration}
|
|
${!this.aiAvailable ? ' • ⚠️ Limited analysis mode' : ' • 🧠 AI analysis'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audio-content">
|
|
<div class="audio-player-section" id="audio-player-section">
|
|
<div class="audio-player-card">
|
|
<div class="player-header">
|
|
<h3>${audioTitle}</h3>
|
|
<div class="player-stats">
|
|
<span class="play-count">Plays: <span id="play-counter">0</span></span>
|
|
<span class="duration">Duration: ${audioDuration}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audio-player" id="audio-player">
|
|
${audioUrl ? `
|
|
<audio id="audio-element" preload="metadata">
|
|
<source src="${audioUrl}" type="audio/mpeg">
|
|
<source src="${audioUrl}" type="audio/wav">
|
|
<source src="${audioUrl}" type="audio/ogg">
|
|
Your browser does not support the audio element.
|
|
</audio>
|
|
` : `
|
|
<div class="audio-placeholder">
|
|
🎵 Audio file placeholder (${audioTitle})
|
|
</div>
|
|
`}
|
|
|
|
<div class="player-controls">
|
|
<button id="play-audio-btn" class="btn btn-primary play-btn" ${!audioUrl ? 'disabled' : ''}>
|
|
<span class="btn-icon" id="play-icon">▶️</span>
|
|
<span class="btn-text" id="play-text">Play Audio</span>
|
|
</button>
|
|
|
|
<div class="progress-container">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="audio-progress" style="width: 0%"></div>
|
|
</div>
|
|
<div class="time-display">
|
|
<span id="current-time">0:00</span> / <span id="total-time">${audioDuration}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="transcript-section" id="transcript-section" style="display: none;">
|
|
<div class="transcript-header">
|
|
<h4>📄 Transcript</h4>
|
|
<span class="transcript-note">Available after ${this.config.showTranscriptAfter} plays</span>
|
|
</div>
|
|
<div class="transcript-content" id="transcript-content">
|
|
${this.currentAudio.transcript || 'Transcript not available for this audio.'}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="listening-actions">
|
|
<button id="start-questions-btn" class="btn btn-success" style="display: none;">
|
|
<span class="btn-icon">❓</span>
|
|
<span class="btn-text">Start Questions</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="questions-section" id="questions-section" style="display: none;">
|
|
<div class="question-card">
|
|
<div class="question-progress">
|
|
<div class="progress-indicator">
|
|
<span id="question-counter">Question 1 of ${this.questions?.length || 0}</span>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="question-content" id="question-content">
|
|
<!-- Question content will be populated dynamically -->
|
|
</div>
|
|
|
|
<div class="answer-input-section">
|
|
<label for="answer-input">Your Answer:</label>
|
|
<textarea
|
|
id="answer-input"
|
|
placeholder="Write what you heard and understood from the audio..."
|
|
rows="4"
|
|
autocomplete="off"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="question-controls">
|
|
<button id="replay-audio-btn" class="btn btn-outline">
|
|
<span class="btn-icon">🔄</span>
|
|
<span class="btn-text">Replay Audio</span>
|
|
</button>
|
|
<button id="validate-answer-btn" class="btn btn-primary" disabled>
|
|
<span class="btn-icon">${this.aiAvailable ? '🧠' : '🔍'}</span>
|
|
<span class="btn-text">${this.aiAvailable ? 'Validate with AI' : 'Validate Answer'}</span>
|
|
</button>
|
|
<div id="validation-status" class="validation-status"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="explanation-panel" id="explanation-panel" style="display: none;">
|
|
<div class="panel-header">
|
|
<h3>${this.aiAvailable ? '🤖 AI Analysis' : '🔍 Analysis'}</h3>
|
|
<span class="analysis-model">${this.aiAvailable ? this.config.model : 'Basic Analysis'}</span>
|
|
</div>
|
|
<div class="explanation-content" id="explanation-content">
|
|
<!-- Analysis will appear here -->
|
|
</div>
|
|
<div class="panel-actions">
|
|
<button id="next-question-btn" class="btn btn-primary" style="display: none;">
|
|
Next Question
|
|
</button>
|
|
<button id="retry-answer-btn" class="btn btn-secondary" style="display: none;">
|
|
Try Again
|
|
</button>
|
|
<button id="finish-audio-btn" class="btn btn-success" style="display: none;">
|
|
Complete Audio Exercise
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audio-results" id="audio-results" style="display: none;">
|
|
<!-- Final results will be shown here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add CSS styles
|
|
this._addStyles();
|
|
|
|
// Setup audio element
|
|
this._setupAudioElement();
|
|
|
|
// Add event listeners
|
|
this._setupEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Setup audio element and controls
|
|
* @private
|
|
*/
|
|
_setupAudioElement() {
|
|
this.audioElement = document.getElementById('audio-element');
|
|
|
|
if (this.audioElement) {
|
|
this.audioElement.addEventListener('loadedmetadata', () => {
|
|
const totalTime = document.getElementById('total-time');
|
|
if (totalTime && this.audioElement.duration) {
|
|
const minutes = Math.floor(this.audioElement.duration / 60);
|
|
const seconds = Math.floor(this.audioElement.duration % 60);
|
|
totalTime.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
});
|
|
|
|
this.audioElement.addEventListener('timeupdate', this._handleAudioProgress);
|
|
this.audioElement.addEventListener('ended', this._handleAudioEnded);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners for audio exercise
|
|
* @private
|
|
*/
|
|
_setupEventListeners() {
|
|
const playAudioBtn = document.getElementById('play-audio-btn');
|
|
const replayAudioBtn = document.getElementById('replay-audio-btn');
|
|
const startQuestionsBtn = document.getElementById('start-questions-btn');
|
|
const answerInput = document.getElementById('answer-input');
|
|
const validateBtn = document.getElementById('validate-answer-btn');
|
|
const retryBtn = document.getElementById('retry-answer-btn');
|
|
const nextBtn = document.getElementById('next-question-btn');
|
|
const finishBtn = document.getElementById('finish-audio-btn');
|
|
|
|
// Audio control buttons
|
|
if (playAudioBtn) {
|
|
playAudioBtn.onclick = this._handlePlayAudio;
|
|
}
|
|
|
|
if (replayAudioBtn) {
|
|
replayAudioBtn.onclick = this._handlePlayAudio;
|
|
}
|
|
|
|
// Start questions button
|
|
if (startQuestionsBtn) {
|
|
startQuestionsBtn.onclick = () => this._startQuestions();
|
|
}
|
|
|
|
// Answer input validation
|
|
if (answerInput) {
|
|
answerInput.addEventListener('input', () => {
|
|
const hasText = answerInput.value.trim().length > 0;
|
|
if (validateBtn) {
|
|
validateBtn.disabled = !hasText || this.validationInProgress;
|
|
}
|
|
});
|
|
|
|
// Allow Ctrl+Enter to validate
|
|
answerInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !validateBtn.disabled) {
|
|
e.preventDefault();
|
|
this._handleUserInput();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Validate button
|
|
if (validateBtn) {
|
|
validateBtn.onclick = this._handleUserInput;
|
|
}
|
|
|
|
// Action buttons
|
|
if (retryBtn) retryBtn.onclick = this._handleRetry;
|
|
if (nextBtn) nextBtn.onclick = this._handleNextQuestion;
|
|
if (finishBtn) finishBtn.onclick = () => this._completeAudioExercise();
|
|
}
|
|
|
|
/**
|
|
* Handle audio playback
|
|
* @private
|
|
*/
|
|
_handlePlayAudio() {
|
|
if (!this.audioElement) {
|
|
// For demo mode without actual audio
|
|
this._simulateAudioPlayback();
|
|
return;
|
|
}
|
|
|
|
const playBtn = document.getElementById('play-audio-btn');
|
|
const replayBtn = document.getElementById('replay-audio-btn');
|
|
const playIcon = document.getElementById('play-icon');
|
|
const playText = document.getElementById('play-text');
|
|
|
|
if (this.audioElement.paused) {
|
|
this.audioElement.play();
|
|
this.playCount++;
|
|
|
|
// Update play counter
|
|
const playCounter = document.getElementById('play-counter');
|
|
if (playCounter) playCounter.textContent = this.playCount;
|
|
|
|
// Update button states
|
|
if (playIcon) playIcon.textContent = '⏸️';
|
|
if (playText) playText.textContent = 'Pause';
|
|
|
|
// Show transcript after enough plays
|
|
if (this.playCount >= this.config.showTranscriptAfter) {
|
|
const transcriptSection = document.getElementById('transcript-section');
|
|
if (transcriptSection) transcriptSection.style.display = 'block';
|
|
}
|
|
|
|
// Show questions button after first play
|
|
if (this.playCount === 1) {
|
|
const startQuestionsBtn = document.getElementById('start-questions-btn');
|
|
if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block';
|
|
}
|
|
|
|
} else {
|
|
this.audioElement.pause();
|
|
|
|
if (playIcon) playIcon.textContent = '▶️';
|
|
if (playText) playText.textContent = 'Resume';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simulate audio playback for demo mode
|
|
* @private
|
|
*/
|
|
_simulateAudioPlayback() {
|
|
this.playCount++;
|
|
|
|
const playCounter = document.getElementById('play-counter');
|
|
if (playCounter) playCounter.textContent = this.playCount;
|
|
|
|
// Simulate progress
|
|
let progress = 0;
|
|
const progressFill = document.getElementById('audio-progress');
|
|
const currentTime = document.getElementById('current-time');
|
|
|
|
const interval = setInterval(() => {
|
|
progress += 2;
|
|
if (progressFill) progressFill.style.width = `${progress}%`;
|
|
if (currentTime) {
|
|
const seconds = Math.floor((progress / 100) * 30); // Assume 30s demo
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
currentTime.textContent = `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
if (progress >= 100) {
|
|
clearInterval(interval);
|
|
this._handleAudioEnded();
|
|
}
|
|
}, 100);
|
|
|
|
// Show transcript after enough plays
|
|
if (this.playCount >= this.config.showTranscriptAfter) {
|
|
const transcriptSection = document.getElementById('transcript-section');
|
|
if (transcriptSection) transcriptSection.style.display = 'block';
|
|
}
|
|
|
|
// Show questions button after first play
|
|
if (this.playCount === 1) {
|
|
const startQuestionsBtn = document.getElementById('start-questions-btn');
|
|
if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle audio progress updates
|
|
* @private
|
|
*/
|
|
_handleAudioProgress() {
|
|
if (!this.audioElement) return;
|
|
|
|
const progress = (this.audioElement.currentTime / this.audioElement.duration) * 100;
|
|
const progressFill = document.getElementById('audio-progress');
|
|
const currentTime = document.getElementById('current-time');
|
|
|
|
if (progressFill) progressFill.style.width = `${progress}%`;
|
|
|
|
if (currentTime && this.audioElement.currentTime) {
|
|
const minutes = Math.floor(this.audioElement.currentTime / 60);
|
|
const seconds = Math.floor(this.audioElement.currentTime % 60);
|
|
currentTime.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle audio playback end
|
|
* @private
|
|
*/
|
|
_handleAudioEnded() {
|
|
const playIcon = document.getElementById('play-icon');
|
|
const playText = document.getElementById('play-text');
|
|
|
|
if (playIcon) playIcon.textContent = '▶️';
|
|
if (playText) playText.textContent = 'Play Again';
|
|
|
|
// Reset audio to beginning
|
|
if (this.audioElement) {
|
|
this.audioElement.currentTime = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show audio listening phase
|
|
* @private
|
|
*/
|
|
_showAudioListening() {
|
|
const audioSection = document.getElementById('audio-player-section');
|
|
const questionsSection = document.getElementById('questions-section');
|
|
|
|
if (audioSection) audioSection.style.display = 'block';
|
|
if (questionsSection) questionsSection.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Start questions phase
|
|
* @private
|
|
*/
|
|
_startQuestions() {
|
|
const audioSection = document.getElementById('audio-player-section');
|
|
const questionsSection = document.getElementById('questions-section');
|
|
|
|
// Keep audio player visible but smaller
|
|
if (audioSection) {
|
|
audioSection.style.display = 'block';
|
|
audioSection.classList.add('minimized');
|
|
}
|
|
if (questionsSection) questionsSection.style.display = 'block';
|
|
|
|
this._presentCurrentQuestion();
|
|
}
|
|
|
|
/**
|
|
* Present current question
|
|
* @private
|
|
*/
|
|
_presentCurrentQuestion() {
|
|
if (this.questionIndex >= this.questions.length) {
|
|
this._showAudioResults();
|
|
return;
|
|
}
|
|
|
|
this.currentQuestion = this.questions[this.questionIndex];
|
|
const questionContent = document.getElementById('question-content');
|
|
const questionCounter = document.getElementById('question-counter');
|
|
const progressFill = document.getElementById('progress-fill');
|
|
|
|
if (!questionContent || !this.currentQuestion) return;
|
|
|
|
// Update progress
|
|
const progressPercentage = ((this.questionIndex + 1) / this.questions.length) * 100;
|
|
if (progressFill) progressFill.style.width = `${progressPercentage}%`;
|
|
if (questionCounter) questionCounter.textContent = `Question ${this.questionIndex + 1} of ${this.questions.length}`;
|
|
|
|
// Display question
|
|
questionContent.innerHTML = `
|
|
<div class="question-display">
|
|
<div class="question-text">${this.currentQuestion.question}</div>
|
|
<div class="question-meta">
|
|
<span class="question-type">${this.currentQuestion.type}</span>
|
|
<span class="question-difficulty difficulty-${this.currentQuestion.difficulty}">
|
|
${this.currentQuestion.difficulty}
|
|
</span>
|
|
${this.currentQuestion.requiresTranscript ? '<span class="requires-transcript">📄 Transcript helpful</span>' : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Clear previous answer and focus
|
|
const answerInput = document.getElementById('answer-input');
|
|
if (answerInput) {
|
|
answerInput.value = '';
|
|
answerInput.focus();
|
|
}
|
|
|
|
// Hide explanation panel
|
|
const explanationPanel = document.getElementById('explanation-panel');
|
|
if (explanationPanel) explanationPanel.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Handle user input validation
|
|
* @private
|
|
*/
|
|
async _handleUserInput() {
|
|
const answerInput = document.getElementById('answer-input');
|
|
const validateBtn = document.getElementById('validate-answer-btn');
|
|
const statusDiv = document.getElementById('validation-status');
|
|
|
|
if (!answerInput || !validateBtn || !statusDiv) return;
|
|
|
|
const userAnswer = answerInput.value.trim();
|
|
if (!userAnswer) return;
|
|
|
|
try {
|
|
// Set validation in progress
|
|
this.validationInProgress = true;
|
|
validateBtn.disabled = true;
|
|
answerInput.disabled = true;
|
|
|
|
// Show loading status
|
|
statusDiv.innerHTML = `
|
|
<div class="status-loading">
|
|
<div class="loading-spinner">🔍</div>
|
|
<span>${this.aiAvailable ? 'AI is analyzing your comprehension...' : 'Analyzing your answer...'}</span>
|
|
</div>
|
|
`;
|
|
|
|
// Call validation
|
|
const result = await this.validate(userAnswer, {});
|
|
this.lastValidationResult = result;
|
|
|
|
// Store result
|
|
this.questionResults[this.questionIndex] = {
|
|
question: this.currentQuestion.question,
|
|
userAnswer: userAnswer,
|
|
correct: result.correct,
|
|
score: result.score,
|
|
feedback: result.feedback,
|
|
timestamp: new Date().toISOString(),
|
|
playbackCount: this.playCount
|
|
};
|
|
|
|
// Show result
|
|
this._showValidationResult(result);
|
|
|
|
// Update status
|
|
statusDiv.innerHTML = `
|
|
<div class="status-complete">
|
|
<span class="result-icon">${result.correct ? '✅' : '👂'}</span>
|
|
<span>Analysis complete</span>
|
|
</div>
|
|
`;
|
|
|
|
} catch (error) {
|
|
console.error('❌ Audio validation error:', error);
|
|
|
|
// Show error status
|
|
statusDiv.innerHTML = `
|
|
<div class="status-error">
|
|
<span class="error-icon">⚠️</span>
|
|
<span>Error: ${error.message}</span>
|
|
</div>
|
|
`;
|
|
|
|
// Re-enable input for retry
|
|
this._enableRetry();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show validation result in explanation panel
|
|
* @private
|
|
*/
|
|
_showValidationResult(result) {
|
|
const explanationPanel = document.getElementById('explanation-panel');
|
|
const explanationContent = document.getElementById('explanation-content');
|
|
const nextBtn = document.getElementById('next-question-btn');
|
|
const retryBtn = document.getElementById('retry-answer-btn');
|
|
const finishBtn = document.getElementById('finish-audio-btn');
|
|
|
|
if (!explanationPanel || !explanationContent) return;
|
|
|
|
// Show panel
|
|
explanationPanel.style.display = 'block';
|
|
|
|
// Set explanation content
|
|
explanationContent.innerHTML = `
|
|
<div class="explanation-result ${result.correct ? 'correct' : 'needs-improvement'}">
|
|
<div class="result-header">
|
|
<span class="result-indicator">
|
|
${result.correct ? '✅ Good Listening!' : '👂 Keep Practicing!'}
|
|
</span>
|
|
<span class="comprehension-score">Score: ${result.score}/100</span>
|
|
</div>
|
|
<div class="explanation-text">${result.explanation || result.feedback}</div>
|
|
${result.audioComprehension ? '<div class="analysis-note">🎧 This analysis focuses on your listening skills and understanding of audio content.</div>' : ''}
|
|
${this.playCount > this.config.maxPlaybacks ? '<div class="playback-note">💡 Try to listen actively on the first few attempts for better comprehension scores.</div>' : ''}
|
|
</div>
|
|
`;
|
|
|
|
// Show appropriate buttons
|
|
const isLastQuestion = this.questionIndex >= this.questions.length - 1;
|
|
|
|
if (nextBtn) nextBtn.style.display = isLastQuestion ? 'none' : 'inline-block';
|
|
if (finishBtn) finishBtn.style.display = isLastQuestion ? 'inline-block' : 'none';
|
|
if (retryBtn) retryBtn.style.display = result.correct ? 'none' : 'inline-block';
|
|
|
|
// Scroll to explanation
|
|
explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
/**
|
|
* Handle next question
|
|
* @private
|
|
*/
|
|
_handleNextQuestion() {
|
|
this.questionIndex++;
|
|
this._presentCurrentQuestion();
|
|
}
|
|
|
|
/**
|
|
* Handle retry
|
|
* @private
|
|
*/
|
|
_handleRetry() {
|
|
// Hide explanation and enable new input
|
|
const explanationPanel = document.getElementById('explanation-panel');
|
|
const statusDiv = document.getElementById('validation-status');
|
|
|
|
if (explanationPanel) explanationPanel.style.display = 'none';
|
|
if (statusDiv) statusDiv.innerHTML = '';
|
|
|
|
this._enableRetry();
|
|
}
|
|
|
|
/**
|
|
* Enable retry after error
|
|
* @private
|
|
*/
|
|
_enableRetry() {
|
|
this.validationInProgress = false;
|
|
|
|
const answerInput = document.getElementById('answer-input');
|
|
const validateBtn = document.getElementById('validate-answer-btn');
|
|
|
|
if (answerInput) {
|
|
answerInput.disabled = false;
|
|
answerInput.focus();
|
|
}
|
|
|
|
if (validateBtn) {
|
|
validateBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show final audio results
|
|
* @private
|
|
*/
|
|
_showAudioResults() {
|
|
const resultsContainer = document.getElementById('audio-results');
|
|
const questionsSection = document.getElementById('questions-section');
|
|
|
|
if (!resultsContainer) return;
|
|
|
|
const correctCount = this.questionResults.filter(result => result.correct).length;
|
|
const totalCount = this.questionResults.length;
|
|
const comprehensionRate = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0;
|
|
|
|
let resultClass = 'results-poor';
|
|
if (comprehensionRate >= 80) resultClass = 'results-excellent';
|
|
else if (comprehensionRate >= 60) resultClass = 'results-good';
|
|
|
|
const resultsHTML = `
|
|
<div class="audio-results-content ${resultClass}">
|
|
<h3>📊 Listening Comprehension Results</h3>
|
|
<div class="results-summary">
|
|
<div class="comprehension-display">
|
|
<span class="comprehension-rate">${comprehensionRate}%</span>
|
|
<span class="comprehension-label">Comprehension Rate</span>
|
|
</div>
|
|
<div class="questions-summary">
|
|
${correctCount} / ${totalCount} questions understood well
|
|
</div>
|
|
<div class="listening-stats">
|
|
<div class="stat-item">
|
|
<span class="stat-value">${this.playCount}</span>
|
|
<span class="stat-label">Total Plays</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-value">${this.playCount <= this.config.maxPlaybacks ? '👍' : '⚠️'}</span>
|
|
<span class="stat-label">${this.playCount <= this.config.maxPlaybacks ? 'Good listening' : 'Practice active listening'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="question-breakdown">
|
|
${this.questionResults.map((result, index) => `
|
|
<div class="question-result ${result.correct ? 'understood' : 'needs-work'}">
|
|
<div class="question-summary">
|
|
<span class="question-num">Q${index + 1}</span>
|
|
<span class="comprehension-icon">${result.correct ? '✅' : '👂'}</span>
|
|
<span class="score">Score: ${result.score}/100</span>
|
|
<span class="playback-info">Plays: ${result.playbackCount || this.playCount}</span>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
|
|
<div class="results-actions">
|
|
<button id="complete-audio-btn" class="btn btn-primary">Continue to Next Exercise</button>
|
|
<button id="replay-audio-btn" class="btn btn-outline">Practice Audio Again</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
resultsContainer.innerHTML = resultsHTML;
|
|
resultsContainer.style.display = 'block';
|
|
|
|
// Hide other sections
|
|
if (questionsSection) questionsSection.style.display = 'none';
|
|
|
|
// Add action listeners
|
|
document.getElementById('complete-audio-btn').onclick = () => this._completeAudioExercise();
|
|
document.getElementById('replay-audio-btn').onclick = () => this._replayAudio();
|
|
}
|
|
|
|
/**
|
|
* Complete audio exercise
|
|
* @private
|
|
*/
|
|
_completeAudioExercise() {
|
|
// Mark audio as comprehended if performance is good
|
|
const correctCount = this.questionResults.filter(result => result.correct).length;
|
|
const comprehensionRate = correctCount / this.questionResults.length;
|
|
|
|
if (comprehensionRate >= 0.6) { // 60% comprehension threshold
|
|
const audioId = this.currentAudio.id || this.currentAudio.title || 'audio_exercise';
|
|
const metadata = {
|
|
comprehensionRate: Math.round(comprehensionRate * 100),
|
|
questionsAnswered: this.questionResults.length,
|
|
correctAnswers: correctCount,
|
|
totalPlaybacks: this.playCount,
|
|
sessionId: this.orchestrator?.sessionId || 'unknown',
|
|
moduleType: 'audio',
|
|
aiAnalysisUsed: this.aiAvailable,
|
|
listeningEfficiency: this.playCount <= this.config.maxPlaybacks ? 'good' : 'needs_improvement'
|
|
};
|
|
|
|
this.prerequisiteEngine.markPhraseMastered(audioId, metadata);
|
|
|
|
// Also save to persistent storage
|
|
if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) {
|
|
window.addMasteredItem(
|
|
this.orchestrator.bookId,
|
|
this.orchestrator.chapterId,
|
|
'audios',
|
|
audioId,
|
|
metadata
|
|
);
|
|
}
|
|
}
|
|
|
|
// Emit completion event
|
|
this.orchestrator._eventBus.emit('drs:exerciseCompleted', {
|
|
moduleType: 'audio',
|
|
results: this.questionResults,
|
|
progress: this.getProgress()
|
|
}, 'AudioModule');
|
|
}
|
|
|
|
/**
|
|
* Replay audio exercise
|
|
* @private
|
|
*/
|
|
_replayAudio() {
|
|
this.questionIndex = 0;
|
|
this.questionResults = [];
|
|
this.playCount = 0;
|
|
this._showAudioListening();
|
|
|
|
const resultsContainer = document.getElementById('audio-results');
|
|
if (resultsContainer) resultsContainer.style.display = 'none';
|
|
|
|
// Reset UI elements
|
|
const playCounter = document.getElementById('play-counter');
|
|
if (playCounter) playCounter.textContent = '0';
|
|
|
|
const startQuestionsBtn = document.getElementById('start-questions-btn');
|
|
if (startQuestionsBtn) startQuestionsBtn.style.display = 'none';
|
|
|
|
const transcriptSection = document.getElementById('transcript-section');
|
|
if (transcriptSection) transcriptSection.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Add CSS styles for audio exercise
|
|
* @private
|
|
*/
|
|
_addStyles() {
|
|
if (document.getElementById('audio-module-styles')) return;
|
|
|
|
const styles = document.createElement('style');
|
|
styles.id = 'audio-module-styles';
|
|
styles.textContent = `
|
|
.audio-exercise {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
display: grid;
|
|
gap: 20px;
|
|
}
|
|
|
|
.exercise-header {
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.audio-info {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.audio-meta {
|
|
color: #666;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.audio-content {
|
|
display: grid;
|
|
gap: 20px;
|
|
}
|
|
|
|
.audio-player-card, .question-card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.audio-player-section.minimized .audio-player-card {
|
|
padding: 20px;
|
|
background: #f8f9fa;
|
|
border: 2px solid #e9ecef;
|
|
}
|
|
|
|
.player-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 2px solid #eee;
|
|
}
|
|
|
|
.player-header h3 {
|
|
margin: 0;
|
|
color: #333;
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
.player-stats {
|
|
display: flex;
|
|
gap: 15px;
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
.play-count {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.audio-player {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.audio-placeholder {
|
|
text-align: center;
|
|
padding: 40px;
|
|
background: linear-gradient(135deg, #f8f9ff, #e8f4fd);
|
|
border-radius: 10px;
|
|
color: #666;
|
|
font-size: 1.1em;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.player-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
align-items: center;
|
|
}
|
|
|
|
.play-btn {
|
|
font-size: 1.1em;
|
|
min-width: 140px;
|
|
}
|
|
|
|
.progress-container {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
text-align: center;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background-color: #e0e0e0;
|
|
border-radius: 4px;
|
|
margin-bottom: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #28a745, #20c997);
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.time-display {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.transcript-section {
|
|
margin-top: 20px;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #fff9e6, #fff3cd);
|
|
border-radius: 10px;
|
|
border-left: 4px solid #ffc107;
|
|
}
|
|
|
|
.transcript-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.transcript-header h4 {
|
|
margin: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.transcript-note {
|
|
font-size: 0.8em;
|
|
color: #666;
|
|
background: rgba(255,255,255,0.7);
|
|
padding: 4px 8px;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.transcript-content {
|
|
line-height: 1.6;
|
|
color: #444;
|
|
font-style: italic;
|
|
}
|
|
|
|
.listening-actions {
|
|
text-align: center;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.question-progress {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.progress-indicator {
|
|
text-align: center;
|
|
}
|
|
|
|
.question-display {
|
|
margin-bottom: 25px;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #f8f9ff, #e8f4fd);
|
|
border-radius: 10px;
|
|
border-left: 4px solid #6c5ce7;
|
|
}
|
|
|
|
.question-text {
|
|
font-size: 1.3em;
|
|
font-weight: 600;
|
|
color: #333;
|
|
margin-bottom: 15px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.question-meta {
|
|
display: flex;
|
|
gap: 15px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.question-type {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.requires-transcript {
|
|
background: #fff3e0;
|
|
color: #f57c00;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.difficulty-easy {
|
|
background: #e8f5e8;
|
|
color: #2e7d32;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.difficulty-medium {
|
|
background: #fff3e0;
|
|
color: #f57c00;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.difficulty-hard {
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.answer-input-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.answer-input-section label {
|
|
display: block;
|
|
margin-bottom: 10px;
|
|
font-weight: 600;
|
|
color: #555;
|
|
}
|
|
|
|
.answer-input-section textarea {
|
|
width: 100%;
|
|
padding: 15px;
|
|
font-size: 1.05em;
|
|
border: 2px solid #ddd;
|
|
border-radius: 8px;
|
|
resize: vertical;
|
|
min-height: 100px;
|
|
box-sizing: border-box;
|
|
transition: border-color 0.3s ease;
|
|
font-family: inherit;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.answer-input-section textarea:focus {
|
|
outline: none;
|
|
border-color: #6c5ce7;
|
|
}
|
|
|
|
.question-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.question-controls > div:first-child {
|
|
display: flex;
|
|
gap: 15px;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
|
|
.validation-status {
|
|
min-height: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.status-loading, .status-complete, .status-error {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 20px;
|
|
border-radius: 25px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-loading {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.status-complete {
|
|
background: #e8f5e8;
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.status-error {
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
}
|
|
|
|
.loading-spinner {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.explanation-panel {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
border-left: 4px solid #6c5ce7;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.panel-header h3 {
|
|
margin: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.analysis-model {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
background: #f5f5f5;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.explanation-result {
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.explanation-result.correct {
|
|
border-left: 4px solid #4caf50;
|
|
background: linear-gradient(135deg, #f1f8e9, #e8f5e8);
|
|
}
|
|
|
|
.explanation-result.needs-improvement {
|
|
border-left: 4px solid #ff9800;
|
|
background: linear-gradient(135deg, #fff8e1, #fff3e0);
|
|
}
|
|
|
|
.result-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.result-indicator {
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
.comprehension-score {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
.explanation-text {
|
|
line-height: 1.6;
|
|
color: #333;
|
|
font-size: 1.05em;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.analysis-note, .playback-note {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
font-style: italic;
|
|
padding-top: 10px;
|
|
border-top: 1px solid #eee;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.panel-actions {
|
|
display: flex;
|
|
gap: 15px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.audio-results-content {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
}
|
|
|
|
.results-summary {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.comprehension-display {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.comprehension-rate {
|
|
font-size: 3em;
|
|
font-weight: bold;
|
|
display: block;
|
|
}
|
|
|
|
.results-excellent .comprehension-rate { color: #4caf50; }
|
|
.results-good .comprehension-rate { color: #ff9800; }
|
|
.results-poor .comprehension-rate { color: #f44336; }
|
|
|
|
.comprehension-label {
|
|
font-size: 1.2em;
|
|
color: #666;
|
|
}
|
|
|
|
.questions-summary {
|
|
font-size: 1.1em;
|
|
color: #555;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.listening-stats {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 40px;
|
|
margin-top: 20px;
|
|
padding: 20px;
|
|
background: #f8f9fa;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.stat-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
display: block;
|
|
font-size: 1.5em;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
.question-breakdown {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.question-result {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.question-result.understood {
|
|
background: linear-gradient(135deg, #e8f5e8, #f1f8e9);
|
|
border-left: 4px solid #4caf50;
|
|
}
|
|
|
|
.question-result.needs-work {
|
|
background: linear-gradient(135deg, #fff8e1, #fff3e0);
|
|
border-left: 4px solid #ff9800;
|
|
}
|
|
|
|
.question-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.question-num {
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
|
|
.playback-info {
|
|
font-size: 0.85em;
|
|
color: #666;
|
|
background: rgba(255,255,255,0.7);
|
|
padding: 4px 8px;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.results-actions {
|
|
display: flex;
|
|
gap: 15px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 24px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
font-weight: 500;
|
|
transition: all 0.3s ease;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(108, 92, 231, 0.3);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #6c757d;
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: #5a6268;
|
|
}
|
|
|
|
.btn-outline {
|
|
background: transparent;
|
|
border: 2px solid #6c5ce7;
|
|
color: #6c5ce7;
|
|
}
|
|
|
|
.btn-outline:hover:not(:disabled) {
|
|
background: #6c5ce7;
|
|
color: white;
|
|
}
|
|
|
|
.btn-success {
|
|
background: linear-gradient(135deg, #00b894, #00cec9);
|
|
color: white;
|
|
}
|
|
|
|
.btn-success:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.audio-exercise {
|
|
padding: 15px;
|
|
}
|
|
|
|
.audio-player-card, .question-card, .explanation-panel {
|
|
padding: 20px;
|
|
}
|
|
|
|
.question-text {
|
|
font-size: 1.2em;
|
|
}
|
|
|
|
.comprehension-rate {
|
|
font-size: 2.5em;
|
|
}
|
|
|
|
.panel-actions, .results-actions {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.player-header {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.listening-stats {
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.question-controls > div:first-child {
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(styles);
|
|
}
|
|
|
|
// ========================================
|
|
// DRSExerciseInterface REQUIRED METHODS
|
|
// ========================================
|
|
|
|
async init(config = {}, content = {}) {
|
|
this.config = { ...this.config, ...config };
|
|
this.currentExerciseData = content;
|
|
this.startTime = Date.now();
|
|
this.initialized = true;
|
|
}
|
|
|
|
async render(container) {
|
|
if (!this.initialized) throw new Error('AudioModule must be initialized before rendering');
|
|
await this.present(container, this.currentExerciseData);
|
|
}
|
|
|
|
async destroy() {
|
|
this.cleanup?.();
|
|
this.container = null;
|
|
this.initialized = false;
|
|
}
|
|
|
|
getResults() {
|
|
return {
|
|
score: this.progress?.averageScore ? Math.round(this.progress.averageScore * 100) : 0,
|
|
attempts: this.userResponses?.length || 0,
|
|
timeSpent: this.startTime ? Date.now() - this.startTime : 0,
|
|
completed: true,
|
|
details: { progress: this.progress, responses: this.userResponses }
|
|
};
|
|
}
|
|
|
|
handleUserInput(event, data) {
|
|
if (event?.type === 'input') this._handleInputChange?.(event);
|
|
if (event?.type === 'click' && event.target.id === 'submitButton') this._handleSubmit?.(event);
|
|
}
|
|
|
|
async markCompleted(results) {
|
|
const { score, details } = results || this.getResults();
|
|
if (this.contextMemory) {
|
|
this.contextMemory.recordInteraction({
|
|
type: 'audio',
|
|
subtype: 'completion',
|
|
content: this.currentExerciseData,
|
|
validation: { score },
|
|
context: { moduleType: 'audio' }
|
|
});
|
|
}
|
|
}
|
|
|
|
getExerciseType() {
|
|
return 'audio';
|
|
}
|
|
|
|
getExerciseConfig() {
|
|
return {
|
|
type: this.getExerciseType(),
|
|
difficulty: this.currentExerciseData?.difficulty || 'medium',
|
|
estimatedTime: 5,
|
|
prerequisites: [],
|
|
metadata: { ...this.config, requiresAI: false }
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
export default AudioModule; |