diff --git a/app.py b/app.py
index 68829b4..79b02d4 100644
--- a/app.py
+++ b/app.py
@@ -4,7 +4,7 @@ import tempfile
import requests
import json
import logging
-from flask import Flask, render_template, request, jsonify, Response, send_file, send_from_directory
+from flask import Flask, render_template, request, jsonify, Response, send_file, send_from_directory, stream_with_context
import whisper
import torch
import ollama
@@ -615,6 +615,109 @@ def translate():
logger.error(f"Translation error: {str(e)}")
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
+@app.route('/translate/stream', methods=['POST'])
+@with_error_boundary
+def translate_stream():
+ """Streaming translation endpoint for reduced latency"""
+ try:
+ # Rate limiting
+ client_ip = request.remote_addr
+ if not Validators.rate_limit_check(
+ client_ip, 'translate_stream', max_requests=20, window_seconds=60, storage=rate_limit_storage
+ ):
+ return jsonify({'error': 'Rate limit exceeded. Please wait before trying again.'}), 429
+
+ # Validate request size
+ if not Validators.validate_json_size(request.json, max_size_kb=100):
+ return jsonify({'error': 'Request too large'}), 413
+
+ data = request.json
+
+ # Sanitize and validate text
+ text = data.get('text', '')
+ text = Validators.sanitize_text(text)
+ if not text:
+ return jsonify({'error': 'No text provided'}), 400
+
+ # Validate language codes
+ allowed_languages = set(SUPPORTED_LANGUAGES.values())
+ source_lang = Validators.validate_language_code(
+ data.get('source_lang', ''), allowed_languages
+ ) or 'auto'
+ target_lang = Validators.validate_language_code(
+ data.get('target_lang', ''), allowed_languages
+ )
+
+ if not target_lang:
+ return jsonify({'error': 'Invalid target language'}), 400
+
+ # Create prompt for streaming translation
+ prompt = f"""
+ Translate the following text from {source_lang} to {target_lang}:
+
+ {text}
+
+ Provide only the translation, no explanations.
+ """
+
+ def generate():
+ """Generator function for streaming response"""
+ try:
+ # Send initial connection
+ yield f"data: {json.dumps({'type': 'start', 'source_lang': source_lang, 'target_lang': target_lang})}\n\n"
+
+ # Stream translation from Ollama
+ stream = ollama.generate(
+ model='gemma2:9b',
+ prompt=prompt,
+ stream=True,
+ options={
+ 'temperature': 0.5,
+ 'top_p': 0.9,
+ 'max_tokens': 2048
+ }
+ )
+
+ accumulated_text = ""
+ word_buffer = ""
+
+ for chunk in stream:
+ if 'response' in chunk:
+ chunk_text = chunk['response']
+ word_buffer += chunk_text
+
+ # Send complete words/phrases for better UX
+ if ' ' in word_buffer or '\n' in word_buffer or '.' in word_buffer or ',' in word_buffer:
+ accumulated_text += word_buffer
+ yield f"data: {json.dumps({'type': 'chunk', 'text': word_buffer})}\n\n"
+ word_buffer = ""
+
+ # Send any remaining text
+ if word_buffer:
+ accumulated_text += word_buffer
+ yield f"data: {json.dumps({'type': 'chunk', 'text': word_buffer})}\n\n"
+
+ # Send completion signal
+ yield f"data: {json.dumps({'type': 'complete', 'full_text': accumulated_text.strip()})}\n\n"
+
+ except Exception as e:
+ logger.error(f"Streaming translation error: {str(e)}")
+ yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
+
+ return Response(
+ stream_with_context(generate()),
+ mimetype='text/event-stream',
+ headers={
+ 'Cache-Control': 'no-cache',
+ 'X-Accel-Buffering': 'no', # Disable Nginx buffering
+ 'Connection': 'keep-alive'
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"Translation stream error: {str(e)}")
+ return jsonify({'error': f'Translation failed: {str(e)}'}), 500
+
@app.route('/speak', methods=['POST'])
@with_error_boundary
def speak():
diff --git a/static/css/styles.css b/static/css/styles.css
index 81df7a3..70bff66 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -422,4 +422,32 @@
max-width: 300px;
font-size: 14px;
}
+}
+
+/* Streaming translation styles */
+.streaming-text {
+ position: relative;
+ min-height: 1.5em;
+}
+
+.streaming-active::after {
+ content: '▊';
+ display: inline-block;
+ animation: cursor-blink 1s infinite;
+ color: #007bff;
+ font-weight: bold;
+}
+
+@keyframes cursor-blink {
+ 0%, 49% {
+ opacity: 1;
+ }
+ 50%, 100% {
+ opacity: 0;
+ }
+}
+
+/* Smooth text appearance for streaming */
+.streaming-text {
+ transition: all 0.1s ease-out;
}
\ No newline at end of file
diff --git a/static/js/src/app.ts b/static/js/src/app.ts
index 004e883..b28e59e 100644
--- a/static/js/src/app.ts
+++ b/static/js/src/app.ts
@@ -18,6 +18,8 @@ import { TranslationCache } from './translationCache';
import { RequestQueueManager } from './requestQueue';
import { ErrorBoundary } from './errorBoundary';
import { Validator } from './validator';
+import { StreamingTranslation } from './streamingTranslation';
+import { PerformanceMonitor } from './performanceMonitor';
// Initialize error boundary
const errorBoundary = ErrorBoundary.getInstance();
@@ -140,6 +142,9 @@ function initApp(): void {
let currentSourceText: string = '';
let currentTranslationText: string = '';
let currentTtsServerUrl: string = '';
+
+ // Performance monitoring
+ const performanceMonitor = PerformanceMonitor.getInstance();
// Check TTS server status on page load
checkTtsServer();
@@ -546,6 +551,9 @@ function initApp(): void {
if (!currentSourceText) {
return;
}
+
+ // Check if streaming is enabled
+ const streamingEnabled = localStorage.getItem('streamingTranslation') !== 'false';
// Check if offline mode is enabled
const offlineModeEnabled = localStorage.getItem('offlineMode') !== 'false';
@@ -577,6 +585,84 @@ function initApp(): void {
// No cache hit, proceed with API call
statusIndicator.textContent = 'Translating...';
+
+ // Use streaming if enabled
+ if (streamingEnabled && navigator.onLine) {
+ // Clear previous translation
+ translatedText.innerHTML = '
';
+ const streamingTextElement = translatedText.querySelector('.streaming-text') as HTMLParagraphElement;
+ let accumulatedText = '';
+
+ // Show minimal loading indicator for streaming
+ statusIndicator.classList.add('processing');
+
+ const streamingTranslation = new StreamingTranslation(
+ // onChunk - append text as it arrives
+ (chunk: string) => {
+ accumulatedText += chunk;
+ streamingTextElement.textContent = accumulatedText;
+ streamingTextElement.classList.add('streaming-active');
+ },
+ // onComplete - finalize the translation
+ async (fullText: string) => {
+ const sanitizedTranslation = Validator.sanitizeText(fullText);
+ currentTranslationText = sanitizedTranslation;
+ streamingTextElement.textContent = sanitizedTranslation;
+ streamingTextElement.classList.remove('streaming-active');
+ playTranslation.disabled = false;
+ statusIndicator.textContent = 'Translation complete';
+ statusIndicator.classList.remove('processing');
+ statusIndicator.classList.add('success');
+ setTimeout(() => statusIndicator.classList.remove('success'), 2000);
+
+ // Cache the translation
+ if (offlineModeEnabled) {
+ await TranslationCache.cacheTranslation(
+ currentSourceText,
+ sourceLanguage.value,
+ sanitizedTranslation,
+ targetLanguage.value
+ );
+ }
+
+ // Save to history
+ saveToIndexedDB('translations', {
+ sourceText: currentSourceText,
+ sourceLanguage: sourceLanguage.value,
+ targetText: sanitizedTranslation,
+ targetLanguage: targetLanguage.value,
+ timestamp: new Date().toISOString()
+ } as TranslationRecord);
+ },
+ // onError - handle streaming errors
+ (error: string) => {
+ translatedText.innerHTML = `Error: ${Validator.sanitizeHTML(error)}
`;
+ statusIndicator.textContent = 'Translation failed';
+ statusIndicator.classList.remove('processing');
+ statusIndicator.classList.add('error');
+ },
+ // onStart
+ () => {
+ console.log('Starting streaming translation');
+ }
+ );
+
+ try {
+ await streamingTranslation.startStreaming(
+ currentSourceText,
+ sourceLanguage.value,
+ targetLanguage.value,
+ true // use streaming
+ );
+ } catch (error) {
+ console.error('Streaming translation failed:', error);
+ // Fall back to regular translation is handled internally
+ }
+
+ return; // Exit early for streaming
+ }
+
+ // Regular non-streaming translation
showProgress();
showLoadingOverlay('Translating to ' + targetLanguage.value + '...');
@@ -614,6 +700,9 @@ function initApp(): void {
};
try {
+ // Start performance timing for regular translation
+ performanceMonitor.startTimer('regular_translation');
+
// Use request queue for throttling
const queue = RequestQueueManager.getInstance();
const data = await queue.enqueue(
@@ -639,6 +728,9 @@ function initApp(): void {
hideProgress();
if (data.success && data.translation) {
+ // End performance timing
+ performanceMonitor.endTimer('regular_translation');
+
// Sanitize the translated text
const sanitizedTranslation = Validator.sanitizeText(data.translation);
currentTranslationText = sanitizedTranslation;
@@ -1184,10 +1276,12 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
const notifyTranscription = (document.getElementById('notifyTranscription') as HTMLInputElement).checked;
const notifyTranslation = (document.getElementById('notifyTranslation') as HTMLInputElement).checked;
const notifyErrors = (document.getElementById('notifyErrors') as HTMLInputElement).checked;
+ const streamingTranslation = (document.getElementById('streamingTranslation') as HTMLInputElement).checked;
localStorage.setItem('notifyTranscription', notifyTranscription.toString());
localStorage.setItem('notifyTranslation', notifyTranslation.toString());
localStorage.setItem('notifyErrors', notifyErrors.toString());
+ localStorage.setItem('streamingTranslation', streamingTranslation.toString());
// Show inline success message
const saveStatus = document.getElementById('settingsSaveStatus') as HTMLDivElement;
@@ -1207,10 +1301,12 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
const notifyTranscription = document.getElementById('notifyTranscription') as HTMLInputElement;
const notifyTranslation = document.getElementById('notifyTranslation') as HTMLInputElement;
const notifyErrors = document.getElementById('notifyErrors') as HTMLInputElement;
+ const streamingTranslation = document.getElementById('streamingTranslation') as HTMLInputElement;
notifyTranscription.checked = localStorage.getItem('notifyTranscription') !== 'false';
notifyTranslation.checked = localStorage.getItem('notifyTranslation') !== 'false';
notifyErrors.checked = localStorage.getItem('notifyErrors') === 'true';
+ streamingTranslation.checked = localStorage.getItem('streamingTranslation') !== 'false';
// Initialize cache management UI
initCacheManagement();
diff --git a/static/js/src/performanceMonitor.ts b/static/js/src/performanceMonitor.ts
new file mode 100644
index 0000000..d3ec3e1
--- /dev/null
+++ b/static/js/src/performanceMonitor.ts
@@ -0,0 +1,147 @@
+// Performance monitoring for translation latency
+export class PerformanceMonitor {
+ private static instance: PerformanceMonitor;
+ private metrics: Map = new Map();
+ private timers: Map = new Map();
+
+ private constructor() {}
+
+ static getInstance(): PerformanceMonitor {
+ if (!PerformanceMonitor.instance) {
+ PerformanceMonitor.instance = new PerformanceMonitor();
+ }
+ return PerformanceMonitor.instance;
+ }
+
+ // Start timing an operation
+ startTimer(operation: string): void {
+ this.timers.set(operation, performance.now());
+ }
+
+ // End timing and record the duration
+ endTimer(operation: string): number {
+ const startTime = this.timers.get(operation);
+ if (!startTime) {
+ console.warn(`No start time found for operation: ${operation}`);
+ return 0;
+ }
+
+ const duration = performance.now() - startTime;
+ this.recordMetric(operation, duration);
+ this.timers.delete(operation);
+
+ return duration;
+ }
+
+ // Record a metric value
+ recordMetric(name: string, value: number): void {
+ if (!this.metrics.has(name)) {
+ this.metrics.set(name, []);
+ }
+
+ const values = this.metrics.get(name)!;
+ values.push(value);
+
+ // Keep only last 100 values
+ if (values.length > 100) {
+ values.shift();
+ }
+ }
+
+ // Get average metric value
+ getAverageMetric(name: string): number {
+ const values = this.metrics.get(name);
+ if (!values || values.length === 0) {
+ return 0;
+ }
+
+ const sum = values.reduce((a, b) => a + b, 0);
+ return sum / values.length;
+ }
+
+ // Get time to first byte (TTFB) for streaming
+ measureTTFB(operation: string, firstByteTime: number): number {
+ const startTime = this.timers.get(operation);
+ if (!startTime) {
+ return 0;
+ }
+
+ const ttfb = firstByteTime - startTime;
+ this.recordMetric(`${operation}_ttfb`, ttfb);
+ return ttfb;
+ }
+
+ // Get performance summary
+ getPerformanceSummary(): {
+ streaming: {
+ avgTotalTime: number;
+ avgTTFB: number;
+ count: number;
+ };
+ regular: {
+ avgTotalTime: number;
+ count: number;
+ };
+ improvement: {
+ ttfbReduction: number;
+ perceivedLatencyReduction: number;
+ };
+ } {
+ const streamingTotal = this.getAverageMetric('streaming_translation');
+ const streamingTTFB = this.getAverageMetric('streaming_translation_ttfb');
+ const streamingCount = this.metrics.get('streaming_translation')?.length || 0;
+
+ const regularTotal = this.getAverageMetric('regular_translation');
+ const regularCount = this.metrics.get('regular_translation')?.length || 0;
+
+ // Calculate improvements
+ const ttfbReduction = regularTotal > 0 && streamingTTFB > 0
+ ? ((regularTotal - streamingTTFB) / regularTotal) * 100
+ : 0;
+
+ // Perceived latency is based on TTFB for streaming vs total time for regular
+ const perceivedLatencyReduction = ttfbReduction;
+
+ return {
+ streaming: {
+ avgTotalTime: streamingTotal,
+ avgTTFB: streamingTTFB,
+ count: streamingCount
+ },
+ regular: {
+ avgTotalTime: regularTotal,
+ count: regularCount
+ },
+ improvement: {
+ ttfbReduction: Math.round(ttfbReduction),
+ perceivedLatencyReduction: Math.round(perceivedLatencyReduction)
+ }
+ };
+ }
+
+ // Log performance stats to console
+ logPerformanceStats(): void {
+ const summary = this.getPerformanceSummary();
+
+ console.group('Translation Performance Stats');
+ console.log('Streaming Translation:');
+ console.log(` Average Total Time: ${summary.streaming.avgTotalTime.toFixed(2)}ms`);
+ console.log(` Average TTFB: ${summary.streaming.avgTTFB.toFixed(2)}ms`);
+ console.log(` Sample Count: ${summary.streaming.count}`);
+
+ console.log('Regular Translation:');
+ console.log(` Average Total Time: ${summary.regular.avgTotalTime.toFixed(2)}ms`);
+ console.log(` Sample Count: ${summary.regular.count}`);
+
+ console.log('Improvements:');
+ console.log(` TTFB Reduction: ${summary.improvement.ttfbReduction}%`);
+ console.log(` Perceived Latency Reduction: ${summary.improvement.perceivedLatencyReduction}%`);
+ console.groupEnd();
+ }
+
+ // Clear all metrics
+ clearMetrics(): void {
+ this.metrics.clear();
+ this.timers.clear();
+ }
+}
\ No newline at end of file
diff --git a/static/js/src/streamingTranslation.ts b/static/js/src/streamingTranslation.ts
new file mode 100644
index 0000000..6438472
--- /dev/null
+++ b/static/js/src/streamingTranslation.ts
@@ -0,0 +1,250 @@
+// Streaming translation implementation for reduced latency
+import { Validator } from './validator';
+import { PerformanceMonitor } from './performanceMonitor';
+
+export interface StreamChunk {
+ type: 'start' | 'chunk' | 'complete' | 'error';
+ text?: string;
+ full_text?: string;
+ error?: string;
+ source_lang?: string;
+ target_lang?: string;
+}
+
+export class StreamingTranslation {
+ private eventSource: EventSource | null = null;
+ private abortController: AbortController | null = null;
+ private performanceMonitor = PerformanceMonitor.getInstance();
+ private firstChunkReceived = false;
+
+ constructor(
+ private onChunk: (text: string) => void,
+ private onComplete: (fullText: string) => void,
+ private onError: (error: string) => void,
+ private onStart?: () => void
+ ) {}
+
+ async startStreaming(
+ text: string,
+ sourceLang: string,
+ targetLang: string,
+ useStreaming: boolean = true
+ ): Promise {
+ // Cancel any existing stream
+ this.cancel();
+
+ // Validate inputs
+ const sanitizedText = Validator.sanitizeText(text);
+ if (!sanitizedText) {
+ this.onError('No text to translate');
+ return;
+ }
+
+ if (!useStreaming) {
+ // Fall back to regular translation
+ await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang);
+ return;
+ }
+
+ try {
+ // Check if browser supports EventSource
+ if (!window.EventSource) {
+ console.warn('EventSource not supported, falling back to regular translation');
+ await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang);
+ return;
+ }
+
+ // Notify start
+ if (this.onStart) {
+ this.onStart();
+ }
+
+ // Start performance timing
+ this.performanceMonitor.startTimer('streaming_translation');
+ this.firstChunkReceived = false;
+
+ // Create abort controller for cleanup
+ this.abortController = new AbortController();
+
+ // Start streaming request
+ const response = await fetch('/translate/stream', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ text: sanitizedText,
+ source_lang: sourceLang,
+ target_lang: targetLang
+ }),
+ signal: this.abortController.signal
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ // Check if response is event-stream
+ const contentType = response.headers.get('content-type');
+ if (!contentType || !contentType.includes('text/event-stream')) {
+ throw new Error('Server does not support streaming');
+ }
+
+ // Process the stream
+ await this.processStream(response);
+
+ } catch (error: any) {
+ if (error.name === 'AbortError') {
+ console.log('Stream cancelled');
+ return;
+ }
+
+ console.error('Streaming error:', error);
+
+ // Fall back to regular translation on error
+ await this.fallbackToRegularTranslation(sanitizedText, sourceLang, targetLang);
+ }
+ }
+
+ private async processStream(response: Response): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error('No response body');
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete SSE messages
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ try {
+ const data = JSON.parse(line.slice(6)) as StreamChunk;
+ this.handleStreamChunk(data);
+ } catch (e) {
+ console.error('Failed to parse SSE data:', e);
+ }
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ }
+
+ private handleStreamChunk(chunk: StreamChunk): void {
+ switch (chunk.type) {
+ case 'start':
+ console.log('Translation started:', chunk.source_lang, '->', chunk.target_lang);
+ break;
+
+ case 'chunk':
+ if (chunk.text) {
+ // Record time to first byte
+ if (!this.firstChunkReceived) {
+ this.firstChunkReceived = true;
+ this.performanceMonitor.measureTTFB('streaming_translation', performance.now());
+ }
+ this.onChunk(chunk.text);
+ }
+ break;
+
+ case 'complete':
+ if (chunk.full_text) {
+ // End performance timing
+ this.performanceMonitor.endTimer('streaming_translation');
+ this.onComplete(chunk.full_text);
+
+ // Log performance stats periodically
+ if (Math.random() < 0.1) { // 10% of the time
+ this.performanceMonitor.logPerformanceStats();
+ }
+ }
+ break;
+
+ case 'error':
+ this.onError(chunk.error || 'Unknown streaming error');
+ break;
+ }
+ }
+
+ private async fallbackToRegularTranslation(
+ text: string,
+ sourceLang: string,
+ targetLang: string
+ ): Promise {
+ try {
+ const response = await fetch('/translate', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ text: text,
+ source_lang: sourceLang,
+ target_lang: targetLang
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.success && data.translation) {
+ // Simulate streaming by showing text progressively
+ this.simulateStreaming(data.translation);
+ } else {
+ this.onError(data.error || 'Translation failed');
+ }
+ } catch (error: any) {
+ this.onError(error.message || 'Translation failed');
+ }
+ }
+
+ private simulateStreaming(text: string): void {
+ // Simulate streaming for better UX even with non-streaming response
+ const words = text.split(' ');
+ let index = 0;
+ let accumulated = '';
+
+ const interval = setInterval(() => {
+ if (index >= words.length) {
+ clearInterval(interval);
+ this.onComplete(accumulated.trim());
+ return;
+ }
+
+ const chunk = words[index] + (index < words.length - 1 ? ' ' : '');
+ accumulated += chunk;
+ this.onChunk(chunk);
+ index++;
+ }, 50); // 50ms between words for smooth appearance
+ }
+
+ cancel(): void {
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = null;
+ }
+
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index c8b0e4a..7bf17d2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -297,6 +297,17 @@
+ Translation Settings
+
+
+
+
+
+
+
Offline Cache