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. ---- +![App Screenshot](https://raw.githubusercontent.com/TorresjDev/Python-Sound-Wave-Analysis/main/assets/app_preview.png) +*(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 -[![DOI](https://zenodo.org/badge/1096327265.svg)](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}' + )) + + fig.update_layout( + title=dict(text=f'{title}', font=dict(size=16)), + xaxis=dict( + title='Time (seconds)', + gridcolor=COLORS['grid'], + showgrid=True, + zeroline=True, + zerolinecolor=COLORS['grid'] + ), + yaxis=dict( + title='Amplitude', + gridcolor=COLORS['grid'], + showgrid=True, + zeroline=True, + zerolinecolor=COLORS['accent'] + ), + **LAYOUT_DEFAULTS + ) + + return fig + + +def create_frequency_spectrum_plot(waveform, sample_rate, title="Frequency Spectrum"): + """ + Create a frequency spectrum plot styled like Audacity's Frequency Analysis. + + Shows magnitude in dB vs frequency on a log scale. + Matches Audacity's display with clear dB and Hz labels. + """ + # 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] + + # Convert to dB (relative to max, like Audacity) + magnitude_db = 20 * np.log10(magnitude / magnitude.max() + 1e-10) + + # Smooth for cleaner display (like Audacity's smoothing) + if len(magnitude_db) > 2000: + window_size = len(magnitude_db) // 1000 + magnitude_db = np.convolve(magnitude_db, np.ones(window_size)/window_size, mode='same') + + fig = go.Figure() + + # Filled area plot like Audacity + fig.add_trace(go.Scatter( + x=freqs, + y=magnitude_db, + mode='lines', + fill='tozeroy', + fillcolor='rgba(138, 43, 226, 0.5)', # Purple like Audacity + line=dict(color='#8B2BE2', width=1), + name='Level', + hovertemplate='%{x:.1f} Hz
%{y:.1f} dB' + )) + + # Create frequency tick values for log scale (like Audacity) + freq_ticks = [20, 50, 100, 200, 500, 1000, 2000, 5000] + max_freq = sample_rate / 2 + freq_ticks = [f for f in freq_ticks if f <= max_freq] + + # Create dB tick values (like Audacity: -60 to 0 in steps of 6) + db_ticks = list(range(-60, 6, 6)) + + fig.update_layout( + title=dict( + text=f'{title}
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' + )) + + fig.update_layout( + title=dict(text=f'{title}', font=dict(size=16)), + xaxis=dict( + title='Time (seconds)', + gridcolor=COLORS['grid'] + ), + yaxis=dict( + title='Frequency (Hz)', + gridcolor=COLORS['grid'], + range=[0, min(8000, sample_rate/2)] # Cap at 8kHz for visibility + ), + **LAYOUT_DEFAULTS + ) + + return fig + + +def create_psd_plot(waveform, sample_rate, title="Power Spectral Density"): + """ + Create a Power Spectral Density plot. + + Shows power distribution across frequencies. + Useful for: + - Energy analysis + - Noise characterization + - Comparing signal strengths + """ + frequencies, psd = signal.welch(waveform, fs=sample_rate, nperseg=min(1024, len(waveform)//4)) + + # Convert to dB + psd_db = 10 * np.log10(psd + 1e-10) + + fig = go.Figure() + + fig.add_trace(go.Scatter( + x=frequencies, + y=psd_db, + mode='lines', + fill='tozeroy', + fillcolor='rgba(6, 182, 212, 0.3)', + line=dict(color=COLORS['accent'], width=1.5), + name='PSD', + hovertemplate='Frequency: %{x:.1f} Hz
Power: %{y:.1f} dB/Hz' + )) + + fig.update_layout( + title=dict(text=f'{title}', font=dict(size=16)), + xaxis=dict( + title='Frequency (Hz)', + type='log', + gridcolor=COLORS['grid'], + showgrid=True + ), + yaxis=dict( + title='Power/Frequency (dB/Hz)', + gridcolor=COLORS['grid'], + showgrid=True + ), + **LAYOUT_DEFAULTS + ) + + return fig + + +def create_phase_plot(waveform, sample_rate, title="Phase Response"): + """ + Create a phase response plot. + + Shows phase angle vs frequency. + Useful for: + - Phase relationship analysis + - Filter characterization + - Signal processing validation + """ + fft = np.fft.fft(waveform) + freqs = np.fft.fftfreq(len(fft), 1/sample_rate) + phase = np.angle(fft, deg=True) + + # Only positive frequencies + positive_mask = freqs > 0 + freqs = freqs[positive_mask] + phase = phase[positive_mask] + + # Subsample for performance + if len(freqs) > 5000: + step = len(freqs) // 5000 + freqs = freqs[::step] + phase = phase[::step] + + fig = go.Figure() + + fig.add_trace(go.Scatter( + x=freqs, + y=phase, + mode='markers', + marker=dict(color=COLORS['secondary'], size=2, opacity=0.5), + name='Phase', + hovertemplate='Frequency: %{x:.1f} Hz
Phase: %{y:.1f}ยฐ' + )) + + fig.update_layout( + title=dict(text=f'{title}', font=dict(size=16)), + xaxis=dict( + title='Frequency (Hz)', + type='log', + gridcolor=COLORS['grid'], + showgrid=True + ), + yaxis=dict( + title='Phase (degrees)', + gridcolor=COLORS['grid'], + showgrid=True, + range=[-180, 180] + ), + **LAYOUT_DEFAULTS + ) + + return fig + + +def create_histogram_plot(waveform, title="Amplitude Distribution"): + """ + Create an amplitude distribution histogram. + + Shows the distribution of amplitude values. + Useful for: + - Dynamic range analysis + - Clipping detection + - Signal statistics + """ + fig = go.Figure() + + fig.add_trace(go.Histogram( + x=waveform, + nbinsx=100, + marker=dict( + color=COLORS['success'], + line=dict(color=COLORS['text'], width=0.5) + ), + opacity=0.8, + name='Distribution', + hovertemplate='Amplitude: %{x:,.0f}
Count: %{y:,}' + )) + + fig.update_layout( + title=dict(text=f'{title}', font=dict(size=16)), + xaxis=dict( + title='Amplitude', + gridcolor=COLORS['grid'], + showgrid=True + ), + yaxis=dict( + title='Count', + gridcolor=COLORS['grid'], + showgrid=True + ), + **LAYOUT_DEFAULTS + ) + + return fig + + +def create_all_visualizations(waveform, sample_rate, duration, filename="Audio"): + """ + Generate all 6 professional visualizations. + + Returns a dictionary of Plotly figures. + """ + return { + 'waveform': create_waveform_plot( + waveform, sample_rate, duration, + f"Waveform - {filename}" + ), + 'spectrum': create_frequency_spectrum_plot( + waveform, sample_rate, + f"Frequency Spectrum - {filename}" + ), + 'spectrogram': create_spectrogram_plot( + waveform, sample_rate, + f"Spectrogram - {filename}" + ), + 'psd': create_psd_plot( + waveform, sample_rate, + f"Power Spectral Density - {filename}" + ), + 'phase': create_phase_plot( + waveform, sample_rate, + f"Phase Response - {filename}" + ), + 'histogram': create_histogram_plot( + waveform, + f"Amplitude Distribution - {filename}" + ) + } diff --git a/sound_analysis/tools.py b/sound_analysis/tools.py new file mode 100644 index 0000000..cb44fcf --- /dev/null +++ b/sound_analysis/tools.py @@ -0,0 +1,227 @@ +""" +Sound Analysis Tools + +Mathematical functions for audio processing and analysis. +File management and user interface utilities. +""" + +import os +import numpy as np + + +def wave_to_db(waveform): + """Convert waveform to decibels.""" + sq_amplitude = waveform ** 2 + mean_squared_amplitude = np.mean(sq_amplitude) + decibels = 10 * np.log10(mean_squared_amplitude / 1e-6) + return decibels + + +def wave_to_db_rms(waveform): + """Convert waveform to decibels using RMS.""" + sq_amplitude = np.mean(waveform ** 2) + rms_value = np.sqrt(sq_amplitude) + decibels = 20 * np.log10(rms_value / 1e-6) + return decibels + + +def detect_db_range(waveform): + """Detect the dynamic range of the audio in decibels.""" + max_amplitude = np.max(np.abs(waveform)) + min_amplitude = np.min(np.abs(waveform[waveform != 0])) # Avoid log(0) + + max_db = 20 * np.log10(max_amplitude / 1e-6) if max_amplitude > 0 else -np.inf + min_db = 20 * np.log10(min_amplitude / 1e-6) if min_amplitude > 0 else -np.inf + + dynamic_range = max_db - min_db if min_db != -np.inf else 0 + + return { + 'max_db': max_db, + 'min_db': min_db, + 'dynamic_range': dynamic_range + } + + +def normalize_waveform(waveform): + """Normalize waveform to [-1, 1] range.""" + max_val = np.max(np.abs(waveform)) + if max_val > 0: + return waveform / max_val + return waveform + + +def list_wav_files(): + """List all WAV files in the data directory.""" + data_dir = "data" + wav_files = [] + + if os.path.exists(data_dir): + for file in os.listdir(data_dir): + if file.lower().endswith('.wav'): + wav_files.append(file) + + return wav_files + + +def select_wav_file(): + """Let user select a WAV file with arrow key navigation.""" + try: + import keyboard + keyboard_available = True + except ImportError: + keyboard_available = False + + wav_files = list_wav_files() + + if not wav_files: + print("โŒ No WAV files found in the 'data' directory!") + print("๐Ÿ“ Please add some .wav files to the 'data' folder and try again.") + return None + + if not keyboard_available: + # Fallback to number selection if keyboard module not available + print("๐ŸŽต Available WAV files:") + print("=" * 30) + + for i, file in enumerate(wav_files, 1): + print(f"{i}. {file}") + + while True: + try: + choice = input(f"\n๐Ÿ“‚ Select a file (1-{len(wav_files)}) or 'q' to quit: ").strip() + + if choice.lower() == 'q': + return None + + choice_num = int(choice) + if 1 <= choice_num <= len(wav_files): + selected_file = wav_files[choice_num - 1] + return os.path.join("data", selected_file) + else: + print(f"โŒ Please enter a number between 1 and {len(wav_files)}") + + except ValueError: + print("โŒ Please enter a valid number or 'q' to quit") + else: + # Enhanced UX with arrow key navigation + selected_index = 0 + + def display_menu(): + os.system('cls' if os.name == 'nt' else 'clear') # Clear screen + print("๐ŸŽต Available WAV files:") + print("=" * 50) + print("Use โ†‘/โ†“ arrows to navigate, Enter to select, 'q' to quit") + print("=" * 50) + + for i, file in enumerate(wav_files): + if i == selected_index: + print(f"โ–บ {file} โ—„") # Highlight selected file + else: + print(f" {file}") + + print("\n" + "=" * 50) + print(f"Selected: {wav_files[selected_index]}") + + display_menu() + + while True: + try: + event = keyboard.read_event() + + if event.event_type == keyboard.KEY_DOWN: + if event.name == 'up': + selected_index = (selected_index - 1) % len(wav_files) + display_menu() + elif event.name == 'down': + selected_index = (selected_index + 1) % len(wav_files) + display_menu() + elif event.name == 'enter': + selected_file = wav_files[selected_index] + print(f"\nโœ… Selected: {selected_file}") + return os.path.join("data", selected_file) + elif event.name == 'q': + return None + elif event.name == 'esc': + return None + + except KeyboardInterrupt: + return None + + +def get_analysis_options(): + """Get user preferences for analysis options.""" + try: + import keyboard + keyboard_available = True + except ImportError: + keyboard_available = False + + options_list = [ + {"name": "Standard analysis (waveform + spectrogram)", "show_plots": True, "save_figures": False}, + {"name": "Save figures to files", "show_plots": True, "save_figures": True}, + {"name": "Analysis only (no plots)", "show_plots": False, "save_figures": False} + ] + + if not keyboard_available: + # Fallback to number selection + print("\n๐Ÿ”ง Analysis Options:") + for i, option in enumerate(options_list, 1): + print(f"{i}. {option['name']}") + + while True: + try: + choice = input("\n๐Ÿ“Š Select option (1-3) or press Enter for default: ").strip() + + if choice == "" or choice == "1": + return options_list[0] + elif choice == "2": + return options_list[1] + elif choice == "3": + return options_list[2] + else: + print("โŒ Please enter 1, 2, or 3") + + except ValueError: + print("โŒ Please enter a valid option") + else: + # Enhanced UX with arrow key navigation + selected_index = 0 + + def display_options_menu(): + os.system('cls' if os.name == 'nt' else 'clear') + print("๐Ÿ”ง Analysis Options:") + print("=" * 50) + print("Use โ†‘/โ†“ arrows to navigate, Enter to select") + print("=" * 50) + + for i, option in enumerate(options_list): + if i == selected_index: + print(f"โ–บ {option['name']} โ—„") + else: + print(f" {option['name']}") + + print("\n" + "=" * 50) + print(f"Selected: {options_list[selected_index]['name']}") + + display_options_menu() + + while True: + try: + event = keyboard.read_event() + + if event.event_type == keyboard.KEY_DOWN: + if event.name == 'up': + selected_index = (selected_index - 1) % len(options_list) + display_options_menu() + elif event.name == 'down': + selected_index = (selected_index + 1) % len(options_list) + display_options_menu() + elif event.name == 'enter': + selected_option = options_list[selected_index] + print(f"\nโœ… Selected: {selected_option['name']}") + return selected_option + elif event.name == 'esc': + return options_list[0] # Default option + + except KeyboardInterrupt: + return options_list[0] # Default option diff --git a/sound_analysis/visualization.py b/sound_analysis/visualization.py new file mode 100644 index 0000000..8794b95 --- /dev/null +++ b/sound_analysis/visualization.py @@ -0,0 +1,108 @@ +""" +Sound Analysis Visualization + +Plotting and visualization functions for audio analysis. +""" + +import numpy as np +import matplotlib.pyplot as plt + + +def plot_waveform(waveform, sample_rate, duration, title="Audio Waveform", save_path=None): + """Plot the audio waveform.""" + time = np.linspace(0, duration, num=len(waveform)) + + plt.figure(figsize=(12, 6)) + plt.plot(time, waveform, color='blue', linewidth=0.5) + plt.title(title, fontsize=16, fontweight='bold') + plt.xlabel('Time (seconds)', fontsize=12) + plt.ylabel('Amplitude', fontsize=12) + plt.grid(True, alpha=0.3) + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=300, bbox_inches='tight') + print(f"๐Ÿ“ Waveform saved to: {save_path}") + + plt.show() + + +def plot_spectrogram(waveform, sample_rate, title="Frequency Spectrogram", save_path=None): + """Plot the frequency spectrogram.""" + plt.figure(figsize=(12, 6)) + plt.specgram(waveform, Fs=sample_rate, vmin=-20, vmax=50, cmap='viridis') + plt.title(title, fontsize=16, fontweight='bold') + plt.xlabel('Time (seconds)', fontsize=12) + plt.ylabel('Frequency (Hz)', fontsize=12) + plt.colorbar(label="Intensity (dB)") + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=300, bbox_inches='tight') + print(f"๐Ÿ“ Spectrogram saved to: {save_path}") + + plt.show() + + +def plot_frequency_analysis(waveform, sample_rate, title="Frequency Analysis"): + """Plot frequency domain analysis.""" + # Compute FFT + fft = np.fft.fft(waveform) + freqs = np.fft.fftfreq(len(fft), 1/sample_rate) + magnitude = np.abs(fft) + + # Only plot positive frequencies + positive_freqs = freqs[:len(freqs)//2] + positive_magnitude = magnitude[:len(magnitude)//2] + + plt.figure(figsize=(12, 6)) + plt.plot(positive_freqs, 20 * np.log10(positive_magnitude + 1e-10)) + plt.title(title, fontsize=16, fontweight='bold') + plt.xlabel('Frequency (Hz)', fontsize=12) + plt.ylabel('Magnitude (dB)', fontsize=12) + plt.grid(True, alpha=0.3) + plt.tight_layout() + plt.show() + + +def plot_combined_analysis(waveform, sample_rate, duration, filename): + """Create a combined visualization with multiple plots.""" + fig, axes = plt.subplots(2, 2, figsize=(15, 10)) + fig.suptitle(f"Complete Analysis: {filename}", fontsize=16, fontweight='bold') + + # Time domain plot + time = np.linspace(0, duration, num=len(waveform)) + axes[0, 0].plot(time, waveform, color='blue', linewidth=0.5) + axes[0, 0].set_title('Waveform') + axes[0, 0].set_xlabel('Time (seconds)') + axes[0, 0].set_ylabel('Amplitude') + axes[0, 0].grid(True, alpha=0.3) + + # Frequency domain plot + fft = np.fft.fft(waveform) + freqs = np.fft.fftfreq(len(fft), 1/sample_rate) + magnitude = np.abs(fft) + positive_freqs = freqs[:len(freqs)//2] + positive_magnitude = magnitude[:len(magnitude)//2] + + axes[0, 1].plot(positive_freqs, 20 * np.log10(positive_magnitude + 1e-10)) + axes[0, 1].set_title('Frequency Spectrum') + axes[0, 1].set_xlabel('Frequency (Hz)') + axes[0, 1].set_ylabel('Magnitude (dB)') + axes[0, 1].grid(True, alpha=0.3) + + # Spectrogram + axes[1, 0].specgram(waveform, Fs=sample_rate, vmin=-20, vmax=50, cmap='viridis') + axes[1, 0].set_title('Spectrogram') + axes[1, 0].set_xlabel('Time (seconds)') + axes[1, 0].set_ylabel('Frequency (Hz)') + + # Amplitude histogram + axes[1, 1].hist(waveform, bins=50, alpha=0.7, color='green') + axes[1, 1].set_title('Amplitude Distribution') + axes[1, 1].set_xlabel('Amplitude') + axes[1, 1].set_ylabel('Count') + axes[1, 1].grid(True, alpha=0.3) + + plt.tight_layout() + plt.show() diff --git a/space_odyssey_radar.wav b/space_odyssey_radar.wav new file mode 100644 index 0000000..0b4e34e Binary files /dev/null and b/space_odyssey_radar.wav differ diff --git a/streamlit_app.py b/streamlit_app.py new file mode 100644 index 0000000..bb1718c --- /dev/null +++ b/streamlit_app.py @@ -0,0 +1,491 @@ +""" +Sound Wave Analysis - Streamlit Web Application + +A professional web-based tool for analyzing and visualizing audio files. +Supports WAV, MP3, and FLAC formats with interactive visualizations. + +Author: TorresjDev +License: CC BY-NC 4.0 +""" + +import streamlit as st +import numpy as np +import os +from datetime import datetime + +# Import analysis modules +from sound_analysis.analyzer import get_wave_info, load_wave_data, analyze_audio_levels +from sound_analysis.plotly_viz import create_all_visualizations, create_frequency_spectrum_plot +from sound_analysis.audio_processing import ( + convert_audio_to_wav, + detect_harmonics, + calculate_speed_of_sound, + export_analysis_to_csv, + apply_lowpass_filter, + apply_highpass_filter, + apply_bandpass_filter, + PYDUB_AVAILABLE +) + +# Page configuration +st.set_page_config( + page_title="Sound Wave Analysis", + page_icon="๐ŸŒŠ", + layout="wide", + initial_sidebar_state="expanded" +) + +# Custom CSS for gradients and polish +# We rely on Streamlit's native Light/Dark modes for base colors +st.markdown(""" + +""", unsafe_allow_html=True) + + +def init_session_state(): + """Initialize session state variables.""" + defaults = { + 'analysis_complete': False, + 'file_info': None, + 'audio_levels': None, + 'figures': None, + 'waveform': None, + 'sample_rate': None, + 'duration': None, + 'harmonics': None, + 'uploaded_filename': None + } + for key, value in defaults.items(): + if key not in st.session_state: + st.session_state[key] = value + + +def render_sidebar(): + """Render the sidebar with settings and tools.""" + with st.sidebar: + st.markdown("### โš™๏ธ Settings") + + st.markdown(""" + **๐ŸŽจ Theme:** + Use the app menu (**โ‹ฎ**) โžœ **Settings** โžœ **Theme** + to toggle Light/Dark mode. + """) + + st.divider() + + # FFT Settings + st.markdown("### ๐Ÿ”ง FFT Settings") + + fft_window = st.select_slider( + "Window Size", + options=[256, 512, 1024, 2048, 4096, 8192], + value=1024, + help="Larger = better frequency resolution, worse time resolution" + ) + + st.session_state['fft_window'] = fft_window + + st.divider() + + # Speed of Sound Calculator + st.markdown("### ๐Ÿ”Š Speed of Sound") + + medium = st.selectbox( + "Medium", + ['air', 'water', 'steel', 'aluminum', 'glass'] + ) + + temp = st.slider("Temperature (ยฐC)", -20, 50, 20) if medium in ['air', 'water'] else 20 + + speed = calculate_speed_of_sound(temp, medium) + st.metric("Speed of Sound", f"{speed:.1f} m/s") + + st.divider() + + # About section + st.markdown("### ๐Ÿ“Š About") + st.markdown(""" + **Sound Wave Analysis** - Professional audio analysis tool. + + **Supported formats:** WAV, MP3, FLAC + **Max file size:** 50MB + """) + + if not PYDUB_AVAILABLE: + st.warning("โš ๏ธ MP3/FLAC support requires pydub. Install with: `pip install pydub`") + + st.markdown(""" +
+ Created by TorresjDev
+ GitHub +
+ """, unsafe_allow_html=True) + + +def render_header(): + """Render the main header.""" + st.markdown('

๐ŸŒŠ Sound Wave Analysis

', unsafe_allow_html=True) + st.markdown('

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(""" +
+ ๐Ÿ‘‹ Welcome!
+ Upload an audio file above to get started with your analysis. +
+ """, unsafe_allow_html=True) + + st.markdown("### ๐ŸŽฏ Features") + + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown(""" + **๐Ÿ“Š 6 Professional Graphs** + - Waveform + - Frequency Spectrum + - Spectrogram + - Power Spectral Density + - Phase Response + - Amplitude Histogram + """) + + with col2: + st.markdown(""" + **๐Ÿ”ฌ Detailed Analysis** + - Sample rate & duration + - dB levels & dynamic range + - Harmonic detection + - Physics calculators + """) + + with col3: + st.markdown(""" + **๐Ÿ’พ Export Options** + - Download graphs (PNG) + - Export summary (TXT) + - Export data (CSV) + - Audio playback + """) + + +if __name__ == "__main__": + main() diff --git a/verify_analysis.py b/verify_analysis.py new file mode 100644 index 0000000..3e88f30 --- /dev/null +++ b/verify_analysis.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Verification Script for Sound Wave Analysis + +This script compares the app's analysis results with scipy (trusted library) +to verify the calculations are correct. +""" + +import wave +import numpy as np +from scipy.io import wavfile +from sound_analysis.analyzer import get_wave_info, load_wave_data, analyze_audio_levels + + +def verify_analysis(file_path): + """Compare app results with scipy for verification.""" + print("=" * 60) + print("VERIFICATION REPORT") + print("=" * 60) + print(f"File: {file_path}\n") + + # --- Get app's results --- + app_info = get_wave_info(file_path) + app_data = load_wave_data(file_path) + app_levels = analyze_audio_levels(app_data['waveform']) + + # --- Get scipy's results --- + scipy_rate, scipy_data = wavfile.read(file_path) + + # --- Compare basic properties --- + print("1. BASIC PROPERTIES") + print("-" * 40) + + checks = [] + + # Sample rate + match = app_info['sample_rate'] == scipy_rate + checks.append(match) + status = "PASS" if match else "FAIL" + print(f" Sample Rate: {app_info['sample_rate']} Hz [{status}]") + + # Duration (within 0.01 seconds) + scipy_duration = len(scipy_data) / scipy_rate + match = abs(app_info['duration'] - scipy_duration) < 0.01 + checks.append(match) + status = "PASS" if match else "FAIL" + print(f" Duration: {app_info['duration']:.4f}s (scipy: {scipy_duration:.4f}s) [{status}]") + + # Channels + scipy_channels = 1 if len(scipy_data.shape) == 1 else scipy_data.shape[1] + match = app_info['channels'] == scipy_channels + checks.append(match) + status = "PASS" if match else "FAIL" + print(f" Channels: {app_info['channels']} [{status}]") + + print() + print("2. AUDIO DATA QUALITY") + print("-" * 40) + + # Check if waveform has reasonable values + waveform = app_data['waveform'] + + has_data = len(waveform) > 0 + checks.append(has_data) + status = "PASS" if has_data else "FAIL" + print(f" Waveform loaded: {len(waveform):,} samples [{status}]") + + has_variation = np.std(waveform) > 0 + checks.append(has_variation) + status = "PASS" if has_variation else "FAIL" + print(f" Has audio content (not silence): [{status}]") + + print() + print("3. DECIBEL CALCULATIONS") + print("-" * 40) + + # Verify dB calculations make sense + avg_db = app_levels['avg_db'] + rms_db = app_levels['rms_db'] + dynamic_range = app_levels['db_range']['dynamic_range'] + + # dB should be positive for audible sound + valid_avg = avg_db > 0 + checks.append(valid_avg) + status = "PASS" if valid_avg else "FAIL" + print(f" Average dB: {avg_db:.2f} (positive = audible) [{status}]") + + valid_rms = rms_db > 0 + checks.append(valid_rms) + status = "PASS" if valid_rms else "FAIL" + print(f" RMS dB: {rms_db:.2f} (positive = audible) [{status}]") + + # Dynamic range typically 40-120 dB for real audio + valid_range = 10 < dynamic_range < 150 + checks.append(valid_range) + status = "PASS" if valid_range else "FAIL" + print(f" Dynamic Range: {dynamic_range:.2f} dB (reasonable range) [{status}]") + + print() + print("=" * 60) + passed = sum(checks) + total = len(checks) + + if passed == total: + print(f"RESULT: ALL {total} CHECKS PASSED!") + print("The analysis results appear to be correct.") + else: + print(f"RESULT: {passed}/{total} checks passed") + print("Some issues may need investigation.") + print("=" * 60) + + return passed == total + + +if __name__ == "__main__": + import sys + + # Default to the test file + file_path = "data/space_odyssey_radar.wav" + if len(sys.argv) > 1: + file_path = sys.argv[1] + + verify_analysis(file_path)