Compare commits

..

4 Commits

Author SHA1 Message Date
d548bd6fde YT Audio Encoding 2026-01-07 04:34:24 +00:00
abceb1be7b Docker File Update 2026-01-07 04:16:40 +00:00
983b548d7b Code Update 2026-01-07 04:09:56 +00:00
585830103b UI and Audio Processing Update 2026-01-07 04:09:35 +00:00
24 changed files with 2339 additions and 516 deletions

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ vite.config.ts.timestamp-*
*/__pycache__/ */__pycache__/
.vscode/ .vscode/
*/uploads/*

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Stage 1: Build Frontend (SvelteKit)
FROM node:20-slim AS frontend-builder
WORKDIR /app
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Build static files (outputs to build/)
RUN npm run build
# Stage 2: Backend (Python Flask + GPU Support)
# Use official PyTorch image with CUDA 12.1 support
FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime
# Install system dependencies for audio (libsndfile) and ffmpeg
# reliable ffmpeg install on debian-based images
RUN apt-get update && apt-get install -y \
libsndfile1 \
ffmpeg \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy python requirements
# We remove torch/torchaudio from requirements briefly during install to avoid re-installing CPU versions
# or we trust pip to see the installed version satisfies it.
COPY server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY server/ .
# Copy built frontend from Stage 1 into the expected relative path
# app.py expects '../build', so we copy to /build and structure appropriately
# However, simpler is to copy build/ to /app/build and adjust app.py or folder structure.
# Let's mirror the structure: /app/server (WORKDIR) and /app/build
COPY --from=frontend-builder /app/build /app/build
# Set working directory to server where app.py resides
WORKDIR /app/server
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV PORT=5000
# Expose port
EXPOSE ${PORT}
# Run the application
CMD ["python", "app.py"]

View File

@@ -1 +1,70 @@
# AudioImage # AudioImage
AudioImage is a powerful web-based tool for Audio Spectrogram Art and Digital Steganography, powered by a Flask backend and Svelte 5 frontend. It transforms audio into visual art and allows for secure, invisible file embedding.
## Features
* **Process Visualizer**: A real-time, interactive visualization page that lets you watch and hear the transformation process.
* **Live Waveform**: See the audio signal rendered as a glowing, real-time oscilloscope.
* **Step-by-Step Reveal**: Watch the spectrogram and steganographic image being constructed layer by layer.
* **YouTube Audio Encoder**: Directly download audio from YouTube videos (with length validation) and embed it into images seamlessly.
* **Audio Art Generation**: Convert MP3/AAC audio files into high-resolution visual spectrograms using Python's `librosa` and `matplotlib`.
* **GPU Acceleration**: Automatically uses `torchaudio` and CUDA if available for lightning-fast processing.
* **Steganography Hider**: Hide secret audio or image files inside a "host" PNG image effectively using LSB (Least Significant Bit) encoding.
* **Universal Decoder**: Decode any image created with AudioImage to retrieve the original hidden files.
* **Secure & Private**: All processing happens locally on your server; files are cleaned up automatically.
## Requirements
* Python 3.12+
* Node.js & npm (or Bun)
* CUDA-enabled GPU (optional, for faster spectrogram generation)
## Installation
### Method 1: Docker (Recommended for GPU)
The easiest way to run AudioImage with full GPU acceleration is using Docker.
1. **Prerequisites**: Docker Desktop + NVIDIA Container Toolkit (for GPU).
2. **Run**:
```bash
docker compose up --build
```
3. Open `http://localhost:5000`.
### Method 2: Manual Setup
1. **Backend Setup**:
```bash
cd server
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
2. **Frontend Setup**:
```bash
# Root directory
npm install
npm run build
```
3. **Run**:
```bash
cd server
python app.py
```
## Architecture
* **Frontend**: SvelteKit (SPA mode), Svelte 5 Runes, TailwindCSS.
* Uses **Server-Sent Events (SSE)** for real-time progress streaming.
* **Canvas API** & **Web Audio API** for the visualizer.
* **Backend**: Flask, NumPy, PIL, Librosa, PyTorch (optional).
* **Integration**: Flask serves the static Svelte build for a unified deployment experience.
## Usage Notes
* **Supported Audio**: MP3, AAC, WAV, FLAC.
* **Supported Images**: PNG (host/stego).
* **Visualizer**: Visit the `/visualizer` page or click the link in the header to see the magic happen in real-time.

View File

@@ -5,6 +5,7 @@
"": { "": {
"name": "audioimage", "name": "audioimage",
"devDependencies": { "devDependencies": {
"@iconify/svelte": "^5.2.1",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.49.1",
@@ -76,6 +77,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@iconify/svelte": ["@iconify/svelte@5.2.1", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "svelte": ">5.0.0" } }, "sha512-zHmsIPmnIhGd5gc95bNN5FL+GifwMZv7M2rlZEpa7IXYGFJm/XGHdWf6PWQa6OBoC+R69WyiPO9NAj5wjfjbow=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
audio-image:
build: .
container_name: audio-image
ports:
- "${PORT:-5000}:5000"
volumes:
- ./server/uploads:/app/server/uploads
environment:
- FLASK_APP=app.py
- FLASK_DEBUG=${FLASK_DEBUG:-false}
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]

12
example.env Normal file
View File

@@ -0,0 +1,12 @@
# Server Configuration
PORT=5000
FLASK_DEBUG=false
FLASK_ENV=production
# Application Settings
# Max upload size in MB (default 40 in code)
MAX_UPLOAD_MB=40
# Optional: UID/GID for permission handling in Docker volume mapping
# PUID=1000
# PGID=1000

View File

@@ -4,7 +4,7 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
@@ -14,6 +14,7 @@
"lint": "prettier --check ." "lint": "prettier --check ."
}, },
"devDependencies": { "devDependencies": {
"@iconify/svelte": "^5.2.1",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.49.1",

View File

@@ -1,14 +1,14 @@
import os import os
import time import time
from flask import Flask, request, send_file, jsonify import json
from flask import Flask, request, send_file, jsonify, send_from_directory, Response
from flask_cors import CORS from flask_cors import CORS
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from processor import AudioImageProcessor from processor import AudioImageProcessor
app = Flask(__name__) 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') UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads')
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
processor = AudioImageProcessor(UPLOAD_FOLDER) processor = AudioImageProcessor(UPLOAD_FOLDER)
@@ -19,11 +19,46 @@ def save_upload(file_obj):
file_obj.save(path) file_obj.save(path)
return path return path
@app.route('/')
def index():
return send_from_directory(app.static_folder, 'index.html')
@app.errorhandler(404)
def not_found(e):
if request.path.startswith('/api/'):
return jsonify({"error": "Not found"}), 404
return send_from_directory(app.static_folder, 'index.html')
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health(): def health():
return jsonify({"status": "ok", "max_mb": 40}) return jsonify({"status": "ok", "max_mb": 40})
# --- Endpoint 1: Create Art (Optional: Embed Audio in it) --- import threading
def cleanup_task():
expiration_seconds = 600
while True:
try:
now = time.time()
if os.path.exists(UPLOAD_FOLDER):
for filename in os.listdir(UPLOAD_FOLDER):
filepath = os.path.join(UPLOAD_FOLDER, filename)
if os.path.isfile(filepath):
if now - os.path.getctime(filepath) > expiration_seconds:
try:
os.remove(filepath)
print(f"Cleaned up: {filename}")
except Exception as e:
print(f"Error cleaning {filename}: {e}")
except Exception as e:
print(f"Cleanup Error: {e}")
time.sleep(60)
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true' or not os.environ.get('WERKZEUG_RUN_MAIN'):
t = threading.Thread(target=cleanup_task, daemon=True)
t.start()
@app.route('/api/generate-art', methods=['POST']) @app.route('/api/generate-art', methods=['POST'])
def generate_art(): def generate_art():
if 'audio' not in request.files: if 'audio' not in request.files:
@@ -32,67 +67,211 @@ def generate_art():
audio_file = request.files['audio'] audio_file = request.files['audio']
should_embed = request.form.get('embed', 'false').lower() == 'true' should_embed = request.form.get('embed', 'false').lower() == 'true'
audio_path = None
art_path = None
try: try:
# 1. Save Audio
audio_path = save_upload(audio_file) audio_path = save_upload(audio_file)
# 2. Generate Art min_pixels = 0
art_path = processor.generate_spectrogram(audio_path) if should_embed:
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 final_path = art_path
if should_embed: if should_embed:
final_path = processor.encode_stego(audio_path, art_path) final_path = processor.encode_stego(audio_path, art_path)
if art_path != final_path:
try: os.remove(art_path)
except: pass
return send_file(final_path, mimetype='image/png') return send_file(final_path, mimetype='image/png')
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
finally:
if audio_path and os.path.exists(audio_path):
try: os.remove(audio_path)
except: pass
# --- Endpoint 2: Format Shift (Audio -> Static) ---
@app.route('/api/shift', methods=['POST'])
def shift_format():
if 'file' not in request.files:
return jsonify({"error": "No file provided"}), 400
try:
f_path = save_upload(request.files['file'])
img_path = processor.encode_shift(f_path)
return send_file(img_path, mimetype='image/png')
except Exception as e:
return jsonify({"error": str(e)}), 500
# --- Endpoint 3: Steganography (Audio + Custom Host) ---
@app.route('/api/hide', methods=['POST']) @app.route('/api/hide', methods=['POST'])
def hide_data(): def hide_data():
if 'data' not in request.files or 'host' not in request.files: if 'data' not in request.files or 'host' not in request.files:
return jsonify({"error": "Requires 'data' and 'host' files"}), 400 return jsonify({"error": "Missing files"}), 400
data_file = request.files['data']
host_file = request.files['host']
data_path = None
host_path = None
try: try:
data_path = save_upload(request.files['data']) data_path = save_upload(data_file)
host_path = save_upload(request.files['host']) host_path = save_upload(host_file)
stego_path = processor.encode_stego(data_path, host_path) output_path = processor.encode_stego(data_path, host_path)
return send_file(stego_path, mimetype='image/png') return send_file(output_path, mimetype='image/png')
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
finally:
if data_path and os.path.exists(data_path):
try: os.remove(data_path)
except: pass
if host_path and os.path.exists(host_path):
try: os.remove(host_path)
except: pass
import youtube_utils
@app.route('/api/hide-yt', methods=['POST'])
def hide_yt_data():
if 'url' not in request.form or 'host' not in request.files:
return jsonify({"error": "Missing URL or Host Image"}), 400
youtube_url = request.form['url']
host_file = request.files['host']
audio_path = None
host_path = None
try:
# Download Audio
audio_path = youtube_utils.download_audio(youtube_url, app.config['UPLOAD_FOLDER'])
# Save Host
host_path = save_upload(host_file)
# Encode
output_path = processor.encode_stego(audio_path, host_path)
return send_file(output_path, mimetype='image/png')
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
# Cleanup
if audio_path and os.path.exists(audio_path):
try: os.remove(audio_path)
except: pass
if host_path and os.path.exists(host_path):
try: os.remove(host_path)
except: pass
# --- Endpoint 4: Decode (Universal) ---
@app.route('/api/decode', methods=['POST']) @app.route('/api/decode', methods=['POST'])
def decode(): def decode():
if 'image' not in request.files: if 'image' not in request.files:
return jsonify({"error": "No image provided"}), 400 return jsonify({"error": "No image provided"}), 400
img_path = None
try: try:
img_path = save_upload(request.files['image']) img_path = save_upload(request.files['image'])
restored_path = processor.decode_image(img_path) restored_path = processor.decode_image(img_path)
# Determine mimetype based on extension for browser friendliness
filename = os.path.basename(restored_path) filename = os.path.basename(restored_path)
return send_file(restored_path, as_attachment=True, download_name=filename) return send_file(restored_path, as_attachment=True, download_name=filename)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
finally:
if img_path and os.path.exists(img_path):
try: os.remove(img_path)
except: pass
@app.route('/api/visualize', methods=['POST'])
def visualize():
if 'audio' not in request.files:
return jsonify({"error": "No audio file provided"}), 400
audio_file = request.files['audio']
audio_path = None
try:
audio_path = save_upload(audio_file)
file_size = os.path.getsize(audio_path)
min_pixels = int((file_size * 8 / 3) * 1.05)
def generate_steps():
art_path = None
final_path = None
try:
import base64
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)
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)
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}")
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)
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)
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)
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:
if art_path and art_path != final_path and os.path.exists(art_path):
try: os.remove(art_path)
except: pass
if audio_path and os.path.exists(audio_path):
try: os.remove(audio_path)
except: pass
response = Response(generate_steps(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
except Exception as e:
if audio_path and os.path.exists(audio_path):
try: os.remove(audio_path)
except: pass
return jsonify({"error": str(e)}), 500
@app.route('/api/result/<result_id>', methods=['GET'])
def get_result(result_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__': if __name__ == '__main__':
# Threaded=True is important for processing images without blocking app.run(host='0.0.0.0', debug=True, port=5000, threaded=True)
app.run(debug=True, port=5000, threaded=True)

View File

@@ -1,16 +1,15 @@
import os import os
import time
import struct import struct
import math import math
import numpy as np import numpy as np
import librosa import librosa
import librosa.display import librosa.display
import matplotlib import matplotlib
# Set backend to Agg (Anti-Grain Geometry) to render without a GUI (essential for servers)
matplotlib.use('Agg') matplotlib.use('Agg')
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from PIL import Image from PIL import Image
# --- Constants ---
MAX_MB = 40 MAX_MB = 40
SIG_SHIFT = b'B2I!' SIG_SHIFT = b'B2I!'
SIG_STEGO = b'B2S!' SIG_STEGO = b'B2S!'
@@ -24,7 +23,6 @@ class AudioImageProcessor:
os.makedirs(upload_folder, exist_ok=True) os.makedirs(upload_folder, exist_ok=True)
def _get_bytes(self, path): def _get_bytes(self, path):
"""Helper to safely read bytes"""
if os.path.getsize(path) > (MAX_MB * 1024 * 1024): if os.path.getsize(path) > (MAX_MB * 1024 * 1024):
raise ValueError("File too large (Max 40MB)") raise ValueError("File too large (Max 40MB)")
with open(path, 'rb') as f: with open(path, 'rb') as f:
@@ -35,59 +33,78 @@ class AudioImageProcessor:
ext_bytes = ext.encode('utf-8') ext_bytes = ext.encode('utf-8')
return struct.pack(HEADER_FMT, signature, file_size, len(ext_bytes)) + ext_bytes 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):
def generate_spectrogram(self, audio_path): try:
"""Generates a visual spectrogram from audio.""" import torch
import torchaudio
has_torch = True
except ImportError:
has_torch = False
if has_torch and torch.cuda.is_available():
try:
device = "cuda"
waveform, sr = torchaudio.load(audio_path)
waveform = waveform.to(device)
n_fft = 2048
win_length = n_fft
hop_length = 512
n_mels = 128
mel_spectrogram = torchaudio.transforms.MelSpectrogram(
sample_rate=sr,
n_fft=n_fft,
win_length=win_length,
hop_length=hop_length,
n_mels=n_mels,
f_max=8000
).to(device)
S = mel_spectrogram(waveform)
S_dB = torchaudio.transforms.AmplitudeToDB()(S)
S_dB = S_dB.cpu().numpy()[0]
except Exception as e:
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)
return self._plot_spectrogram(S_dB, sr, min_pixels)
def _generate_spectrogram_cpu(self, audio_path, min_pixels=0):
y, sr = librosa.load(audio_path) y, sr = librosa.load(audio_path)
S = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=256, fmax=8000) S = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=128, fmax=8000)
S_dB = librosa.power_to_db(S, ref=np.max) S_dB = librosa.power_to_db(S, ref=np.max)
return self._plot_spectrogram(S_dB, sr, min_pixels)
plt.figure(figsize=(12, 6)) def _plot_spectrogram(self, S_dB, sr, min_pixels=0):
plt.axis('off') dpi = 300
plt.margins(0, 0) if min_pixels > 0:
plt.gca().xaxis.set_major_locator(plt.NullLocator()) required_dpi = math.ceil((min_pixels / 72) ** 0.5)
plt.gca().yaxis.set_major_locator(plt.NullLocator()) dpi = max(dpi, int(required_dpi * 1.05))
# 'magma' is a nice default, but you could parameterize this width_in = 12
librosa.display.specshow(S_dB, sr=sr, fmax=8000, cmap='magma') height_in = 6
fig = plt.figure(figsize=(width_in, height_in))
output_path = os.path.join(self.upload_folder, f"art_{os.path.basename(audio_path)}.png") ax = plt.axes([0, 0, 1, 1], frameon=False)
plt.savefig(output_path, bbox_inches='tight', pad_inches=0, dpi=300) ax.set_axis_off()
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")
plt.savefig(output_path, dpi=dpi)
plt.close() plt.close()
return output_path return output_path
# --- Feature 2: Format Shift (Raw Data to Image) ---
def encode_shift(self, file_path):
file_data = self._get_bytes(file_path)
file_size = len(file_data)
header = self._create_header(SIG_SHIFT, file_size, file_path)
payload = header + file_data
# Calculate size
pixels = math.ceil(len(payload) / 3)
side = math.ceil(math.sqrt(pixels))
padding = (side * side * 3) - len(payload)
# Pad and Reshape
arr = np.frombuffer(payload, dtype=np.uint8)
if padding > 0:
arr = np.pad(arr, (0, padding), 'constant')
img = Image.fromarray(arr.reshape((side, side, 3)), 'RGB')
output_path = os.path.join(self.upload_folder, f"shift_{os.path.basename(file_path)}.png")
img.save(output_path, "PNG")
return output_path
# --- Feature 3: Steganography (Embed in Host) ---
def encode_stego(self, data_path, host_path): def encode_stego(self, data_path, host_path):
# 1. Prepare Data
file_data = self._get_bytes(data_path) file_data = self._get_bytes(data_path)
header = self._create_header(SIG_STEGO, len(file_data), 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)) payload_bits = np.unpackbits(np.frombuffer(header + file_data, dtype=np.uint8))
# 2. Prepare Host
host = Image.open(host_path).convert('RGB') host = Image.open(host_path).convert('RGB')
host_arr = np.array(host) host_arr = np.array(host)
flat_host = host_arr.flatten() flat_host = host_arr.flatten()
@@ -95,7 +112,6 @@ class AudioImageProcessor:
if len(payload_bits) > len(flat_host): if len(payload_bits) > len(flat_host):
raise ValueError(f"Host image too small. Need {len(payload_bits)/3/1e6:.2f} MP.") 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') padded_bits = np.pad(payload_bits, (0, len(flat_host) - len(payload_bits)), 'constant')
embedded_flat = (flat_host & 0xFE) + padded_bits embedded_flat = (flat_host & 0xFE) + padded_bits
@@ -105,19 +121,16 @@ class AudioImageProcessor:
embedded_img.save(output_path, "PNG") embedded_img.save(output_path, "PNG")
return output_path return output_path
# --- Feature 4: Universal Decoder ---
def decode_image(self, image_path): def decode_image(self, image_path):
img = Image.open(image_path).convert('RGB') img = Image.open(image_path).convert('RGB')
flat_bytes = np.array(img).flatten() flat_bytes = np.array(img).flatten()
# Strategy A: Check for Shift Signature (Raw Bytes)
try: try:
sig = struct.unpack('>4s', flat_bytes[:4])[0] sig = struct.unpack('>4s', flat_bytes[:4])[0]
if sig == SIG_SHIFT: if sig == SIG_SHIFT:
return self._extract(flat_bytes, image_path, is_bits=False) return self._extract(flat_bytes, image_path, is_bits=False)
except: pass except: pass
# Strategy B: Check for Stego Signature (LSB)
try: try:
sample_bytes = np.packbits(flat_bytes[:300] & 1) sample_bytes = np.packbits(flat_bytes[:300] & 1)
sig = struct.unpack('>4s', sample_bytes[:4])[0] sig = struct.unpack('>4s', sample_bytes[:4])[0]

View File

@@ -1,6 +1,10 @@
--extra-index-url https://download.pytorch.org/whl/cu121
Flask Flask
Flask-Cors Flask-Cors
numpy numpy
Pillow Pillow
librosa librosa
matplotlib matplotlib
torch
torchaudio
yt-dlp

52
server/youtube_utils.py Normal file
View File

@@ -0,0 +1,52 @@
import yt_dlp
import os
import time
def download_audio(url, output_folder, max_length_seconds=600):
"""
Downloads audio from a YouTube URL.
Returns the path to the downloaded file or raises an Exception.
Enforces max_length_seconds (default 10 mins).
"""
timestamp = int(time.time())
output_template = os.path.join(output_folder, f'yt_{timestamp}_%(id)s.%(ext)s')
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': output_template,
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}],
'quiet': True,
'no_warnings': True,
'noplaylist': True,
'match_filter': yt_dlp.utils.match_filter_func("duration <= " + str(max_length_seconds))
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
# The file path might differ slightly because of the postprocessor (mp3 conversion)
# yt-dlp usually returns the final filename in 'requested_downloads' or similar,
# but constructing it from info is safer if we know the template.
# However, extract_info returns the info dict.
# Since we force mp3, the file will end in .mp3
# We used %(id)s in template, so we can reconstruct or find it.
# Let's find the file in the folder that matches the timestamp prefix
# This is safer than guessing what yt-dlp named it exactly
valid_files = [f for f in os.listdir(output_folder) if f.startswith(f'yt_{timestamp}_') and f.endswith('.mp3')]
if not valid_files:
raise Exception("Download failed: Audio file not found after processing.")
return os.path.join(output_folder, valid_files[0])
except yt_dlp.utils.DownloadError as e:
if "video is too long" in str(e).lower() or "duration" in str(e).lower():
raise Exception(f"Video is too long. Maximum allowed duration is {max_length_seconds} seconds.")
raise Exception(f"Failed to download video: {str(e)}")

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { slide } from 'svelte/transition';
import Icon from '@iconify/svelte';
const dispatch = createEventDispatcher();
let { loading = false } = $props();
let decodeImageFile: File | null = $state(null);
function handleFileDrop(e: DragEvent, setter: (f: File) => void) {
e.preventDefault();
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
setter(e.dataTransfer.files[0]);
}
}
function handleKeyEnter(e: KeyboardEvent, id: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
document.getElementById(id)?.click();
}
}
async function handleDecode() {
if (!decodeImageFile) return;
dispatch('start');
const formData = new FormData();
formData.append('image', decodeImageFile);
try {
const res = await fetch('http://127.0.0.1:5000/api/decode', {
method: 'POST',
body: formData
});
if (!res.ok) {
let text = await res.text();
try {
const json = JSON.parse(text);
if (json.error) text = json.error;
} catch (e) {}
throw new Error(
text.includes('No encoded data') ? 'No hidden data found in this image.' : text
);
}
const blob = await res.blob();
const downloadUrl = URL.createObjectURL(blob);
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)) {
dispatch('decodesuccess', {
audioUrl: downloadUrl,
imageFile: decodeImageFile
});
} else {
triggerDownload(downloadUrl, fileName);
URL.revokeObjectURL(downloadUrl);
}
} catch (e: any) {
dispatch('error', e.message);
} finally {
dispatch('end');
}
}
function triggerDownload(url: string, name: string) {
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<section class="glass-panel border border-(--accent)/30 p-6">
<h2 class="section-title text-purple-400">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16a2 2 0 01-2-2v-4a2 2 0 114 0v4a2 2 0 01-2 2zm0 2c-1.657 0-3-1.343-3-3v-4a3 3 0 116 0v4c0 1.657-1.343 3-3 3zm7-6a2 2 0 114 0 2 2 0 01-4 0zm0 4a2 2 0 114 0 2 2 0 01-4 0zm0-8a2 2 0 114 0 2 2 0 01-4 0z"
/></svg
>
Universal Decoder
</h2>
<p class="mb-6 text-sm text-(--text-muted)">
Upload ANY image created with this tool to retrieve the original audio file.
</p>
<div
class="mb-4 cursor-pointer rounded-lg border-2 border-dashed border-(--border-subtle) bg-(--bg-deep) p-10 text-center transition-colors hover:border-(--accent) {loading
? 'pointer-events-none opacity-50'
: ''}"
role="button"
tabindex="0"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => handleFileDrop(e, (f) => (decodeImageFile = f))}
onclick={() => !loading && document.getElementById('decodeInput')?.click()}
onkeydown={(e) => handleKeyEnter(e, 'decodeInput')}
>
<input
type="file"
id="decodeInput"
accept="image/png"
class="hidden"
disabled={loading}
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target && target.files) {
decodeImageFile = target.files[0];
}
}}
/>
<svg
class="mx-auto mb-3 h-12 w-12 text-(--text-muted)"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/></svg
>
{#if decodeImageFile}
<div class="font-semibold text-(--accent)">{decodeImageFile.name}</div>
{:else}
<div class="text-(--text-muted)">Drop Stego-Image to Decode</div>
{/if}
</div>
<button
class="btn-primary flex w-full items-center justify-center gap-2"
onclick={handleDecode}
disabled={loading || !decodeImageFile}
>
{#if loading}<div class="loader"></div>{/if} Retrieve Original File
</button>
</section>

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
const dispatch = createEventDispatcher();
let { loading = false } = $props();
let stegoDataFile: File | null = $state(null);
let stegoHostFile: File | null = $state(null);
let hostImageMP = $state(0);
async function handleHide() {
if (!stegoDataFile || !stegoHostFile) return;
dispatch('start');
const formData = new FormData();
formData.append('data', stegoDataFile);
formData.append('host', stegoHostFile);
try {
const res = await fetch('http://127.0.0.1:5000/api/hide', { method: 'POST', body: formData });
if (!res.ok) {
const text = await res.text();
let errorMsg = text;
try {
const json = JSON.parse(text);
if (json.error) errorMsg = json.error;
} catch {}
throw new Error(errorMsg);
}
const blob = await res.blob();
dispatch('complete', { url: URL.createObjectURL(blob) });
} catch (e: any) {
dispatch('error', e.message);
} finally {
dispatch('end');
}
}
</script>
<section class="glass-panel p-6">
<h2 class="section-title text-(--accent)">Stego Hider</h2>
<div transition:fade>
<p class="mb-6 text-sm text-(--text-muted)">
Hide a audio file inside an innocent looking host image.
</p>
<div class="space-y-4">
{#each ['Payload File (Secret)', 'Host Image (Cover)'] as label, i}
<div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label
for="stego-input-{i}"
class="text-xs font-bold tracking-wider text-(--text-muted) uppercase"
>
{label}
</label>
{#if i === 1}
<div class="flex items-center gap-3 text-[10px] font-medium">
{#if stegoDataFile}
<div class="rounded-full bg-(--accent)/10 px-2 py-0.5 text-(--accent)">
Min: {((stegoDataFile.size * 8) / 3 / 1000000).toFixed(2)} MP
</div>
{/if}
{#if stegoHostFile && hostImageMP > 0}
<div
class="rounded-full px-2 py-0.5 transition-colors {hostImageMP >=
((stegoDataFile?.size || 0) * 8) / 3 / 1000000
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-red-500/10 text-red-400'}"
>
Current: {hostImageMP.toFixed(2)} MP
</div>
{/if}
</div>
{/if}
</div>
<input
type="file"
id="stego-input-{i}"
class="glass-input text-sm"
accept={i === 1 ? 'image/png, .png' : 'audio/*, image/*'}
disabled={loading}
onchange={async (e) => {
const target = e.target as HTMLInputElement;
if (target && target.files && target.files[0]) {
const file = target.files[0];
if (i === 0) {
stegoDataFile = file;
} else {
stegoHostFile = file;
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
hostImageMP = (img.width * img.height) / 1000000;
URL.revokeObjectURL(url);
};
img.src = url;
}
}
}}
/>
</div>
{/each}
<button
class="btn-primary group relative flex w-full items-center justify-center gap-2 overflow-hidden shadow-lg shadow-purple-500/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
onclick={handleHide}
disabled={loading || !stegoDataFile || !stegoHostFile}
>
<div
class="absolute inset-0 bg-white/20 opacity-0 transition-opacity group-hover:opacity-100"
></div>
{#if loading}<div class="loader"></div>{/if}
<span class="relative">Hide Data</span>
</button>
</div>
</div>
</section>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
const year = new Date().getFullYear();
</script>
<footer class="mt-20 border-t border-white/5 py-8 text-center text-sm text-(--text-muted)">
<p>
Made by <a href="https://dev.sirblob.co/" target="_blank">Sir Blob</a>.
</p>
</footer>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
let { text = '' } = $props();
</script>
<header class="mb-8 pt-6 text-center sm:mb-12 sm:pt-8">
<h1 class="mb-3 text-4xl font-black tracking-tighter sm:mb-4 sm:text-5xl md:text-6xl">
<span class="gradient-text">Audio</span>Image
</h1>
<p class="mx-auto max-w-2xl px-4 text-base text-(--text-muted) sm:px-0 sm:text-xl">
{text}
</p>
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
<a
href="/how"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-(--text-muted) transition hover:border-white/20 hover:bg-white/10 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
How It Works
</a>
<a
href="/visualizer"
class="inline-flex items-center gap-2 rounded-full border border-(--primary)/30 bg-(--primary)/10 px-4 py-2 text-sm font-medium text-(--primary) transition hover:border-(--primary)/50 hover:bg-(--primary)/20"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Visualizer
</a>
</div>
</header>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import Icon from '@iconify/svelte';
import { slide, fade } from 'svelte/transition';
interface Props {
artResultUrl?: string | null;
encoderResultUrl?: string | null;
decodedAudioUrl?: string | null;
decodedImageFile?: File | null;
}
let {
artResultUrl = null,
encoderResultUrl = null,
decodedAudioUrl = null,
decodedImageFile = null
}: Props = $props();
let decodedImageUrl: string | null = $state(null);
$effect(() => {
if (decodedImageFile) {
decodedImageUrl = URL.createObjectURL(decodedImageFile);
}
});
let audioElement: HTMLAudioElement | undefined = $state();
let isPaused = $state(true);
let currentTime = $state(0);
let duration = $state(0);
function togglePlay() {
if (audioElement) {
if (audioElement.paused) audioElement.play();
else audioElement.pause();
}
}
function seekAudio(e: MouseEvent) {
const target = e.currentTarget as HTMLDivElement;
const rect = target.getBoundingClientRect();
const x = e.clientX - rect.left;
const ratio = Math.max(0, Math.min(1, x / rect.width));
if (audioElement && duration) {
audioElement.currentTime = ratio * duration;
}
}
function formatTime(time: number) {
if (!time || isNaN(time)) return '0:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
</script>
<section class="mt-10 space-y-6">
<div class="grid gap-6 md:grid-cols-2">
{#if artResultUrl}
<div
class="glass-panel group relative overflow-hidden rounded-2xl border border-white/10 p-0 shadow-xl"
transition:slide
>
<div class="relative aspect-video w-full overflow-hidden bg-black/30">
<img
src={artResultUrl}
alt="Generated Spectrogram"
class="h-full w-full object-contain transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div class="p-5">
<h3 class="mb-3 flex items-center gap-2 text-lg font-bold text-(--primary)">
<Icon icon="heroicons:photo" class="h-5 w-5" />
Spectrogram Result
</h3>
<a
href={artResultUrl}
download="spectrogram.png"
class="btn-primary flex w-full items-center justify-center gap-2 rounded-lg"
>
<Icon icon="heroicons:arrow-down-tray" class="h-5 w-5" /> Download Image
</a>
</div>
</div>
{/if}
{#if encoderResultUrl}
<div
class="glass-panel group relative overflow-hidden rounded-2xl border border-white/10 p-0 shadow-xl"
transition:slide
>
<div class="relative aspect-video w-full overflow-hidden bg-black/30">
<img
src={encoderResultUrl}
alt="Steganography Result"
class="h-full w-full object-contain transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div class="p-5">
<h3 class="mb-3 flex items-center gap-2 text-lg font-bold text-pink-500">
<Icon icon="heroicons:lock-closed" class="h-5 w-5" />
Stego Result
</h3>
<a
href={encoderResultUrl}
download="encoded_image.png"
class="btn-primary flex w-full items-center justify-center gap-2 rounded-lg"
>
<Icon icon="heroicons:arrow-down-tray" class="h-5 w-5" /> Download Stego Image
</a>
</div>
</div>
{/if}
</div>
{#if decodedAudioUrl}
<div class="glass-panel p-6" transition:slide>
<h3 class="section-title mb-6 flex items-center gap-2 text-emerald-400">
<Icon icon="heroicons:musical-note-20-solid" class="h-6 w-6" />
Decoded Audio Player
</h3>
<div class="flex flex-col gap-6 md:flex-row md:items-center">
<div class="shrink-0">
<div
class="h-32 w-32 overflow-hidden rounded-xl border border-white/10 bg-(--bg-deep) md:h-40 md:w-40"
>
{#if decodedImageUrl}
<img src={decodedImageUrl} alt="Decoded Cover" class="h-full w-full object-cover" />
{:else}
<div class="flex h-full w-full items-center justify-center text-white/20">
<Icon icon="heroicons:musical-note" class="h-12 w-12" />
</div>
{/if}
</div>
</div>
<div class="flex flex-1 flex-col justify-center">
<audio
src={decodedAudioUrl}
bind:this={audioElement}
bind:paused={isPaused}
bind:currentTime
bind:duration
onended={() => (isPaused = true)}
></audio>
<div class="mb-1 text-xl font-bold text-white">Decoded Track</div>
<div class="mb-6 text-sm text-(--text-muted)">Restored from Image</div>
<div class="mb-4 flex items-center gap-4">
<button
class="flex h-12 w-12 items-center justify-center rounded-full bg-(--primary) text-white transition-all hover:scale-105 active:scale-95"
onclick={togglePlay}
aria-label={isPaused ? 'Play' : 'Pause'}
>
<Icon
icon={isPaused ? 'heroicons:play-solid' : 'heroicons:pause-solid'}
class="ml-0.5 h-6 w-6"
/>
</button>
<div class="flex flex-1 flex-col gap-1">
<button
type="button"
class="group relative h-2 w-full cursor-pointer rounded-full bg-white/10"
onclick={seekAudio}
aria-label="Seek audio"
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-(--primary) transition-all"
style="width: {(currentTime / (duration || 1)) * 100}%"
></div>
<div
class="absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
style="left: {(currentTime / (duration || 1)) * 100}%"
></div>
</button>
<div class="flex justify-between text-xs text-(--text-muted)">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
</div>
<div class="flex justify-end">
<a
href={decodedAudioUrl}
download="decoded_audio.mp3"
class="btn-primary flex items-center gap-2"
>
<Icon icon="heroicons:arrow-down-tray" class="h-4 w-4" />
Download MP3
</a>
</div>
</div>
</div>
</div>
{/if}
</section>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
let { loading = false } = $props();
let audioFile: File | null = $state(null);
let embedAudio = $state(true);
function handleFileDrop(e: DragEvent, setter: (f: File) => void) {
e.preventDefault();
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
setter(e.dataTransfer.files[0]);
}
}
function handleKeyEnter(e: KeyboardEvent, id: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
document.getElementById(id)?.click();
}
}
async function handleArtGenerate() {
if (!audioFile) return;
dispatch('start');
const formData = new FormData();
formData.append('audio', audioFile);
formData.append('embed', embedAudio.toString());
try {
const res = await fetch('http://127.0.0.1:5000/api/generate-art', {
method: 'POST',
body: formData
});
if (!res.ok) {
const text = await res.text();
let errorMsg = text;
try {
const json = JSON.parse(text);
if (json.error) errorMsg = json.error;
} catch {}
throw new Error(errorMsg);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
dispatch('complete', { url });
} catch (e: any) {
dispatch('error', e.message);
} finally {
dispatch('end');
}
}
</script>
<section class="glass-panel group relative overflow-hidden p-6">
<div class="absolute top-0 right-0 p-4 opacity-10 transition-opacity group-hover:opacity-20">
<svg class="h-24 w-24" fill="currentColor" viewBox="0 0 24 24"
><path
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
/></svg
>
</div>
<h2 class="section-title text-(--primary)">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/></svg
>
Audio Art
</h2>
<p class="mb-6 text-sm text-(--text-muted)">
Convert your favorite songs into visual spectrograms. Optionally hide the song inside the image
itself!
</p>
<div class="space-y-4">
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-(--border-subtle) p-8 text-center transition-colors hover:border-(--primary) {loading
? 'pointer-events-none opacity-50'
: ''}"
role="button"
tabindex="0"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => handleFileDrop(e, (f) => (audioFile = f))}
onclick={() => !loading && document.getElementById('audioInput')?.click()}
onkeydown={(e) => handleKeyEnter(e, 'audioInput')}
>
<input
type="file"
id="audioInput"
accept="audio/mpeg, audio/aac, .mp3, .aac"
class="hidden"
disabled={loading}
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target && target.files) audioFile = target.files[0];
}}
/>
{#if audioFile}
<div class="font-semibold text-(--primary)">{audioFile.name}</div>
{:else}
<div class="text-(--text-muted)">Drag audio here or click to browse</div>
{/if}
</div>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition {embedAudio
? 'border-(--primary) bg-(--primary)/10'
: 'border-(--border-subtle) hover:bg-white/5'}"
>
<div class="relative flex items-center">
<input
type="checkbox"
bind:checked={embedAudio}
class="peer h-5 w-5 rounded border-gray-600 bg-transparent text-(--primary) focus:ring-0 focus:ring-offset-0"
/>
</div>
<div class="flex-1">
<span class="block font-medium {embedAudio ? 'text-(--primary)' : 'text-white'}"
>Embed Audio Source</span
>
<span class="text-xs text-(--text-muted)"
>Enable this to hide the audio file inside the image so it can be decoded later.</span
>
</div>
</label>
<button
class="btn-primary flex w-full items-center justify-center gap-2"
onclick={handleArtGenerate}
disabled={loading || !audioFile}
>
{#if loading}<div class="loader"></div>{/if}
Generate Spectrogram
</button>
</div>
</section>

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import Icon from '@iconify/svelte';
const dispatch = createEventDispatcher();
let { loading = false } = $props();
let youtubeUrl = $state('');
let stegoHostFile: File | null = $state(null);
let hostImageMP = $state(0);
function getYoutubeVideoId(url: string) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return match && match[2].length === 11 ? match[2] : null;
}
async function handleHide() {
if (!youtubeUrl || !stegoHostFile) return;
const videoId = getYoutubeVideoId(youtubeUrl);
if (!videoId) {
dispatch('error', 'Invalid YouTube URL');
return;
}
dispatch('start');
const formData = new FormData();
formData.append('url', youtubeUrl);
formData.append('host', stegoHostFile);
try {
const res = await fetch('/api/hide-yt', { method: 'POST', body: formData });
if (!res.ok) {
const text = await res.text();
let errorMsg = text;
try {
const json = JSON.parse(text);
if (json.error) errorMsg = json.error;
} catch {}
throw new Error(errorMsg);
}
const blob = await res.blob();
dispatch('complete', { url: URL.createObjectURL(blob) });
} catch (e: any) {
dispatch('error', e.message);
} finally {
dispatch('end');
}
}
</script>
<section class="glass-panel p-6">
<h2 class="section-title text-red-500">
<Icon icon="heroicons:video-camera" class="h-6 w-6" />
YouTube Encoder
</h2>
<div transition:fade>
<p class="mb-6 text-sm text-(--text-muted)">
Download audio from a YouTube video and hide it inside an image.
</p>
<div class="space-y-4">
<!-- YouTube URL Input -->
<div>
<label
for="yt-url"
class="mb-2 block text-xs font-bold tracking-wider text-(--text-muted) uppercase"
>
YouTube Video URL
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icon icon="heroicons:link" class="h-5 w-5 text-white/50" />
</div>
<input
type="text"
id="yt-url"
bind:value={youtubeUrl}
placeholder="https://www.youtube.com/watch?v=..."
class="glass-input w-full py-3 pr-4 !pl-10 text-sm text-white placeholder-white/30 focus:ring-2 focus:ring-red-500/50 focus:outline-none"
disabled={loading}
/>
</div>
{#if youtubeUrl && getYoutubeVideoId(youtubeUrl)}
<div class="mt-2 flex items-center gap-1 text-xs text-emerald-400">
<Icon icon="heroicons:check-circle" class="h-3 w-3" /> Valid YouTube URL detected
</div>
{/if}
</div>
<!-- Host Image Input -->
<div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label
for="yt-host-input"
class="text-xs font-bold tracking-wider text-(--text-muted) uppercase"
>
Host Image (Cover)
</label>
{#if stegoHostFile && hostImageMP > 0}
<div
class="rounded-full bg-(--accent)/10 px-2 py-0.5 text-[10px] font-medium text-(--accent)"
>
{hostImageMP.toFixed(2)} MP
</div>
{/if}
</div>
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-(--border-subtle) bg-(--bg-deep) p-6 text-center transition-colors hover:border-red-500/50 {loading
? 'pointer-events-none opacity-50'
: ''}"
role="button"
tabindex="0"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => {
e.preventDefault();
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
stegoHostFile = file;
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
hostImageMP = (img.width * img.height) / 1000000;
URL.revokeObjectURL(url);
};
img.src = url;
}
}}
onclick={() => !loading && document.getElementById('yt-host-input')?.click()}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') &&
document.getElementById('yt-host-input')?.click()}
>
<input
type="file"
id="yt-host-input"
class="hidden"
accept="image/png, .png"
disabled={loading}
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target && target.files && target.files[0]) {
const file = target.files[0];
stegoHostFile = file;
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
hostImageMP = (img.width * img.height) / 1000000;
URL.revokeObjectURL(url);
};
img.src = url;
}
}}
/>
{#if stegoHostFile}
<div class="flex flex-col items-center gap-2">
<Icon icon="heroicons:photo" class="h-8 w-8 text-red-500" />
<div class="font-semibold text-white">{stegoHostFile.name}</div>
<div class="text-xs text-(--text-muted)">
{(stegoHostFile.size / 1024 / 1024).toFixed(2)} MB
</div>
</div>
{:else}
<div class="flex flex-col items-center gap-2">
<Icon icon="heroicons:photo" class="h-8 w-8 text-(--text-muted)" />
<div class="text-(--text-muted)">Drop PNG image here or click to browse</div>
</div>
{/if}
</div>
</div>
<button
class="btn-primary group relative flex w-full items-center justify-center gap-2 overflow-hidden border-red-400/30 bg-linear-to-r from-red-600 to-red-500 shadow-lg shadow-red-500/20 transition-all hover:scale-[1.02] hover:from-red-500 hover:to-red-400 active:scale-[0.98]"
onclick={handleHide}
disabled={loading || !youtubeUrl || !stegoHostFile}
>
<div
class="absolute inset-0 bg-white/20 opacity-0 transition-opacity group-hover:opacity-100"
></div>
{#if loading}<div class="loader"></div>{/if}
<span class="relative">Encode Image with YT Audio</span>
</button>
</div>
</div>
</section>

View File

@@ -1,142 +1,46 @@
<script lang="ts"> <script lang="ts">
import { fade, fly } from 'svelte/transition'; import { fade } from 'svelte/transition';
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import ResultPreview from '$lib/components/ResultPreview.svelte';
import SpectrogramTool from '$lib/components/SpectrogramTool.svelte';
import EncoderTool from '$lib/components/EncoderTool.svelte';
import YouTubeEncoderTool from '$lib/components/YouTubeEncoderTool.svelte';
import DecoderTool from '$lib/components/DecoderTool.svelte';
// State
const API_URL = 'http://127.0.0.1:5000/api';
let loading = false; let loading = false;
let errorMsg = ''; let errorMsg = '';
// --- Audio Art State ---
let audioFile: File | null = null;
let embedAudio = false;
let artResultUrl = ''; let artResultUrl = '';
// --- Shifter/Stego State ---
let activeTab = 'shift'; // 'shift' or 'hide'
let shiftFile: File | null = null;
let stegoDataFile: File | null = null;
let stegoHostFile: File | null = null;
let encoderResultUrl = ''; let encoderResultUrl = '';
// --- Decoder State --- let decodedAudioUrl: string | null = null;
let decodeImageFile: File | null = null; let decodedImageFile: File | null = null;
// --- Actions --- function handleStart() {
async function handleArtGenerate() {
if (!audioFile) return;
loading = true; loading = true;
errorMsg = ''; errorMsg = '';
}
const formData = new FormData(); function handleEnd() {
formData.append('audio', audioFile);
formData.append('embed', embedAudio.toString());
try {
const res = await fetch(`${API_URL}/generate-art`, { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
artResultUrl = URL.createObjectURL(blob);
} catch (e: any) {
errorMsg = e.message;
} finally {
loading = false; loading = false;
} }
}
async function handleShift() { function handleError(e: CustomEvent<string>) {
if (!shiftFile) return; errorMsg = e.detail;
loading = true;
errorMsg = '';
const formData = new FormData();
formData.append('file', shiftFile);
try {
const res = await fetch(`${API_URL}/shift`, { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
encoderResultUrl = URL.createObjectURL(blob);
} catch (e: any) {
errorMsg = e.message;
} finally {
loading = false; loading = false;
} }
function handleArtComplete(e: CustomEvent<{ url: string }>) {
artResultUrl = e.detail.url;
} }
async function handleHide() { function handleEncoderComplete(e: CustomEvent<{ url: string }>) {
if (!stegoDataFile || !stegoHostFile) return; encoderResultUrl = e.detail.url;
loading = true;
errorMsg = '';
const formData = new FormData();
formData.append('data', stegoDataFile);
formData.append('host', stegoHostFile);
try {
const res = await fetch(`${API_URL}/hide`, { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
encoderResultUrl = URL.createObjectURL(blob);
} catch (e: any) {
errorMsg = e.message;
} finally {
loading = false;
}
} }
async function handleDecode() { function handleDecodeSuccess(e: CustomEvent<{ audioUrl: string; imageFile: File }>) {
if (!decodeImageFile) return; decodedAudioUrl = e.detail.audioUrl;
loading = true; decodedImageFile = e.detail.imageFile;
errorMsg = '';
const formData = new FormData();
formData.append('image', decodeImageFile);
try {
const res = await fetch(`${API_URL}/decode`, { method: 'POST', body: formData });
if (!res.ok) {
const text = await res.text();
throw new Error(
text.includes('No encoded data') ? 'No hidden data found in this image.' : text
);
}
// Trigger Download
const blob = await res.blob();
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
// Try to get filename from header or default
const contentDisposition = res.headers.get('Content-Disposition');
let fileName = 'decoded_file';
if (contentDisposition) {
const match = contentDisposition.match(/filename="?(.+)"?/);
if (match && match[1]) fileName = match[1];
}
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
} catch (e: any) {
errorMsg = e.message;
} finally {
loading = false;
}
}
// --- UI Helpers ---
function handleFileDrop(e: DragEvent, setter: (f: File) => void) {
e.preventDefault();
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
setter(e.dataTransfer.files[0]);
}
} }
</script> </script>
@@ -144,316 +48,89 @@
<title>AudioImage | Steganography Suite</title> <title>AudioImage | Steganography Suite</title>
</svelte:head> </svelte:head>
<div class="container"> <div class="container flex min-h-screen flex-col">
<header class="mb-12 pt-8 text-center"> <Header
<h1 class="mb-4 text-6xl font-black tracking-tighter"> text="The ultimate suite for Spectrogram Art and Digital Steganography. Visualize sound, hide data, and decode secrets."
<span class="gradient-text">Audio</span>Image />
</h1>
<p class="mx-auto max-w-2xl text-xl text-[var(--text-muted)]">
The ultimate suite for Spectrogram Art and Digital Steganography. Visualize sound, hide data,
and decode secrets.
</p>
</header>
{#if errorMsg} {#if errorMsg}
<div <div
class="glass-panel mb-8 flex items-center justify-between border-l-4 border-red-500 p-4 text-red-100" class="glass-panel mb-8 flex items-start gap-3 border-l-4 border-red-500 bg-red-500/10 p-4 text-red-200 shadow-lg backdrop-blur-md"
transition:fade transition:fade
> >
<span>{errorMsg}</span>
<button class="text-sm opacity-70 hover:opacity-100" on:click={() => (errorMsg = '')}
>Dismiss</button
>
</div>
{/if}
<div class="grid-cols-2">
<!-- LEFT COLUMN: CREATION TOOLS -->
<div class="space-y-8">
<!-- TOOL 1: SPECTROGRAM ART -->
<section class="glass-panel group relative overflow-hidden p-6">
<div
class="absolute top-0 right-0 p-4 opacity-10 transition-opacity group-hover:opacity-20"
>
<svg class="h-24 w-24" fill="currentColor" viewBox="0 0 24 24"
><path
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
/></svg
>
</div>
<h2 class="section-title text-[var(--primary)]">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/></svg
>
Audio Art
</h2>
<p class="mb-6 text-sm text-[var(--text-muted)]">
Convert your favorite songs into visual spectrograms. Optionally hide the song inside the
image itself!
</p>
<div class="space-y-4">
<!-- File Input -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-[var(--border-subtle)] p-8 text-center transition-colors hover:border-[var(--primary)]"
on:dragover|preventDefault
on:drop={(e) => handleFileDrop(e, (f) => (audioFile = f))}
on:click={() => document.getElementById('audioInput').click()}
>
<input
type="file"
id="audioInput"
accept="audio/*"
class="hidden"
on:change={(e) => (audioFile = e.target.files[0])}
/>
{#if audioFile}
<div class="font-semibold text-[var(--primary)]">{audioFile.name}</div>
{:else}
<div class="text-[var(--text-muted)]">Drag audio here or click to browse</div>
{/if}
</div>
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-[var(--border-subtle)] p-3 transition hover:bg-white/5"
>
<input
type="checkbox"
bind:checked={embedAudio}
class="form-checkbox h-5 w-5 rounded border-gray-600 bg-transparent text-[var(--primary)] focus:ring-0 focus:ring-offset-0"
/>
<div>
<span class="block font-medium">Embed Audio Source</span>
<span class="text-xs text-[var(--text-muted)]"
>Check this to hide the MP3 inside the generated PNG.</span
>
</div>
</label>
<button
class="btn-primary flex w-full items-center justify-center gap-2"
on:click={handleArtGenerate}
disabled={loading || !audioFile}
>
{#if loading}<div class="loader"></div>{/if}
Generate Spectrogram
</button>
</div>
</section>
<!-- TOOL 2: ENCODER (Shift & Hide) -->
<section class="glass-panel p-6">
<div class="mb-6 flex gap-4 border-b border-[var(--border-subtle)] pb-2">
<button
class="px-1 pb-2 text-lg font-bold transition-colors {activeTab === 'shift'
? 'border-b-2 border-[var(--accent)] text-[var(--accent)]'
: 'text-[var(--text-muted)] hover:text-white'}"
on:click={() => (activeTab = 'shift')}
>
Format Shift
</button>
<button
class="px-1 pb-2 text-lg font-bold transition-colors {activeTab === 'hide'
? 'border-b-2 border-[var(--accent)] text-[var(--accent)]'
: 'text-[var(--text-muted)] hover:text-white'}"
on:click={() => (activeTab = 'hide')}
>
Stego Hider
</button>
</div>
{#if activeTab === 'shift'}
<div transition:fade>
<p class="mb-6 text-sm text-[var(--text-muted)]">
Turn ANY file (zip, exe, txt) into a static-noise PNG image.
</p>
<div
class="mb-4 cursor-pointer rounded-lg border-2 border-dashed border-[var(--border-subtle)] p-6 text-center transition-colors hover:border-[var(--accent)]"
on:dragover|preventDefault
on:drop={(e) => handleFileDrop(e, (f) => (shiftFile = f))}
on:click={() => document.getElementById('shiftInput').click()}
>
<input
type="file"
id="shiftInput"
class="hidden"
on:change={(e) => (shiftFile = e.target.files[0])}
/>
{#if shiftFile}
<div class="font-semibold text-[var(--accent)]">{shiftFile.name}</div>
{:else}
<div class="text-[var(--text-muted)]">Drag any file here</div>
/
{/if}
</div>
<button
class="btn-primary flex w-full items-center justify-center gap-2 bg-gradient-to-r from-pink-500 to-rose-500"
on:click={handleShift}
disabled={loading || !shiftFile}
>
{#if loading}<div class="loader"></div>{/if} Shift Format
</button>
</div>
{:else}
<div transition:fade>
<p class="mb-6 text-sm text-[var(--text-muted)]">
Hide a secret file inside an innocent looking host image.
</p>
<div class="space-y-4">
<!-- Helper for inputs -->
{#each ['Payload File (Secret)', 'Host Image (Cover)'] as label, i}
<div>
<label class="mb-1 block text-xs font-semibold text-[var(--text-muted)] uppercase"
>{label}</label
>
<input
type="file"
class="glass-input text-sm"
accept={i === 1 ? 'image/*' : '*'}
on:change={(e) =>
i === 0
? (stegoDataFile = e.target.files[0])
: (stegoHostFile = e.target.files[0])}
/>
</div>
{/each}
<button
class="btn-primary flex w-full items-center justify-center gap-2 bg-gradient-to-r from-pink-500 to-rose-500"
on:click={handleHide}
disabled={loading || !stegoDataFile || !stegoHostFile}
>
{#if loading}<div class="loader"></div>{/if} Hide Data
</button>
</div>
</div>
{/if}
</section>
</div>
<!-- RIGHT COLUMN: RESULTS & DECODER -->
<div class="space-y-8">
<!-- UNIVERSAL DECODER -->
<section class="glass-panel border border-[var(--accent)]/30 p-6">
<h2 class="section-title text-[var(--accent)]">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16a2 2 0 01-2-2v-4a2 2 0 114 0v4a2 2 0 01-2 2zm0 2c-1.657 0-3-1.343-3-3v-4a3 3 0 116 0v4c0 1.657-1.343 3-3 3zm7-6a2 2 0 114 0 2 2 0 01-4 0zm0 4a2 2 0 114 0 2 2 0 01-4 0zm0-8a2 2 0 114 0 2 2 0 01-4 0z"
/></svg
>
Universal Decoder
</h2>
<p class="mb-6 text-sm text-[var(--text-muted)]">
Upload ANY image created with this tool to retrieve the original hidden files.
</p>
<div
class="mb-4 cursor-pointer rounded-lg border-2 border-dashed border-[var(--border-subtle)] bg-[var(--bg-deep)] p-10 text-center transition-colors hover:border-[var(--accent)]"
on:dragover|preventDefault
on:drop={(e) => handleFileDrop(e, (f) => (decodeImageFile = f))}
on:click={() => document.getElementById('decodeInput').click()}
>
<input
type="file"
id="decodeInput"
accept="image/png"
class="hidden"
on:change={(e) => (decodeImageFile = e.target.files[0])}
/>
<svg <svg
class="mx-auto mb-3 h-12 w-12 text-[var(--text-muted)]" class="h-6 w-6 shrink-0 text-red-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
><path ><path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="1.5" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg /></svg
> >
{#if decodeImageFile} <div class="flex-1">
<div class="font-semibold text-[var(--accent)]">{decodeImageFile.name}</div> <h3 class="font-bold text-red-100">Error</h3>
{:else} <p class="text-sm">{errorMsg}</p>
<div class="text-[var(--text-muted)]">Drop Stego-Image to Decode</div>
{/if}
</div> </div>
<button <button
class="btn-primary flex w-full items-center justify-center gap-2" class="text-white hover:text-red-100"
on:click={handleDecode} on:click={() => (errorMsg = '')}
disabled={loading || !decodeImageFile} aria-label="Dismiss error"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
> >
{#if loading}<div class="loader"></div>{/if} Retrieve Original File
</button> </button>
</section> </div>
{/if}
<!-- RESULTS PREVIEW AREA --> <div class="grid flex-1 gap-8 md:grid-cols-2">
{#if artResultUrl || encoderResultUrl} <div class="space-y-8">
<div class="glass-panel p-6" transition:fly={{ y: 20 }}> <SpectrogramTool
<h3 class="mb-4 text-lg font-bold text-white">Result Preview</h3> {loading}
on:start={handleStart}
{#if artResultUrl} on:end={handleEnd}
<div class="mb-4"> on:error={handleError}
<p class="mb-2 text-xs tracking-wider text-[var(--text-muted)] uppercase"> on:complete={handleArtComplete}
Audio Spectrogram
</p>
<img
src={artResultUrl}
alt="Spectrogram"
class="w-full rounded-lg border border-[var(--border-subtle)] shadow-2xl"
/> />
<a
href={artResultUrl}
download="spectrogram.png"
class="btn-secondary mt-3 block w-full text-center">Download Art</a
>
</div>
{/if}
{#if encoderResultUrl} <EncoderTool
<div class="mb-4 border-t border-[var(--border-subtle)] pt-4"> {loading}
<p class="mb-2 text-xs tracking-wider text-[var(--text-muted)] uppercase"> on:start={handleStart}
Encoded Image on:end={handleEnd}
</p> on:error={handleError}
<img on:complete={handleEncoderComplete}
src={encoderResultUrl}
alt="Encoded Result"
class="w-full rounded-lg border border-[var(--border-subtle)] shadow-2xl"
/> />
<a
href={encoderResultUrl}
download="encoded_image.png"
class="btn-secondary mt-3 block w-full text-center">Download Encoded Image</a
>
</div>
{/if}
</div>
{/if}
<!-- INFO CARD --> <!-- TOOL 2b: YOUTUBE AUDIO ENCODER -->
<div class="rounded-lg border border-white/5 bg-white/5 p-6 text-sm text-[var(--text-muted)]"> <YouTubeEncoderTool
<h4 class="mb-2 font-bold text-white">Did you know?</h4> {loading}
<p> on:start={handleStart}
The "Format Shift" tool visualizes raw data bytes as colored pixels. A dense file like a on:end={handleEnd}
ZIP will look like TV static, while text files might show repeating patterns. on:error={handleError}
</p> on:complete={handleEncoderComplete}
</div> />
</div> </div>
<div class="space-y-8">
<DecoderTool
{loading}
on:start={handleStart}
on:end={handleEnd}
on:error={handleError}
on:decodesuccess={handleDecodeSuccess}
/>
<ResultPreview {artResultUrl} {encoderResultUrl} {decodedAudioUrl} {decodedImageFile} />
</div> </div>
</div> </div>
<style> <Footer />
/* Additional scoped styles if needed, though most are in layout.css */ </div>
</style>

