diff --git a/VERSION b/VERSION index 81fd7ba..268b033 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.0 \ No newline at end of file +v0.3.0 diff --git a/alembic/versions/5fcbe91de50b_add_max_bpm_speed_to_usersettings.py b/alembic/versions/5fcbe91de50b_add_max_bpm_speed_to_usersettings.py new file mode 100644 index 0000000..26ec4c0 --- /dev/null +++ b/alembic/versions/5fcbe91de50b_add_max_bpm_speed_to_usersettings.py @@ -0,0 +1,32 @@ +"""Add max_bpm_speed to UserSettings + +Revision ID: 5fcbe91de50b +Revises: 8f83d622eb4b +Create Date: 2025-10-28 11:34:47.704389 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5fcbe91de50b' +down_revision: Union[str, Sequence[str], None] = '8f83d622eb4b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_settings', sa.Column('max_bpm_speed', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_settings', 'max_bpm_speed') + # ### end Alembic commands ### diff --git a/app.py b/app.py index 1071f31..bc5c06e 100644 --- a/app.py +++ b/app.py @@ -228,6 +228,7 @@ def save_user_settings( metronome_mode: str, metronome_speed: int, target_error_rate: int, + max_bpm_speed: int, speed_display: str ) -> None: """Speichert Benutzereinstellungen in der Datenbank""" @@ -239,6 +240,7 @@ def save_user_settings( settings.metronome_mode = metronome_mode settings.metronome_speed = metronome_speed settings.target_error_rate = target_error_rate + settings.max_bpm_speed = max_bpm_speed settings.speed_display = speed_display db.session.commit() logger.info("Benutzereinstellungen gespeichert") @@ -399,6 +401,7 @@ def index(): metronome_mode = user_settings.metronome_mode metronome_speed = user_settings.metronome_speed target_error_rate = user_settings.target_error_rate + max_bpm_speed = getattr(user_settings, 'max_bpm_speed', 100) # Fallback für Kompatibilität speed_display = user_settings.speed_display # Lade die gespeicherte Benutzereingabe und Cursor-Position aus der Datenbank @@ -410,7 +413,7 @@ def index(): key_stroke_count = session.get('key_stroke_count', 0) is_paused = session.get('is_paused', False) - return render_template('index.html', + return render_template('index.html', current_text=current_text, current_position=cursor_position, last_text=user_input, @@ -426,6 +429,7 @@ def index(): metronome_mode=metronome_mode, metronome_speed=metronome_speed, target_error_rate=target_error_rate, + max_bpm_speed=max_bpm_speed, speed_display=speed_display) @@ -950,11 +954,20 @@ def hilfe(): @app.route('/settings') def settings(): """Einstellungsseite""" - return render_template('settings.html') + # Lade Einstellungen aus Datenbank mit Defaults + user_settings = get_or_create_user_settings() + current_settings = { + 'metronome_mode': user_settings.metronome_mode, + 'metronome_speed': user_settings.metronome_speed, + 'target_error_rate': user_settings.target_error_rate, + 'max_bpm_speed': getattr(user_settings, 'max_bpm_speed', 100), + 'speed_display': user_settings.speed_display + } + return render_template('settings.html', settings=current_settings) @app.route('/save_settings', methods=['POST']) def save_settings(): - """Speichert die Einstellungen in der Session""" + """Speichert die Einstellungen persistent in der Datenbank""" try: data = request.get_json() if not data: @@ -969,6 +982,7 @@ def save_settings(): try: metronome_speed = int(data.get('metronome_speed', 60)) target_error_rate = int(data.get('target_error_rate', 5)) + max_bpm_speed = int(data.get('max_bpm_speed', 100)) except (ValueError, TypeError): return jsonify({'error': 'Ungültige numerische Werte'}), 400 @@ -979,15 +993,31 @@ def save_settings(): if not (0 <= target_error_rate <= 100): return jsonify({'error': 'Fehlerrate muss zwischen 0 und 100 liegen'}), 400 + if not (40 <= max_bpm_speed <= 200): + return jsonify({'error': 'Maximale BPM-Geschwindigkeit muss zwischen 40 und 200 liegen'}), 400 + # Validiere speed_display speed_display = data.get('speed_display', 'zpm') if speed_display not in ['zpm', 'wpm']: return jsonify({'error': 'Ungültige Geschwindigkeitsanzeige'}), 400 - session['metronome_mode'] = metronome_mode - session['metronome_speed'] = metronome_speed - session['target_error_rate'] = target_error_rate - session['speed_display'] = speed_display + # Speichere in Datenbank + progress = Progress.query.first() + current_lesson = progress.current_text_index if progress else 0 + + save_user_settings( + current_lesson_index=current_lesson, + metronome_enabled=True, # Metronome bleibt aktiviert + metronome_bpm=metronome_speed, + metronome_sound='beep', + metronome_mode=metronome_mode, + metronome_speed=metronome_speed, + target_error_rate=target_error_rate, + max_bpm_speed=max_bpm_speed, + speed_display=speed_display + ) + + logger.info(f"Einstellungen gespeichert: mode={metronome_mode}, speed={metronome_speed}, max_bpm={max_bpm_speed}") return jsonify({'status': 'success'}) except Exception as e: logger.error(f"Fehler in save_settings: {e}", exc_info=True) @@ -1052,6 +1082,7 @@ def end_session(): metronome_mode=data.get('metronome_mode', 'automatic'), metronome_speed=data.get('metronome_speed', 60), target_error_rate=data.get('target_error_rate', 5), + max_bpm_speed=data.get('max_bpm_speed', 100), speed_display=data.get('speed_display', 'zpm') ) diff --git a/models.py b/models.py index d03019c..03de880 100644 --- a/models.py +++ b/models.py @@ -52,6 +52,7 @@ class UserSettings(db.Model): metronome_mode = db.Column(db.String(20), default='automatic') metronome_speed = db.Column(db.Integer, default=60) target_error_rate = db.Column(db.Integer, default=5) + max_bpm_speed = db.Column(db.Integer, default=100) speed_display = db.Column(db.String(10), default='zpm') # Zeitstempel updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) diff --git a/static/css/style.css b/static/css/style.css index 19b1dc3..d66a1d8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -157,11 +157,9 @@ body { .input-toggle { cursor: pointer; transition: all 0.3s ease; - color: inherit; } .progress-toggle:hover, -.statistics-link:hover, .input-toggle:hover { color: #ffc107 !important; text-shadow: 0 0 10px #ffc107, 0 0 20px #ffc107; @@ -401,3 +399,25 @@ body { background-color: #90EE90 !important; color: #006400 !important; } + +/* Statistik Button behält weiße Schrift */ +.statistics-link.btn-primary { + color: #ffffff !important; +} + +.statistics-link.btn-primary:hover { + color: #ffffff !important; +} + +/* Weiter Button in Grün (wie korrekte Zeichen) */ +#next-btn { + background-color: #28a745 !important; + border-color: #28a745 !important; + color: #ffffff !important; +} + +[data-bs-theme="dark"] #next-btn { + background-color: #4ade80 !important; + border-color: #4ade80 !important; + color: #000000 !important; +} diff --git a/static/js/main.js b/static/js/main.js index 00bffea..b083883 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -83,7 +83,10 @@ class TypewriterApp { this.themeManager.setupEventListeners(); // Metronome - this.adaptiveMetronome = new AdaptiveMetronome(); + this.adaptiveMetronome = new AdaptiveMetronome({ + initialSpeed: window.metronomeBPM || 60, + maxSpeed: window.maxBpmSpeed || 100 + }); this.metronomePlayer = new MetronomePlayer(); this.metronomePlayer.enabled = window.metronomeEnabled || true; this.metronomePlayer.bpm = window.metronomeBPM || 60; diff --git a/static/js/metronome.js b/static/js/metronome.js index 6278ec0..3607137 100644 --- a/static/js/metronome.js +++ b/static/js/metronome.js @@ -1,78 +1,57 @@ -/** - * Adaptive Metronome Module - * Implements an intelligent metronome that adjusts speed based on typing accuracy - */ - +// Adaptive Metronome Module export class AdaptiveMetronome { - constructor() { - this.speed = 60.0; // Starting speed in BPM - this.accuracy_history = []; // Last 50 results (1=correct, 0=incorrect) - this.consecutive_correct = 0; // Consecutive correct characters - this.target_accuracy = 0.96; // Target accuracy (96%) + constructor(options = {}) { + this.speed = options.initialSpeed || 60.0; // Starting speed in BPM + this.max_speed = options.maxSpeed || 100.0; // Maximum speed in BPM (default: 100) + this.accuracy_history = []; // Last 50 results (1=correct, 0=incorrect) + this.consecutive_correct = 0; // Consecutive correct characters + this.target_accuracy = 0.96; // Target accuracy (96%) } - /** - * Process a keystroke and adjust speed - * @param {boolean} is_correct - Whether the typed character was correct - * @param {number} current_difficulty - Difficulty factor (1.0 = normal) - * @returns {number} New BPM speed - */ + // Process a keystroke and adjust speed process_keystroke(is_correct, current_difficulty = 1.0) { // 1. Update history this.accuracy_history.push(is_correct ? 1.0 : 0.0); if (this.accuracy_history.length > 50) { this.accuracy_history.shift(); } - // 2. Count consecutive correct characters if (is_correct) { this.consecutive_correct += 1; } else { this.consecutive_correct = 0; } - // 3. Adjust speed return this._adjust_speed(current_difficulty); } - /** - * Adjust speed based on current accuracy - * @param {number} difficulty - Difficulty factor - * @returns {number} New BPM speed - * @private - */ + // Adjust speed based on current accuracy _adjust_speed(difficulty) { if (this.accuracy_history.length === 0) { return this.speed; } - let current_accuracy = this.accuracy_history.reduce((a, b) => a + b, 0) / this.accuracy_history.length; let adjusted_target = this.target_accuracy - (difficulty * 0.05); - // Multiple adjustment strategies: if (this.consecutive_correct >= 15) { - // Reward for sustained accuracy - this.speed *= 1.03; + this.speed *= 1.03; // Reward for sustained accuracy } else if (current_accuracy > adjusted_target + 0.04) { - // Too easy - accelerate more - this.speed *= 1.02; + this.speed *= 1.02; // Too easy - accelerate more } else if (current_accuracy < adjusted_target - 0.06) { - // Too difficult - brake harder - this.speed *= 0.92; + this.speed *= 0.92; // Too difficult - brake harder } else if (current_accuracy < adjusted_target) { - // Slightly below target - gentle braking - this.speed *= 0.97; + this.speed *= 0.97; // Slightly below target - gentle braking } else { - // In target range - gentle acceleration - this.speed *= 1.01; + this.speed *= 1.01; // In target range - gentle acceleration } - // Speed limits - this.speed = Math.max(40, Math.min(200, this.speed)); + // Speed limits (configurable max_speed, absolute min 40, absolute max 200) + this.speed = Math.max(40, Math.min(Math.min(200, this.max_speed), this.speed)); return this.speed; } } +// MetronomePlayer: encapsulates the timed metronome logic export class MetronomePlayer { constructor() { this.audioContext = null; @@ -83,35 +62,21 @@ export class MetronomePlayer { this.sound = 'beep'; } - /** - * Create a beep sound - * @param {number} vol - Volume (0-1) - * @param {number} freq - Frequency in Hz - * @param {number} duration - Duration in ms - */ createBeepSound(vol, freq, duration) { if (!this.audioContext) { this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); } const oscillator = this.audioContext.createOscillator(); const gainNode = this.audioContext.createGain(); - oscillator.connect(gainNode); gainNode.connect(this.audioContext.destination); - gainNode.gain.value = vol; oscillator.frequency.value = freq; oscillator.type = 'sine'; - - oscillator.start(); - setTimeout(() => { - oscillator.stop(); - }, duration); + oscillator.start(this.audioContext.currentTime); + oscillator.stop(this.audioContext.currentTime + duration / 1000); } - /** - * Create a click sound - */ createClickSound() { if (!this.audioContext) { this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); @@ -119,28 +84,22 @@ export class MetronomePlayer { const bufferSize = this.audioContext.sampleRate * 0.01; const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate); const data = buffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) { data[i] = Math.random() * 2 - 1; } - const source = this.audioContext.createBufferSource(); source.buffer = buffer; - const gainNode = this.audioContext.createGain(); gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioContext.currentTime + 0.1); - source.connect(gainNode); gainNode.connect(this.audioContext.destination); source.start(); } - /** - * Play metronome sound and provide visual feedback - */ + // Play metronome sound and provide visual feedback; called ONLY by scheduler play() { - // Visual feedback + // Visual feedback (optional) const cursorElement = document.querySelector('.cursor-char'); if (cursorElement) { cursorElement.style.animation = 'pulse 0.3s'; @@ -148,12 +107,10 @@ export class MetronomePlayer { cursorElement.style.animation = ''; }, 300); } - // Audio feedback (only if enabled) if (!this.enabled) { return; } - if (this.sound === 'beep') { this.createBeepSound(0.1, 800, 50); } else { @@ -161,22 +118,16 @@ export class MetronomePlayer { } } - /** - * Start the metronome - */ + // Start the metronome timer sequence start() { this.stop(); this.isRunning = true; this.scheduleNextBeat(); } - /** - * Schedule the next metronome beat - * @private - */ + // Private: schedules next beat - this is the ONLY place play() is called scheduleNextBeat() { if (!this.isRunning) return; - const interval = (60 / this.bpm) * 1000; this.timeout = setTimeout(() => { this.play(); @@ -184,9 +135,6 @@ export class MetronomePlayer { }, interval); } - /** - * Stop the metronome - */ stop() { this.isRunning = false; if (this.timeout) { @@ -195,28 +143,34 @@ export class MetronomePlayer { } } - /** - * Set BPM (takes effect at next beat) - * @param {number} bpm - Beats per minute - */ + // Set BPM - takes effect at next scheduled beat without interrupting current beat setBPM(bpm) { this.bpm = bpm; - // No restart needed - change takes effect at next scheduled beat + // No restart - allows metronome to run continuously during typing + // New BPM will be applied when next beat is scheduled } - /** - * Toggle metronome on/off - */ + // Toggle metronome enabled/disabled toggle() { this.enabled = !this.enabled; return this.enabled; } - /** - * Set sound type - * @param {string} sound - 'beep' or 'click' - */ + // Set sound type ('beep' or 'click') setSound(sound) { this.sound = sound; } } + +/* --- Integration Example (not actual code) --- + * Event Handler does NOT trigger metronome sound directly! + * + * document.addEventListener('keydown', (event) => { + * // Update typing statistics and BPM, but NO direct sound trigger! + * let isCorrect = ...; // your logic here + * let currentDifficulty = ...; // your logic here + * let newBPM = adaptiveMetronome.process_keystroke(isCorrect, currentDifficulty); + * metronomePlayer.setBPM(newBPM); // Sound plays on next scheduled beat + * }); + */ + diff --git a/static/js/statistics.js b/static/js/statistics.js index 218ac64..e5fb8df 100644 --- a/static/js/statistics.js +++ b/static/js/statistics.js @@ -65,7 +65,7 @@ export class StatisticsManager { if (speedLabel) speedLabel.textContent = 'ZPM:'; } - if (errorRateElement) errorRateElement.textContent = stats.errorRate.toFixed(1); + if (errorRateElement) errorRateElement.textContent = (100.0 - stats.errorRate).toFixed(1); if (correctElement) correctElement.textContent = stats.correctKeyStrokes; if (incorrectElement) incorrectElement.textContent = stats.incorrectKeyStrokes; if (totalElement) totalElement.textContent = stats.totalKeyStrokes; diff --git a/static/js/ui.js b/static/js/ui.js index 2fee8b7..b948afd 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -225,13 +225,13 @@ export class UIManager { if (visible) { progressContainer.classList.add('visible'); - performanceBtn.classList.remove('btn-light-custom'); - performanceBtn.classList.add('btn-primary'); } else { progressContainer.classList.remove('visible'); - performanceBtn.classList.remove('btn-primary'); - performanceBtn.classList.add('btn-light-custom'); } + + // Button bleibt immer blau (btn-primary) + performanceBtn.classList.remove('btn-light-custom'); + performanceBtn.classList.add('btn-primary'); } /** diff --git a/templates/index.html b/templates/index.html index 1500155..837648e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,7 +18,7 @@