Ver Fonte

initial commit

Your Name há 2 meses atrás
commit
bc0999b0ce
41 ficheiros alterados com 3020 adições e 0 exclusões
  1. 86 0
      TDK B60.json
  2. 5 0
      config.json
  3. 1 0
      core/__init__.py
  4. BIN
      core/__pycache__/__init__.cpython-312.pyc
  5. BIN
      core/__pycache__/analyzer.cpython-312.pyc
  6. BIN
      core/__pycache__/audio_device.cpython-312.pyc
  7. BIN
      core/__pycache__/audio_player.cpython-312.pyc
  8. BIN
      core/__pycache__/audio_recorder.cpython-312.pyc
  9. BIN
      core/__pycache__/calibrator.cpython-312.pyc
  10. BIN
      core/__pycache__/eq_engine.cpython-312.pyc
  11. BIN
      core/__pycache__/file_loader.cpython-312.pyc
  12. BIN
      core/__pycache__/signal_generator.cpython-312.pyc
  13. BIN
      core/__pycache__/tape_processor.cpython-312.pyc
  14. 106 0
      core/analyzer.py
  15. 264 0
      core/audio_device.py
  16. 182 0
      core/audio_player.py
  17. 107 0
      core/audio_recorder.py
  18. 145 0
      core/calibrator.py
  19. 175 0
      core/eq_engine.py
  20. 109 0
      core/file_loader.py
  21. 98 0
      core/signal_generator.py
  22. 66 0
      core/tape_processor.py
  23. 21 0
      main.py
  24. 7 0
      requirements.txt
  25. 161 0
      resources/style.qss
  26. 1 0
      ui/__init__.py
  27. BIN
      ui/__pycache__/__init__.cpython-312.pyc
  28. BIN
      ui/__pycache__/calibration_panel.cpython-312.pyc
  29. BIN
      ui/__pycache__/device_panel.cpython-312.pyc
  30. BIN
      ui/__pycache__/eq_panel.cpython-312.pyc
  31. BIN
      ui/__pycache__/main_window.cpython-312.pyc
  32. BIN
      ui/__pycache__/player_panel.cpython-312.pyc
  33. BIN
      ui/__pycache__/spectrum_widget.cpython-312.pyc
  34. BIN
      ui/__pycache__/waveform_widget.cpython-312.pyc
  35. 388 0
      ui/calibration_panel.py
  36. 149 0
      ui/device_panel.py
  37. 325 0
      ui/eq_panel.py
  38. 103 0
      ui/main_window.py
  39. 288 0
      ui/player_panel.py
  40. 127 0
      ui/spectrum_widget.py
  41. 106 0
      ui/waveform_widget.py

+ 86 - 0
TDK B60.json

@@ -0,0 +1,86 @@
+[
+    {
+        "freq": 75.00000000000001,
+        "gain": -1.5780807172998181,
+        "q": 1.072689141634719,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 118.96954681991149,
+        "gain": -1.9415360986532162,
+        "q": 0.7137679161711851,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 188.7167076071348,
+        "gain": -2.226013533461414,
+        "q": 0.9614054218716012,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 299.3538824182209,
+        "gain": -2.7445520165264856,
+        "q": 0.1,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 474.8532764010246,
+        "gain": -3.9116381263620656,
+        "q": 0.1,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 753.2410546584008,
+        "gain": -2.646123345431033,
+        "q": 0.1,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 1194.8366255848282,
+        "gain": -3.3689143755929765,
+        "q": 0.1,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 1895.3222915954586,
+        "gain": -1.9553600347749993,
+        "q": 0.11019223974470843,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 3006.4751214505045,
+        "gain": -2.354763794788129,
+        "q": 0.8054795434264226,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 4769.053102990731,
+        "gain": -2.6472090749812462,
+        "q": 0.9391870274630437,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 7564.961152305332,
+        "gain": -1.8630099706791532,
+        "q": 2.884552417996847,
+        "filter_type": "peaking",
+        "enabled": true
+    },
+    {
+        "freq": 12000.000000000007,
+        "gain": 3.4703475793597844,
+        "q": 0.6967846709608635,
+        "filter_type": "peaking",
+        "enabled": true
+    }
+]

+ 5 - 0
config.json

@@ -0,0 +1,5 @@
+{
+    "sample_rate": 44100,
+    "input_device_name": "Microphone (HD Audio Mixed capture)",
+    "output_device_name": "Remote Audio"
+}

+ 1 - 0
core/__init__.py

@@ -0,0 +1 @@
+# Core audio engine package

BIN
core/__pycache__/__init__.cpython-312.pyc


BIN
core/__pycache__/analyzer.cpython-312.pyc


BIN
core/__pycache__/audio_device.cpython-312.pyc


BIN
core/__pycache__/audio_player.cpython-312.pyc


BIN
core/__pycache__/audio_recorder.cpython-312.pyc


BIN
core/__pycache__/calibrator.cpython-312.pyc


BIN
core/__pycache__/eq_engine.cpython-312.pyc


BIN
core/__pycache__/file_loader.cpython-312.pyc


BIN
core/__pycache__/signal_generator.cpython-312.pyc


BIN
core/__pycache__/tape_processor.cpython-312.pyc


+ 106 - 0
core/analyzer.py

@@ -0,0 +1,106 @@
+"""
+Analyzer — Performs frequency response measurement.
+
+Compares a recorded signal against a reference signal to determine
+the transfer function (frequency response) of the tape deck.
+"""
+
+import numpy as np
+from scipy import signal
+from typing import Tuple, Dict
+
+def align_signals(reference: np.ndarray, recorded: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Align recorded signal to the reference signal using cross-correlation.
+    Accounts for latency introduced by the audio interface and tape path.
+    """
+    # Use cross-correlation to find the time delay
+    # We correlate a portion of the signals for speed if they are long
+    max_len = min(len(reference), len(recorded), 44100 * 2) # use max 2 seconds for alignment
+    
+    ref_slice = reference[:max_len]
+    rec_slice = recorded[:max_len]
+    
+    # correlation
+    corr = signal.correlate(rec_slice, ref_slice, mode='full')
+    delay = np.argmax(corr) - len(ref_slice) + 1
+    
+    if delay > 0:
+        # Recorded is delayed relative to reference
+        aligned_rec = recorded[delay:]
+        aligned_ref = reference[:len(aligned_rec)]
+    elif delay < 0:
+        # Should not happen physically, but handle just in case
+        aligned_ref = reference[-delay:]
+        aligned_rec = recorded[:len(aligned_ref)]
+    else:
+        aligned_ref = reference
+        aligned_rec = recorded
+        
+    # Trim to same length
+    min_len = min(len(aligned_ref), len(aligned_rec))
+    
+    return aligned_ref[:min_len], aligned_rec[:min_len]
+
+def calculate_transfer_function(
+    reference: np.ndarray, 
+    recorded: np.ndarray, 
+    sample_rate: int = 44100,
+    nperseg: int = 4096
+) -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Calculate the transfer function H(f) = cross_power(ref, rec) / auto_power(ref).
+    Using Welch's method for better noise reduction.
+    
+    Returns:
+        freqs: Frequency bins (Hz)
+        response_db: Magnitude response in dB
+    """
+    # Ensure aligned
+    ref_aligned, rec_aligned = align_signals(reference, recorded)
+    
+    if len(ref_aligned) < nperseg:
+        # Fallback if signal is too short
+        nperseg = len(ref_aligned)
+        
+    # Calculate cross power spectral density (Pxy) and power spectral density of reference (Pxx)
+    freqs, Pxy = signal.csd(ref_aligned, rec_aligned, fs=sample_rate, nperseg=nperseg)
+    _, Pxx = signal.welch(ref_aligned, fs=sample_rate, nperseg=nperseg)
+    
+    # Transfer function H = Pxy / Pxx
+    # Add small epsilon to avoid division by zero
+    H = Pxy / (Pxx + 1e-10)
+    
+    # Magnitude response
+    magnitude = np.abs(H)
+    
+    # Convert to dB
+    response_db = 20 * np.log10(magnitude + 1e-10)
+    
+    return freqs, response_db
+
+def smooth_curve(freqs: np.ndarray, response_db: np.ndarray, fraction: float = 1/6) -> np.ndarray:
+    """
+    Smooth the frequency response curve using fractional octave smoothing.
+    fraction = 1/3 for third-octave, 1/6 for sixth-octave.
+    """
+    smoothed = np.zeros_like(response_db)
+    
+    for i, f in enumerate(freqs):
+        if f == 0:
+            smoothed[i] = response_db[i]
+            continue
+            
+        # Octave bandwidth around frequency f
+        f_lower = f * (2.0 ** (-fraction / 2.0))
+        f_upper = f * (2.0 ** (fraction / 2.0))
+        
+        # Find indices within this band
+        idx = np.where((freqs >= f_lower) & (freqs <= f_upper))[0]
+        
+        if len(idx) > 0:
+            smoothed[i] = np.mean(response_db[idx])
+        else:
+            smoothed[i] = response_db[i]
+            
+    return smoothed

+ 264 - 0
core/audio_device.py

@@ -0,0 +1,264 @@
+"""
+Audio Device Manager — Enumerate and manage sound card I/O devices.
+
+Provides a singleton DeviceManager that all components use to access
+the currently selected input/output devices and sample rate.
+"""
+
+import sounddevice as sd
+import numpy as np
+import json
+import os
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+@dataclass
+class AudioDeviceInfo:
+    """Information about an audio device."""
+    index: int
+    name: str
+    max_input_channels: int
+    max_output_channels: int
+    default_samplerate: float
+    hostapi_name: str
+
+    @property
+    def is_input(self) -> bool:
+        return self.max_input_channels > 0
+
+    @property
+    def is_output(self) -> bool:
+        return self.max_output_channels > 0
+
+    def __str__(self) -> str:
+        direction = []
+        if self.is_input:
+            direction.append(f"IN:{self.max_input_channels}ch")
+        if self.is_output:
+            direction.append(f"OUT:{self.max_output_channels}ch")
+        return f"[{self.index}] {self.name} ({', '.join(direction)}) [{self.hostapi_name}]"
+
+
+class DeviceManager:
+    """
+    Manages audio device selection and configuration.
+    
+    Singleton pattern — use DeviceManager.instance() to get the shared instance.
+    """
+
+    _instance: Optional["DeviceManager"] = None
+
+    def __init__(self):
+        self._input_device_id: Optional[int] = None
+        self._output_device_id: Optional[int] = None
+        self._sample_rate: int = 44100
+        self._channels: int = 2  # Stereo
+        self._block_size: int = 1024
+        self._devices: list[AudioDeviceInfo] = []
+        self.refresh_devices()
+        
+        # Load saved preferences if available
+        self._load_config()
+
+    def _load_config(self):
+        """Load settings from config.json."""
+        if not os.path.exists("config.json"):
+            return
+            
+        try:
+            with open("config.json", "r") as f:
+                config = json.load(f)
+                
+            if "sample_rate" in config:
+                self.sample_rate = config["sample_rate"]
+                
+            # Try to match saved device names to current indices
+            if "input_device_name" in config:
+                for dev in self.get_input_devices():
+                    if dev.name == config["input_device_name"]:
+                        self._input_device_id = dev.index
+                        break
+                        
+            if "output_device_name" in config:
+                for dev in self.get_output_devices():
+                    if dev.name == config["output_device_name"]:
+                        self._output_device_id = dev.index
+                        break
+                        
+        except Exception as e:
+            print(f"Failed to load config: {e}")
+
+    def _save_config(self):
+        """Save settings to config.json."""
+        config = {
+            "sample_rate": self.sample_rate
+        }
+        
+        if self._input_device_id is not None:
+            dev = self.get_device_by_index(self._input_device_id)
+            if dev: config["input_device_name"] = dev.name
+            
+        if self._output_device_id is not None:
+            dev = self.get_device_by_index(self._output_device_id)
+            if dev: config["output_device_name"] = dev.name
+            
+        try:
+            with open("config.json", "w") as f:
+                json.dump(config, f, indent=4)
+        except Exception as e:
+            print(f"Failed to save config: {e}")
+
+    @classmethod
+    def instance(cls) -> "DeviceManager":
+        """Get the singleton DeviceManager instance."""
+        if cls._instance is None:
+            cls._instance = DeviceManager()
+        return cls._instance
+
+    @classmethod
+    def reset_instance(cls):
+        """Reset the singleton (useful for testing)."""
+        cls._instance = None
+
+    def refresh_devices(self):
+        """Re-scan available audio devices."""
+        self._devices = []
+        devices = sd.query_devices()
+        hostapis = sd.query_hostapis()
+
+        for i, dev in enumerate(devices):
+            hostapi_name = hostapis[dev["hostapi"]]["name"] if dev["hostapi"] < len(hostapis) else "Unknown"
+            self._devices.append(AudioDeviceInfo(
+                index=i,
+                name=dev["name"],
+                max_input_channels=dev["max_input_channels"],
+                max_output_channels=dev["max_output_channels"],
+                default_samplerate=dev["default_samplerate"],
+                hostapi_name=hostapi_name,
+            ))
+
+    def get_all_devices(self) -> list[AudioDeviceInfo]:
+        """Return all available audio devices."""
+        return list(self._devices)
+
+    def get_input_devices(self) -> list[AudioDeviceInfo]:
+        """Return devices that support audio input."""
+        return [d for d in self._devices if d.is_input]
+
+    def get_output_devices(self) -> list[AudioDeviceInfo]:
+        """Return devices that support audio output."""
+        return [d for d in self._devices if d.is_output]
+
+    def get_device_by_index(self, index: int) -> Optional[AudioDeviceInfo]:
+        """Get device info by its index."""
+        for d in self._devices:
+            if d.index == index:
+                return d
+        return None
+
+    @property
+    def input_device_id(self) -> Optional[int]:
+        return self._input_device_id
+
+    @input_device_id.setter
+    def input_device_id(self, device_id: int):
+        dev = self.get_device_by_index(device_id)
+        if dev is None:
+            raise ValueError(f"Device index {device_id} not found")
+        if not dev.is_input:
+            raise ValueError(f"Device '{dev.name}' has no input channels")
+        self._input_device_id = device_id
+        self._save_config()
+
+    @property
+    def output_device_id(self) -> Optional[int]:
+        return self._output_device_id
+
+    @output_device_id.setter
+    def output_device_id(self, device_id: int):
+        dev = self.get_device_by_index(device_id)
+        if dev is None:
+            raise ValueError(f"Device index {device_id} not found")
+        if not dev.is_output:
+            raise ValueError(f"Device '{dev.name}' has no output channels")
+        self._output_device_id = device_id
+        
+        # Automatically update sample rate to device's default to prevent speed mismatch
+        if dev.default_samplerate > 0:
+            self._sample_rate = int(dev.default_samplerate)
+            
+        self._save_config()
+
+    @property
+    def sample_rate(self) -> int:
+        return self._sample_rate
+
+    @sample_rate.setter
+    def sample_rate(self, rate: int):
+        if rate not in (22050, 44100, 48000, 88200, 96000):
+            raise ValueError(f"Unsupported sample rate: {rate}")
+        self._sample_rate = rate
+        self._save_config()
+
+    @property
+    def channels(self) -> int:
+        return self._channels
+
+    @channels.setter
+    def channels(self, ch: int):
+        if ch not in (1, 2):
+            raise ValueError(f"Unsupported channel count: {ch}")
+        self._channels = ch
+
+    @property
+    def block_size(self) -> int:
+        return self._block_size
+
+    @block_size.setter
+    def block_size(self, size: int):
+        if size < 64 or size > 8192:
+            raise ValueError(f"Block size must be 64-8192, got {size}")
+        self._block_size = size
+
+    def get_sd_input_kwargs(self) -> dict:
+        """Get sounddevice kwargs for input stream."""
+        kwargs = {
+            "samplerate": self._sample_rate,
+            "channels": self._channels,
+            "blocksize": self._block_size,
+            "dtype": "float32",
+        }
+        if self._input_device_id is not None:
+            kwargs["device"] = self._input_device_id
+        return kwargs
+
+    def get_sd_output_kwargs(self) -> dict:
+        """Get sounddevice kwargs for output stream."""
+        kwargs = {
+            "samplerate": self._sample_rate,
+            "channels": self._channels,
+            "blocksize": self._block_size,
+            "dtype": "float32",
+        }
+        if self._output_device_id is not None:
+            kwargs["device"] = self._output_device_id
+        return kwargs
+
+    def auto_select_defaults(self):
+        """Auto-select the system default input and output devices."""
+        defaults = sd.query_devices()
+        default_input = sd.default.device[0]
+        default_output = sd.default.device[1]
+
+        if default_input is not None and isinstance(default_input, int):
+            try:
+                self.input_device_id = default_input
+            except ValueError:
+                pass
+
+        if default_output is not None and isinstance(default_output, int):
+            try:
+                self.output_device_id = default_output
+            except ValueError:
+                pass

+ 182 - 0
core/audio_player.py

@@ -0,0 +1,182 @@
+"""
+Audio Player — Play audio files through the EQ engine to the sound card output.
+"""
+
+import sounddevice as sd
+import numpy as np
+from typing import Optional, Callable
+import threading
+
+from .file_loader import load_audio, AudioData, normalize_audio
+from .eq_engine import EQEngine
+from .tape_processor import TapeProcessor
+from .audio_device import DeviceManager
+
+class AudioPlayer:
+    """
+    Handles playback of audio files through a TapeProcessor and EQEngine.
+    """
+    def __init__(self, eq_engine: EQEngine, tape_processor: TapeProcessor, device_manager: DeviceManager):
+        self.eq_engine = eq_engine
+        self.tape_processor = tape_processor
+        self.device_manager = device_manager
+        
+        self.audio_data: Optional[AudioData] = None
+        self.stream: Optional[sd.OutputStream] = None
+        
+        self.current_frame: int = 0
+        self.is_playing: bool = False
+        self.is_looping: bool = False
+        
+        # Callbacks for UI updates
+        self.on_position_update: Optional[Callable[[float], None]] = None
+        self.on_playback_stopped: Optional[Callable[[], None]] = None
+        
+        # VU meter levels (left, right RMS in dB)
+        self.vu_levels = (-100.0, -100.0)
+
+    def load_file(self, file_path: str, normalize: bool = False):
+        """Load an audio file and prepare for playback."""
+        self.stop()
+        audio = load_audio(file_path)
+        
+        if normalize:
+            audio = normalize_audio(audio)
+        
+
+            
+        # Convert mono to stereo for output if needed
+        if audio.channels == 1 and self.device_manager.channels == 2:
+            audio.samples = np.repeat(audio.samples, 2, axis=1)
+            audio.channels = 2
+            
+        self.audio_data = audio
+        self.current_frame = 0
+
+    def play(self):
+        """Start or resume playback."""
+        if self.audio_data is None:
+            return
+            
+        if self.is_playing:
+            return
+            
+        self.eq_engine.reset_state()
+        self.eq_engine.set_sample_rate(self.audio_data.sample_rate)
+        
+        self.tape_processor.reset_state()
+        self.tape_processor.set_sample_rate(self.audio_data.sample_rate)
+        
+        kwargs = self.device_manager.get_sd_output_kwargs()
+        kwargs["samplerate"] = self.audio_data.sample_rate
+        
+        self.stream = sd.OutputStream(
+            callback=self._audio_callback,
+            finished_callback=self._on_finished,
+            **kwargs
+        )
+        
+        self.is_playing = True
+        self.stream.start()
+
+    def pause(self):
+        """Pause playback."""
+        if self.is_playing and self.stream:
+            self.stream.stop()
+            self.stream.close()
+            self.stream = None
+            self.is_playing = False
+
+    def stop(self):
+        """Stop playback and reset position."""
+        self.pause()
+        self.current_frame = 0
+        self.vu_levels = (-100.0, -100.0)
+        if self.on_position_update:
+            self.on_position_update(0.0)
+
+    def seek(self, position_sec: float):
+        """Seek to a specific position in seconds."""
+        if self.audio_data is None:
+            return
+        
+        frame = int(position_sec * self.audio_data.sample_rate)
+        self.current_frame = max(0, min(frame, self.audio_data.num_samples - 1))
+        
+        if self.is_playing:
+            self.eq_engine.reset_state()
+            self.tape_processor.reset_state()
+            
+    def _audio_callback(self, outdata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
+        """sounddevice callback for generating output audio."""
+        if status:
+            print(f"Playback status: {status}")
+            
+        if self.audio_data is None or not self.is_playing:
+            outdata.fill(0)
+            return
+
+        chunk_size = frames
+        remaining = self.audio_data.num_samples - self.current_frame
+        
+        if remaining <= 0:
+            if self.is_looping:
+                self.current_frame = 0
+                self.eq_engine.reset_state()
+                self.tape_processor.reset_state()
+                remaining = self.audio_data.num_samples
+            else:
+                outdata.fill(0)
+                raise sd.CallbackStop()
+
+        valid_frames = min(chunk_size, remaining)
+        
+        # Read from audio data
+        chunk = self.audio_data.samples[self.current_frame:self.current_frame + valid_frames]
+        
+        # Tape Processing & EQ Chain
+        chunk = self.tape_processor.process_pre_eq(chunk)
+        chunk = self.eq_engine.process(chunk)
+        chunk = self.tape_processor.process_post_eq(chunk)
+        
+        processed_chunk = chunk
+        
+        # Write to output buffer
+        outdata[:valid_frames] = processed_chunk
+        
+        # Pad with zeros if we reached the end
+        if valid_frames < chunk_size:
+            outdata[valid_frames:] = 0
+            if not self.is_looping:
+                raise sd.CallbackStop()
+            
+        # Update VU meters
+        self._update_vu_levels(processed_chunk)
+            
+        self.current_frame += valid_frames
+        
+        # Notify UI (safely, could be high rate, UI should throttle)
+        if self.on_position_update:
+            pos_sec = self.current_frame / self.audio_data.sample_rate
+            self.on_position_update(pos_sec)
+
+    def _update_vu_levels(self, chunk: np.ndarray):
+        """Calculate RMS levels for VU meters."""
+        if chunk.size == 0:
+            return
+            
+        # Calculate RMS per channel
+        rms = np.sqrt(np.mean(chunk**2, axis=0))
+        # Convert to dB, avoid log(0)
+        db = 20 * np.log10(np.maximum(rms, 1e-5))
+        
+        if chunk.shape[1] >= 2:
+            self.vu_levels = (float(db[0]), float(db[1]))
+        else:
+            self.vu_levels = (float(db[0]), float(db[0]))
+
+    def _on_finished(self):
+        """Called when stream stops."""
+        self.is_playing = False
+        if self.on_playback_stopped:
+            self.on_playback_stopped()

+ 107 - 0
core/audio_recorder.py

@@ -0,0 +1,107 @@
+"""
+Audio Recorder — Capture audio from sound card input.
+"""
+
+import sounddevice as sd
+import numpy as np
+import soundfile as sf
+from typing import Optional, Callable
+import threading
+
+from .audio_device import DeviceManager
+
+class AudioRecorder:
+    """
+    Captures audio from the selected input device.
+    Supports continuous ring-buffering for UI updates and saving to file.
+    """
+    def __init__(self, device_manager: DeviceManager):
+        self.device_manager = device_manager
+        
+        self.stream: Optional[sd.InputStream] = None
+        self.is_recording: bool = False
+        
+        # Buffer for capturing the full recording
+        self.recorded_data: list[np.ndarray] = []
+        
+        # Callbacks
+        self.on_audio_data: Optional[Callable[[np.ndarray], None]] = None
+        
+        self.vu_levels = (-100.0, -100.0)
+
+    def start(self):
+        """Start capturing audio."""
+        if self.is_recording:
+            return
+            
+        self.recorded_data = []
+        
+        kwargs = self.device_manager.get_sd_input_kwargs()
+        
+        self.stream = sd.InputStream(
+            callback=self._audio_callback,
+            **kwargs
+        )
+        
+        self.is_recording = True
+        self.stream.start()
+
+    def stop(self) -> np.ndarray:
+        """Stop capturing and return the recorded data."""
+        if self.is_recording and self.stream:
+            self.stream.stop()
+            self.stream.close()
+            self.stream = None
+            self.is_recording = False
+            
+        if not self.recorded_data:
+            channels = self.device_manager.channels
+            return np.zeros((0, channels), dtype=np.float32)
+            
+        # Concatenate all blocks
+        full_data = np.concatenate(self.recorded_data, axis=0)
+        return full_data
+
+    def save_to_wav(self, file_path: str):
+        """Save the recorded data to a WAV file."""
+        data = self.stop()
+        if len(data) > 0:
+            sf.write(
+                file_path, 
+                data, 
+                self.device_manager.sample_rate, 
+                subtype='FLOAT'
+            )
+            return True
+        return False
+
+    def _audio_callback(self, indata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
+        """sounddevice callback for incoming audio."""
+        if status:
+            print(f"Record status: {status}")
+            
+        # Make a copy of the input data
+        chunk = indata.copy()
+        
+        # Store for full recording
+        self.recorded_data.append(chunk)
+        
+        # Update VU
+        self._update_vu_levels(chunk)
+        
+        # Notify UI/analyzers (e.g. for real-time spectrum)
+        if self.on_audio_data:
+            self.on_audio_data(chunk)
+
+    def _update_vu_levels(self, chunk: np.ndarray):
+        """Calculate RMS levels for VU meters."""
+        if chunk.size == 0:
+            return
+            
+        rms = np.sqrt(np.mean(chunk**2, axis=0))
+        db = 20 * np.log10(np.maximum(rms, 1e-5))
+        
+        if chunk.shape[1] >= 2:
+            self.vu_levels = (float(db[0]), float(db[1]))
+        else:
+            self.vu_levels = (float(db[0]), float(db[0]))

+ 145 - 0
core/calibrator.py

@@ -0,0 +1,145 @@
+"""
+Calibrator — Auto-fits EQ bands to compensate for measured frequency response.
+"""
+
+import numpy as np
+from scipy.optimize import minimize
+from typing import List, Tuple, Dict
+from .eq_engine import EQBand, EQEngine
+
+TAPE_PROFILES = {
+    "Type I (Ferric)": {"low_cut": 50.0, "high_cut": 15000.0, "eq_us": 120},
+    "Type II (Chrome)": {"low_cut": 45.0, "high_cut": 16000.0, "eq_us": 70},
+    "Type IV (Metal)": {"low_cut": 40.0, "high_cut": 17000.0, "eq_us": 70},
+}
+
+def calculate_correction_curve(response_db: np.ndarray, max_correction: float = 15.0) -> np.ndarray:
+    """
+    Invert the measured response to get the correction curve.
+    Limits the maximum boost/cut to prevent extreme EQ values.
+    """
+    # Invert the response (if tape cuts 5dB, we boost 5dB)
+    correction = -response_db
+    
+    # Normalize to 0dB mean around 1kHz (typical reference)
+    # Assume response_db is already somewhat normalized, or just mean shift
+    mean_level = np.mean(correction)
+    correction -= mean_level
+    
+    # Clip to max correction
+    correction = np.clip(correction, -max_correction, max_correction)
+    
+    return correction
+
+def _evaluate_eq_curve(freqs: np.ndarray, bands: List[EQBand], sample_rate: int) -> np.ndarray:
+    """
+    Calculate the combined frequency response of the EQ bands.
+    """
+    # Create temporary EQ engine to calculate response
+    engine = EQEngine(sample_rate=sample_rate)
+    engine.bands = bands
+    engine.update_filters()
+    
+    # If no filters, return flat response
+    if engine._sos is None:
+        return np.zeros_like(freqs)
+        
+    # Calculate frequency response using sosfreqz
+    # freqs normalized to nyquist
+    w = 2 * np.pi * freqs / sample_rate
+    
+    from scipy.signal import sosfreqz
+    _, h = sosfreqz(engine._sos, worN=w)
+    
+    return 20 * np.log10(np.abs(h) + 1e-10)
+
+def fit_eq_bands(
+    freqs: np.ndarray, 
+    correction_curve: np.ndarray, 
+    num_peaking_bands: int = 10,
+    sample_rate: int = 44100,
+    tape_type: str = "Type I (Ferric)"
+) -> List[EQBand]:
+    """
+    Fit parametric EQ bands to the target correction curve, constrained by tape type.
+    """
+    profile = TAPE_PROFILES.get(tape_type, TAPE_PROFILES["Type I (Ferric)"])
+    low_cut = profile["low_cut"]
+    high_cut = profile["high_cut"]
+    
+    # Initialize bands
+    bands = []
+    
+    # Instead of shelves that fight the natural roll-off, use peaking bands 
+    # distributed strictly within the usable frequency range of the tape.
+    # We add 2 extra bands instead of the shelves to give 12 peaking bands total.
+    total_bands = num_peaking_bands + 2
+    
+    # Log-spaced center frequencies
+    f_centers = np.logspace(np.log10(low_cut * 1.5), np.log10(high_cut * 0.8), total_bands)
+    for f in f_centers:
+        bands.append(EQBand(freq=float(f), gain=0.0, q=1.414, filter_type='peaking'))
+    
+    # Optimization target function
+    def error_func(params):
+        # params is a flattened array of [gain, q] for each band
+        # Keep frequencies fixed for stability
+        temp_bands = []
+        for i, b in enumerate(bands):
+            gain = params[i*2]
+            q = max(0.1, min(10.0, params[i*2+1])) # Constrain Q
+            temp_bands.append(EQBand(
+                freq=b.freq,
+                gain=gain,
+                q=q,
+                filter_type=b.filter_type
+            ))
+            
+        eq_curve = _evaluate_eq_curve(freqs, temp_bands, sample_rate)
+        
+        # Weighted error: ignore extremes outside tape limits, focus on mids
+        weights = np.zeros_like(freqs)
+        # Weight 1.0 for usable range
+        valid_mask = (freqs >= low_cut) & (freqs <= high_cut)
+        weights[valid_mask] = 1.0
+        # Extra weight for critical mid-range (300Hz - 5kHz)
+        mid_mask = (freqs >= 300) & (freqs <= 5000)
+        weights[mid_mask] = 2.0
+        
+        # Mean squared error
+        mse = np.average((eq_curve - correction_curve)**2, weights=weights)
+        return mse
+        
+    # Initial guess: flat 0dB, default Q
+    initial_guess = np.zeros(len(bands) * 2)
+    for i in range(len(bands)):
+        initial_guess[i*2+1] = 1.414 if bands[i].filter_type == 'peaking' else 0.707
+        
+    # Bounds: Gain [-15, 15], Q [0.1, 10]
+    bounds = []
+    for _ in range(len(bands)):
+        bounds.append((-15.0, 15.0)) # Gain
+        bounds.append((0.1, 10.0))   # Q
+        
+    # Run optimization
+    result = minimize(
+        error_func, 
+        initial_guess, 
+        bounds=bounds,
+        method='L-BFGS-B',
+        options={'maxiter': 100}
+    )
+    
+    # Apply optimized parameters
+    optimized_bands = []
+    for i, b in enumerate(bands):
+        gain = float(result.x[i*2])
+        q = float(result.x[i*2+1])
+        optimized_bands.append(EQBand(
+            freq=b.freq,
+            gain=gain,
+            q=q,
+            filter_type=b.filter_type
+        ))
+        
+    return optimized_bands

+ 175 - 0
core/eq_engine.py

@@ -0,0 +1,175 @@
+"""
+Parametric EQ Engine — Applies biquad filters to audio streams.
+"""
+
+import numpy as np
+import scipy.signal as signal
+from dataclasses import dataclass
+from typing import List, Optional
+
+@dataclass
+class EQBand:
+    """Parameters for a single EQ band."""
+    freq: float       # Center frequency in Hz
+    gain: float       # Gain in dB (not used for HP/LP)
+    q: float          # Quality factor
+    filter_type: str  # 'peaking', 'lowshelf', 'highshelf', 'highpass', 'lowpass'
+    enabled: bool = True
+
+class EQEngine:
+    """
+    Processes audio through a chain of parametric EQ bands.
+    Uses Second-Order Sections (SOS) for numerical stability.
+    """
+    def __init__(self, sample_rate: int = 44100):
+        self.sample_rate = sample_rate
+        self.bands: List[EQBand] = []
+        self._sos = None
+        self._zi = None  # Filter state for continuous processing
+        
+        # Default initialization: 1 low shelf, 10 peaking, 1 high shelf
+        self.reset_to_default_bands()
+
+    def reset_to_default_bands(self):
+        """Setup a default 12-band EQ."""
+        self.bands = [
+            EQBand(freq=100.0, gain=0.0, q=0.707, filter_type='lowshelf'),
+            EQBand(freq=250.0, gain=0.0, q=1.414, filter_type='peaking'),
+            EQBand(freq=500.0, gain=0.0, q=1.414, filter_type='peaking'),
+            EQBand(freq=1000.0, gain=0.0, q=1.414, filter_type='peaking'),
+            EQBand(freq=2000.0, gain=0.0, q=1.414, filter_type='peaking'),
+            EQBand(freq=4000.0, gain=0.0, q=1.414, filter_type='peaking'),
+            EQBand(freq=8000.0, gain=0.0, q=1.414, filter_type='peaking'),
+            EQBand(freq=12000.0, gain=0.0, q=0.707, filter_type='highshelf'),
+        ]
+        self.update_filters()
+
+    def _get_biquad_coeffs(self, band: EQBand) -> tuple:
+        """Calculate biquad coefficients based on Audio EQ Cookbook."""
+        A = 10.0 ** (band.gain / 40.0)
+        w0 = 2.0 * np.pi * band.freq / self.sample_rate
+        alpha = np.sin(w0) / (2.0 * band.q)
+        
+        if band.filter_type == 'peaking':
+            b0 = 1.0 + alpha * A
+            b1 = -2.0 * np.cos(w0)
+            b2 = 1.0 - alpha * A
+            a0 = 1.0 + alpha / A
+            a1 = -2.0 * np.cos(w0)
+            a2 = 1.0 - alpha / A
+        elif band.filter_type == 'lowshelf':
+            sqrtA = np.sqrt(A)
+            b0 = A * ((A + 1.0) - (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha)
+            b1 = 2.0 * A * ((A - 1.0) - (A + 1.0) * np.cos(w0))
+            b2 = A * ((A + 1.0) - (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha)
+            a0 = (A + 1.0) + (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha
+            a1 = -2.0 * ((A - 1.0) + (A + 1.0) * np.cos(w0))
+            a2 = (A + 1.0) + (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha
+        elif band.filter_type == 'highshelf':
+            sqrtA = np.sqrt(A)
+            b0 = A * ((A + 1.0) + (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha)
+            b1 = -2.0 * A * ((A - 1.0) + (A + 1.0) * np.cos(w0))
+            b2 = A * ((A + 1.0) + (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha)
+            a0 = (A + 1.0) - (A - 1.0) * np.cos(w0) + 2.0 * sqrtA * alpha
+            a1 = 2.0 * ((A - 1.0) - (A + 1.0) * np.cos(w0))
+            a2 = (A + 1.0) - (A - 1.0) * np.cos(w0) - 2.0 * sqrtA * alpha
+        elif band.filter_type == 'highpass':
+            b0 = (1.0 + np.cos(w0)) / 2.0
+            b1 = -(1.0 + np.cos(w0))
+            b2 = (1.0 + np.cos(w0)) / 2.0
+            a0 = 1.0 + alpha
+            a1 = -2.0 * np.cos(w0)
+            a2 = 1.0 - alpha
+        elif band.filter_type == 'lowpass':
+            b0 = (1.0 - np.cos(w0)) / 2.0
+            b1 = 1.0 - np.cos(w0)
+            b2 = (1.0 - np.cos(w0)) / 2.0
+            a0 = 1.0 + alpha
+            a1 = -2.0 * np.cos(w0)
+            a2 = 1.0 - alpha
+        else:
+            raise ValueError(f"Unknown filter type: {band.filter_type}")
+            
+        return np.array([b0, b1, b2, a0, a1, a2])
+
+    def update_filters(self):
+        """Recompute SOS coefficients from all enabled bands."""
+        sos_list = []
+        for band in self.bands:
+            if band.enabled:
+                coeffs = self._get_biquad_coeffs(band)
+                # Normalize by a0
+                a0 = coeffs[3]
+                sos_section = np.array([coeffs[0]/a0, coeffs[1]/a0, coeffs[2]/a0, 
+                                        1.0, coeffs[4]/a0, coeffs[5]/a0])
+                sos_list.append(sos_section)
+                
+        if not sos_list:
+            self._sos = None
+            self._zi = None
+        else:
+            self._sos = np.vstack(sos_list)
+            # Reset zi to match the new filter order (number of sections)
+            # zi shape needs to handle stereo if needed, but for now we'll 
+            # initialize it on the first process() call based on input shape.
+            self._zi = None
+
+    def set_sample_rate(self, sr: int):
+        if self.sample_rate != sr:
+            self.sample_rate = sr
+            self.update_filters()
+
+    def process(self, chunk: np.ndarray) -> np.ndarray:
+        """
+        Process a chunk of audio data.
+        chunk: shape (N_samples, channels)
+        """
+        if self._sos is None:
+            return chunk
+
+        # Initialize or reshape filter state zi if needed
+        n_sections = self._sos.shape[0]
+        n_channels = chunk.shape[1] if chunk.ndim > 1 else 1
+        
+        if self._zi is None or self._zi.shape[2] != n_channels or self._zi.shape[0] != n_sections:
+             # zi needs shape (n_sections, 2, n_channels) for sosfilt
+             self._zi = np.zeros((n_sections, 2, n_channels))
+
+        # Apply filter and update state
+        out, self._zi = signal.sosfilt(self._sos, chunk, axis=0, zi=self._zi)
+        return out.astype(np.float32)
+
+    def reset_state(self):
+        """Clear filter state (call when stopping/starting playback to avoid glitches)."""
+        self._zi = None
+
+    def save_bands(self, filepath: str):
+        import json
+        data = []
+        for b in self.bands:
+            data.append({
+                'freq': b.freq,
+                'gain': b.gain,
+                'q': b.q,
+                'filter_type': b.filter_type,
+                'enabled': b.enabled
+            })
+        with open(filepath, 'w') as f:
+            json.dump(data, f, indent=4)
+            
+    def load_bands(self, filepath: str):
+        import json
+        with open(filepath, 'r') as f:
+            data = json.load(f)
+        
+        new_bands = []
+        for d in data:
+            new_bands.append(EQBand(
+                freq=d['freq'],
+                gain=d['gain'],
+                q=d['q'],
+                filter_type=d['filter_type'],
+                enabled=d.get('enabled', True)
+            ))
+        self.bands = new_bands
+        self.update_filters()

+ 109 - 0
core/file_loader.py

@@ -0,0 +1,109 @@
+"""
+File Loader — Load and decode WAV/MP3 audio files.
+
+Supports WAV via soundfile and MP3 via pydub (requires ffmpeg).
+All audio is returned as normalized float32 numpy arrays.
+"""
+
+import numpy as np
+import soundfile as sf
+from pathlib import Path
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class AudioData:
+    """Container for loaded audio data."""
+    samples: np.ndarray  # float32, shape: (num_samples, channels)
+    sample_rate: int
+    channels: int
+    duration: float  # seconds
+    file_path: Optional[str] = None
+    format_name: Optional[str] = None
+
+    @property
+    def num_samples(self) -> int:
+        return self.samples.shape[0]
+
+    def to_mono(self) -> "AudioData":
+        """Convert to mono by averaging channels."""
+        if self.channels == 1:
+            return self
+        mono = np.mean(self.samples, axis=1, keepdims=True)
+        return AudioData(
+            samples=mono,
+            sample_rate=self.sample_rate,
+            channels=1,
+            duration=self.duration,
+            file_path=self.file_path,
+            format_name=self.format_name,
+        )
+
+    def resample(self, target_rate: int) -> "AudioData":
+        """Resample audio to a different sample rate using linear interpolation."""
+        if self.sample_rate == target_rate:
+            return self
+
+        ratio = target_rate / self.sample_rate
+        new_length = int(self.num_samples * ratio)
+
+        # Use scipy for high-quality resampling
+        from scipy.signal import resample as scipy_resample
+        resampled = scipy_resample(self.samples, new_length, axis=0).astype(np.float32)
+
+        return AudioData(
+            samples=resampled,
+            sample_rate=target_rate,
+            channels=self.channels,
+            duration=new_length / target_rate,
+            file_path=self.file_path,
+            format_name=self.format_name,
+        )
+
+def get_audio_duration(file_path: str) -> float:
+    """Get the duration of an audio file in seconds without fully loading it."""
+    try:
+        info = sf.info(file_path)
+        return info.duration
+    except Exception:
+        return 0.0
+
+def load_audio(file_path: str) -> AudioData:
+    """
+    Load an audio file using soundfile.
+    
+    Supported: WAV, FLAC, OGG, AIFF, MP3, OPUS.
+    """
+    path = Path(file_path)
+    if not path.exists():
+        raise FileNotFoundError(f"File not found: {file_path}")
+
+    # soundfile natively supports MP3 in newer versions
+    data, sample_rate = sf.read(file_path, dtype="float32", always_2d=True)
+
+    return AudioData(
+        samples=data,
+        sample_rate=sample_rate,
+        channels=data.shape[1],
+        duration=len(data) / sample_rate,
+        file_path=str(path.resolve()),
+        format_name=path.suffix.upper().strip("."),
+    )
+
+
+def normalize_audio(audio: AudioData, target_peak: float = 0.95) -> AudioData:
+    """Normalize audio to a target peak level."""
+    peak = np.max(np.abs(audio.samples))
+    if peak == 0:
+        return audio
+
+    scale = target_peak / peak
+    return AudioData(
+        samples=(audio.samples * scale).astype(np.float32),
+        sample_rate=audio.sample_rate,
+        channels=audio.channels,
+        duration=audio.duration,
+        file_path=audio.file_path,
+        format_name=audio.format_name,
+    )

+ 98 - 0
core/signal_generator.py

@@ -0,0 +1,98 @@
+"""
+Signal Generator — Generate test signals for audio calibration.
+
+Supports:
+- Logarithmic sine sweep (chirp)
+- Pink noise (1/f)
+- White noise
+- Sine tone
+"""
+
+import numpy as np
+from scipy.signal import chirp
+
+def generate_log_sweep(
+    start_freq: float = 20.0,
+    end_freq: float = 20000.0,
+    duration: float = 10.0,
+    sample_rate: int = 44100,
+    amplitude: float = 0.8
+) -> np.ndarray:
+    """
+    Generate a logarithmic sine sweep (chirp).
+    
+    This is the preferred method for measuring frequency response
+    as it provides a high signal-to-noise ratio across the spectrum.
+    """
+    t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
+    # Generate the sweep
+    sweep = chirp(t, f0=start_freq, f1=end_freq, t1=duration, method='logarithmic')
+    
+    # Apply a short fade-in and fade-out to prevent clicks
+    fade_len = int(0.05 * sample_rate)  # 50ms fade
+    window = np.ones_like(sweep)
+    if len(window) > 2 * fade_len:
+        fade_in = np.linspace(0, 1, fade_len)
+        fade_out = np.linspace(1, 0, fade_len)
+        window[:fade_len] = fade_in
+        window[-fade_len:] = fade_out
+        
+    return (sweep * window * amplitude).astype(np.float32)
+
+def generate_white_noise(
+    duration: float = 10.0,
+    sample_rate: int = 44100,
+    amplitude: float = 0.8
+) -> np.ndarray:
+    """Generate white noise (equal energy per Hz)."""
+    num_samples = int(sample_rate * duration)
+    # np.random.randn generates Gaussian white noise (mean 0, variance 1)
+    noise = np.random.randn(num_samples)
+    # Normalize and scale
+    noise = (noise / np.max(np.abs(noise))) * amplitude
+    return noise.astype(np.float32)
+
+def generate_pink_noise(
+    duration: float = 10.0,
+    sample_rate: int = 44100,
+    amplitude: float = 0.8
+) -> np.ndarray:
+    """
+    Generate pink noise (equal energy per octave, 1/f spectrum).
+    
+    Uses a standard IIR filter approximation (Voss-McCartney algorithm or similar filter bank)
+    applied to white noise.
+    """
+    import scipy.signal as signal
+    
+    white = np.random.randn(int(sample_rate * duration))
+    
+    # Filter coefficients for a rough pinking filter
+    # (Paul Kellet's refined filter)
+    b = [0.049922035, -0.095993537, 0.050612699, -0.004408786]
+    a = [1.000000000, -2.494956002, 2.017265875, -0.522189400]
+    
+    pink = signal.lfilter(b, a, white)
+    
+    # Normalize and scale
+    pink = (pink / np.max(np.abs(pink))) * amplitude
+    return pink.astype(np.float32)
+
+def generate_sine_tone(
+    frequency: float = 1000.0,
+    duration: float = 5.0,
+    sample_rate: int = 44100,
+    amplitude: float = 0.8
+) -> np.ndarray:
+    """Generate a pure sine wave tone."""
+    t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
+    tone = np.sin(2 * np.pi * frequency * t)
+    
+    # Apply fade in/out
+    fade_len = int(0.05 * sample_rate)
+    window = np.ones_like(tone)
+    if len(window) > 2 * fade_len:
+        window[:fade_len] = np.linspace(0, 1, fade_len)
+        window[-fade_len:] = np.linspace(1, 0, fade_len)
+        
+    return (tone * window * amplitude).astype(np.float32)

+ 66 - 0
core/tape_processor.py

@@ -0,0 +1,66 @@
+"""
+Tape Processor — Simulates tape saturation and applies safety filters.
+"""
+import numpy as np
+from .eq_engine import EQBand, EQEngine
+from .calibrator import TAPE_PROFILES
+
+class TapeProcessor:
+    def __init__(self, sample_rate: int = 44100):
+        self.sample_rate = sample_rate
+        
+        self.tape_type = "Type I (Ferric)"
+        self.enable_highpass = True
+        self.enable_lowpass = True
+        self.enable_saturation = True
+        self.saturation_threshold = 0.95
+        
+        # Internal filter engine for safety filters
+        self._filter_engine = EQEngine(sample_rate)
+        self._update_filters()
+        
+    def _update_filters(self):
+        profile = TAPE_PROFILES.get(self.tape_type, TAPE_PROFILES["Type I (Ferric)"])
+        
+        bands = []
+        if self.enable_highpass:
+            bands.append(EQBand(freq=profile["low_cut"], gain=0.0, q=0.707, filter_type='highpass'))
+        if self.enable_lowpass:
+            bands.append(EQBand(freq=profile["high_cut"], gain=0.0, q=0.707, filter_type='lowpass'))
+            
+        self._filter_engine.bands = bands
+        self._filter_engine.update_filters()
+
+    def set_tape_type(self, tape_type: str):
+        if tape_type != self.tape_type:
+            self.tape_type = tape_type
+            self._update_filters()
+            
+    def set_sample_rate(self, sr: int):
+        self.sample_rate = sr
+        self._filter_engine.set_sample_rate(sr)
+
+    def process_pre_eq(self, chunk: np.ndarray) -> np.ndarray:
+        if chunk.size == 0:
+            return chunk
+            
+        # 1. Apply safety HP/LP filters
+        if self.enable_highpass or self.enable_lowpass:
+            chunk = self._filter_engine.process(chunk)
+            
+        return chunk
+        
+    def process_post_eq(self, chunk: np.ndarray) -> np.ndarray:
+        if chunk.size == 0:
+            return chunk
+            
+        # 2. Soft Saturation
+        if self.enable_saturation:
+            # Scale so threshold maps to where tanh starts rounding off
+            scale = 1.0 / max(0.1, self.saturation_threshold)
+            chunk = np.tanh(chunk * scale) / scale
+            
+        return chunk
+        
+    def reset_state(self):
+        self._filter_engine.reset_state()

+ 21 - 0
main.py

@@ -0,0 +1,21 @@
+import sys
+from PyQt6.QtWidgets import QApplication
+from ui.main_window import MainWindow
+
+def main():
+    app = QApplication(sys.argv)
+    
+    # Load stylesheet
+    try:
+        with open("resources/style.qss", "r") as f:
+            app.setStyleSheet(f.read())
+    except FileNotFoundError:
+        print("Warning: style.qss not found, using default styling.")
+
+    window = MainWindow()
+    window.show()
+    
+    sys.exit(app.exec())
+
+if __name__ == "__main__":
+    main()

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+sounddevice>=0.4.6
+numpy>=1.24
+scipy>=1.10
+soundfile>=0.12
+pydub>=0.25
+PyQt6>=6.5
+pyqtgraph>=0.13

+ 161 - 0
resources/style.qss

@@ -0,0 +1,161 @@
+/* Dark Theme for Tape Recorder Pro */
+
+QMainWindow {
+    background-color: #1a1a2e;
+    color: #e0e0e0;
+}
+
+QWidget {
+    background-color: #1a1a2e;
+    color: #e0e0e0;
+    font-family: "Segoe UI", Arial, sans-serif;
+    font-size: 10pt;
+}
+
+QTabWidget::pane {
+    border: 1px solid #30304a;
+    background-color: #161625;
+    border-radius: 4px;
+}
+
+QTabBar::tab {
+    background-color: #252538;
+    color: #a0a0b0;
+    padding: 8px 16px;
+    border: 1px solid #30304a;
+    border-bottom: none;
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+    margin-right: 2px;
+}
+
+QTabBar::tab:selected {
+    background-color: #161625;
+    color: #ffffff;
+    border-bottom: 2px solid #ff7b00; /* Analog amber accent */
+}
+
+QTabBar::tab:hover:!selected {
+    background-color: #2a2a40;
+}
+
+QPushButton {
+    background-color: #30304a;
+    color: #ffffff;
+    border: 1px solid #4a4a6a;
+    border-radius: 4px;
+    padding: 6px 12px;
+}
+
+QPushButton:hover {
+    background-color: #3d3d5c;
+    border: 1px solid #ff7b00;
+}
+
+QPushButton:pressed {
+    background-color: #ff7b00;
+    color: #1a1a2e;
+}
+
+QPushButton:disabled {
+    background-color: #202030;
+    color: #606070;
+    border: 1px solid #303040;
+}
+
+/* Accent Buttons (Primary Actions) */
+QPushButton#primaryButton {
+    background-color: #ff7b00;
+    color: #1a1a2e;
+    border: 1px solid #ff9933;
+    font-weight: bold;
+}
+
+QPushButton#primaryButton:hover {
+    background-color: #ff9933;
+}
+
+QComboBox {
+    background-color: #252538;
+    color: #ffffff;
+    border: 1px solid #4a4a6a;
+    border-radius: 4px;
+    padding: 4px 8px;
+}
+
+QComboBox:hover {
+    border: 1px solid #ff7b00;
+}
+
+QComboBox::drop-down {
+    border: none;
+    width: 24px;
+}
+
+QGroupBox {
+    border: 1px solid #30304a;
+    border-radius: 6px;
+    margin-top: 12px;
+    padding-top: 10px;
+    font-weight: bold;
+    color: #a0a0b0;
+}
+
+QGroupBox::title {
+    subcontrol-origin: margin;
+    subcontrol-position: top left;
+    padding: 0 5px;
+    left: 10px;
+}
+
+QSlider::groove:horizontal {
+    border: 1px solid #30304a;
+    height: 6px;
+    background: #161625;
+    border-radius: 3px;
+}
+
+QSlider::handle:horizontal {
+    background: #ff7b00;
+    border: 1px solid #ff9933;
+    width: 14px;
+    height: 14px;
+    margin: -4px 0;
+    border-radius: 7px;
+}
+
+QSlider::handle:horizontal:hover {
+    background: #ff9933;
+}
+
+/* Vertical Sliders (for EQ) */
+QSlider::groove:vertical {
+    border: 1px solid #30304a;
+    width: 6px;
+    background: #161625;
+    border-radius: 3px;
+}
+
+QSlider::handle:vertical {
+    background: #a0a0b0;
+    border: 1px solid #d0d0d0;
+    height: 18px;
+    width: 18px;
+    margin: 0 -6px;
+    border-radius: 2px; /* Rectangular for EQ faders */
+}
+
+QSlider::handle:vertical:hover {
+    background: #ff7b00;
+    border: 1px solid #ff9933;
+}
+
+QLabel {
+    color: #d0d0d0;
+}
+
+QStatusBar {
+    background-color: #12121c;
+    color: #808090;
+    border-top: 1px solid #30304a;
+}

+ 1 - 0
ui/__init__.py

@@ -0,0 +1 @@
+# UI package

BIN
ui/__pycache__/__init__.cpython-312.pyc


BIN
ui/__pycache__/calibration_panel.cpython-312.pyc


BIN
ui/__pycache__/device_panel.cpython-312.pyc


BIN
ui/__pycache__/eq_panel.cpython-312.pyc


BIN
ui/__pycache__/main_window.cpython-312.pyc


BIN
ui/__pycache__/player_panel.cpython-312.pyc


BIN
ui/__pycache__/spectrum_widget.cpython-312.pyc


BIN
ui/__pycache__/waveform_widget.cpython-312.pyc


+ 388 - 0
ui/calibration_panel.py

@@ -0,0 +1,388 @@
+"""
+Calibration Panel — Step-by-step wizard for frequency response measurement and EQ fitting.
+"""
+
+from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, 
+                             QPushButton, QLabel, QComboBox, 
+                             QProgressBar, QStackedWidget,
+                             QMessageBox, QGroupBox)
+from PyQt6.QtCore import Qt, QTimer, pyqtSignal
+import pyqtgraph as pg
+import numpy as np
+import threading
+import time
+
+from core.audio_device import DeviceManager
+from core.signal_generator import generate_log_sweep, generate_pink_noise
+from core.analyzer import calculate_transfer_function, smooth_curve
+from core.calibrator import calculate_correction_curve, fit_eq_bands
+from core.eq_engine import EQEngine
+from core.tape_processor import TapeProcessor
+import sounddevice as sd
+
+class CalibrationPanel(QWidget):
+    
+    # Signal emitted when calibration finishes and new EQ bands are ready
+    calibration_complete = pyqtSignal(list) # list of EQBand
+    
+    # Internal signals for cross-thread UI updates
+    _update_plots_signal = pyqtSignal(np.ndarray, np.ndarray, np.ndarray, np.ndarray)
+    _fit_complete_signal = pyqtSignal()
+    _set_play_btn_signal = pyqtSignal(bool, str)
+    _set_prog_signal = pyqtSignal(bool)
+    _set_analyze_btn_signal = pyqtSignal(str)
+    _set_apply_btn_signal = pyqtSignal(str)
+    
+    def __init__(self, device_manager: DeviceManager, eq_engine: EQEngine, tape_processor: TapeProcessor):
+        super().__init__()
+        self.device_manager = device_manager
+        self.eq_engine = eq_engine
+        self.tape_processor = tape_processor
+        
+        # State
+        self.reference_signal = None
+        self.recorded_signal = None
+        self.freqs = None
+        self.response_db = None
+        self.correction_db = None
+        
+        self._update_plots_signal.connect(self._update_plots_slot)
+        self._fit_complete_signal.connect(self._fit_complete_slot)
+        self._set_play_btn_signal.connect(self._set_play_btn_slot)
+        self._set_prog_signal.connect(self._set_prog_slot)
+        self._set_analyze_btn_signal.connect(self._set_analyze_btn_slot)
+        self._set_apply_btn_signal.connect(self._set_apply_btn_slot)
+        
+        self._init_ui()
+        
+    def _init_ui(self):
+        layout = QVBoxLayout(self)
+        
+        # --- Wizard Header ---
+        header = QLabel("Calibration Wizard")
+        header.setStyleSheet("font-size: 16pt; font-weight: bold; color: #ff7b00;")
+        layout.addWidget(header)
+        
+        # --- Steps Stack ---
+        self.stack = QStackedWidget()
+        
+        # Step 1: Generate & Play
+        self.step1 = self._create_step1()
+        self.stack.addWidget(self.step1)
+        
+        # Step 2: Record
+        self.step2 = self._create_step2()
+        self.stack.addWidget(self.step2)
+        
+        # Step 3: Analyze & Apply
+        self.step3 = self._create_step3()
+        self.stack.addWidget(self.step3)
+        
+        layout.addWidget(self.stack, 1)
+        
+        # --- Navigation ---
+        nav_row = QHBoxLayout()
+        self.prev_btn = QPushButton("← Back")
+        self.prev_btn.clicked.connect(self._prev_step)
+        self.prev_btn.setEnabled(False)
+        
+        self.next_btn = QPushButton("Next →")
+        self.next_btn.setObjectName("primaryButton")
+        self.next_btn.clicked.connect(self._next_step)
+        
+        nav_row.addWidget(self.prev_btn)
+        nav_row.addStretch()
+        nav_row.addWidget(self.next_btn)
+        
+        layout.addLayout(nav_row)
+
+    def _create_step1(self) -> QWidget:
+        w = QWidget()
+        l = QVBoxLayout(w)
+        
+        info = QLabel(
+            "Step 1: Generate Test Signal\n\n"
+            "Prepare your cassette deck to record. "
+            "A log sine sweep provides the best accuracy for calibration."
+        )
+        info.setWordWrap(True)
+        l.addWidget(info)
+        
+        grp = QGroupBox("Signal & Tape Settings")
+        gl = QVBoxLayout(grp)
+        
+        row0 = QHBoxLayout()
+        row0.addWidget(QLabel("Tape Type:"))
+        self.tape_type_cb = QComboBox()
+        self.tape_type_cb.addItems(["Type I (Ferric)", "Type II (Chrome)", "Type IV (Metal)"])
+        self.tape_type_cb.currentTextChanged.connect(self._on_tape_type_changed)
+        row0.addWidget(self.tape_type_cb)
+        row0.addStretch()
+        gl.addLayout(row0)
+        
+        row1 = QHBoxLayout()
+        row1.addWidget(QLabel("Signal Type:"))
+        self.sig_type = QComboBox()
+        self.sig_type.addItems(["Log Sine Sweep (Recommended)", "Pink Noise"])
+        row1.addWidget(self.sig_type)
+        row1.addStretch()
+        gl.addLayout(row1)
+        
+        row2 = QHBoxLayout()
+        row2.addWidget(QLabel("Duration:"))
+        self.sig_dur = QComboBox()
+        self.sig_dur.addItems(["10 Seconds", "20 Seconds", "30 Seconds"])
+        row2.addWidget(self.sig_dur)
+        row2.addStretch()
+        gl.addLayout(row2)
+        
+        l.addWidget(grp)
+        
+        self.play_test_btn = QPushButton("Generate & Play Signal")
+        self.play_test_btn.setObjectName("primaryButton")
+        self.play_test_btn.clicked.connect(self._play_test_signal)
+        l.addWidget(self.play_test_btn)
+        
+        self.play_prog = QProgressBar()
+        self.play_prog.setTextVisible(False)
+        self.play_prog.hide()
+        l.addWidget(self.play_prog)
+        
+        l.addStretch()
+        return w
+
+    def _create_step2(self) -> QWidget:
+        w = QWidget()
+        l = QVBoxLayout(w)
+        
+        info = QLabel(
+            "Step 2: Record Playback\n\n"
+            "If you have a 3-head deck, you can record this immediately. "
+            "For 2-head decks, rewind the tape and press play."
+        )
+        info.setWordWrap(True)
+        l.addWidget(info)
+        
+        # Realtime view for recording
+        pg.setConfigOption('background', '#161625')
+        self.rec_plot = pg.PlotWidget()
+        self.rec_plot.setYRange(-1, 1)
+        self.rec_curve = self.rec_plot.plot(pen='#ff7b00')
+        l.addWidget(self.rec_plot, 1)
+        
+        self.rec_btn = QPushButton("Start Recording Capture")
+        self.rec_btn.setObjectName("primaryButton")
+        self.rec_btn.clicked.connect(self._toggle_recording)
+        l.addWidget(self.rec_btn)
+        
+        return w
+
+    def _create_step3(self) -> QWidget:
+        w = QWidget()
+        l = QVBoxLayout(w)
+        
+        self.analyze_btn = QPushButton("Analyze Recorded Signal")
+        self.analyze_btn.setObjectName("primaryButton")
+        self.analyze_btn.clicked.connect(self._analyze)
+        l.addWidget(self.analyze_btn)
+        
+        # Result plot
+        self.res_plot = pg.PlotWidget(title="Frequency Response & EQ Target")
+        self.res_plot.setLogMode(x=True, y=False)
+        self.res_plot.setXRange(np.log10(20), np.log10(20000))
+        self.res_plot.setYRange(-20, 20)
+        self.res_plot.showGrid(x=True, y=True, alpha=0.3)
+        self.res_plot.addLegend()
+        
+        self.curve_raw = self.res_plot.plot(pen=pg.mkPen('#505050', width=1), name="Raw Response")
+        self.curve_smooth = self.res_plot.plot(pen=pg.mkPen('#ff7b00', width=2), name="Smoothed Response")
+        self.curve_target = self.res_plot.plot(pen=pg.mkPen('#4CAF50', width=2, style=Qt.PenStyle.DashLine), name="EQ Target")
+        
+        l.addWidget(self.res_plot, 1)
+        
+        self.apply_eq_btn = QPushButton("Auto-Fit EQ & Apply")
+        self.apply_eq_btn.setObjectName("primaryButton")
+        self.apply_eq_btn.setEnabled(False)
+        self.apply_eq_btn.clicked.connect(self._apply_eq)
+        l.addWidget(self.apply_eq_btn)
+        
+        return w
+
+    def _set_play_btn_slot(self, enabled, text):
+        self.play_test_btn.setEnabled(enabled)
+        self.play_test_btn.setText(text)
+        
+    def _set_prog_slot(self, show):
+        self.play_prog.setVisible(show)
+        
+    def _set_analyze_btn_slot(self, text):
+        self.analyze_btn.setText(text)
+        
+    def _set_apply_btn_slot(self, text):
+        self.apply_eq_btn.setText(text)
+        
+    def _on_tape_type_changed(self, tape_type: str):
+        self.tape_processor.set_tape_type(tape_type)
+
+    # --- Step 1 Logic ---
+    def _play_test_signal(self):
+        if self.device_manager.output_device_id is None:
+            QMessageBox.warning(self, "Error", "No output device selected in Devices tab.")
+            return
+            
+        dur = int(self.sig_dur.currentText().split()[0])
+        sr = self.device_manager.sample_rate
+        
+        if "Sweep" in self.sig_type.currentText():
+            self.reference_signal = generate_log_sweep(duration=dur, sample_rate=sr)
+        else:
+            self.reference_signal = generate_pink_noise(duration=dur, sample_rate=sr)
+            
+        # Play in background thread
+        threading.Thread(target=self._play_thread, args=(self.reference_signal, sr), daemon=True).start()
+        
+    def _play_thread(self, signal_data, sr):
+        # UI updates
+        self._set_play_btn_signal.emit(False, "Playing...")
+        self._set_prog_signal.emit(True)
+        
+        try:
+            sd.play(signal_data, sr, device=self.device_manager.output_device_id)
+            sd.wait()
+        except Exception as e:
+            print(f"Playback error: {e}")
+            
+        self._set_play_btn_signal.emit(True, "Generate & Play Signal")
+        self._set_prog_signal.emit(False)
+
+    # --- Step 2 Logic ---
+    def _toggle_recording(self):
+        if hasattr(self, 'is_recording') and self.is_recording:
+            # Stop
+            self.is_recording = False
+            self.rec_btn.setText("Start Recording Capture")
+            self.rec_btn.setStyleSheet("")
+            if hasattr(self, 'rec_stream'):
+                self.rec_stream.stop()
+                self.rec_stream.close()
+                self.recorded_signal = np.concatenate(self.rec_chunks, axis=0)
+                # Take left channel if stereo
+                if self.recorded_signal.ndim > 1:
+                    self.recorded_signal = self.recorded_signal[:, 0]
+        else:
+            # Start
+            if self.device_manager.input_device_id is None:
+                QMessageBox.warning(self, "Error", "No input device selected.")
+                return
+                
+            self.is_recording = True
+            self.rec_btn.setText("Stop Recording")
+            self.rec_btn.setStyleSheet("background-color: #F44336; color: white;")
+            self.rec_chunks = []
+            
+            kwargs = self.device_manager.get_sd_input_kwargs()
+            self.rec_stream = sd.InputStream(callback=self._rec_callback, **kwargs)
+            self.rec_stream.start()
+            
+    def _rec_callback(self, indata, frames, time, status):
+        self.rec_chunks.append(indata.copy())
+        # Update plot with decimated data for performance
+        if len(self.rec_chunks) % 5 == 0:
+            chunk = indata[:, 0]
+            decimated = chunk[::max(1, len(chunk)//500)]
+            from PyQt6.QtCore import QMetaObject, Q_ARG
+            # Thread-safe plot update (simplified for UI)
+            pass
+
+    # --- Step 3 Logic ---
+    def _analyze(self):
+        if self.reference_signal is None or self.recorded_signal is None:
+            QMessageBox.warning(self, "Error", "Missing reference or recorded signal.")
+            return
+            
+        self.analyze_btn.setText("Analyzing...")
+        self.analyze_btn.setEnabled(False)
+        
+        # Run in thread
+        threading.Thread(target=self._analyze_thread, daemon=True).start()
+        
+    def _analyze_thread(self):
+        try:
+            sr = self.device_manager.sample_rate
+            
+            # 1. Transfer function
+            freqs, resp = calculate_transfer_function(self.reference_signal, self.recorded_signal, sr)
+            
+            # 2. Smooth
+            resp_smooth = smooth_curve(freqs, resp, fraction=1/3)
+            
+            # 3. Target correction
+            correction = calculate_correction_curve(resp_smooth)
+            
+            self.freqs = freqs
+            self.response_db = resp_smooth
+            self.correction_db = correction
+            
+            # Update UI
+            self._update_plots_signal.emit(freqs, resp, resp_smooth, correction)
+        except Exception as e:
+            print(f"Analysis error: {e}")
+            self._set_analyze_btn_signal.emit("Analysis Failed")
+            
+    def _update_plots_slot(self, freqs, resp_raw, resp_smooth, correction):
+        self.curve_raw.setData(freqs, resp_raw)
+        self.curve_smooth.setData(freqs, resp_smooth)
+        self.curve_target.setData(freqs, correction)
+        
+        self.analyze_btn.setText("Analysis Complete")
+        self.apply_eq_btn.setEnabled(True)
+        
+    def _apply_eq(self):
+        if self.freqs is None or self.correction_db is None:
+            return
+            
+        self.apply_eq_btn.setText("Fitting EQ...")
+        self.apply_eq_btn.setEnabled(False)
+        
+        # Fit in thread
+        threading.Thread(target=self._fit_thread, daemon=True).start()
+        
+    def _fit_thread(self):
+        sr = self.device_manager.sample_rate
+        try:
+            ttype = self.tape_type_cb.currentText()
+            # Fit 10 peaking bands constrained by tape type
+            bands = fit_eq_bands(self.freqs, self.correction_db, num_peaking_bands=10, sample_rate=sr, tape_type=ttype)
+            
+            # Apply to engine
+            self.eq_engine.bands = bands
+            self.eq_engine.update_filters()
+            
+            self._fit_complete_signal.emit()
+            
+        except Exception as e:
+            print(f"Fitting error: {e}")
+            self._set_apply_btn_signal.emit("Fit Failed")
+            
+    def _fit_complete_slot(self):
+        self.apply_eq_btn.setText("EQ Applied ✓")
+        self.calibration_complete.emit(self.eq_engine.bands)
+        QMessageBox.information(self, "Success", "EQ calibrated and applied to engine!")
+
+    # --- Navigation Logic ---
+    def _prev_step(self):
+        idx = self.stack.currentIndex()
+        if idx > 0:
+            self.stack.setCurrentIndex(idx - 1)
+            self._update_nav_buttons()
+
+    def _next_step(self):
+        idx = self.stack.currentIndex()
+        if idx < self.stack.count() - 1:
+            self.stack.setCurrentIndex(idx + 1)
+            self._update_nav_buttons()
+
+    def _update_nav_buttons(self):
+        idx = self.stack.currentIndex()
+        self.prev_btn.setEnabled(idx > 0)
+        self.next_btn.setEnabled(idx < self.stack.count() - 1)

+ 149 - 0
ui/device_panel.py

@@ -0,0 +1,149 @@
+"""
+Device Panel — UI for selecting audio input/output devices.
+"""
+
+from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, 
+                             QLabel, QComboBox, QPushButton, QGroupBox,
+                             QMessageBox)
+from PyQt6.QtCore import pyqtSignal
+
+from core.audio_device import DeviceManager
+
+class DevicePanel(QWidget):
+    # Signal emitted when devices are successfully applied
+    devices_changed = pyqtSignal()
+    
+    def __init__(self, device_manager: DeviceManager):
+        super().__init__()
+        self.device_manager = device_manager
+        self._init_ui()
+        self.refresh_device_lists()
+        
+    def _init_ui(self):
+        layout = QVBoxLayout()
+        layout.setSpacing(20)
+        
+        # --- Audio Hardware Settings Group ---
+        hw_group = QGroupBox("Audio Hardware Setup")
+        hw_layout = QVBoxLayout()
+        
+        # Info text
+        info_lbl = QLabel(
+            "Select the sound card inputs and outputs connected to your cassette deck.\n"
+            "• Connect PC Output to Tape Deck REC IN\n"
+            "• Connect Tape Deck PLAY OUT to PC Input"
+        )
+        info_lbl.setWordWrap(True)
+        info_lbl.setStyleSheet("color: #a0a0b0; font-style: italic; margin-bottom: 10px;")
+        hw_layout.addWidget(info_lbl)
+        
+        # Input Device Row
+        in_row = QHBoxLayout()
+        in_lbl = QLabel("Recording Input (From Tape):")
+        in_lbl.setFixedWidth(180)
+        self.in_combo = QComboBox()
+        self.in_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
+        in_row.addWidget(in_lbl)
+        in_row.addWidget(self.in_combo, 1)
+        hw_layout.addLayout(in_row)
+        
+        # Output Device Row
+        out_row = QHBoxLayout()
+        out_lbl = QLabel("Playback Output (To Tape):")
+        out_lbl.setFixedWidth(180)
+        self.out_combo = QComboBox()
+        self.out_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
+        out_row.addWidget(out_lbl)
+        out_row.addWidget(self.out_combo, 1)
+        hw_layout.addLayout(out_row)
+        
+        # Sample Rate Row
+        sr_row = QHBoxLayout()
+        sr_lbl = QLabel("Sample Rate:")
+        sr_lbl.setFixedWidth(180)
+        self.sr_combo = QComboBox()
+        self.sr_combo.addItems(["44100 Hz", "48000 Hz", "96000 Hz"])
+        self.sr_combo.setCurrentText("44100 Hz")
+        sr_row.addWidget(sr_lbl)
+        sr_row.addWidget(self.sr_combo)
+        sr_row.addStretch()
+        hw_layout.addLayout(sr_row)
+        
+        hw_group.setLayout(hw_layout)
+        layout.addWidget(hw_group)
+        
+        # --- Actions Row ---
+        action_row = QHBoxLayout()
+        
+        self.refresh_btn = QPushButton("Refresh Devices")
+        self.refresh_btn.clicked.connect(self.refresh_device_lists)
+        
+        self.apply_btn = QPushButton("Apply Settings")
+        self.apply_btn.setObjectName("primaryButton")
+        self.apply_btn.clicked.connect(self.apply_settings)
+        
+        action_row.addWidget(self.refresh_btn)
+        action_row.addStretch()
+        action_row.addWidget(self.apply_btn)
+        
+        layout.addLayout(action_row)
+        layout.addStretch()
+        
+        self.setLayout(layout)
+
+    def refresh_device_lists(self):
+        """Update the dropdowns with available devices."""
+        self.device_manager.refresh_devices()
+        
+        self.in_combo.clear()
+        for dev in self.device_manager.get_input_devices():
+            self.in_combo.addItem(str(dev), dev.index)
+            
+        self.out_combo.clear()
+        for dev in self.device_manager.get_output_devices():
+            self.out_combo.addItem(str(dev), dev.index)
+            
+        # Try to select current if set
+        if self.device_manager.input_device_id is not None:
+            idx = self.in_combo.findData(self.device_manager.input_device_id)
+            if idx >= 0:
+                self.in_combo.setCurrentIndex(idx)
+                
+        if self.device_manager.output_device_id is not None:
+            idx = self.out_combo.findData(self.device_manager.output_device_id)
+            if idx >= 0:
+                self.out_combo.setCurrentIndex(idx)
+
+    def apply_settings(self):
+        """Apply selected devices to the DeviceManager."""
+        in_idx = self.in_combo.currentData()
+        out_idx = self.out_combo.currentData()
+        
+        sr_str = self.sr_combo.currentText().split()[0]
+        sr = int(sr_str)
+        
+        try:
+            if in_idx is not None:
+                self.device_manager.input_device_id = in_idx
+            if out_idx is not None:
+                self.device_manager.output_device_id = out_idx
+                
+            self.device_manager.sample_rate = sr
+            
+            self.devices_changed.emit()
+            
+            # Change button appearance briefly to show success
+            original_text = self.apply_btn.text()
+            self.apply_btn.setText("Applied ✓")
+            self.apply_btn.setStyleSheet("background-color: #4CAF50; color: white;")
+            
+            # Reset after 2 seconds
+            import PyQt6.QtCore as QtCore
+            QtCore.QTimer.singleShot(2000, lambda: self._reset_apply_btn(original_text))
+            
+        except Exception as e:
+            QMessageBox.critical(self, "Device Error", f"Failed to apply audio settings:\n{str(e)}")
+            
+    def _reset_apply_btn(self, text):
+        self.apply_btn.setText(text)
+        self.apply_btn.setStyleSheet("") # Revert to stylesheet default

+ 325 - 0
ui/eq_panel.py

@@ -0,0 +1,325 @@
+"""
+EQ Panel — Interactive UI for the Parametric EQ Engine.
+"""
+
+from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, 
+                             QPushButton, QLabel, QSlider, QGroupBox,
+                             QCheckBox, QScrollArea, QComboBox, QFileDialog)
+from PyQt6.QtCore import Qt, QSettings
+import os
+import pyqtgraph as pg
+import numpy as np
+import scipy.signal as signal
+from core.eq_engine import EQEngine, EQBand
+from core.tape_processor import TapeProcessor
+
+class EQBandWidget(QGroupBox):
+    """Widget for controlling a single EQ band."""
+    def __init__(self, band: EQBand, index: int, parent_panel):
+        super().__init__(f"Band {index + 1}")
+        self.band = band
+        self.parent_panel = parent_panel
+        
+        self.setFixedWidth(80)
+        self._init_ui()
+        self._update_ui_from_band()
+        
+    def _init_ui(self):
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(5, 10, 5, 5)
+        layout.setSpacing(10)
+        
+        # Enabled toggle
+        self.enabled_cb = QCheckBox("On")
+        self.enabled_cb.clicked.connect(self._on_changed)
+        layout.addWidget(self.enabled_cb, alignment=Qt.AlignmentFlag.AlignHCenter)
+        
+        # Gain slider (-15 to +15 dB)
+        self.gain_slider = QSlider(Qt.Orientation.Vertical)
+        self.gain_slider.setRange(-150, 150) # -15.0 to 15.0 dB
+        self.gain_slider.setTickPosition(QSlider.TickPosition.TicksBothSides)
+        self.gain_slider.setTickInterval(50)
+        self.gain_slider.valueChanged.connect(self._on_gain_changed)
+        layout.addWidget(self.gain_slider, alignment=Qt.AlignmentFlag.AlignHCenter)
+        
+        # Gain Label
+        self.gain_lbl = QLabel("0.0 dB")
+        self.gain_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        layout.addWidget(self.gain_lbl)
+        
+        # Freq
+        freq_lbl = QLabel("Freq (Hz):")
+        freq_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        freq_lbl.setStyleSheet("font-size: 8pt; color: #808090;")
+        layout.addWidget(freq_lbl)
+        
+        self.freq_val = QLabel(f"{self.band.freq:.0f}")
+        self.freq_val.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        layout.addWidget(self.freq_val)
+        
+        # Q
+        q_lbl = QLabel("Q:")
+        q_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        q_lbl.setStyleSheet("font-size: 8pt; color: #808090;")
+        layout.addWidget(q_lbl)
+        
+        self.q_val = QLabel(f"{self.band.q:.2f}")
+        self.q_val.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        layout.addWidget(self.q_val)
+        
+    def _update_ui_from_band(self):
+        """Update widgets when the underlying band object changes (e.g. after calibration)."""
+        self.enabled_cb.setChecked(self.band.enabled)
+        # Block signals to avoid infinite loop
+        self.gain_slider.blockSignals(True)
+        self.gain_slider.setValue(int(self.band.gain * 10))
+        self.gain_slider.blockSignals(False)
+        self.gain_lbl.setText(f"{self.band.gain:.1f} dB")
+        self.freq_val.setText(f"{self.band.freq:.0f}")
+        self.q_val.setText(f"{self.band.q:.2f}")
+        
+    def _on_gain_changed(self, value):
+        self.band.gain = value / 10.0
+        self.gain_lbl.setText(f"{self.band.gain:.1f} dB")
+        self.parent_panel.on_band_changed()
+        
+    def _on_changed(self):
+        self.band.enabled = self.enabled_cb.isChecked()
+        self.parent_panel.on_band_changed()
+
+class EQPanel(QWidget):
+    def __init__(self, eq_engine: EQEngine, tape_processor: TapeProcessor):
+        super().__init__()
+        self.eq_engine = eq_engine
+        self.tape_processor = tape_processor
+        self.band_widgets = []
+        self.settings = QSettings("TapeRecorder", "EQ")
+        self._init_ui()
+        
+        last_profile = self.settings.value("last_profile", "")
+        if last_profile and os.path.exists(last_profile):
+            try:
+                self.eq_engine.load_bands(last_profile)
+            except Exception as e:
+                print(f"Failed to load last profile: {e}")
+                
+        self.refresh_ui()
+
+    def _init_ui(self):
+        layout = QVBoxLayout(self)
+        
+        # --- Top: Response Plot ---
+        pg.setConfigOption('background', '#161625')
+        self.plot_widget = pg.PlotWidget(title="Combined EQ Response")
+        self.plot_widget.setLogMode(x=True, y=False)
+        self.plot_widget.setXRange(np.log10(20), np.log10(20000))
+        self.plot_widget.setYRange(-20, 20)
+        self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
+        self.plot_widget.setLabel('left', 'Gain', units='dB')
+        self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
+        
+        self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
+        self.plot_widget.setFixedHeight(250)
+        
+        layout.addWidget(self.plot_widget)
+        
+        # --- Middle: Band Controls ---
+        scroll = QScrollArea()
+        scroll.setWidgetResizable(True)
+        scroll.setStyleSheet("border: none;")
+        
+        bands_container = QWidget()
+        self.bands_layout = QHBoxLayout(bands_container)
+        self.bands_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
+        
+        scroll.setWidget(bands_container)
+        layout.addWidget(scroll)
+        
+        # --- Bottom: Presets & Tools & Tape Processing ---
+        bottom_layout = QHBoxLayout()
+        
+        # Tools Group
+        tools_grp = QGroupBox("EQ Tools")
+        tools_layout = QVBoxLayout(tools_grp)
+        row1 = QHBoxLayout()
+        
+        self.bypass_cb = QCheckBox("Bypass EQ")
+        self.bypass_cb.setStyleSheet("font-weight: bold; color: #F44336;")
+        self.bypass_cb.clicked.connect(self._on_bypass)
+        row1.addWidget(self.bypass_cb)
+        row1.addStretch()
+        
+        row1.addWidget(QLabel("Presets:"))
+        self.preset_combo = QComboBox()
+        self.preset_combo.addItems(["Custom", "Flat", "Type I (Ferric) Boost", "Type II (Chrome) Cut"])
+        self.preset_combo.currentIndexChanged.connect(self._on_preset)
+        row1.addWidget(self.preset_combo)
+        
+        reset_btn = QPushButton("Reset to Flat")
+        reset_btn.clicked.connect(self._reset_flat)
+        row1.addWidget(reset_btn)
+        tools_layout.addLayout(row1)
+        
+        row2 = QHBoxLayout()
+        save_btn = QPushButton("Save Profile...")
+        save_btn.clicked.connect(self._save_profile)
+        load_btn = QPushButton("Load Profile...")
+        load_btn.clicked.connect(self._load_profile)
+        row2.addWidget(save_btn)
+        row2.addWidget(load_btn)
+        row2.addStretch()
+        tools_layout.addLayout(row2)
+        bottom_layout.addWidget(tools_grp)
+        
+        # Tape Processing Group
+        tape_grp = QGroupBox("Tape Processing (Record Mode)")
+        tape_layout = QVBoxLayout(tape_grp)
+        
+        t_row1 = QHBoxLayout()
+        self.tape_hp_cb = QCheckBox("High-Pass (Sub-Bass Cut)")
+        self.tape_hp_cb.setChecked(self.tape_processor.enable_highpass)
+        self.tape_hp_cb.clicked.connect(self._on_tape_proc_changed)
+        t_row1.addWidget(self.tape_hp_cb)
+        
+        self.tape_lp_cb = QCheckBox("Low-Pass (Ultrasonic Cut)")
+        self.tape_lp_cb.setChecked(self.tape_processor.enable_lowpass)
+        self.tape_lp_cb.clicked.connect(self._on_tape_proc_changed)
+        t_row1.addWidget(self.tape_lp_cb)
+        tape_layout.addLayout(t_row1)
+        
+        t_row2 = QHBoxLayout()
+        self.tape_sat_cb = QCheckBox("Soft Saturation (Limiter)")
+        self.tape_sat_cb.setChecked(self.tape_processor.enable_saturation)
+        self.tape_sat_cb.clicked.connect(self._on_tape_proc_changed)
+        t_row2.addWidget(self.tape_sat_cb)
+        
+        self.sat_thresh_slider = QSlider(Qt.Orientation.Horizontal)
+        self.sat_thresh_slider.setRange(10, 200) # 0.1 to 2.0
+        self.sat_thresh_slider.setValue(int(self.tape_processor.saturation_threshold * 100))
+        self.sat_thresh_slider.setToolTip("Saturation Threshold")
+        self.sat_thresh_slider.valueChanged.connect(self._on_sat_slider)
+        t_row2.addWidget(self.sat_thresh_slider)
+        tape_layout.addLayout(t_row2)
+        
+        bottom_layout.addWidget(tape_grp)
+        
+        layout.addLayout(bottom_layout)
+
+    def refresh_ui(self):
+        """Rebuild the UI to match the EQEngine's current bands."""
+        # Clear existing widgets
+        for i in reversed(range(self.bands_layout.count())): 
+            self.bands_layout.itemAt(i).widget().setParent(None)
+            
+        self.band_widgets = []
+        
+        # Create new widgets
+        for i, band in enumerate(self.eq_engine.bands):
+            bw = EQBandWidget(band, i, self)
+            self.bands_layout.addWidget(bw)
+            self.band_widgets.append(bw)
+            
+        self.update_plot()
+            
+    def on_band_changed(self):
+        """Called by a child BandWidget when a parameter changes."""
+        self.preset_combo.blockSignals(True)
+        self.preset_combo.setCurrentIndex(0) # Custom
+        self.preset_combo.blockSignals(False)
+        
+        if not self.bypass_cb.isChecked():
+            self.eq_engine.update_filters()
+            
+        self.update_plot()
+        
+    def _on_bypass(self):
+        if self.bypass_cb.isChecked():
+            # Clear filters in engine to bypass
+            self.eq_engine._sos = None
+            self.eq_engine._zi = None
+        else:
+            self.eq_engine.update_filters()
+        self.update_plot()
+
+    def _on_tape_proc_changed(self):
+        self.tape_processor.enable_highpass = self.tape_hp_cb.isChecked()
+        self.tape_processor.enable_lowpass = self.tape_lp_cb.isChecked()
+        self.tape_processor.enable_saturation = self.tape_sat_cb.isChecked()
+        self.tape_processor._update_filters()
+        self.update_plot()
+        
+    def _on_sat_slider(self, val):
+        self.tape_processor.saturation_threshold = val / 100.0
+
+    def update_plot(self):
+        """Recalculate and redraw the frequency response curve."""
+        freqs = np.logspace(np.log10(20), np.log10(20000), 500)
+        
+        if self.bypass_cb.isChecked():
+            response = np.zeros_like(freqs)
+        else:
+            # Need to temporarily generate SOS for the plot if bypassed, but here we just
+            # Use the active SOS or calculate it from the bands.
+            engine = EQEngine(self.eq_engine.sample_rate)
+            
+            all_bands = list(self.eq_engine.bands)
+            if self.tape_processor.enable_highpass or self.tape_processor.enable_lowpass:
+                all_bands.extend(self.tape_processor._filter_engine.bands)
+                
+            engine.bands = all_bands
+            engine.update_filters()
+            
+            if engine._sos is None:
+                response = np.zeros_like(freqs)
+            else:
+                w = 2 * np.pi * freqs / engine.sample_rate
+                _, h = signal.sosfreqz(engine._sos, worN=w)
+                response = 20 * np.log10(np.abs(h) + 1e-10)
+                
+        self.curve.setData(freqs, response)
+
+    def _reset_flat(self):
+        for band in self.eq_engine.bands:
+            band.gain = 0.0
+            
+        for bw in self.band_widgets:
+            bw._update_ui_from_band()
+            
+        self.on_band_changed()
+        
+    def _on_preset(self, index):
+        if index == 1: # Flat
+            self._reset_flat()
+        elif index == 2: # Type I Boost (example)
+            self._reset_flat()
+            # Boost highs to compensate for tape roll-off
+            if len(self.eq_engine.bands) > 0:
+                self.eq_engine.bands[-1].gain = 6.0 # High shelf boost
+        elif index == 3: # Type II Cut (example)
+            self._reset_flat()
+            # Cut highs if playing 70us tape on 120us setting
+            if len(self.eq_engine.bands) > 0:
+                self.eq_engine.bands[-1].gain = -4.5 # High shelf cut
+                
+        for bw in self.band_widgets:
+            bw._update_ui_from_band()
+            
+        self.eq_engine.update_filters()
+        self.update_plot()
+        
+    def _save_profile(self):
+        path, _ = QFileDialog.getSaveFileName(self, "Save EQ Profile", "", "JSON Files (*.json)")
+        if path:
+            self.eq_engine.save_bands(path)
+            self.settings.setValue("last_profile", path)
+            
+    def _load_profile(self):
+        path, _ = QFileDialog.getOpenFileName(self, "Load EQ Profile", "", "JSON Files (*.json)")
+        if path:
+            try:
+                self.eq_engine.load_bands(path)
+                self.settings.setValue("last_profile", path)
+                self.refresh_ui()
+            except Exception as e:
+                from PyQt6.QtWidgets import QMessageBox
+                QMessageBox.warning(self, "Error", f"Failed to load profile:\n{e}")

+ 103 - 0
ui/main_window.py

@@ -0,0 +1,103 @@
+"""
+Main Window — Main application shell with tabbed interface.
+"""
+
+from PyQt6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout, QWidget, QLabel
+from PyQt6.QtCore import Qt
+
+from core.audio_device import DeviceManager
+from core.eq_engine import EQEngine
+from core.tape_processor import TapeProcessor
+from core.audio_player import AudioPlayer
+from core.audio_recorder import AudioRecorder
+
+from ui.device_panel import DevicePanel
+from ui.player_panel import PlayerPanel
+from ui.calibration_panel import CalibrationPanel
+from ui.eq_panel import EQPanel
+
+class MainWindow(QMainWindow):
+    def __init__(self):
+        super().__init__()
+        self.setWindowTitle("Tape Recorder Pro - Auto-Calibrating EQ")
+        self.resize(1000, 700)
+        
+        # --- Initialize Core Audio Engine ---
+        self.device_manager = DeviceManager.instance()
+        self.device_manager.auto_select_defaults()
+        self.eq_engine = EQEngine()
+        self.tape_processor = TapeProcessor(self.device_manager.sample_rate)
+        
+        self.player = AudioPlayer(self.eq_engine, self.tape_processor, self.device_manager)
+        self.recorder = AudioRecorder(self.device_manager)
+        
+        self._init_ui()
+        self._update_status()
+
+    def _init_ui(self):
+        # Central widget and layout
+        central_widget = QWidget()
+        self.setCentralWidget(central_widget)
+        
+        layout = QVBoxLayout(central_widget)
+        layout.setContentsMargins(10, 10, 10, 10)
+        
+        # Tabs
+        self.tabs = QTabWidget()
+        
+        # 1. Device Setup Tab
+        self.device_panel = DevicePanel(self.device_manager)
+        self.device_panel.devices_changed.connect(self._update_status)
+        self.tabs.addTab(self.device_panel, "🔊 Devices")
+        
+        # 2. Calibration Tab
+        self.calibration_panel = CalibrationPanel(self.device_manager, self.eq_engine, self.tape_processor)
+        self.calibration_panel.calibration_complete.connect(self._on_calibration_complete)
+        self.tabs.addTab(self.calibration_panel, "📐 Calibrate")
+        
+        # 3. EQ Tab
+        self.eq_panel = EQPanel(self.eq_engine, self.tape_processor)
+        self.tabs.addTab(self.eq_panel, "🎚️ EQ")
+        
+        # 4. Player Tab
+        self.player_panel = PlayerPanel(self.player)
+        self.tabs.addTab(self.player_panel, "▶️ Player")
+        
+        layout.addWidget(self.tabs)
+        
+        # Status Bar
+        self.statusBar().showMessage("Ready")
+        
+        # Persistent device info labels in status bar
+        self.in_status_lbl = QLabel("Input: None")
+        self.out_status_lbl = QLabel("Output: None")
+        self.statusBar().addPermanentWidget(self.in_status_lbl)
+        self.statusBar().addPermanentWidget(self.out_status_lbl)
+
+    def _update_status(self):
+        """Update status bar with current device names."""
+        in_id = self.device_manager.input_device_id
+        if in_id is not None:
+            dev = self.device_manager.get_device_by_index(in_id)
+            name = dev.name if dev else "Unknown"
+            # truncate long names
+            if len(name) > 30: name = name[:27] + "..."
+            self.in_status_lbl.setText(f"In: {name}")
+        else:
+            self.in_status_lbl.setText("In: None")
+            
+        out_id = self.device_manager.output_device_id
+        if out_id is not None:
+            dev = self.device_manager.get_device_by_index(out_id)
+            name = dev.name if dev else "Unknown"
+            if len(name) > 30: name = name[:27] + "..."
+            self.out_status_lbl.setText(f"Out: {name}")
+        else:
+            self.out_status_lbl.setText("Out: None")
+
+    def _on_calibration_complete(self, bands):
+        """Called when calibration finishes successfully."""
+        # Refresh the EQ UI to show the newly computed bands
+        self.eq_panel.refresh_ui()
+        # Switch to the EQ tab so the user can see the result
+        self.tabs.setCurrentWidget(self.eq_panel)

+ 288 - 0
ui/player_panel.py

@@ -0,0 +1,288 @@
+"""
+Player Panel — UI for playing files with EQ applied.
+"""
+
+import os
+from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, 
+                             QPushButton, QLabel, QFileDialog, QProgressBar,
+                             QListWidget, QSplitter, QMessageBox, QCheckBox)
+from PyQt6.QtCore import Qt, QTimer, QSettings
+from core.file_loader import get_audio_duration
+
+from core.audio_player import AudioPlayer
+from ui.waveform_widget import WaveformWidget
+from ui.spectrum_widget import SpectrumWidget
+
+class VUMeter(QProgressBar):
+    """Custom progress bar for VU metering."""
+    def __init__(self):
+        super().__init__()
+        self.setOrientation(Qt.Orientation.Vertical)
+        self.setRange(-60, 0) # dB range
+        self.setValue(-60)
+        self.setTextVisible(False)
+        self.setFixedWidth(15)
+        # Styling handled in global QSS, but we can dynamic color here if needed
+        self.setStyleSheet("""
+            QProgressBar {
+                border: 1px solid #30304a;
+                background-color: #161625;
+            }
+            QProgressBar::chunk {
+                background-color: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0, 
+                                                stop: 0 #4CAF50, stop: 0.7 #FFEB3B, stop: 1 #F44336);
+            }
+        """)
+
+class PlayerPanel(QWidget):
+    def __init__(self, player: AudioPlayer):
+        super().__init__()
+        self.player = player
+        self.playlist = []
+        self.current_track_index = -1
+        self.settings = QSettings("TapeRecorder", "Player")
+        self._init_ui()
+        
+        # Connect player callbacks
+        # Note: These are called from the audio thread. 
+        # For thread safety, we use a QTimer to poll state instead of direct signals.
+        self.poll_timer = QTimer()
+        self.poll_timer.timeout.connect(self._poll_player_state)
+        self.poll_timer.start(50) # 20Hz update
+
+    def _init_ui(self):
+        # --- Left Side: Player Controls ---
+        left_widget = QWidget()
+        left_layout = QVBoxLayout(left_widget)
+        
+        # --- Top: File Info & Load ---
+        top_row = QHBoxLayout()
+        self.file_lbl = QLabel("No file loaded")
+        self.file_lbl.setStyleSheet("font-weight: bold; font-size: 12pt;")
+        
+        load_btn = QPushButton("Load Files...")
+        load_btn.clicked.connect(self.load_files)
+        
+        load_folder_btn = QPushButton("Load Folder...")
+        load_folder_btn.clicked.connect(self.load_folder)
+        
+        top_row.addWidget(self.file_lbl, 1)
+        top_row.addWidget(load_btn)
+        top_row.addWidget(load_folder_btn)
+        left_layout.addLayout(top_row)
+        
+        # --- Middle: Waveform ---
+        self.waveform = WaveformWidget()
+        self.waveform.setFixedHeight(120)
+        self.waveform.seek_requested.connect(self.player.seek)
+        left_layout.addWidget(self.waveform)
+        
+        # --- Time display ---
+        self.time_lbl = QLabel("00:00.0 / 00:00.0")
+        self.time_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        self.time_lbl.setStyleSheet("font-family: monospace; font-size: 14pt; color: #ff7b00;")
+        left_layout.addWidget(self.time_lbl)
+        
+        # --- Controls & VU ---
+        control_row = QHBoxLayout()
+        
+        # Buttons
+        btn_layout = QHBoxLayout()
+        self.play_btn = QPushButton("▶ Play")
+        self.play_btn.clicked.connect(self.toggle_play)
+        
+        self.stop_btn = QPushButton("■ Stop")
+        self.stop_btn.clicked.connect(self._on_stop_clicked)
+        
+        self.norm_cb = QCheckBox("Normalize Volume")
+        self.norm_cb.setChecked(self.settings.value("normalize_volume", True, type=bool))
+        self.norm_cb.clicked.connect(self._on_norm_changed)
+        
+        btn_layout.addWidget(self.play_btn)
+        btn_layout.addWidget(self.stop_btn)
+        btn_layout.addWidget(self.norm_cb)
+        btn_layout.addStretch()
+        
+        control_row.addLayout(btn_layout, 1)
+        
+        # VU Meters
+        vu_layout = QHBoxLayout()
+        self.vu_l = VUMeter()
+        self.vu_r = VUMeter()
+        vu_layout.addWidget(QLabel("L"))
+        vu_layout.addWidget(self.vu_l)
+        vu_layout.addWidget(self.vu_r)
+        vu_layout.addWidget(QLabel("R"))
+        
+        control_row.addLayout(vu_layout)
+        left_layout.addLayout(control_row)
+        
+        # --- Bottom: Spectrum Analyzer ---
+        self.spectrum = SpectrumWidget()
+        self.spectrum.setFixedHeight(200)
+        left_layout.addWidget(self.spectrum)
+        
+        # --- Right Side: Playlist ---
+        right_widget = QWidget()
+        right_layout = QVBoxLayout(right_widget)
+        
+        self.playlist_lbl = QLabel("Playlist")
+        self.playlist_lbl.setStyleSheet("font-weight: bold;")
+        right_layout.addWidget(self.playlist_lbl)
+        
+        self.playlist_widget = QListWidget()
+        self.playlist_widget.itemDoubleClicked.connect(self._on_playlist_double_click)
+        right_layout.addWidget(self.playlist_widget)
+        
+        # Splitter
+        splitter = QSplitter(Qt.Orientation.Horizontal)
+        splitter.addWidget(left_widget)
+        splitter.addWidget(right_widget)
+        splitter.setSizes([700, 300]) # Default proportions
+        
+        layout = QVBoxLayout(self)
+        layout.addWidget(splitter)
+
+    def load_files(self):
+        files, _ = QFileDialog.getOpenFileNames(
+            self, "Open Audio Files", "", "Audio Files (*.wav *.mp3 *.opus *.flac *.ogg)"
+        )
+        if files:
+            self.playlist = files
+            self.current_track_index = 0
+            self._update_playlist_ui()
+            self._load_track(self.playlist[0])
+
+    def load_folder(self):
+        last_folder = self.settings.value("last_folder", "")
+        folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", last_folder)
+        if folder_path:
+            self.settings.setValue("last_folder", folder_path)
+            supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff')
+            files = []
+            for f in os.listdir(folder_path):
+                if f.lower().endswith(supported):
+                    files.append(os.path.join(folder_path, f))
+            
+            if not files:
+                QMessageBox.information(self, "Info", "No supported audio files found in folder.")
+                return
+                
+            self.playlist = sorted(files)
+            self.current_track_index = 0
+            self._update_playlist_ui()
+            self._load_track(self.playlist[self.current_track_index], auto_play=True)
+
+    def _update_playlist_ui(self):
+        self.playlist_widget.clear()
+        total_duration = 0.0
+        
+        for f in self.playlist:
+            self.playlist_widget.addItem(os.path.basename(f))
+            total_duration += get_audio_duration(f)
+            
+        dur_m = int(total_duration // 60)
+        dur_s = int(total_duration % 60)
+        self.playlist_lbl.setText(f"Playlist (Total: {dur_m:02d}:{dur_s:02d})")
+        
+        if self.playlist and self.current_track_index >= 0:
+            self.playlist_widget.setCurrentRow(self.current_track_index)
+            
+    def _on_playlist_double_click(self, item):
+        idx = self.playlist_widget.row(item)
+        if 0 <= idx < len(self.playlist):
+            self.current_track_index = idx
+            self._load_track(self.playlist[self.current_track_index], auto_play=True)
+
+    def _on_norm_changed(self):
+        self.settings.setValue("normalize_volume", self.norm_cb.isChecked())
+
+    def _load_track(self, file_path, auto_play=False):
+        try:
+            should_normalize = self.norm_cb.isChecked()
+            self.player.load_file(file_path, normalize=should_normalize)
+            filename = os.path.basename(file_path)
+            
+            prefix = f"[{self.current_track_index + 1}/{len(self.playlist)}] " if self.playlist else ""
+            self.file_lbl.setText(f"Loaded: {prefix}{filename}")
+            
+            self.waveform.load_audio(self.player.audio_data)
+            self._update_time_display(0)
+            self.spectrum.set_sample_rate(self.player.device_manager.sample_rate)
+            
+            if auto_play:
+                try:
+                    self.player.play()
+                    self.play_btn.setText("⏸ Pause")
+                except Exception as e:
+                    QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}")
+                
+        except Exception as e:
+            self.file_lbl.setText(f"Error loading file: {e}")
+
+    def play_next_track(self):
+        if self.playlist and self.current_track_index < len(self.playlist) - 1:
+            self.current_track_index += 1
+            self.playlist_widget.setCurrentRow(self.current_track_index)
+            self._load_track(self.playlist[self.current_track_index], auto_play=True)
+        else:
+            self.play_btn.setText("▶ Play")
+            
+    def _on_stop_clicked(self):
+        self.play_btn.setText("▶ Play")
+        self.player.stop()
+
+    def toggle_play(self):
+        if self.player.is_playing:
+            self.player.pause()
+            self.play_btn.setText("▶ Play")
+        else:
+            if self.player.audio_data is not None:
+                try:
+                    self.player.play()
+                    self.play_btn.setText("⏸ Pause")
+                except Exception as e:
+                    QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}")
+
+    def _poll_player_state(self):
+        """Poll the player state to update UI (thread-safe)."""
+        # Update play button state if stopped automatically (reached end of file)
+        if not self.player.is_playing and self.play_btn.text() == "⏸ Pause":
+            self.play_next_track()
+            
+        # Update playhead
+        if self.player.audio_data:
+            pos_sec = self.player.current_frame / self.player.audio_data.sample_rate
+            self.waveform.set_position(pos_sec)
+            self._update_time_display(pos_sec)
+            
+            # If playing, we can pull the latest chunk from the player for the spectrum.
+            # In a real app we'd pass this via queue, but this is a simple approximation.
+            if self.player.is_playing and self.player.current_frame > 1024:
+                idx = self.player.current_frame
+                chunk = self.player.audio_data.samples[max(0, idx-1024):idx]
+                self.spectrum.add_audio_data(chunk)
+                
+        # Update VU meters
+        l_db, r_db = self.player.vu_levels
+        # Map dB to progress bar (-60 to 0)
+        self.vu_l.setValue(int(max(-60, l_db)))
+        self.vu_r.setValue(int(max(-60, r_db)))
+        
+        # Decay VU meters when stopped
+        if not self.player.is_playing:
+            self.player.vu_levels = (max(-100, l_db - 2), max(-100, r_db - 2))
+
+    def _update_time_display(self, pos_sec: float):
+        if not self.player.audio_data:
+            return
+        
+        dur_sec = self.player.audio_data.duration
+        
+        pos_m = int(pos_sec // 60)
+        pos_s = pos_sec % 60
+        
+        dur_m = int(dur_sec // 60)
+        dur_s = dur_sec % 60
+        
+        self.time_lbl.setText(f"{pos_m:02d}:{pos_s:04.1f} / {dur_m:02d}:{dur_s:04.1f}")

+ 127 - 0
ui/spectrum_widget.py

@@ -0,0 +1,127 @@
+"""
+Spectrum Widget — Real-time FFT spectrum display using pyqtgraph.
+"""
+
+import numpy as np
+import pyqtgraph as pg
+from PyQt6.QtWidgets import QWidget, QVBoxLayout
+from PyQt6.QtCore import QTimer
+
+class SpectrumWidget(QWidget):
+    """
+    Displays a real-time frequency spectrum.
+    """
+    def __init__(self, sample_rate: int = 44100, update_interval_ms: int = 50):
+        super().__init__()
+        self.sample_rate = sample_rate
+        self.update_interval_ms = update_interval_ms
+        
+        # Audio buffer for FFT
+        self.fft_size = 4096
+        self.buffer = np.zeros(self.fft_size, dtype=np.float32)
+        self.buffer_idx = 0
+        
+        # Window function for FFT (Hanning)
+        self.window = np.hanning(self.fft_size)
+        
+        self._init_ui()
+        
+        # Setup timer for UI updates (to avoid overwhelming the GUI thread)
+        self.update_timer = QTimer()
+        self.update_timer.timeout.connect(self._update_plot)
+        self.update_timer.start(self.update_interval_ms)
+        
+        self._new_data = False
+
+    def _init_ui(self):
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        
+        # Configure pyqtgraph
+        pg.setConfigOption('background', '#161625')
+        pg.setConfigOption('foreground', '#a0a0b0')
+        
+        self.plot_widget = pg.PlotWidget()
+        self.plot_widget.setLogMode(x=True, y=False)
+        self.plot_widget.setLabel('bottom', 'Frequency', units='Hz')
+        self.plot_widget.setLabel('left', 'Magnitude', units='dB')
+        self.plot_widget.setYRange(-100, 0)
+        self.plot_widget.setXRange(np.log10(20), np.log10(20000))
+        self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
+        
+        # Plot curve (Amber color for analog feel)
+        self.curve = self.plot_widget.plot(pen=pg.mkPen('#ff7b00', width=2))
+        
+        # Prepare frequency bins
+        self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
+        # Avoid log(0) for the very first bin
+        self.freqs[0] = 1e-10
+        
+        layout.addWidget(self.plot_widget)
+
+    def set_sample_rate(self, sr: int):
+        if self.sample_rate != sr:
+            self.sample_rate = sr
+            self.freqs = np.fft.rfftfreq(self.fft_size, d=1.0/self.sample_rate)
+            self.freqs[0] = 1e-10
+
+    def add_audio_data(self, data: np.ndarray):
+        """
+        Add new audio data to the buffer.
+        Called from the audio callback thread.
+        """
+        if data.size == 0:
+            return
+            
+        # Convert to mono if stereo
+        if data.ndim > 1:
+            chunk = np.mean(data, axis=1)
+        else:
+            chunk = data
+            
+        n = len(chunk)
+        if n >= self.fft_size:
+            # Take the latest fft_size samples
+            self.buffer[:] = chunk[-self.fft_size:]
+            self.buffer_idx = self.fft_size
+        else:
+            # Shift buffer left and append new data
+            self.buffer[:-n] = self.buffer[n:]
+            self.buffer[-n:] = chunk
+            self.buffer_idx = min(self.buffer_idx + n, self.fft_size)
+            
+        self._new_data = True
+
+    def _update_plot(self):
+        """Calculate FFT and update the plot on the GUI thread."""
+        if not self._new_data or self.buffer_idx < self.fft_size // 4:
+            # Decay the plot if no new data
+            if not self._new_data:
+                 current_data = self.curve.getData()[1]
+                 if current_data is not None:
+                     decayed = current_data - 2.0 # -2dB per frame decay
+                     self.curve.setData(self.freqs, decayed)
+            return
+            
+        self._new_data = False
+        
+        # Apply window and calculate FFT
+        windowed = self.buffer * self.window
+        mag = np.abs(np.fft.rfft(windowed))
+        
+        # Normalize and convert to dB
+        # Reference is amplitude 1.0 (0 dBFS)
+        mag_db = 20 * np.log10(mag / (self.fft_size / 2) + 1e-10)
+        
+        # Smooth slightly in time domain (optional, but looks better)
+        current_data = self.curve.getData()[1]
+        if current_data is not None:
+             alpha = 0.6
+             mag_db = alpha * mag_db + (1 - alpha) * current_data
+        
+        # Update curve
+        self.curve.setData(self.freqs, mag_db)
+
+    def closeEvent(self, event):
+        self.update_timer.stop()
+        super().closeEvent(event)

+ 106 - 0
ui/waveform_widget.py

@@ -0,0 +1,106 @@
+"""
+Waveform Widget — Overview display of an entire audio file.
+"""
+
+import numpy as np
+import pyqtgraph as pg
+from PyQt6.QtWidgets import QWidget, QVBoxLayout
+from PyQt6.QtCore import pyqtSignal
+
+class WaveformWidget(QWidget):
+    """
+    Displays the static waveform of a loaded audio file
+    and an interactive playhead cursor.
+    """
+    
+    # Emitted when user clicks on the waveform to seek
+    seek_requested = pyqtSignal(float) # position in seconds
+    
+    def __init__(self):
+        super().__init__()
+        self._init_ui()
+        self.duration_sec = 0.0
+
+    def _init_ui(self):
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        
+        # Configure pyqtgraph
+        pg.setConfigOption('background', '#161625')
+        pg.setConfigOption('foreground', '#a0a0b0')
+        
+        self.plot_widget = pg.PlotWidget()
+        self.plot_widget.setMouseEnabled(x=False, y=False)
+        self.plot_widget.hideAxis('left')
+        self.plot_widget.hideAxis('bottom')
+        self.plot_widget.setYRange(-1.1, 1.1)
+        
+        # Waveform curves (L/R)
+        self.curve_l = self.plot_widget.plot(pen=pg.mkPen('#3a3a5c', width=1))
+        self.curve_r = self.plot_widget.plot(pen=pg.mkPen('#4a4a7a', width=1))
+        
+        # Playhead line
+        self.playhead = pg.InfiniteLine(pos=0, angle=90, pen=pg.mkPen('#ff7b00', width=2))
+        self.plot_widget.addItem(self.playhead)
+        
+        # Mouse click handling for seek
+        self.plot_widget.scene().sigMouseClicked.connect(self._on_mouse_clicked)
+        
+        layout.addWidget(self.plot_widget)
+
+    def load_audio(self, audio_data):
+        """Generate waveform overview from AudioData."""
+        self.duration_sec = audio_data.duration
+        self.plot_widget.setXRange(0, self.duration_sec)
+        
+        # Decimate data for display (we don't need millions of points)
+        # Target ~2000 points for the view
+        target_points = 2000
+        n_samples = audio_data.num_samples
+        step = max(1, n_samples // target_points)
+        
+        # Instead of just picking every Nth sample, get min/max over blocks 
+        # to correctly represent the envelope
+        blocks = n_samples // step
+        
+        # Time axis
+        t = np.linspace(0, self.duration_sec, blocks)
+        
+        samples = audio_data.samples[:blocks * step]
+        if audio_data.channels >= 2:
+            left = samples[:, 0].reshape(-1, step)
+            right = samples[:, 1].reshape(-1, step)
+            
+            l_max = np.max(left, axis=1)
+            l_min = np.min(left, axis=1)
+            r_max = np.max(right, axis=1)
+            r_min = np.min(right, axis=1)
+            
+            # Interleave min/max for shaded look, or just use max
+            # For simplicity, we just plot the max envelope
+            self.curve_l.setData(t, l_max)
+            # Offset right channel negative for classic display
+            self.curve_r.setData(t, -r_max)
+        else:
+            mono = samples.reshape(-1, step)
+            m_max = np.max(mono, axis=1)
+            m_min = np.min(mono, axis=1)
+            self.curve_l.setData(t, m_max)
+            self.curve_r.setData(t, m_min)
+            
+        self.set_position(0)
+
+    def set_position(self, pos_sec: float):
+        """Update playhead position."""
+        if 0 <= pos_sec <= self.duration_sec:
+            self.playhead.setValue(pos_sec)
+
+    def _on_mouse_clicked(self, event):
+        """Handle click to seek."""
+        if event.button() == 1: # Left click
+            # Map scene coordinates to plot coordinates
+            pos = self.plot_widget.plotItem.vb.mapSceneToView(event.scenePos())
+            x_sec = pos.x()
+            if 0 <= x_sec <= self.duration_sec:
+                self.seek_requested.emit(x_sec)
+                self.set_position(x_sec)