321
src/routes/how/+page.svelte Normal file
View File

@@ -0,0 +1,321 @@
<script lang="ts">
import Icon from '@iconify/svelte';
</script>
<svelte:head>
<title>How It Works | AudioImage</title>
<meta
name="description"
content="Deep dive into the mathematics and computer science behind AudioImage steganography and audio processing."
/>
</svelte:head>
<div class="container py-8 sm:py-12">
<div class="mb-8 text-center sm:mb-12">
<a
href="/"
class="mb-4 inline-flex items-center gap-2 text-sm text-(--text-muted) transition hover:text-white sm:mb-6"
>
<Icon icon="heroicons:arrow-left" class="h-4 w-4" />
Back to App
</a>
<h1 class="text-3xl font-bold text-white sm:text-4xl md:text-5xl">
How It <span class="text-(--primary)">Works</span>
</h1>
<p class="mx-auto mt-3 max-w-2xl px-4 text-sm text-(--text-muted) sm:mt-4 sm:px-0 sm:text-base">
A deep dive into the Mathematics and Computer Science concepts driving AudioImage.
</p>
</div>
<article class="mx-auto prose max-w-4xl prose-invert">
<section class="glass-panel mb-6 p-4 sm:mb-8 sm:p-6 md:p-8">
<h2 class="flex items-center gap-2 text-xl font-bold text-(--primary) sm:gap-3 sm:text-2xl">
<Icon icon="heroicons:cube-transparent" class="h-6 w-6 sm:h-7 sm:w-7" />
1. The Anatomy of Digital Images
</h2>
<p class="text-sm tracking-wider text-(--text-muted) uppercase">
Linear Algebra & Data Structures
</p>
<p class="mt-4 text-white/80">
To a Computer Scientist, an image is not a "picture"; it is a <strong class="text-white"
>Tensor</strong
> (a multi-dimensional array).
</p>
<h3 class="mt-6 text-lg font-semibold text-white">The Data Structure</h3>
<p class="text-white/80">
A standard color image is a <strong class="text-white">3-Dimensional Array</strong> with the
shape <code class="rounded bg-white/10 px-2 py-0.5">H × W × C</code>:
</p>
<ul class="list-disc space-y-1 pl-6 text-white/80">
<li><strong class="text-white">H (Height):</strong> The number of rows.</li>
<li><strong class="text-white">W (Width):</strong> The number of columns.</li>
<li>
<strong class="text-white">C (Channels):</strong> The depth of the color information (usually
3 for Red, Green, Blue).
</li>
</ul>
<p class="mt-4 text-white/80">
Mathematically, a single pixel at coordinates <code class="rounded bg-white/10 px-2 py-0.5"
>(i, j)</code
> is a vector:
</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
P(i,j) = [R, G, B]
</div>
<p class="text-white/80">
Where R, G, B are integers typically ranging from <code
class="rounded bg-white/10 px-2 py-0.5">0</code
>
to <code class="rounded bg-white/10 px-2 py-0.5">255</code> (8-bit unsigned integers, or
<code class="rounded bg-white/10 px-2 py-0.5">uint8</code>).
</p>
<h3 class="mt-6 text-lg font-semibold text-white">The Storage Limit</h3>
<p class="text-white/80">Since every channel is 1 byte (8 bits), a single pixel consumes:</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
3 channels × 1 byte = 3 bytes/pixel
</div>
<p class="text-white/80">
This is why the capacity calculation is <code class="rounded bg-white/10 px-2 py-0.5"
>Total_Bytes / 3</code
>. We are mapping one byte of your file to one channel of a pixel.
</p>
</section>
<section class="glass-panel mb-6 p-4 sm:mb-8 sm:p-6 md:p-8">
<h2 class="flex items-center gap-3 text-2xl font-bold text-pink-500">
<Icon icon="heroicons:lock-closed" class="h-7 w-7" />
2. The Math of LSB Steganography
</h2>
<p class="text-sm tracking-wider text-(--text-muted) uppercase">Bitwise Logic</p>
<p class="mt-4 text-white/80">
Steganography relies on the concept of <strong class="text-white">Bit Significance</strong>.
In binary, the number <code class="rounded bg-white/10 px-2 py-0.5">200</code> is written as
<code class="rounded bg-white/10 px-2 py-0.5">11001000</code>:
</p>
<ul class="list-disc space-y-2 pl-6 text-white/80">
<li>
<strong class="text-white">MSB (Most Significant Bit):</strong> The leftmost
<code class="rounded bg-white/10 px-2 py-0.5">1</code>. It represents 2⁷ (128). If you
flip this, the value becomes 72, which is a massive color shift.
</li>
<li>
<strong class="text-white">LSB (Least Significant Bit):</strong> The rightmost
<code class="rounded bg-white/10 px-2 py-0.5">0</code>. It represents 2⁰ (1). If you flip
this, the value becomes 201.
</li>
</ul>
<div class="my-6 rounded-lg border border-purple-500/30 bg-purple-500/10 p-4">
<p class="font-semibold text-purple-400">The Visual Tolerance Threshold</p>
<p class="mt-2 text-white/80">
The Human Visual System (HVS) cannot distinguish color differences smaller than roughly ±2
values on the 0255 scale. Therefore, modifying the LSB (±1) is mathematically invisible
to us.
</p>
</div>
<h3 class="mt-6 text-lg font-semibold text-white">The Algorithm: Bitwise Masking</h3>
<p class="text-white/80">
The code uses <strong class="text-white">Boolean Algebra</strong> to inject the data. Let
<code class="rounded bg-white/10 px-2 py-0.5">H</code>
be the Host Byte and <code class="rounded bg-white/10 px-2 py-0.5">S</code> be the Secret Bit.
</p>
<h4 class="mt-4 font-semibold text-white">Step 1: Clearing the LSB</h4>
<div class="my-2 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
H' = H & 0xFE (binary: 11111110)
</div>
<p class="text-white/80">
This forces the last bit to be 0 regardless of what it was before.
</p>
<h4 class="mt-4 font-semibold text-white">Step 2: Injection</h4>
<div class="my-2 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
H'' = H' | S
</div>
<p class="text-white/80">
Since the last bit of H' is guaranteed to be 0, adding S (which is 0 or 1) simply places S
into that slot.
</p>
<h4 class="mt-4 font-semibold text-white">The Combined Equation:</h4>
<div class="my-2 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
Encoded_Byte = (Host_Byte & 0xFE) | Secret_Bit
</div>
</section>
<section class="glass-panel mb-6 p-4 sm:mb-8 sm:p-6 md:p-8">
<h2 class="flex items-center gap-3 text-2xl font-bold text-purple-400">
<Icon icon="heroicons:arrows-pointing-out" class="h-7 w-7" />
3. Format Shifting: Dimensionality Transformation
</h2>
<p class="text-sm tracking-wider text-(--text-muted) uppercase">Reshape Operations</p>
<p class="mt-4 text-white/80">
When we turn a file directly into an image, we are performing a <strong class="text-white"
>Reshape Operation</strong
>.
</p>
<h3 class="mt-6 text-lg font-semibold text-white">1D to 3D Mapping</h3>
<p class="text-white/80">
Your file on the hard drive is a <strong class="text-white">1-Dimensional Stream</strong> of bytes:
</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
[b₀, b₁, b₂, ..., bₙ₋₁]
</div>
<p class="text-white/80">
We need to fold this line into a square block (the image). The math to find the square side
length:
</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
S = ⌈√(N / 3)⌉
</div>
<ul class="list-disc space-y-1 pl-6 text-white/80">
<li><strong class="text-white">N</strong> is total bytes.</li>
<li>Divisor <strong class="text-white">3</strong> accounts for the RGB channels.</li>
<li>
<strong class="text-white">⌈ ⌉</strong> is the Ceiling Function, ensuring we have enough space.
</li>
</ul>
<h3 class="mt-6 text-lg font-semibold text-white">The Padding Problem (Modulo Arithmetic)</h3>
<p class="text-white/80">Since N is rarely perfectly divisible, we have a "Remainder."</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
Padding_Needed = (S² × 3) - N
</div>
<p class="text-white/80">
We fill with zeros. In Computer Science, this is called <strong class="text-white"
>Zero Padding</strong
>. Without it, the matrix transformation library (NumPy) would crash because a matrix must
be a perfect rectangle.
</p>
</section>
<section class="glass-panel mb-6 p-4 sm:mb-8 sm:p-6 md:p-8">
<h2 class="flex items-center gap-3 text-2xl font-bold text-emerald-400">
<Icon icon="heroicons:musical-note" class="h-7 w-7" />
4. Audio Signal Processing (Spectrograms)
</h2>
<p class="text-sm tracking-wider text-(--text-muted) uppercase">
Fourier Transforms & Psychoacoustics
</p>
<p class="mt-4 text-white/80">
When we generate the "Art," we use a <strong class="text-white"
>Short-Time Fourier Transform (STFT)</strong
>.
</p>
<h3 class="mt-6 text-lg font-semibold text-white">The Fourier Transform</h3>
<p class="text-white/80">
A raw audio file (PCM) records air pressure over <strong class="text-white">Time</strong>.
The Fourier Transform converts <strong class="text-white">Time Domain</strong> signal into
<strong class="text-white">Frequency Domain</strong>
signal. It asks: <em>"What notes (frequencies) make up this sound right now?"</em>
</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
X(f) = Σ x(t) × e^(-i2πft)
</div>
<ul class="list-disc space-y-1 pl-6 text-white/80">
<li><strong class="text-white">x(t)</strong>: The audio signal at time t.</li>
<li>
<strong class="text-white">e^(-i2πft)</strong>: Euler's formula, representing sine/cosine
waves.
</li>
<li><strong class="text-white">X(f)</strong>: The intensity of frequency f.</li>
</ul>
<h3 class="mt-6 text-lg font-semibold text-white">The Mel Scale (Psychoacoustics)</h3>
<p class="text-white/80">
Standard frequency scales are linear (0Hz, 100Hz, 200Hz...). However, human hearing is <strong
class="text-white">Logarithmic</strong
>. We can easily hear the difference between 100Hz and 200Hz, but 10,000Hz and 10,100Hz
sound identical to us.
</p>
<p class="mt-2 text-white/80">
The Mel Scale stretches the bass frequencies and compresses the treble frequencies to match
how humans perceive sound:
</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
Mel(f) = 2595 × log₁₀(1 + f/700)
</div>
</section>
<section class="glass-panel mb-6 p-4 sm:mb-8 sm:p-6 md:p-8">
<h2 class="flex items-center gap-3 text-2xl font-bold text-amber-400">
<Icon icon="heroicons:cog-6-tooth" class="h-7 w-7" />
5. System Design: Protocol & Metadata
</h2>
<p class="text-sm tracking-wider text-(--text-muted) uppercase">
Binary Protocols & Complexity Analysis
</p>
<p class="mt-4 text-white/80">
To make the decoder "smart" (auto-detecting), we implemented a <strong class="text-white"
>Binary Protocol</strong
>.
</p>
<h3 class="mt-6 text-lg font-semibold text-white">The Header</h3>
<p class="text-white/80">
Just like an HTTP request has headers before the body, our image has a header before the
payload:
</p>
<div class="my-4 rounded-lg bg-black/40 p-4 font-mono text-sm text-emerald-400">
[SIG] [SIZE] [EXT_LEN] [EXT] [DATA...]
</div>
<ul class="list-disc space-y-2 pl-6 text-white/80">
<li>
<strong class="text-white">Magic Bytes:</strong> A "File Signature" that reduces false positives.
The probability of the first 4 bytes randomly matching is 1 in ~4 billion.
</li>
<li>
<strong class="text-white">Unsigned Long Long (8 bytes):</strong> Stores the file size. Max
value: ~18 Exabytes. This is future-proofing.
</li>
</ul>
<h3 class="mt-6 text-lg font-semibold text-white">Big O Notation (Complexity)</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-white/10 bg-black/20 p-4">
<p class="font-semibold text-white">Time Complexity</p>
<p class="mt-1 font-mono text-2xl text-emerald-400">O(n)</p>
<p class="mt-2 text-sm text-white/60">
The script iterates over the file bytes once. If the file size doubles, the processing
time doubles. This is linear and efficient.
</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/20 p-4">
<p class="font-semibold text-white">Space Complexity</p>
<p class="mt-1 font-mono text-2xl text-emerald-400">O(n)</p>
<p class="mt-2 text-sm text-white/60">
We load the file into RAM (byte array) and then create an image array of roughly the
same size.
</p>
</div>
</div>
<div class="mt-6 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="font-semibold text-amber-400">Optimization Note</p>
<p class="mt-2 text-white/80">
For the 40MB limit, this is fine. If we were processing 10GB files, we would need to
switch to <strong class="text-white">Streaming I/O</strong>, processing the file in
"chunks" (e.g., 4KB at a time) rather than loading it all at once to prevent Out Of Memory
(OOM) errors.
</p>
</div>
</section>
</article>
<div class="mt-8 text-center sm:mt-12">
<a href="/" class="btn-primary inline-flex items-center gap-2">
<Icon icon="heroicons:arrow-left" class="h-5 w-5" />
Back to App
</a>
</div>
</div>

