diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..006adc9
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: Streamlit App CI
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v3
+ with:
+ python-version: "3.10"
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y ffmpeg
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8 pytest
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+
+ - name: Lint with flake8
+ run: |
+ # stop the build if there are Python syntax errors or undefined names
+ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+ # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+ - name: Test Streamlit Config
+ run: |
+ streamlit config show
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000..8ca8d14
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,15 @@
+[theme]
+base = "dark"
+primaryColor = "#6366f1"
+# Removing hardcoded backgrounds to allow native toggling
+# backgroundColor = "#0e1117"
+# secondaryBackgroundColor = "#1e1e2e"
+# textColor = "#fafafa"
+font = "sans serif"
+
+[server]
+maxUploadSize = 50
+enableXsrfProtection = true
+
+[browser]
+gatherUsageStats = false
diff --git a/README.md b/README.md
index a882f95..1a6dd9b 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,82 @@
-# Python Sound Wave Analysis
+# ๐ Sound Wave Analysis
-A simple open-source project for analyzing and visualizing sound waves using Python. Clean, lightweight, and beginner-friendly.
+A professional, web-based tool for analyzing and visualizing audio files. Built with **Streamlit** and **Plotly**, this application provides physics-grade analysis of sound waves, supporting WAV, MP3, and FLAC formats.
----
+
+*(Note: Replace with actual screenshot path once pushed)*
-## License
-This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)** license.
-You may use, share, and modify the project **with attribution**, but **not for commercial purposes**.
+## ๐ Features
-[](https://doi.org/10.5281/zenodo.17607793)
+### ๐ Professional Visualization
+- **Waveform**: Interactive time-domain display.
+- **Frequency Spectrum**: Audacity-style spectrum analysis with log scale frequency and dB.
+- **Spectrogram**: Time-frequency intensity heatmap.
+- **Power Spectral Density (PSD)**: Energy distribution across frequencies.
+- **Phase Response**: Phase angle vs. frequency.
+- **Amplitude Histogram**: Distribution of signal amplitudes.
-Full license text is available in the `LICENSE` file.
+### ๐ฌ Detailed Analysis
+- **Audio Metrics**: Sample rate, duration, channels, RMS dB, dynamic range.
+- **Harmonic Detection**: Identifies fundamental frequency and up to 5 overtones.
+- **Speed of Sound Calculator**: Real-time calculator for various media (Air, Water, Steel, etc.) with temperature adjustment.
----
+### ๐ ๏ธ Key Capabilities
+- **Multi-Format Support**: Upload WAV, MP3, or FLAC files (auto-converted).
+- **Audio Playback**: Listen to your audio directly in the browser.
+- **Interactive UI**: Native Dark/Light mode support (toggles via Streamlit Settings).
+- **Export Options**: Download analysis data as CSV or a text summary.
-## Getting Started
+## ๐ ๏ธ Tech Stack
-Clone the repository:
+- **Frontend**: [Streamlit](https://streamlit.io/)
+- **Visualization**: [Plotly](https://plotly.com/python/)
+- **Audio Processing**: [NumPy](https://numpy.org/), [SciPy](https://scipy.org/), [Pydub](https://github.com/jiaaro/pydub)
+- **Deployment**: Streamlit Cloud
-```bash
-git clone https://github.com/TorresjDev/Python-Sound-Wave-Analysis.git
-````
+## ๐ฆ Installation & Local Development
-Install dependencies:
+1. **Clone the repository:**
+ ```bash
+ git clone https://github.com/TorresjDev/Python-Sound-Wave-Analysis.git
+ cd Python-Sound-Wave-Analysis
+ ```
-```bash
-pip install -r requirements.txt
-```
+2. **Install dependencies:**
+ ```bash
+ pip install -r requirements.txt
+ ```
+ *Note: For MP3/FLAC support, ensure you have [ffmpeg](https://ffmpeg.org/) installed on your system.*
-Run the program:
+3. **Run the app:**
+ ```bash
+ streamlit run streamlit_app.py
+ ```
-```bash
-python main.py
-```
+4. **Open in browser:**
+ The app will automatically open at `http://localhost:8501`.
----
+## โ๏ธ Deployment
+
+### Deploying to Streamlit Cloud
+
+1. Push your code to GitHub.
+2. Sign in to [Streamlit Cloud](https://share.streamlit.io/).
+3. Click **"New App"**.
+4. Select your repository (`TorresjDev/Python-Sound-Wave-Analysis`), branch (`main`), and main file (`streamlit_app.py`).
+5. Click **"Deploy"**.
-## Author
+Streamlit Cloud will automatically detect `packages.txt` (if added for ffmpeg) and `requirements.txt` to install dependencies.
-Created by **Jesus Torres (TorresjDev)**
+## ๐งช CI/CD
+This project uses **GitHub Actions** for continuous integration:
+- **Python Linting**: Checks for syntax errors and coding standards.
+- **Dependency Test**: Verifies that `requirements.txt` installs correctly.
+- **Streamlit Config Check**: Ensures the app configuration is valid.
+
+## ๐ License
+
+This project is licensed under the CC BY-NC 4.0 License.
+
+---
+**Created by [TorresjDev](https://github.com/TorresjDev)**
diff --git a/data/space_odyssey_radar.wav b/data/space_odyssey_radar.wav
new file mode 100644
index 0000000..0b4e34e
Binary files /dev/null and b/data/space_odyssey_radar.wav differ
diff --git a/figures/frequency_spectrum_audacity_style.png b/figures/frequency_spectrum_audacity_style.png
new file mode 100644
index 0000000..4deb1d8
Binary files /dev/null and b/figures/frequency_spectrum_audacity_style.png differ
diff --git a/figures/space_odyssey_radar_spectrogram.png b/figures/space_odyssey_radar_spectrogram.png
new file mode 100644
index 0000000..086f8b6
Binary files /dev/null and b/figures/space_odyssey_radar_spectrogram.png differ
diff --git a/figures/space_odyssey_radar_waveform.png b/figures/space_odyssey_radar_waveform.png
new file mode 100644
index 0000000..9e6dfbb
Binary files /dev/null and b/figures/space_odyssey_radar_waveform.png differ
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..8989fa3
--- /dev/null
+++ b/main.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""
+Sound Wave Analysis - Clean and Modular
+
+A user-friendly tool for analyzing WAV files with visualization.
+
+Author: TorresjDev
+License: MIT
+"""
+
+import os
+from sound_analysis.analyzer import perform_complete_analysis
+from sound_analysis.tools import select_wav_file, get_analysis_options
+
+
+def main():
+ """Main function - clean and modular."""
+ print("๐ Welcome to Sound Wave Analysis!")
+ print("=" * 40)
+
+ # Let user select a WAV file
+ selected_file = select_wav_file()
+
+ if selected_file:
+ print(f"\n๐ฏ Analyzing: {os.path.basename(selected_file)}")
+
+ # Get analysis options
+ options = get_analysis_options()
+
+ # Perform analysis using the analyzer module
+ perform_complete_analysis(
+ selected_file,
+ show_plots=options["show_plots"],
+ save_figures=options["save_figures"]
+ )
+ else:
+ print("\n๐ Goodbye!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages.txt b/packages.txt
new file mode 100644
index 0000000..20645e6
--- /dev/null
+++ b/packages.txt
@@ -0,0 +1 @@
+ffmpeg
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..cf03237
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,19 @@
+# Core dependencies for Sound Wave Analysis
+numpy>=1.21.0
+matplotlib>=3.5.0
+scipy>=1.7.0
+
+# Enhanced user interface (CLI)
+keyboard>=0.13.5
+
+# Streamlit Web App
+streamlit>=1.29.0
+plotly>=5.18.0
+
+# Audio format support (MP3, FLAC)
+pydub>=0.25.1
+
+# Development dependencies (optional)
+# pytest>=6.0.0
+# black>=21.0.0
+# flake8>=3.9.0
diff --git a/sound_analysis/__pycache__/analyzer.cpython-312.pyc b/sound_analysis/__pycache__/analyzer.cpython-312.pyc
new file mode 100644
index 0000000..1d4de7c
Binary files /dev/null and b/sound_analysis/__pycache__/analyzer.cpython-312.pyc differ
diff --git a/sound_analysis/__pycache__/audio_processing.cpython-312.pyc b/sound_analysis/__pycache__/audio_processing.cpython-312.pyc
new file mode 100644
index 0000000..f1da09a
Binary files /dev/null and b/sound_analysis/__pycache__/audio_processing.cpython-312.pyc differ
diff --git a/sound_analysis/__pycache__/plotly_viz.cpython-312.pyc b/sound_analysis/__pycache__/plotly_viz.cpython-312.pyc
new file mode 100644
index 0000000..a6365ae
Binary files /dev/null and b/sound_analysis/__pycache__/plotly_viz.cpython-312.pyc differ
diff --git a/sound_analysis/__pycache__/tools.cpython-312.pyc b/sound_analysis/__pycache__/tools.cpython-312.pyc
new file mode 100644
index 0000000..064567d
Binary files /dev/null and b/sound_analysis/__pycache__/tools.cpython-312.pyc differ
diff --git a/sound_analysis/__pycache__/visualization.cpython-312.pyc b/sound_analysis/__pycache__/visualization.cpython-312.pyc
new file mode 100644
index 0000000..aed2808
Binary files /dev/null and b/sound_analysis/__pycache__/visualization.cpython-312.pyc differ
diff --git a/sound_analysis/analyzer.py b/sound_analysis/analyzer.py
new file mode 100644
index 0000000..8c1e025
--- /dev/null
+++ b/sound_analysis/analyzer.py
@@ -0,0 +1,162 @@
+"""
+Sound Analysis Analyzer
+
+Core analysis functions for processing WAV files.
+"""
+
+import os
+import wave
+import numpy as np
+from .tools import wave_to_db, wave_to_db_rms, detect_db_range, list_wav_files
+from .visualization import plot_waveform, plot_spectrogram, plot_combined_analysis
+
+
+def get_wave_info(file_path):
+ """Get basic information about a WAV file."""
+ try:
+ wav_obj = wave.open(file_path, "rb")
+
+ info = {
+ 'sample_rate': wav_obj.getframerate(),
+ 'total_samples': wav_obj.getnframes(),
+ 'channels': wav_obj.getnchannels(),
+ 'sample_width': wav_obj.getsampwidth(),
+ }
+
+ info['duration'] = info['total_samples'] / info['sample_rate']
+ info['channel_type'] = "Mono" if info['channels'] == 1 else "Stereo"
+
+ wav_obj.close()
+ return info
+
+ except Exception as e:
+ raise Exception(f"Error reading WAV file info: {str(e)}")
+
+
+def load_wave_data(file_path):
+ """Load waveform data from a WAV file."""
+ try:
+ wav_obj = wave.open(file_path, "rb")
+
+ # Get file info
+ sample_rate = wav_obj.getframerate()
+ total_samples = wav_obj.getnframes()
+ channels = wav_obj.getnchannels()
+
+ # Read audio data
+ raw_data = wav_obj.readframes(total_samples)
+ audio_data = np.frombuffer(raw_data, dtype=np.int16)
+
+ # Handle mono/stereo
+ if channels == 1:
+ waveform = audio_data
+ else:
+ waveform = audio_data[0::2] # Use left channel for stereo
+
+ wav_obj.close()
+
+ return {
+ 'waveform': waveform,
+ 'sample_rate': sample_rate,
+ 'duration': total_samples / sample_rate,
+ 'channels': channels
+ }
+
+ except Exception as e:
+ raise Exception(f"Error loading WAV data: {str(e)}")
+
+
+def analyze_audio_levels(waveform):
+ """Analyze various audio level metrics."""
+ # Basic statistics
+ max_amplitude = np.max(np.abs(waveform))
+ min_amplitude = np.min(np.abs(waveform))
+ mean_amplitude = np.mean(np.abs(waveform))
+
+ # Decibel calculations
+ avg_db = wave_to_db(waveform)
+ rms_db = wave_to_db_rms(waveform)
+ db_range = detect_db_range(waveform)
+
+ return {
+ 'max_amplitude': max_amplitude,
+ 'min_amplitude': min_amplitude,
+ 'mean_amplitude': mean_amplitude,
+ 'avg_db': avg_db,
+ 'rms_db': rms_db,
+ 'db_range': db_range
+ }
+
+
+def perform_complete_analysis(file_path, show_plots=True, save_figures=False):
+ """Perform complete analysis of a WAV file."""
+ try:
+ # Get file info
+ file_info = get_wave_info(file_path)
+
+ # Load waveform data
+ wave_data = load_wave_data(file_path)
+ waveform = wave_data['waveform']
+ sample_rate = wave_data['sample_rate']
+ duration = wave_data['duration']
+
+ # Analyze audio levels
+ audio_levels = analyze_audio_levels(waveform)
+
+ # Create filename for plots
+ filename = os.path.basename(file_path)
+
+ # Display results
+ print("\n๐ต Analysis Results")
+ print("=" * 40)
+ print(f"๐ File: {filename}")
+ print(f"๐ Sample Rate: {file_info['sample_rate']:,} Hz")
+ print(f"โฑ๏ธ Duration: {duration:.2f} seconds")
+ print(
+ f"๐ง Channels: {file_info['channels']} ({file_info['channel_type']})")
+ print(f"๐ Total Samples: {file_info['total_samples']:,}")
+
+ print(f"\n๐ Sound Levels:")
+ print(f"๐ Average dB: {audio_levels['avg_db']:.2f}")
+ print(f"๐ RMS dB: {audio_levels['rms_db']:.2f}")
+ print(
+ f"๐ Dynamic Range: {audio_levels['db_range']['dynamic_range']:.2f} dB")
+ print(f"๐ Max dB: {audio_levels['db_range']['max_db']:.2f}")
+ print(f"๐ Min dB: {audio_levels['db_range']['min_db']:.2f}")
+
+ # Generate visualizations
+ if show_plots:
+ print("\n๐จ Generating visualizations...")
+
+ if save_figures:
+ figures_dir = "figures"
+ os.makedirs(figures_dir, exist_ok=True)
+ base_name = os.path.splitext(filename)[0]
+
+ waveform_path = os.path.join(
+ figures_dir, f"{base_name}_waveform.png")
+ spectrogram_path = os.path.join(
+ figures_dir, f"{base_name}_spectrogram.png")
+ else:
+ waveform_path = None
+ spectrogram_path = None
+
+ plot_waveform(waveform, sample_rate, duration,
+ f"{filename} - Waveform", waveform_path)
+ plot_spectrogram(waveform, sample_rate,
+ f"{filename} - Spectrogram", spectrogram_path)
+
+ # Optional: Combined analysis plot
+ # plot_combined_analysis(waveform, sample_rate, duration, filename)
+
+ print("\nโ
Analysis completed!")
+
+ return {
+ 'file_info': file_info,
+ 'wave_data': wave_data,
+ 'audio_levels': audio_levels
+ }
+
+ except Exception as e:
+ print(f"โ Error analyzing file: {str(e)}")
+ return None
diff --git a/sound_analysis/audio_processing.py b/sound_analysis/audio_processing.py
new file mode 100644
index 0000000..3e115ed
--- /dev/null
+++ b/sound_analysis/audio_processing.py
@@ -0,0 +1,267 @@
+"""
+Audio Processing Module
+
+Handles audio format conversion, filtering, and advanced processing.
+Supports WAV, MP3, and FLAC formats.
+"""
+
+import io
+import tempfile
+import os
+import numpy as np
+from scipy import signal
+from scipy.io import wavfile
+
+# Try to import pydub for MP3/FLAC support
+try:
+ from pydub import AudioSegment
+ PYDUB_AVAILABLE = True
+except ImportError:
+ PYDUB_AVAILABLE = False
+
+
+def convert_audio_to_wav(uploaded_file, file_extension):
+ """
+ Convert uploaded audio file to WAV format.
+
+ Supports: WAV, MP3, FLAC
+ Returns: Path to temporary WAV file
+ """
+ if not PYDUB_AVAILABLE and file_extension != '.wav':
+ raise ImportError("pydub is required for MP3/FLAC support. Install with: pip install pydub")
+
+ # Create temp file for the uploaded content
+ with tempfile.NamedTemporaryFile(delete=False, suffix=file_extension) as tmp_input:
+ tmp_input.write(uploaded_file.getvalue())
+ tmp_input_path = tmp_input.name
+
+ try:
+ if file_extension == '.wav':
+ # Already WAV, just return the path
+ return tmp_input_path
+
+ # Convert to WAV using pydub
+ if file_extension == '.mp3':
+ audio = AudioSegment.from_mp3(tmp_input_path)
+ elif file_extension == '.flac':
+ audio = AudioSegment.from_file(tmp_input_path, format='flac')
+ else:
+ audio = AudioSegment.from_file(tmp_input_path)
+
+ # Export as WAV
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as tmp_output:
+ tmp_output_path = tmp_output.name
+
+ audio.export(tmp_output_path, format='wav')
+
+ # Clean up input temp file
+ os.unlink(tmp_input_path)
+
+ return tmp_output_path
+
+ except Exception as e:
+ # Clean up on error
+ if os.path.exists(tmp_input_path):
+ os.unlink(tmp_input_path)
+ raise Exception(f"Error converting audio: {str(e)}")
+
+
+def apply_lowpass_filter(waveform, sample_rate, cutoff_freq, order=5):
+ """Apply a low-pass Butterworth filter."""
+ nyquist = sample_rate / 2
+ normalized_cutoff = cutoff_freq / nyquist
+
+ if normalized_cutoff >= 1:
+ return waveform # Cutoff is too high, return original
+
+ b, a = signal.butter(order, normalized_cutoff, btype='low', analog=False)
+ filtered = signal.filtfilt(b, a, waveform)
+ return filtered.astype(waveform.dtype)
+
+
+def apply_highpass_filter(waveform, sample_rate, cutoff_freq, order=5):
+ """Apply a high-pass Butterworth filter."""
+ nyquist = sample_rate / 2
+ normalized_cutoff = cutoff_freq / nyquist
+
+ if normalized_cutoff <= 0:
+ return waveform # Cutoff is too low, return original
+
+ b, a = signal.butter(order, normalized_cutoff, btype='high', analog=False)
+ filtered = signal.filtfilt(b, a, waveform)
+ return filtered.astype(waveform.dtype)
+
+
+def apply_bandpass_filter(waveform, sample_rate, low_freq, high_freq, order=5):
+ """Apply a band-pass Butterworth filter."""
+ nyquist = sample_rate / 2
+ low = low_freq / nyquist
+ high = high_freq / nyquist
+
+ if low <= 0:
+ low = 0.001
+ if high >= 1:
+ high = 0.999
+ if low >= high:
+ return waveform
+
+ b, a = signal.butter(order, [low, high], btype='band', analog=False)
+ filtered = signal.filtfilt(b, a, waveform)
+ return filtered.astype(waveform.dtype)
+
+
+def detect_harmonics(waveform, sample_rate, num_harmonics=10):
+ """
+ Detect the fundamental frequency and harmonics.
+
+ Returns a list of (frequency, magnitude_db) tuples.
+ """
+ # Compute FFT
+ fft = np.fft.fft(waveform)
+ freqs = np.fft.fftfreq(len(fft), 1/sample_rate)
+ magnitude = np.abs(fft)
+
+ # Only positive frequencies
+ positive_mask = freqs > 0
+ freqs = freqs[positive_mask]
+ magnitude = magnitude[positive_mask]
+
+ # Find peaks
+ peaks, properties = signal.find_peaks(magnitude, height=np.max(magnitude) * 0.01)
+
+ if len(peaks) == 0:
+ return []
+
+ # Sort by magnitude
+ peak_magnitudes = magnitude[peaks]
+ sorted_indices = np.argsort(peak_magnitudes)[::-1]
+
+ # Get top harmonics
+ harmonics = []
+ for i in sorted_indices[:num_harmonics]:
+ freq = freqs[peaks[i]]
+ mag_db = 20 * np.log10(peak_magnitudes[i] / np.max(magnitude) + 1e-10)
+ harmonics.append({
+ 'frequency': freq,
+ 'magnitude_db': mag_db
+ })
+
+ return harmonics
+
+
+def calculate_speed_of_sound(temperature_celsius=20, medium='air'):
+ """
+ Calculate speed of sound in different media.
+
+ Args:
+ temperature_celsius: Temperature in Celsius (for air/water)
+ medium: 'air', 'water', 'steel', 'aluminum', 'glass'
+
+ Returns:
+ Speed of sound in m/s
+ """
+ if medium == 'air':
+ # v = 331.3 * sqrt(1 + T/273.15)
+ return 331.3 * np.sqrt(1 + temperature_celsius / 273.15)
+ elif medium == 'water':
+ # Approximate formula
+ return 1403 + 4.7 * temperature_celsius
+ elif medium == 'steel':
+ return 5960 # m/s (approximately constant)
+ elif medium == 'aluminum':
+ return 6420 # m/s
+ elif medium == 'glass':
+ return 5640 # m/s
+ else:
+ return 343 # Default to air at 20ยฐC
+
+
+def generate_synthetic_wave(wave_type, frequency, duration, sample_rate=44100, amplitude=0.8):
+ """
+ Generate synthetic sound waves for educational purposes.
+
+ Args:
+ wave_type: 'sine', 'square', 'sawtooth', 'triangle'
+ frequency: Frequency in Hz
+ duration: Duration in seconds
+ sample_rate: Samples per second
+ amplitude: Wave amplitude (0 to 1)
+
+ Returns:
+ numpy array of the waveform
+ """
+ t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
+
+ if wave_type == 'sine':
+ wave = np.sin(2 * np.pi * frequency * t)
+ elif wave_type == 'square':
+ wave = signal.square(2 * np.pi * frequency * t)
+ elif wave_type == 'sawtooth':
+ wave = signal.sawtooth(2 * np.pi * frequency * t)
+ elif wave_type == 'triangle':
+ wave = signal.sawtooth(2 * np.pi * frequency * t, width=0.5)
+ else:
+ wave = np.sin(2 * np.pi * frequency * t)
+
+ # Normalize and apply amplitude
+ wave = wave * amplitude
+
+ # Convert to int16 for WAV compatibility
+ wave_int16 = (wave * 32767).astype(np.int16)
+
+ return wave_int16, sample_rate
+
+
+def export_audio_to_wav_bytes(waveform, sample_rate):
+ """
+ Export waveform to WAV bytes for download.
+
+ Returns: BytesIO object containing WAV data
+ """
+ buffer = io.BytesIO()
+ wavfile.write(buffer, sample_rate, waveform)
+ buffer.seek(0)
+ return buffer
+
+
+def export_analysis_to_csv(file_info, audio_levels, waveform, sample_rate):
+ """
+ Export analysis data to CSV format.
+
+ Returns: CSV string
+ """
+ import csv
+ import io
+
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ # Metadata section
+ writer.writerow(['=== FILE INFORMATION ==='])
+ writer.writerow(['Property', 'Value'])
+ writer.writerow(['Sample Rate (Hz)', file_info['sample_rate']])
+ writer.writerow(['Duration (s)', f"{file_info['duration']:.4f}"])
+ writer.writerow(['Channels', file_info['channels']])
+ writer.writerow(['Channel Type', file_info['channel_type']])
+ writer.writerow(['Total Samples', file_info['total_samples']])
+ writer.writerow([])
+
+ # Audio levels section
+ writer.writerow(['=== AUDIO LEVELS ==='])
+ writer.writerow(['Metric', 'Value'])
+ writer.writerow(['Average dB', f"{audio_levels['avg_db']:.2f}"])
+ writer.writerow(['RMS dB', f"{audio_levels['rms_db']:.2f}"])
+ writer.writerow(['Max dB', f"{audio_levels['db_range']['max_db']:.2f}"])
+ writer.writerow(['Min dB', f"{audio_levels['db_range']['min_db']:.2f}"])
+ writer.writerow(['Dynamic Range (dB)', f"{audio_levels['db_range']['dynamic_range']:.2f}"])
+ writer.writerow([])
+
+ # Amplitude statistics
+ writer.writerow(['=== AMPLITUDE STATISTICS ==='])
+ writer.writerow(['Metric', 'Value'])
+ writer.writerow(['Max Amplitude', np.max(waveform)])
+ writer.writerow(['Min Amplitude', np.min(waveform)])
+ writer.writerow(['Mean Amplitude', f"{np.mean(waveform):.2f}"])
+ writer.writerow(['Std Deviation', f"{np.std(waveform):.2f}"])
+
+ return output.getvalue()
diff --git a/sound_analysis/plotly_viz.py b/sound_analysis/plotly_viz.py
new file mode 100644
index 0000000..c702444
--- /dev/null
+++ b/sound_analysis/plotly_viz.py
@@ -0,0 +1,402 @@
+"""
+Professional Plotly Visualizations for Sound Wave Analysis
+
+Publication-quality interactive graphs for physics professionals.
+"""
+
+import numpy as np
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+from scipy import signal
+
+
+# Professional color scheme
+COLORS = {
+ 'primary': '#6366f1', # Indigo
+ 'secondary': '#8b5cf6', # Purple
+ 'accent': '#06b6d4', # Cyan
+ 'warning': '#f59e0b', # Amber
+ 'success': '#10b981', # Emerald
+ 'background': '#1e1e2e',
+ 'grid': 'rgba(255,255,255,0.1)',
+ 'text': '#fafafa'
+}
+
+# Common layout settings for professional appearance
+LAYOUT_DEFAULTS = {
+ 'template': 'plotly_dark',
+ 'paper_bgcolor': 'rgba(0,0,0,0)',
+ 'plot_bgcolor': 'rgba(0,0,0,0)',
+ 'font': {'family': 'Inter, sans-serif', 'size': 12, 'color': COLORS['text']},
+ 'margin': {'l': 60, 'r': 40, 't': 50, 'b': 50},
+ 'hovermode': 'x unified'
+}
+
+
+def create_waveform_plot(waveform, sample_rate, duration, title="Waveform"):
+ """
+ Create an interactive waveform plot.
+
+ Shows amplitude over time - useful for identifying:
+ - Transients and attack characteristics
+ - Envelope shape
+ - Clipping
+ """
+ time = np.linspace(0, duration, num=len(waveform))
+
+ # Downsample for performance if too many points
+ max_points = 50000
+ if len(waveform) > max_points:
+ step = len(waveform) // max_points
+ time = time[::step]
+ waveform = waveform[::step]
+
+ fig = go.Figure()
+
+ fig.add_trace(go.Scatter(
+ x=time,
+ y=waveform,
+ mode='lines',
+ line=dict(color=COLORS['primary'], width=0.5),
+ name='Amplitude',
+ hovertemplate='Time: %{x:.4f}s
Amplitude: %{y:,.0f}
%{y:.1f} dB
Frequency Analysis (like Audacity)',
+ font=dict(size=16)
+ ),
+ xaxis=dict(
+ title=dict(text='Frequency (Hz)', font=dict(size=14)),
+ type='log',
+ gridcolor='rgba(255,255,255,0.2)',
+ showgrid=True,
+ gridwidth=1,
+ tickvals=freq_ticks,
+ ticktext=[f'{f} Hz' if f < 1000 else f'{f//1000}k Hz' for f in freq_ticks],
+ tickfont=dict(size=11),
+ range=[np.log10(20), np.log10(max_freq)],
+ dtick=None,
+ minor=dict(showgrid=True, gridcolor='rgba(255,255,255,0.05)')
+ ),
+ yaxis=dict(
+ title=dict(text='Level (dB)', font=dict(size=14)),
+ gridcolor='rgba(255,255,255,0.2)',
+ showgrid=True,
+ gridwidth=1,
+ tickvals=db_ticks,
+ ticktext=[f'{db} dB' for db in db_ticks],
+ tickfont=dict(size=11),
+ range=[-66, 3],
+ zeroline=True,
+ zerolinecolor='rgba(255,255,255,0.4)',
+ zerolinewidth=2
+ ),
+ template='plotly_dark',
+ paper_bgcolor='rgba(0,0,0,0)',
+ plot_bgcolor='rgba(30,30,46,0.8)',
+ font={'family': 'Inter, sans-serif', 'size': 12, 'color': '#fafafa'},
+ margin={'l': 70, 'r': 40, 't': 70, 'b': 60},
+ hovermode='x unified'
+ )
+
+ return fig
+
+
+def create_spectrogram_plot(waveform, sample_rate, title="Spectrogram"):
+ """
+ Create a time-frequency spectrogram.
+
+ Shows how frequency content evolves over time.
+ Useful for:
+ - Tracking pitch changes
+ - Identifying frequency modulation
+ - Visualizing speech/music structure
+ """
+ # Compute spectrogram
+ nperseg = min(1024, len(waveform) // 8)
+ frequencies, times, Sxx = signal.spectrogram(
+ waveform,
+ fs=sample_rate,
+ nperseg=nperseg,
+ noverlap=nperseg // 2
+ )
+
+ # Convert to dB
+ Sxx_db = 10 * np.log10(Sxx + 1e-10)
+
+ fig = go.Figure()
+
+ fig.add_trace(go.Heatmap(
+ x=times,
+ y=frequencies,
+ z=Sxx_db,
+ colorscale='Viridis',
+ colorbar=dict(title='dB', title_side='right'),
+ hovertemplate='Time: %{x:.3f}s
Freq: %{y:.0f} Hz
Power: %{z:.1f} dB
Power: %{y:.1f} dB/Hz
Phase: %{y:.1f}ยฐ
Count: %{y:,}
Professional audio analysis and visualization tool
', unsafe_allow_html=True) + + +def render_upload_section(): + """Render the file upload section.""" + st.markdown("### ๐ Upload Audio File") + + # Determine supported formats + formats = ['wav'] + if PYDUB_AVAILABLE: + formats.extend(['mp3', 'flac']) + + col1, col2, col3 = st.columns([1, 2, 1]) + + with col2: + uploaded_file = st.file_uploader( + "Drag and drop or click to upload", + type=formats, + help=f"Supported: {', '.join(f.upper() for f in formats)} (max 50MB)", + label_visibility="collapsed" + ) + + format_str = ', '.join(f.upper() for f in formats) + st.caption(f"๐ Supported formats: {format_str} | Maximum size: 50MB") + + return uploaded_file + + +def render_metrics(file_info, audio_levels): + """Render the metrics cards.""" + st.markdown("### ๐ Analysis Results") + + col1, col2, col3, col4, col5, col6 = st.columns(6) + + with col1: + st.metric("Sample Rate", f"{file_info['sample_rate']:,} Hz") + with col2: + st.metric("Duration", f"{file_info['duration']:.2f}s") + with col3: + st.metric("Channels", f"{file_info['channels']} ({file_info['channel_type']})") + with col4: + st.metric("Avg dB", f"{audio_levels['avg_db']:.1f}") + with col5: + st.metric("RMS dB", f"{audio_levels['rms_db']:.1f}") + with col6: + st.metric("Dynamic Range", f"{audio_levels['db_range']['dynamic_range']:.1f} dB") + + +def render_harmonics(harmonics): + """Render harmonic analysis results.""" + if not harmonics: + return + + st.markdown("### ๐ต Harmonic Analysis") + st.caption("Detected frequency peaks (fundamental and overtones)") + + cols = st.columns(min(5, len(harmonics))) + + for i, harm in enumerate(harmonics[:5]): + with cols[i]: + freq_str = f"{harm['frequency']:.1f} Hz" if harm['frequency'] < 1000 else f"{harm['frequency']/1000:.2f} kHz" + label = "Fundamental" if i == 0 else f"Harmonic {i}" + st.metric(label, freq_str, f"{harm['magnitude_db']:.1f} dB") + + +def render_visualizations(figures): + """Render all visualizations in a grid.""" + st.markdown("### ๐ Visualizations") + st.markdown("*Hover for values โข Click camera icon to download โข Zoom/pan with mouse*") + + # Row 1: Waveform and Spectrum + col1, col2 = st.columns(2) + with col1: + st.plotly_chart(figures['waveform'], use_container_width=True, key='waveform') + with col2: + st.plotly_chart(figures['spectrum'], use_container_width=True, key='spectrum') + + # Row 2: Spectrogram and PSD + col3, col4 = st.columns(2) + with col3: + st.plotly_chart(figures['spectrogram'], use_container_width=True, key='spectrogram') + with col4: + st.plotly_chart(figures['psd'], use_container_width=True, key='psd') + + # Row 3: Phase and Histogram + col5, col6 = st.columns(2) + with col5: + st.plotly_chart(figures['phase'], use_container_width=True, key='phase') + with col6: + st.plotly_chart(figures['histogram'], use_container_width=True, key='histogram') + + +def analyze_audio(uploaded_file): + """Analyze the uploaded audio file.""" + # Get file extension + file_ext = os.path.splitext(uploaded_file.name)[1].lower() + + # Convert to WAV if needed + tmp_path = convert_audio_to_wav(uploaded_file, file_ext) + + try: + # Get file info + file_info = get_wave_info(tmp_path) + + # Load waveform data + wave_data = load_wave_data(tmp_path) + waveform = wave_data['waveform'] + sample_rate = wave_data['sample_rate'] + duration = wave_data['duration'] + + # Analyze audio levels + audio_levels = analyze_audio_levels(waveform) + + # Detect harmonics + harmonics = detect_harmonics(waveform, sample_rate) + + # Generate visualizations + figures = create_all_visualizations( + waveform, sample_rate, duration, uploaded_file.name + ) + + return { + 'file_info': file_info, + 'audio_levels': audio_levels, + 'figures': figures, + 'waveform': waveform, + 'sample_rate': sample_rate, + 'duration': duration, + 'harmonics': harmonics, + 'wav_path': tmp_path + } + + except Exception as e: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise e + + +def main(): + """Main application entry point.""" + init_session_state() + render_sidebar() + render_header() + + # File upload + uploaded_file = render_upload_section() + + if uploaded_file is not None: + # Check file size + file_size_mb = uploaded_file.size / (1024 * 1024) + + if file_size_mb > 50: + st.error(f"โ File too large ({file_size_mb:.1f}MB). Maximum size is 50MB.") + return + + # Show file info + st.info(f"๐ **{uploaded_file.name}** ({file_size_mb:.2f} MB)") + + # Analyze button + if st.button("๐ฌ Analyze Audio", type="primary", use_container_width=True): + with st.spinner("Analyzing audio... This may take a moment for large files."): + try: + results = analyze_audio(uploaded_file) + + # Store in session state + st.session_state.analysis_complete = True + st.session_state.file_info = results['file_info'] + st.session_state.audio_levels = results['audio_levels'] + st.session_state.figures = results['figures'] + st.session_state.waveform = results['waveform'] + st.session_state.sample_rate = results['sample_rate'] + st.session_state.duration = results['duration'] + st.session_state.harmonics = results['harmonics'] + st.session_state.uploaded_filename = uploaded_file.name + + # Clean up temp file + if os.path.exists(results.get('wav_path', '')): + os.unlink(results['wav_path']) + + st.success("โ Analysis complete!") + st.rerun() + + except Exception as e: + st.error(f"โ Error analyzing file: {str(e)}") + return + + # Show results if analysis is complete + if st.session_state.analysis_complete: + st.divider() + + # Audio Playback + st.markdown("### ๐ Audio Playback") + st.audio(uploaded_file, format=f"audio/{os.path.splitext(uploaded_file.name)[1][1:]}") + + st.divider() + + # Export Options + st.markdown("### ๐พ Export Options") + + col1, col2, col3, col4 = st.columns(4) + + with col1: + # TXT Summary + summary = f"""Sound Wave Analysis Report +Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} + +File: {st.session_state.uploaded_filename} +Sample Rate: {st.session_state.file_info['sample_rate']:,} Hz +Duration: {st.session_state.file_info['duration']:.2f} seconds +Channels: {st.session_state.file_info['channels']} ({st.session_state.file_info['channel_type']}) + +Audio Levels: +- Average dB: {st.session_state.audio_levels['avg_db']:.2f} +- RMS dB: {st.session_state.audio_levels['rms_db']:.2f} +- Max dB: {st.session_state.audio_levels['db_range']['max_db']:.2f} +- Min dB: {st.session_state.audio_levels['db_range']['min_db']:.2f} +- Dynamic Range: {st.session_state.audio_levels['db_range']['dynamic_range']:.2f} dB + +Harmonics Detected: +""" + for i, h in enumerate(st.session_state.harmonics[:5]): + label = "Fundamental" if i == 0 else f"Harmonic {i}" + summary += f"- {label}: {h['frequency']:.1f} Hz ({h['magnitude_db']:.1f} dB)\n" + + st.download_button( + "๐ Summary (TXT)", + data=summary, + file_name=f"analysis_{st.session_state.uploaded_filename.replace('.', '_')}.txt", + mime="text/plain", + use_container_width=True + ) + + with col2: + # CSV Export + csv_data = export_analysis_to_csv( + st.session_state.file_info, + st.session_state.audio_levels, + st.session_state.waveform, + st.session_state.sample_rate + ) + st.download_button( + "๐ Data (CSV)", + data=csv_data, + file_name=f"analysis_{st.session_state.uploaded_filename.replace('.', '_')}.csv", + mime="text/csv", + use_container_width=True + ) + + with col3: + st.markdown("**๐ท Graphs:**") + st.caption("Click camera icon on graphs") + + with col4: + st.caption("๐ PDF export coming soon!") + + st.divider() + + # Analysis Results + render_metrics(st.session_state.file_info, st.session_state.audio_levels) + + st.divider() + + # Harmonic Analysis + render_harmonics(st.session_state.harmonics) + + st.divider() + + # Visualizations + render_visualizations(st.session_state.figures) + + # Educational section + st.divider() + st.markdown("### ๐ Physics Reference") + + with st.expander("Wave Equations & Formulas"): + col1, col2 = st.columns(2) + + with col1: + st.markdown("**Wave Equation:**") + st.latex(r"y(x,t) = A \sin(kx - \omega t + \phi)") + + st.markdown("**Frequency & Period:**") + st.latex(r"f = \frac{1}{T}, \quad \omega = 2\pi f") + + with col2: + st.markdown("**Speed of Sound:**") + st.latex(r"v = 331.3 \sqrt{1 + \frac{T}{273.15}} \, \text{m/s}") + + st.markdown("**Decibel Level:**") + st.latex(r"L_{dB} = 20 \log_{10}\left(\frac{A}{A_{ref}}\right)") + + else: + # Welcome message + st.markdown(""" +