UI and Audio Processing Update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,3 +27,5 @@ vite.config.ts.timestamp-*
|
||||
*/__pycache__/
|
||||
|
||||
.vscode/
|
||||
|
||||
*/uploads/*
|
||||
66
README.md
66
README.md
@@ -1 +1,67 @@
|
||||
# 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.
|
||||
* **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
|
||||
|
||||
1. **Backend Setup**:
|
||||
```bash
|
||||
cd server
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
*Optional for GPU support:*
|
||||
```bash
|
||||
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
```
|
||||
*(Adjust CUDA version as needed)*
|
||||
|
||||
2. **Frontend Setup**:
|
||||
```bash
|
||||
# Root directory
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
1. Start the Flask server (which serves the frontend):
|
||||
```bash
|
||||
cd server
|
||||
source venv/bin/activate
|
||||
python app.py
|
||||
```
|
||||
2. Open your browser to `http://127.0.0.1:5000`.
|
||||
|
||||
## 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.
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "audioimage",
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@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=="],
|
||||
|
||||
"@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/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
@@ -14,6 +14,7 @@
|
||||
"lint": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
|
||||
210
server/app.py
210
server/app.py
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
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 werkzeug.utils import secure_filename
|
||||
from processor import AudioImageProcessor
|
||||
|
||||
app = Flask(__name__)
|
||||
# Serve the build folder from the parent directory
|
||||
app = Flask(__name__, static_folder='../build', static_url_path='')
|
||||
CORS(app) # Allow Svelte to communicate
|
||||
|
||||
# Configuration
|
||||
@@ -19,10 +21,55 @@ def save_upload(file_obj):
|
||||
file_obj.save(path)
|
||||
return path
|
||||
|
||||
# --- Frontend Routes ---
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
# If the path starts with /api, return actual 404
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
# Otherwise return index.html for SPA routing
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
return jsonify({"status": "ok", "max_mb": 40})
|
||||
|
||||
# --- Background Cleanup ---
|
||||
import threading
|
||||
|
||||
def cleanup_task():
|
||||
"""Background thread to clean up old files."""
|
||||
expiration_seconds = 600 # 10 minutes
|
||||
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):
|
||||
# check creation time
|
||||
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) # Run every minute
|
||||
|
||||
# Start cleanup thread safely
|
||||
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true' or not os.environ.get('WERKZEUG_RUN_MAIN'):
|
||||
# Simple check to try and avoid double threads in reloader, though not perfect
|
||||
t = threading.Thread(target=cleanup_task, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
# --- Endpoint 1: Create Art (Optional: Embed Audio in it) ---
|
||||
@app.route('/api/generate-art', methods=['POST'])
|
||||
def generate_art():
|
||||
@@ -32,35 +79,48 @@ def generate_art():
|
||||
audio_file = request.files['audio']
|
||||
should_embed = request.form.get('embed', 'false').lower() == 'true'
|
||||
|
||||
audio_path = None
|
||||
art_path = None
|
||||
|
||||
try:
|
||||
# 1. Save Audio
|
||||
audio_path = save_upload(audio_file)
|
||||
|
||||
# 2. Generate Art
|
||||
art_path = processor.generate_spectrogram(audio_path)
|
||||
min_pixels = 0
|
||||
if should_embed:
|
||||
# Calculate required pixels: File Bytes * 8 (bits) / 3 (channels)
|
||||
# Add 5% buffer for header and safety
|
||||
file_size = os.path.getsize(audio_path)
|
||||
min_pixels = int((file_size * 8 / 3) * 1.05)
|
||||
|
||||
art_path = processor.generate_spectrogram(audio_path, min_pixels=min_pixels)
|
||||
|
||||
# 3. If Embed requested, run Steganography immediately using the art as host
|
||||
final_path = art_path
|
||||
if should_embed:
|
||||
# art_path becomes the host, audio_path is the data
|
||||
final_path = processor.encode_stego(audio_path, art_path)
|
||||
|
||||
# If we created a new stego image, the pure art_path is intermediate (and audio is input)
|
||||
# We can delete art_path now if it's different (it is)
|
||||
if art_path != final_path:
|
||||
try: os.remove(art_path)
|
||||
except: pass
|
||||
|
||||
return send_file(final_path, mimetype='image/png')
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
# Cleanup Inputs
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try: os.remove(audio_path)
|
||||
except: pass
|
||||
|
||||
# --- Endpoint 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'])
|
||||
@@ -68,14 +128,25 @@ def hide_data():
|
||||
if 'data' not in request.files or 'host' not in request.files:
|
||||
return jsonify({"error": "Requires 'data' and 'host' files"}), 400
|
||||
|
||||
data_path = None
|
||||
host_path = None
|
||||
try:
|
||||
data_path = save_upload(request.files['data'])
|
||||
host_path = save_upload(request.files['host'])
|
||||
|
||||
stego_path = processor.encode_stego(data_path, host_path)
|
||||
return send_file(stego_path, mimetype='image/png')
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
# --- Endpoint 4: Decode (Universal) ---
|
||||
@app.route('/api/decode', methods=['POST'])
|
||||
@@ -83,6 +154,7 @@ def decode():
|
||||
if 'image' not in request.files:
|
||||
return jsonify({"error": "No image provided"}), 400
|
||||
|
||||
img_path = None
|
||||
try:
|
||||
img_path = save_upload(request.files['image'])
|
||||
restored_path = processor.decode_image(img_path)
|
||||
@@ -90,8 +162,118 @@ def decode():
|
||||
# Determine mimetype based on extension for browser friendliness
|
||||
filename = os.path.basename(restored_path)
|
||||
return send_file(restored_path, as_attachment=True, download_name=filename)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
if img_path and os.path.exists(img_path):
|
||||
try: os.remove(img_path)
|
||||
except: pass
|
||||
|
||||
# --- Endpoint 4: Visualizer SSE Stream ---
|
||||
@app.route('/api/visualize', methods=['POST'])
|
||||
def visualize():
|
||||
"""
|
||||
SSE endpoint that streams the spectrogram generation process.
|
||||
Returns step-by-step updates for visualization.
|
||||
"""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
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
|
||||
|
||||
# Step 1: Audio loaded
|
||||
yield f"data: {json.dumps({'step': 1, 'status': 'loading', 'message': 'Loading audio file...', 'progress': 10})}\n\n"
|
||||
time.sleep(0.8)
|
||||
|
||||
yield f"data: {json.dumps({'step': 1, 'status': 'complete', 'message': f'Audio loaded: {audio_file.filename}', 'progress': 20, 'fileSize': file_size})}\n\n"
|
||||
time.sleep(0.5)
|
||||
|
||||
# Step 2: Analyzing audio
|
||||
yield f"data: {json.dumps({'step': 2, 'status': 'loading', 'message': 'Analyzing audio frequencies...', 'progress': 30})}\n\n"
|
||||
time.sleep(1.0)
|
||||
|
||||
yield f"data: {json.dumps({'step': 2, 'status': 'complete', 'message': 'Frequency analysis complete', 'progress': 40})}\n\n"
|
||||
time.sleep(0.5)
|
||||
|
||||
# Step 3: Generating spectrogram
|
||||
yield f"data: {json.dumps({'step': 3, 'status': 'loading', 'message': 'Generating spectrogram image...', 'progress': 50})}\n\n"
|
||||
|
||||
print(f"[VISUALIZE] Starting spectrogram generation for {audio_path}")
|
||||
art_path = processor.generate_spectrogram(audio_path, min_pixels=min_pixels)
|
||||
print(f"[VISUALIZE] Spectrogram generated at {art_path}")
|
||||
|
||||
# Read the spectrogram image and encode as base64
|
||||
with open(art_path, 'rb') as img_file:
|
||||
spectrogram_b64 = base64.b64encode(img_file.read()).decode('utf-8')
|
||||
print(f"[VISUALIZE] Spectrogram base64 length: {len(spectrogram_b64)}")
|
||||
|
||||
yield f"data: {json.dumps({'step': 3, 'status': 'complete', 'message': 'Spectrogram generated!', 'progress': 70, 'spectrogramImage': f'data:image/png;base64,{spectrogram_b64}'})}\n\n"
|
||||
print("[VISUALIZE] Sent spectrogram image")
|
||||
time.sleep(2.0) # Pause to let user see the spectrogram
|
||||
|
||||
# Step 4: Embedding audio
|
||||
yield f"data: {json.dumps({'step': 4, 'status': 'loading', 'message': 'Embedding audio into image (LSB steganography)...', 'progress': 80})}\n\n"
|
||||
|
||||
final_path = processor.encode_stego(audio_path, art_path)
|
||||
|
||||
# Read the final image and encode as base64
|
||||
with open(final_path, 'rb') as img_file:
|
||||
final_b64 = base64.b64encode(img_file.read()).decode('utf-8')
|
||||
|
||||
yield f"data: {json.dumps({'step': 4, 'status': 'complete', 'message': 'Audio embedded successfully!', 'progress': 95, 'finalImage': f'data:image/png;base64,{final_b64}'})}\n\n"
|
||||
time.sleep(2.0) # Pause to let user see the final image
|
||||
|
||||
# Step 5: Complete - send the result URL
|
||||
result_id = os.path.basename(final_path)
|
||||
yield f"data: {json.dumps({'step': 5, 'status': 'complete', 'message': 'Process complete!', 'progress': 100, 'resultId': result_id})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'step': 0, 'status': 'error', 'message': str(e), 'progress': 0})}\n\n"
|
||||
finally:
|
||||
# Clean up intermediate files (but keep final)
|
||||
if art_path and art_path != final_path and os.path.exists(art_path):
|
||||
try: os.remove(art_path)
|
||||
except: pass
|
||||
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):
|
||||
"""Serve the result image by ID."""
|
||||
result_path = os.path.join(app.config['UPLOAD_FOLDER'], result_id)
|
||||
if os.path.exists(result_path):
|
||||
return send_file(result_path, mimetype='image/png', as_attachment=False)
|
||||
return jsonify({"error": "Result not found"}), 404
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Threaded=True is important for processing images without blocking
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import time
|
||||
import struct
|
||||
import math
|
||||
import numpy as np
|
||||
@@ -36,49 +37,90 @@ class AudioImageProcessor:
|
||||
return struct.pack(HEADER_FMT, signature, file_size, len(ext_bytes)) + ext_bytes
|
||||
|
||||
# --- Feature 1: Spectrogram Art ---
|
||||
def generate_spectrogram(self, audio_path):
|
||||
def generate_spectrogram(self, audio_path, min_pixels=0):
|
||||
"""Generates a visual spectrogram from audio."""
|
||||
try:
|
||||
import torch
|
||||
import torchaudio
|
||||
has_torch = True
|
||||
except ImportError:
|
||||
has_torch = False
|
||||
|
||||
if has_torch and torch.cuda.is_available():
|
||||
try:
|
||||
# GPU Accelerated Path
|
||||
device = "cuda"
|
||||
waveform, sr = torchaudio.load(audio_path)
|
||||
waveform = waveform.to(device)
|
||||
|
||||
# Create transformation
|
||||
# Mimic librosa defaults roughly: n_fft=2048, hop_length=512
|
||||
n_fft = 2048
|
||||
win_length = n_fft
|
||||
hop_length = 512
|
||||
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)
|
||||
|
||||
# Back to CPU for plotting
|
||||
S_dB = S_dB.cpu().numpy()[0] # Take first channel
|
||||
# Librosa display expects numpy
|
||||
except Exception as e:
|
||||
# Fallback to CPU/Librosa if any error occurs
|
||||
print(f"GPU processing failed, falling back to CPU: {e}")
|
||||
return self._generate_spectrogram_cpu(audio_path, min_pixels)
|
||||
else:
|
||||
return self._generate_spectrogram_cpu(audio_path, min_pixels)
|
||||
|
||||
# Plotting (Common)
|
||||
return self._plot_spectrogram(S_dB, sr, min_pixels)
|
||||
|
||||
def _generate_spectrogram_cpu(self, audio_path, min_pixels=0):
|
||||
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)
|
||||
return self._plot_spectrogram(S_dB, sr, min_pixels)
|
||||
|
||||
plt.figure(figsize=(12, 6))
|
||||
plt.axis('off')
|
||||
plt.margins(0, 0)
|
||||
plt.gca().xaxis.set_major_locator(plt.NullLocator())
|
||||
plt.gca().yaxis.set_major_locator(plt.NullLocator())
|
||||
def _plot_spectrogram(self, S_dB, sr, min_pixels=0):
|
||||
# Calculate DPI dynamically to ensure we have enough pixels for steganography
|
||||
dpi = 300
|
||||
if min_pixels > 0:
|
||||
# Figure is 12x6 inches. Area = 72 sq inches.
|
||||
# Total Pixels = 72 * dpi^2
|
||||
required_dpi = math.ceil((min_pixels / 72) ** 0.5)
|
||||
# Add a small buffer
|
||||
dpi = max(dpi, int(required_dpi * 1.05))
|
||||
|
||||
# 'magma' is a nice default, but you could parameterize this
|
||||
librosa.display.specshow(S_dB, sr=sr, fmax=8000, cmap='magma')
|
||||
# Use exact dimensions without margins
|
||||
width_in = 12
|
||||
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")
|
||||
plt.savefig(output_path, bbox_inches='tight', pad_inches=0, dpi=300)
|
||||
# Add axes covering the entire figure [left, bottom, width, height]
|
||||
ax = plt.axes([0, 0, 1, 1], frameon=False)
|
||||
ax.set_axis_off()
|
||||
|
||||
# 'magma' is a nice default
|
||||
librosa.display.specshow(S_dB, sr=sr, fmax=8000, cmap='magma', ax=ax)
|
||||
|
||||
output_path = os.path.join(self.upload_folder, f"art_{int(time.time())}.png")
|
||||
|
||||
# specific DPI, no bbox_inches='tight' (which shrinks the image)
|
||||
plt.savefig(output_path, dpi=dpi)
|
||||
plt.close()
|
||||
return output_path
|
||||
|
||||
# --- Feature 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):
|
||||
|
||||
@@ -4,3 +4,5 @@ numpy
|
||||
Pillow
|
||||
librosa
|
||||
matplotlib
|
||||
torch
|
||||
torchaudio
|
||||
|
||||
155
src/lib/components/DecoderTool.svelte
Normal file
155
src/lib/components/DecoderTool.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<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
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle success
|
||||
const blob = await res.blob();
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Detect if it's audio
|
||||
const contentDisposition = res.headers.get('Content-Disposition') || '';
|
||||
let fileName = 'decoded_file';
|
||||
const match = contentDisposition.match(/filename="?(.+)"?/);
|
||||
if (match && match[1]) fileName = match[1];
|
||||
|
||||
if (fileName.match(/\.(mp3|aac|wav|ogg|m4a)$/i)) {
|
||||
// Success! Dispatch the URL and image to parent for playing
|
||||
// We'll pass the blob URL and the original file (to show cover art)
|
||||
dispatch('decodesuccess', {
|
||||
audioUrl: downloadUrl,
|
||||
imageFile: decodeImageFile
|
||||
});
|
||||
} else {
|
||||
// For non-audio (or unknown), auto-download as before
|
||||
triggerDownload(downloadUrl, fileName);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
} 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);
|
||||
// Revoke the URL after download to free up memory
|
||||
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>
|
||||
120
src/lib/components/EncoderTool.svelte
Normal file
120
src/lib/components/EncoderTool.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<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">
|
||||
<!-- Helper for inputs -->
|
||||
{#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;
|
||||
// Calculate MP
|
||||
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>
|
||||
9
src/lib/components/Footer.svelte
Normal file
9
src/lib/components/Footer.svelte
Normal 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>
|
||||
48
src/lib/components/Header.svelte
Normal file
48
src/lib/components/Header.svelte
Normal 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>
|
||||
207
src/lib/components/ResultPreview.svelte
Normal file
207
src/lib/components/ResultPreview.svelte
Normal file
@@ -0,0 +1,207 @@
|
||||
<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);
|
||||
}
|
||||
});
|
||||
|
||||
// Audio Player State
|
||||
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">
|
||||
<!-- Audio Art / Encoder Results -->
|
||||
<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>
|
||||
|
||||
<!-- Decoded Audio Player -->
|
||||
{#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">
|
||||
<!-- Cover Art -->
|
||||
<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>
|
||||
|
||||
<!-- Player Controls -->
|
||||
<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>
|
||||
|
||||
<!-- Progress -->
|
||||
<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>
|
||||
154
src/lib/components/SpectrogramTool.svelte
Normal file
154
src/lib/components/SpectrogramTool.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let { loading = false } = $props();
|
||||
let audioFile: File | null = $state(null);
|
||||
let embedAudio = $state(true);
|
||||
|
||||
// Helper functions passed from parent or redefined?
|
||||
// Redefining helper for self-contained component
|
||||
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 {
|
||||
// We need API_URL. We can pass it as prop or assume generic relative path if served from same origin
|
||||
// The original used absolute http://127.0.0.1:5000/api
|
||||
// Let's assume the parent handles the API_URL or we import it.
|
||||
// For now, hardcode or use relative '/api' if proxy is set up?
|
||||
// User had 'http://127.0.0.1:5000/api'.
|
||||
|
||||
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">
|
||||
<!-- File Input -->
|
||||
<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>
|
||||
@@ -1,142 +1,46 @@
|
||||
<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 DecoderTool from '$lib/components/DecoderTool.svelte';
|
||||
|
||||
// State
|
||||
const API_URL = 'http://127.0.0.1:5000/api';
|
||||
let loading = false;
|
||||
let errorMsg = '';
|
||||
|
||||
// --- Audio Art State ---
|
||||
let audioFile: File | null = null;
|
||||
let embedAudio = false;
|
||||
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 = '';
|
||||
|
||||
// --- Decoder State ---
|
||||
let decodeImageFile: File | null = null;
|
||||
// Decoder State
|
||||
let decodedAudioUrl: string | null = null;
|
||||
let decodedImageFile: File | null = null;
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async function handleArtGenerate() {
|
||||
if (!audioFile) return;
|
||||
function handleStart() {
|
||||
loading = true;
|
||||
errorMsg = '';
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
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 {
|
||||
function handleEnd() {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShift() {
|
||||
if (!shiftFile) return;
|
||||
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 {
|
||||
function handleError(e: CustomEvent<string>) {
|
||||
errorMsg = e.detail;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleArtComplete(e: CustomEvent<{ url: string }>) {
|
||||
artResultUrl = e.detail.url;
|
||||
}
|
||||
|
||||
async function handleHide() {
|
||||
if (!stegoDataFile || !stegoHostFile) return;
|
||||
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;
|
||||
}
|
||||
function handleEncoderComplete(e: CustomEvent<{ url: string }>) {
|
||||
encoderResultUrl = e.detail.url;
|
||||
}
|
||||
|
||||
async function handleDecode() {
|
||||
if (!decodeImageFile) return;
|
||||
loading = true;
|
||||
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]);
|
||||
}
|
||||
function handleDecodeSuccess(e: CustomEvent<{ audioUrl: string; imageFile: File }>) {
|
||||
decodedAudioUrl = e.detail.audioUrl;
|
||||
decodedImageFile = e.detail.imageFile;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -144,316 +48,86 @@
|
||||
<title>AudioImage | Steganography Suite</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<header class="mb-12 pt-8 text-center">
|
||||
<h1 class="mb-4 text-6xl font-black tracking-tighter">
|
||||
<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>
|
||||
<div class="container flex min-h-screen flex-col">
|
||||
<Header
|
||||
text="The ultimate suite for Spectrogram Art and Digital Steganography. Visualize sound, hide data, and decode secrets."
|
||||
/>
|
||||
|
||||
{#if errorMsg}
|
||||
<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
|
||||
>
|
||||
<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
|
||||
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"
|
||||
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"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
{#if decodeImageFile}
|
||||
<div class="font-semibold text-[var(--accent)]">{decodeImageFile.name}</div>
|
||||
{:else}
|
||||
<div class="text-[var(--text-muted)]">Drop Stego-Image to Decode</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold text-red-100">Error</h3>
|
||||
<p class="text-sm">{errorMsg}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-white hover:text-red-100"
|
||||
on:click={() => (errorMsg = '')}
|
||||
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
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid flex-1 gap-8 md:grid-cols-2">
|
||||
<!-- LEFT COLUMN: CREATION TOOLS -->
|
||||
<div class="space-y-8">
|
||||
<!-- TOOL 1: SPECTROGRAM ART -->
|
||||
<SpectrogramTool
|
||||
{loading}
|
||||
on:start={handleStart}
|
||||
on:end={handleEnd}
|
||||
on:error={handleError}
|
||||
on:complete={handleArtComplete}
|
||||
/>
|
||||
|
||||
<!-- TOOL 2: ENCODER (Shift & Hide) -->
|
||||
<EncoderTool
|
||||
{loading}
|
||||
on:start={handleStart}
|
||||
on:end={handleEnd}
|
||||
on:error={handleError}
|
||||
on:complete={handleEncoderComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-primary flex w-full items-center justify-center gap-2"
|
||||
on:click={handleDecode}
|
||||
disabled={loading || !decodeImageFile}
|
||||
>
|
||||
{#if loading}<div class="loader"></div>{/if} Retrieve Original File
|
||||
</button>
|
||||
</section>
|
||||
<!-- RIGHT COLUMN: RESULTS & DECODER -->
|
||||
<div class="space-y-8">
|
||||
<!-- TOOL 3: UNIVERSAL DECODER -->
|
||||
<DecoderTool
|
||||
{loading}
|
||||
on:start={handleStart}
|
||||
on:end={handleEnd}
|
||||
on:error={handleError}
|
||||
on:decodesuccess={handleDecodeSuccess}
|
||||
/>
|
||||
|
||||
<!-- RESULTS PREVIEW AREA -->
|
||||
{#if artResultUrl || encoderResultUrl}
|
||||
<div class="glass-panel p-6" transition:fly={{ y: 20 }}>
|
||||
<h3 class="mb-4 text-lg font-bold text-white">Result Preview</h3>
|
||||
<ResultPreview {artResultUrl} {encoderResultUrl} {decodedAudioUrl} {decodedImageFile} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if artResultUrl}
|
||||
<div class="mb-4">
|
||||
<p class="mb-2 text-xs tracking-wider text-[var(--text-muted)] uppercase">
|
||||
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}
|
||||
<div class="mb-4 border-t border-[var(--border-subtle)] pt-4">
|
||||
<p class="mb-2 text-xs tracking-wider text-[var(--text-muted)] uppercase">
|
||||
Encoded Image
|
||||
</p>
|
||||
<img
|
||||
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 -->
|
||||
<div class="rounded-lg border border-white/5 bg-white/5 p-6 text-sm text-[var(--text-muted)]">
|
||||
<h4 class="mb-2 font-bold text-white">Did you know?</h4>
|
||||
<p>
|
||||
The "Format Shift" tool visualizes raw data bytes as colored pixels. A dense file like a
|
||||
ZIP will look like TV static, while text files might show repeating patterns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Additional scoped styles if needed, though most are in layout.css */
|
||||
</style>
|
||||
|
||||
329
src/routes/how/+page.svelte
Normal file
329
src/routes/how/+page.svelte
Normal file
@@ -0,0 +1,329 @@
|
||||
<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">
|
||||
<!-- Header -->
|
||||
<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>
|
||||
|
||||
<!-- Content -->
|
||||
<article class="mx-auto prose max-w-4xl prose-invert">
|
||||
<!-- Section 1 -->
|
||||
<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 2 -->
|
||||
<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 0–255 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 3 -->
|
||||
<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 4 -->
|
||||
<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 5 -->
|
||||
<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>
|
||||
|
||||
<!-- Back to App Button -->
|
||||
<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>
|
||||
@@ -8,9 +8,11 @@
|
||||
--bg-card: #131318;
|
||||
--bg-glass: rgba(19, 19, 24, 0.7);
|
||||
|
||||
--primary: #6366f1; /* Indigo */
|
||||
--primary: #6366f1;
|
||||
/* Indigo */
|
||||
--primary-glow: rgba(99, 102, 241, 0.4);
|
||||
--accent: #ec4899; /* Pink */
|
||||
--accent: #ec4899;
|
||||
/* Pink */
|
||||
--accent-glow: rgba(236, 72, 153, 0.4);
|
||||
|
||||
--text-main: #ffffff;
|
||||
@@ -43,14 +45,12 @@ h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Glassmorphism Utilities */
|
||||
/* Card Styles (Solid Dark) */
|
||||
.glass-panel {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
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 {
|
||||
@@ -72,7 +72,7 @@ h4 {
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), #4f46e5);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
@@ -81,7 +81,8 @@ h4 {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.2s ease;
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -90,6 +91,7 @@ h4 {
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px var(--primary-glow);
|
||||
background: #5558e8;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
@@ -117,7 +119,19 @@ h4 {
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
@@ -133,13 +147,20 @@ h4 {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, var(--primary), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
|
||||
576
src/routes/visualizer/+page.svelte
Normal file
576
src/routes/visualizer/+page.svelte
Normal file
@@ -0,0 +1,576 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
|
||||
// State (Svelte 5 runes)
|
||||
let audioFile: File | null = $state(null);
|
||||
let isProcessing = $state(false);
|
||||
let currentStep = $state(0);
|
||||
let progress = $state(0);
|
||||
let resultId: string | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Image previews
|
||||
let spectrogramImage: string | null = $state(null);
|
||||
let finalImage: string | null = $state(null);
|
||||
|
||||
// Audio Visualizer State
|
||||
let audioUrl: string | null = $state(null);
|
||||
let audioElement: HTMLAudioElement | undefined = $state();
|
||||
let canvasElement: HTMLCanvasElement | undefined = $state();
|
||||
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;
|
||||
}
|
||||
|
||||
// Create source only once per audio element/context session or reuse
|
||||
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; // Higher resolution for waveform
|
||||
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); // Use TimeDomain for waveform
|
||||
|
||||
const width = canvasElement!.width;
|
||||
const height = canvasElement!.height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw smooth line waveform
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, width, 0);
|
||||
gradient.addColorStop(0, '#a855f7'); // Purple
|
||||
gradient.addColorStop(0.5, '#ec4899'); // Pink
|
||||
gradient.addColorStop(1, '#a855f7'); // Purple
|
||||
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();
|
||||
|
||||
// Optional: Draw a center line for reference
|
||||
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;
|
||||
|
||||
// Initialize and play audio
|
||||
// Small timeout to ensure DOM elements are rendered
|
||||
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 });
|
||||
|
||||
// Process complete SSE messages (they end with \n\n)
|
||||
const messages = buffer.split('\n\n');
|
||||
buffer = messages.pop() || ''; // Keep the incomplete last part
|
||||
|
||||
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;
|
||||
|
||||
// Capture images
|
||||
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);
|
||||
|
||||
// Don't nullify audioContext to reuse it, or close it if you prefer
|
||||
// source = null; // Source node usually stays valid for the context?
|
||||
// Actually, if we remove audio element, we might need to recreate source next time.
|
||||
// For simplicity, we just stop playing. Since audioFile becomes null, the audio element is removed/replaced.
|
||||
source = null;
|
||||
|
||||
audioFile = null;
|
||||
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">
|
||||
<!-- Header -->
|
||||
<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>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<!-- Upload Section (only visible when no images and not processing) -->
|
||||
{#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}
|
||||
|
||||
<!-- Progress Bar & Audio Visualizer (visible during processing) -->
|
||||
{#if isProcessing}
|
||||
<div class="space-y-6" transition:fade>
|
||||
<!-- Audio Visualizer -->
|
||||
{#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={() => {
|
||||
/* Optional: handle end */
|
||||
}}
|
||||
></audio>
|
||||
|
||||
<!-- Overlay Info -->
|
||||
<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}
|
||||
|
||||
<!-- Image Preview (visible when images exist or processing) -->
|
||||
{#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">
|
||||
<!-- Spectrogram Preview -->
|
||||
<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>
|
||||
|
||||
<!-- Final Image Preview -->
|
||||
<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>
|
||||
|
||||
<!-- Download button when complete -->
|
||||
{#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}
|
||||
|
||||
<!-- Error -->
|
||||
{#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>
|
||||
|
||||
<!-- Back Button -->
|
||||
<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>
|
||||
/* Scanline reveal animation for spectrogram */
|
||||
.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>
|
||||
@@ -12,9 +12,4 @@
|
||||
"strict": true,
|
||||
"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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user