View File

@@ -8,9 +8,11 @@
--bg-card: #131318; --bg-card: #131318;
--bg-glass: rgba(19, 19, 24, 0.7); --bg-glass: rgba(19, 19, 24, 0.7);
--primary: #6366f1; /* Indigo */ --primary: #6366f1;
/* Indigo */
--primary-glow: rgba(99, 102, 241, 0.4); --primary-glow: rgba(99, 102, 241, 0.4);
--accent: #ec4899; /* Pink */ --accent: #ec4899;
/* Pink */
--accent-glow: rgba(236, 72, 153, 0.4); --accent-glow: rgba(236, 72, 153, 0.4);
--text-main: #ffffff; --text-main: #ffffff;
@@ -43,14 +45,12 @@ h4 {
margin-top: 0; margin-top: 0;
} }
/* Glassmorphism Utilities */ /* Card Styles (Solid Dark) */
.glass-panel { .glass-panel {
background: var(--bg-glass); background: var(--bg-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4);
} }
.glass-input { .glass-input {
@@ -72,7 +72,7 @@ h4 {
/* Button Styles */ /* Button Styles */
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--primary), #4f46e5); background: var(--primary);
color: white; color: white;
border: none; border: none;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
@@ -81,7 +81,8 @@ h4 {
cursor: pointer; cursor: pointer;
transition: transition:
transform 0.1s ease, transform 0.1s ease,
box-shadow 0.2s ease; box-shadow 0.2s ease,
background-color 0.2s ease;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.875rem; font-size: 0.875rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -90,6 +91,7 @@ h4 {
.btn-primary:hover { .btn-primary:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 20px var(--primary-glow); box-shadow: 0 4px 20px var(--primary-glow);
background: #5558e8;
} }
.btn-primary:active { .btn-primary:active {
@@ -117,8 +119,20 @@ h4 {
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem;
}
@media (min-width: 640px) {
.container {
padding: 1.5rem;
}
}
@media (min-width: 768px) {
.container {
padding: 2rem; padding: 2rem;
} }
}
.grid-cols-2 { .grid-cols-2 {
display: grid; display: grid;
@@ -133,13 +147,20 @@ h4 {
} }
.section-title { .section-title {
font-size: 1.5rem; font-size: 1.25rem;
margin-bottom: 1.5rem; margin-bottom: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
@media (min-width: 640px) {
.section-title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
}
.gradient-text { .gradient-text {
background: linear-gradient(to right, var(--primary), var(--accent)); background: linear-gradient(to right, var(--primary), var(--accent));
-webkit-background-clip: text; -webkit-background-clip: text;

View File

@@ -0,0 +1,542 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { fade, scale } from 'svelte/transition';
let audioFile: File | null = $state(null);
let isProcessing = $state(false);
let currentStep = $state(0);
let progress = $state(0);
let resultId: string | null = $state(null);
let error: string | null = $state(null);
let spectrogramImage: string | null = $state(null);
let finalImage: string | null = $state(null);
let audioUrl: string | null = $state(null);
let audioElement: HTMLAudioElement | undefined = $state();
let canvasElement: HTMLCanvasElement | undefined = $state();
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let source: MediaElementAudioSourceNode | null = null;
let animationFrame: number;
$effect(() => {
if (audioFile) {
audioUrl = URL.createObjectURL(audioFile);
} else {
if (audioUrl) URL.revokeObjectURL(audioUrl);
audioUrl = null;
}
});
function initAudio() {
if (!audioElement || !canvasElement) return;
if (!audioContext) {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
if (!analyser) {
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
}
if (!source) {
try {
source = audioContext.createMediaElementSource(audioElement);
source.connect(analyser);
analyser.connect(audioContext.destination);
} catch (e) {
console.warn('Source already connected or error:', e);
}
}
drawVisualizer();
}
function drawVisualizer() {
if (!canvasElement || !analyser) return;
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const ctx = canvasElement.getContext('2d');
if (!ctx) return;
const draw = () => {
if (!isProcessing && !resultId) {
cancelAnimationFrame(animationFrame);
return;
}
animationFrame = requestAnimationFrame(draw);
analyser!.getByteTimeDomainData(dataArray);
const width = canvasElement!.width;
const height = canvasElement!.height;
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = 3;
const gradient = ctx.createLinearGradient(0, 0, width, 0);
gradient.addColorStop(0, '#a855f7');
gradient.addColorStop(0.5, '#ec4899');
gradient.addColorStop(1, '#a855f7');
ctx.strokeStyle = gradient;
ctx.beginPath();
ctx.shadowBlur = 10;
ctx.shadowColor = '#ec4899';
const sliceWidth = (width * 1.0) / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * height) / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(width, height / 2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
};
draw();
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files[0]) {
audioFile = input.files[0];
error = null;
}
}
async function startVisualization() {
if (!audioFile) return;
isProcessing = true;
setTimeout(() => {
if (audioElement && audioContext?.state === 'suspended') {
audioContext.resume();
}
initAudio();
audioElement?.play();
}, 100);
currentStep = 0;
progress = 0;
resultId = null;
error = null;
spectrogramImage = null;
finalImage = null;
const formData = new FormData();
formData.append('audio', audioFile);
try {
const response = await fetch('/api/visualize', {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to start visualization');
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error('No response stream');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const messages = buffer.split('\n\n');
buffer = messages.pop() || '';
for (const message of messages) {
const lines = message.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
currentStep = data.step;
progress = data.progress;
if (data.spectrogramImage) {
spectrogramImage = data.spectrogramImage;
}
if (data.finalImage) {
finalImage = data.finalImage;
}
if (data.status === 'error') {
error = data.message;
isProcessing = false;
}
if (data.resultId) {
resultId = data.resultId;
}
if (data.step === 5 && data.status === 'complete') {
isProcessing = false;
}
} catch (e) {
console.error('Failed to parse SSE message:', e, line);
}
}
}
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
isProcessing = false;
}
}
function reset() {
if (audioElement) {
audioElement.pause();
audioElement.currentTime = 0;
}
if (animationFrame) cancelAnimationFrame(animationFrame);
source = null;
audioFile = null;
isProcessing = false;
currentStep = 0;
progress = 0;
resultId = null;
error = null;
spectrogramImage = null;
finalImage = null;
}
</script>
<svelte:head>
<title>Visualizer | AudioImage</title>
<meta
name="description"
content="Watch the step-by-step process of converting audio to a spectrogram with embedded audio."
/>
</svelte:head>
<div class="container py-8 sm:py-12">
<div class="mb-8 text-center sm:mb-12">
<a
href="/"
class="mb-4 inline-flex items-center gap-2 text-sm text-(--text-muted) transition hover:text-white sm:mb-6"
>
<Icon icon="heroicons:arrow-left" class="h-4 w-4" />
Back to App
</a>
<h1 class="text-3xl font-bold text-white sm:text-4xl md:text-5xl">
Process <span class="text-(--primary)">Visualizer</span>
</h1>
<p class="mx-auto mt-3 max-w-2xl px-4 text-sm text-(--text-muted) sm:mt-4 sm:px-0 sm:text-base">
Watch the transformation of audio into a steganographic image.
</p>
</div>
<div class="mx-auto max-w-4xl space-y-6">
{#if !spectrogramImage && !finalImage && !isProcessing}
<div class="glass-panel p-6 sm:p-8" transition:fade>
<h2 class="section-title mb-4 text-(--primary)">
<Icon icon="heroicons:arrow-up-tray" class="h-6 w-6" />
Select Audio File
</h2>
<div class="mb-6">
<label
class="flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-white/20 bg-white/5 p-8 transition hover:border-(--primary)/50 hover:bg-white/10"
>
<Icon icon="heroicons:musical-note" class="mb-3 h-12 w-12 text-(--text-muted)" />
<span class="mb-2 text-lg font-medium text-white">
{audioFile ? audioFile.name : 'Drop audio file here'}
</span>
<span class="text-sm text-(--text-muted)">
{audioFile
? `${(audioFile.size / 1024 / 1024).toFixed(2)} MB`
: 'MP3, WAV, FLAC supported'}
</span>
<input type="file" accept="audio/*" class="hidden" onchange={handleFileChange} />
</label>
</div>
<button
class="btn-primary flex w-full items-center justify-center gap-2"
onclick={startVisualization}
disabled={!audioFile}
>
<Icon icon="heroicons:play" class="h-5 w-5" />
Start Visualization
</button>
</div>
{/if}
{#if isProcessing}
<div class="space-y-6" transition:fade>
{#if audioUrl}
<div class="glass-panel relative overflow-hidden p-0">
<canvas
bind:this={canvasElement}
width="800"
height="120"
class="h-32 w-full object-cover opacity-80"
></canvas>
<audio bind:this={audioElement} src={audioUrl} class="hidden" onended={() => {}}
></audio>
<div
class="absolute bottom-3 left-6 flex items-center gap-2 text-sm font-medium text-white/80"
>
<div class="flex h-4 w-4 items-center justify-center">
<span
class="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-pink-400 opacity-75"
></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-pink-500"></span>
</div>
Playing Audio...
</div>
</div>
{/if}
<div class="glass-panel p-6">
<div class="mb-4 flex items-center justify-between">
<span class="font-medium text-white">
{#if currentStep === 1}Loading audio...
{:else if currentStep === 2}Analyzing frequencies...
{:else if currentStep === 3}Generating spectrogram...
{:else if currentStep === 4}Embedding audio...
{:else if currentStep === 5}Complete!
{:else}Processing...{/if}
</span>
<span class="font-mono text-sm text-(--text-muted)">{progress}%</span>
</div>
<div class="h-4 overflow-hidden rounded-full bg-white/10">
<div
class="h-full rounded-full bg-linear-to-r from-(--primary) to-purple-500 transition-all duration-500"
style="width: {progress}%"
></div>
</div>
</div>
</div>
{/if}
{#if spectrogramImage || finalImage || isProcessing}
<div class="glass-panel p-6 sm:p-8" transition:scale>
<div class="mb-6 flex items-center justify-between">
<h2 class="section-title text-pink-500">
<Icon icon="heroicons:photo" class="h-6 w-6" />
Image Preview
</h2>
{#if !isProcessing && (spectrogramImage || finalImage)}
<button
class="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
onclick={reset}
>
<Icon icon="heroicons:x-mark" class="h-4 w-4" />
Clear
</button>
{/if}
</div>
<div class="grid gap-6 md:grid-cols-2">
<div class="relative">
<div
class="absolute -top-3 left-4 z-10 rounded-full border border-pink-500/30 bg-(--bg-card) px-3 py-1 text-xs font-semibold text-pink-500"
>
Spectrogram
</div>
<div class="overflow-hidden rounded-xl border border-white/10 bg-black/40">
{#if spectrogramImage}
<div class="image-reveal">
<img src={spectrogramImage} alt="Generated Spectrogram" class="h-auto w-full" />
</div>
{:else if isProcessing && currentStep >= 3}
<div class="flex aspect-video items-center justify-center bg-black/20">
<div class="text-center">
<div
class="mx-auto mb-3 h-10 w-10 animate-spin rounded-full border-4 border-pink-500/20 border-t-pink-500"
></div>
<p class="text-sm text-(--text-muted)">Generating...</p>
</div>
</div>
{:else}
<div class="flex aspect-video items-center justify-center bg-black/20">
<Icon icon="heroicons:photo" class="h-10 w-10 text-white/10" />
</div>
{/if}
</div>
<p class="mt-2 text-center text-xs text-(--text-muted)">
{spectrogramImage ? 'Raw spectrogram' : 'Waiting...'}
</p>
</div>
<div class="relative">
<div
class="absolute -top-3 left-4 z-10 rounded-full border border-emerald-500/30 bg-(--bg-card) px-3 py-1 text-xs font-semibold text-emerald-400"
>
With Hidden Audio
</div>
<div
class="overflow-hidden rounded-xl border border-white/10 bg-black/40 {finalImage
? 'ring-2 ring-emerald-500/30'
: ''}"
>
{#if finalImage}
<div class="image-reveal-final">
<img
src={finalImage}
alt="Spectrogram with embedded audio"
class="h-auto w-full"
/>
</div>
{:else if isProcessing && currentStep >= 4}
<div class="flex aspect-video items-center justify-center bg-black/20">
<div class="text-center">
<div
class="mx-auto mb-3 h-10 w-10 animate-spin rounded-full border-4 border-emerald-500/20 border-t-emerald-500"
></div>
<p class="text-sm text-(--text-muted)">Embedding...</p>
</div>
</div>
{:else}
<div class="flex aspect-video items-center justify-center bg-black/20">
<Icon icon="heroicons:lock-closed" class="h-10 w-10 text-white/10" />
</div>
{/if}
</div>
<p
class="mt-2 text-center text-xs {finalImage
? 'text-emerald-400'
: 'text-(--text-muted)'}"
>
{finalImage ? '✓ Audio hidden inside!' : 'Waiting...'}
</p>
</div>
</div>
{#if resultId && !isProcessing}
<div class="mt-6">
<a
href="/api/result/{resultId}"
download="spectrogram_with_audio.png"
class="btn-primary flex w-full items-center justify-center gap-2"
>
<Icon icon="heroicons:arrow-down-tray" class="h-5 w-5" />
Download Image
</a>
</div>
{/if}
</div>
{/if}
{#if error}
<div
class="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-red-400"
transition:fade
>
<div class="flex items-center gap-2">
<Icon icon="heroicons:exclamation-triangle" class="h-5 w-5" />
<span class="font-medium">Error</span>
</div>
<p class="mt-2 text-sm">{error}</p>
<button
class="mt-4 rounded-lg bg-red-500/20 px-4 py-2 text-sm font-medium transition hover:bg-red-500/30"
onclick={reset}
>
Try Again
</button>
</div>
{/if}
</div>
<div class="mt-8 text-center sm:mt-12">
<a href="/" class="btn-primary inline-flex items-center gap-2">
<Icon icon="heroicons:arrow-left" class="h-5 w-5" />
Back to App
</a>
</div>
</div>
<style>
.image-reveal {
animation: revealFromTop 1s ease-out forwards;
overflow: hidden;
}
.image-reveal img {
animation: scanlineEffect 1s ease-out forwards;
}
.image-reveal-final {
animation: revealFromTop 0.8s ease-out forwards;
overflow: hidden;
}
.image-reveal-final img {
animation: glowPulse 2s ease-in-out infinite;
}
@keyframes revealFromTop {
0% {
clip-path: inset(0 0 100% 0);
opacity: 0;
}
10% {
opacity: 1;
}
100% {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
@keyframes scanlineEffect {
0% {
filter: brightness(2) contrast(1.5);
}
50% {
filter: brightness(1.2) contrast(1.1);
}
100% {
filter: brightness(1) contrast(1);
}
}
@keyframes glowPulse {
0%,
100% {
filter: brightness(1) drop-shadow(0 0 0 transparent);
}
50% {
filter: brightness(1.05) drop-shadow(0 0 10px rgba(16, 185, 129, 0.3));
}
}
</style>

View File

@@ -1,7 +1,6 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {

View File

@@ -12,9 +12,4 @@
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }