diff --git a/server/app.py b/server/app.py index c348f30..aecc89d 100644 --- a/server/app.py +++ b/server/app.py @@ -6,11 +6,9 @@ from flask_cors import CORS from werkzeug.utils import secure_filename from processor import AudioImageProcessor -# Serve the build folder from the parent directory app = Flask(__name__, static_folder='../build', static_url_path='') -CORS(app) # Allow Svelte to communicate +CORS(app) -# Configuration UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads') app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER processor = AudioImageProcessor(UPLOAD_FOLDER) @@ -21,29 +19,24 @@ def save_upload(file_obj): file_obj.save(path) return path -# --- Frontend Routes --- @app.route('/') def index(): return send_from_directory(app.static_folder, 'index.html') @app.errorhandler(404) def not_found(e): - # If the path starts with /api, return actual 404 if request.path.startswith('/api/'): return jsonify({"error": "Not found"}), 404 - # Otherwise return index.html for SPA routing return send_from_directory(app.static_folder, 'index.html') @app.route('/health', methods=['GET']) def health(): return jsonify({"status": "ok", "max_mb": 40}) -# --- Background Cleanup --- import threading def cleanup_task(): - """Background thread to clean up old files.""" - expiration_seconds = 600 # 10 minutes + expiration_seconds = 600 while True: try: now = time.time() @@ -51,7 +44,6 @@ def cleanup_task(): for filename in os.listdir(UPLOAD_FOLDER): filepath = os.path.join(UPLOAD_FOLDER, filename) if os.path.isfile(filepath): - # check creation time if now - os.path.getctime(filepath) > expiration_seconds: try: os.remove(filepath) @@ -61,16 +53,12 @@ def cleanup_task(): except Exception as e: print(f"Cleanup Error: {e}") - time.sleep(60) # Run every minute + time.sleep(60) -# Start cleanup thread safely if os.environ.get('WERKZEUG_RUN_MAIN') == 'true' or not os.environ.get('WERKZEUG_RUN_MAIN'): - # Simple check to try and avoid double threads in reloader, though not perfect t = threading.Thread(target=cleanup_task, daemon=True) t.start() - -# --- Endpoint 1: Create Art (Optional: Embed Audio in it) --- @app.route('/api/generate-art', methods=['POST']) def generate_art(): if 'audio' not in request.files: @@ -83,27 +71,19 @@ def generate_art(): art_path = None try: - # 1. Save Audio audio_path = save_upload(audio_file) - # 2. Generate Art min_pixels = 0 if should_embed: - # Calculate required pixels: File Bytes * 8 (bits) / 3 (channels) - # Add 5% buffer for header and safety file_size = os.path.getsize(audio_path) min_pixels = int((file_size * 8 / 3) * 1.05) art_path = processor.generate_spectrogram(audio_path, min_pixels=min_pixels) - # 3. If Embed requested, run Steganography immediately using the art as host final_path = art_path if should_embed: - # art_path becomes the host, audio_path is the data final_path = processor.encode_stego(audio_path, art_path) - # If we created a new stego image, the pure art_path is intermediate (and audio is input) - # We can delete art_path now if it's different (it is) if art_path != final_path: try: os.remove(art_path) except: pass @@ -115,14 +95,10 @@ def generate_art(): except Exception as e: return jsonify({"error": str(e)}), 500 finally: - # Cleanup Inputs if audio_path and os.path.exists(audio_path): try: os.remove(audio_path) except: pass - - -# --- Endpoint 3: Steganography (Audio + Custom Host) --- @app.route('/api/hide', methods=['POST']) def hide_data(): if 'data' not in request.files or 'host' not in request.files: @@ -148,7 +124,6 @@ def hide_data(): try: os.remove(host_path) except: pass -# --- Endpoint 4: Decode (Universal) --- @app.route('/api/decode', methods=['POST']) def decode(): if 'image' not in request.files: @@ -159,7 +134,6 @@ def decode(): img_path = save_upload(request.files['image']) restored_path = processor.decode_image(img_path) - # Determine mimetype based on extension for browser friendliness filename = os.path.basename(restored_path) return send_file(restored_path, as_attachment=True, download_name=filename) except ValueError as e: @@ -171,13 +145,8 @@ def decode(): try: os.remove(img_path) except: pass -# --- Endpoint 4: Visualizer SSE Stream --- @app.route('/api/visualize', methods=['POST']) def visualize(): - """ - SSE endpoint that streams the spectrogram generation process. - Returns step-by-step updates for visualization. - """ if 'audio' not in request.files: return jsonify({"error": "No audio file provided"}), 400 @@ -196,56 +165,48 @@ def visualize(): try: import base64 - # Step 1: Audio loaded yield f"data: {json.dumps({'step': 1, 'status': 'loading', 'message': 'Loading audio file...', 'progress': 10})}\n\n" time.sleep(0.8) yield f"data: {json.dumps({'step': 1, 'status': 'complete', 'message': f'Audio loaded: {audio_file.filename}', 'progress': 20, 'fileSize': file_size})}\n\n" time.sleep(0.5) - # Step 2: Analyzing audio yield f"data: {json.dumps({'step': 2, 'status': 'loading', 'message': 'Analyzing audio frequencies...', 'progress': 30})}\n\n" time.sleep(1.0) yield f"data: {json.dumps({'step': 2, 'status': 'complete', 'message': 'Frequency analysis complete', 'progress': 40})}\n\n" time.sleep(0.5) - # Step 3: Generating spectrogram yield f"data: {json.dumps({'step': 3, 'status': 'loading', 'message': 'Generating spectrogram image...', 'progress': 50})}\n\n" print(f"[VISUALIZE] Starting spectrogram generation for {audio_path}") art_path = processor.generate_spectrogram(audio_path, min_pixels=min_pixels) print(f"[VISUALIZE] Spectrogram generated at {art_path}") - # Read the spectrogram image and encode as base64 with open(art_path, 'rb') as img_file: spectrogram_b64 = base64.b64encode(img_file.read()).decode('utf-8') print(f"[VISUALIZE] Spectrogram base64 length: {len(spectrogram_b64)}") yield f"data: {json.dumps({'step': 3, 'status': 'complete', 'message': 'Spectrogram generated!', 'progress': 70, 'spectrogramImage': f'data:image/png;base64,{spectrogram_b64}'})}\n\n" print("[VISUALIZE] Sent spectrogram image") - time.sleep(2.0) # Pause to let user see the spectrogram + time.sleep(2.0) - # Step 4: Embedding audio yield f"data: {json.dumps({'step': 4, 'status': 'loading', 'message': 'Embedding audio into image (LSB steganography)...', 'progress': 80})}\n\n" final_path = processor.encode_stego(audio_path, art_path) - # Read the final image and encode as base64 with open(final_path, 'rb') as img_file: final_b64 = base64.b64encode(img_file.read()).decode('utf-8') yield f"data: {json.dumps({'step': 4, 'status': 'complete', 'message': 'Audio embedded successfully!', 'progress': 95, 'finalImage': f'data:image/png;base64,{final_b64}'})}\n\n" - time.sleep(2.0) # Pause to let user see the final image + time.sleep(2.0) - # Step 5: Complete - send the result URL result_id = os.path.basename(final_path) yield f"data: {json.dumps({'step': 5, 'status': 'complete', 'message': 'Process complete!', 'progress': 100, 'resultId': result_id})}\n\n" except Exception as e: yield f"data: {json.dumps({'step': 0, 'status': 'error', 'message': str(e), 'progress': 0})}\n\n" finally: - # Clean up intermediate files (but keep final) if art_path and art_path != final_path and os.path.exists(art_path): try: os.remove(art_path) except: pass @@ -265,16 +226,12 @@ def visualize(): except: pass return jsonify({"error": str(e)}), 500 - @app.route('/api/result/', methods=['GET']) def get_result(result_id): - """Serve the result image by ID.""" result_path = os.path.join(app.config['UPLOAD_FOLDER'], result_id) if os.path.exists(result_path): return send_file(result_path, mimetype='image/png', as_attachment=False) return jsonify({"error": "Result not found"}), 404 - if __name__ == '__main__': - # Threaded=True is important for processing images without blocking app.run(debug=True, port=5000, threaded=True) diff --git a/server/processor.py b/server/processor.py index 90bab04..8c12b23 100644 --- a/server/processor.py +++ b/server/processor.py @@ -6,12 +6,10 @@ import numpy as np import librosa import librosa.display import matplotlib -# Set backend to Agg (Anti-Grain Geometry) to render without a GUI (essential for servers) matplotlib.use('Agg') import matplotlib.pyplot as plt from PIL import Image -# --- Constants --- MAX_MB = 40 SIG_SHIFT = b'B2I!' SIG_STEGO = b'B2S!' @@ -25,7 +23,6 @@ class AudioImageProcessor: os.makedirs(upload_folder, exist_ok=True) def _get_bytes(self, path): - """Helper to safely read bytes""" if os.path.getsize(path) > (MAX_MB * 1024 * 1024): raise ValueError("File too large (Max 40MB)") with open(path, 'rb') as f: @@ -36,9 +33,7 @@ class AudioImageProcessor: ext_bytes = ext.encode('utf-8') return struct.pack(HEADER_FMT, signature, file_size, len(ext_bytes)) + ext_bytes - # --- Feature 1: Spectrogram Art --- def generate_spectrogram(self, audio_path, min_pixels=0): - """Generates a visual spectrogram from audio.""" try: import torch import torchaudio @@ -48,13 +43,10 @@ class AudioImageProcessor: if has_torch and torch.cuda.is_available(): try: - # GPU Accelerated Path device = "cuda" waveform, sr = torchaudio.load(audio_path) waveform = waveform.to(device) - # Create transformation - # Mimic librosa defaults roughly: n_fft=2048, hop_length=512 n_fft = 2048 win_length = n_fft hop_length = 512 @@ -72,17 +64,13 @@ class AudioImageProcessor: S = mel_spectrogram(waveform) S_dB = torchaudio.transforms.AmplitudeToDB()(S) - # Back to CPU for plotting - S_dB = S_dB.cpu().numpy()[0] # Take first channel - # Librosa display expects numpy + S_dB = S_dB.cpu().numpy()[0] except Exception as e: - # Fallback to CPU/Librosa if any error occurs print(f"GPU processing failed, falling back to CPU: {e}") return self._generate_spectrogram_cpu(audio_path, min_pixels) else: return self._generate_spectrogram_cpu(audio_path, min_pixels) - # Plotting (Common) return self._plot_spectrogram(S_dB, sr, min_pixels) def _generate_spectrogram_cpu(self, audio_path, min_pixels=0): @@ -92,44 +80,31 @@ class AudioImageProcessor: return self._plot_spectrogram(S_dB, sr, min_pixels) def _plot_spectrogram(self, S_dB, sr, min_pixels=0): - # Calculate DPI dynamically to ensure we have enough pixels for steganography dpi = 300 if min_pixels > 0: - # Figure is 12x6 inches. Area = 72 sq inches. - # Total Pixels = 72 * dpi^2 required_dpi = math.ceil((min_pixels / 72) ** 0.5) - # Add a small buffer dpi = max(dpi, int(required_dpi * 1.05)) - # Use exact dimensions without margins width_in = 12 height_in = 6 fig = plt.figure(figsize=(width_in, height_in)) - # Add axes covering the entire figure [left, bottom, width, height] ax = plt.axes([0, 0, 1, 1], frameon=False) ax.set_axis_off() - # 'magma' is a nice default librosa.display.specshow(S_dB, sr=sr, fmax=8000, cmap='magma', ax=ax) output_path = os.path.join(self.upload_folder, f"art_{int(time.time())}.png") - # specific DPI, no bbox_inches='tight' (which shrinks the image) plt.savefig(output_path, dpi=dpi) plt.close() return output_path - - - # --- Feature 3: Steganography (Embed in Host) --- def encode_stego(self, data_path, host_path): - # 1. Prepare Data file_data = self._get_bytes(data_path) header = self._create_header(SIG_STEGO, len(file_data), data_path) payload_bits = np.unpackbits(np.frombuffer(header + file_data, dtype=np.uint8)) - # 2. Prepare Host host = Image.open(host_path).convert('RGB') host_arr = np.array(host) flat_host = host_arr.flatten() @@ -137,7 +112,6 @@ class AudioImageProcessor: if len(payload_bits) > len(flat_host): raise ValueError(f"Host image too small. Need {len(payload_bits)/3/1e6:.2f} MP.") - # 3. Embed (LSB) padded_bits = np.pad(payload_bits, (0, len(flat_host) - len(payload_bits)), 'constant') embedded_flat = (flat_host & 0xFE) + padded_bits @@ -147,19 +121,16 @@ class AudioImageProcessor: embedded_img.save(output_path, "PNG") return output_path - # --- Feature 4: Universal Decoder --- def decode_image(self, image_path): img = Image.open(image_path).convert('RGB') flat_bytes = np.array(img).flatten() - # Strategy A: Check for Shift Signature (Raw Bytes) try: sig = struct.unpack('>4s', flat_bytes[:4])[0] if sig == SIG_SHIFT: return self._extract(flat_bytes, image_path, is_bits=False) except: pass - # Strategy B: Check for Stego Signature (LSB) try: sample_bytes = np.packbits(flat_bytes[:300] & 1) sig = struct.unpack('>4s', sample_bytes[:4])[0] diff --git a/src/lib/components/DecoderTool.svelte b/src/lib/components/DecoderTool.svelte index 8bf3f8f..2a87801 100644 --- a/src/lib/components/DecoderTool.svelte +++ b/src/lib/components/DecoderTool.svelte @@ -45,25 +45,20 @@ ); } - // 3. Handle success const blob = await res.blob(); const downloadUrl = URL.createObjectURL(blob); - // Detect if it's audio const contentDisposition = res.headers.get('Content-Disposition') || ''; let fileName = 'decoded_file'; const match = contentDisposition.match(/filename="?(.+)"?/); if (match && match[1]) fileName = match[1]; if (fileName.match(/\.(mp3|aac|wav|ogg|m4a)$/i)) { - // Success! Dispatch the URL and image to parent for playing - // We'll pass the blob URL and the original file (to show cover art) dispatch('decodesuccess', { audioUrl: downloadUrl, imageFile: decodeImageFile }); } else { - // For non-audio (or unknown), auto-download as before triggerDownload(downloadUrl, fileName); URL.revokeObjectURL(downloadUrl); } @@ -81,7 +76,6 @@ document.body.appendChild(a); a.click(); document.body.removeChild(a); - // Revoke the URL after download to free up memory URL.revokeObjectURL(url); } diff --git a/src/lib/components/EncoderTool.svelte b/src/lib/components/EncoderTool.svelte index 6c3218c..9401450 100644 --- a/src/lib/components/EncoderTool.svelte +++ b/src/lib/components/EncoderTool.svelte @@ -44,7 +44,6 @@

- {#each ['Payload File (Secret)', 'Host Image (Cover)'] as label, i}
@@ -89,7 +88,6 @@ stegoDataFile = file; } else { stegoHostFile = file; - // Calculate MP const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { diff --git a/src/lib/components/ResultPreview.svelte b/src/lib/components/ResultPreview.svelte index 664fcb6..19275fc 100644 --- a/src/lib/components/ResultPreview.svelte +++ b/src/lib/components/ResultPreview.svelte @@ -25,7 +25,6 @@ } }); - // Audio Player State let audioElement: HTMLAudioElement | undefined = $state(); let isPaused = $state(true); let currentTime = $state(0); @@ -57,7 +56,6 @@
-
{#if artResultUrl}
- {#if decodedAudioUrl}

@@ -125,7 +122,6 @@

-
-
-

@@ -201,7 +196,6 @@

-

@@ -252,7 +246,6 @@

-

@@ -319,7 +312,6 @@

-
diff --git a/src/routes/visualizer/+page.svelte b/src/routes/visualizer/+page.svelte index b66a66e..58ddf03 100644 --- a/src/routes/visualizer/+page.svelte +++ b/src/routes/visualizer/+page.svelte @@ -2,7 +2,6 @@ import Icon from '@iconify/svelte'; import { fade, scale } from 'svelte/transition'; - // State (Svelte 5 runes) let audioFile: File | null = $state(null); let isProcessing = $state(false); let currentStep = $state(0); @@ -10,11 +9,9 @@ let resultId: string | null = $state(null); let error: string | null = $state(null); - // Image previews let spectrogramImage: string | null = $state(null); let finalImage: string | null = $state(null); - // Audio Visualizer State let audioUrl: string | null = $state(null); let audioElement: HTMLAudioElement | undefined = $state(); let canvasElement: HTMLCanvasElement | undefined = $state(); @@ -44,7 +41,6 @@ analyser.fftSize = 256; } - // Create source only once per audio element/context session or reuse if (!source) { try { source = audioContext.createMediaElementSource(audioElement); @@ -61,7 +57,7 @@ function drawVisualizer() { if (!canvasElement || !analyser) return; - analyser.fftSize = 2048; // Higher resolution for waveform + analyser.fftSize = 2048; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const ctx = canvasElement.getContext('2d'); @@ -74,21 +70,19 @@ } animationFrame = requestAnimationFrame(draw); - analyser!.getByteTimeDomainData(dataArray); // Use TimeDomain for waveform + analyser!.getByteTimeDomainData(dataArray); const width = canvasElement!.width; const height = canvasElement!.height; ctx.clearRect(0, 0, width, height); - // Draw smooth line waveform ctx.lineWidth = 3; - // Create gradient const gradient = ctx.createLinearGradient(0, 0, width, 0); - gradient.addColorStop(0, '#a855f7'); // Purple - gradient.addColorStop(0.5, '#ec4899'); // Pink - gradient.addColorStop(1, '#a855f7'); // Purple + gradient.addColorStop(0, '#a855f7'); + gradient.addColorStop(0.5, '#ec4899'); + gradient.addColorStop(1, '#a855f7'); ctx.strokeStyle = gradient; ctx.beginPath(); @@ -114,7 +108,6 @@ ctx.lineTo(width, height / 2); ctx.stroke(); - // Optional: Draw a center line for reference ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.lineWidth = 1; @@ -140,8 +133,6 @@ isProcessing = true; - // Initialize and play audio - // Small timeout to ensure DOM elements are rendered setTimeout(() => { if (audioElement && audioContext?.state === 'suspended') { audioContext.resume(); @@ -183,9 +174,8 @@ buffer += decoder.decode(value, { stream: true }); - // Process complete SSE messages (they end with \n\n) const messages = buffer.split('\n\n'); - buffer = messages.pop() || ''; // Keep the incomplete last part + buffer = messages.pop() || ''; for (const message of messages) { const lines = message.split('\n'); @@ -196,7 +186,6 @@ currentStep = data.step; progress = data.progress; - // Capture images if (data.spectrogramImage) { spectrogramImage = data.spectrogramImage; } @@ -236,10 +225,6 @@ } if (animationFrame) cancelAnimationFrame(animationFrame); - // Don't nullify audioContext to reuse it, or close it if you prefer - // source = null; // Source node usually stays valid for the context? - // Actually, if we remove audio element, we might need to recreate source next time. - // For simplicity, we just stop playing. Since audioFile becomes null, the audio element is removed/replaced. source = null; audioFile = null; @@ -262,7 +247,6 @@