import numpy as np
from scipy.io import wavfile
from scipy.signal.windows import flattop

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

import matplotlib
matplotlib.use("TkAgg")
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

import os
import subprocess
import tempfile
import threading

STANDARD_RATES = [44100, 48000, 88200, 96000, 176400, 192000]
AUDIBLE_LIMIT = 20000


def choose_sample_rate(f_carrier, f_message):
    highest = f_carrier + f_message
    for rate in STANDARD_RATES:
        if rate > 2.2 * highest:
            return rate
    return int(2.5 * highest)


def generate_am(f_carrier, f_message, fs, duration, mod_index=1.0):
    t = np.arange(int(duration * fs)) / fs
    message = mod_index * np.cos(2 * np.pi * f_message * t)
    carrier = np.cos(2 * np.pi * f_carrier * t)
    return t, (1.0 + message) * carrier


def am_curve(f_carrier, f_message, mod_index, cycles=5, points_per_cycle=400, max_points=400000):
    view = max(cycles / f_message, 3 / (f_carrier - f_message))
    n = int(min(max(points_per_cycle * (f_carrier + f_message) * view, 2000), max_points))
    t = np.linspace(0, view, n)
    modulation = 1.0 + mod_index * np.cos(2 * np.pi * f_message * t)
    wave = modulation * np.cos(2 * np.pi * f_carrier * t)
    return t, modulation, wave


def compute_spectrum(signal, fs):
    window = flattop(len(signal), sym=False)
    spec = np.fft.rfft(signal * window)
    freqs = np.fft.rfftfreq(len(signal), d=1 / fs)
    return freqs, 2 * np.abs(spec) / np.sum(window)


def peak_near(fft_freqs, fft_amp, f, half_bins=5):
    k = int(round(f / (fft_freqs[1] - fft_freqs[0])))
    return np.max(fft_amp[max(k - half_bins, 0):k + half_bins + 1])


def export_wav(path, signal, fs):
    data = np.clip(signal, -1.0, 1.0)
    data16 = (data * 32767).astype(np.int16)
    wavfile.write(path, int(round(fs)), data16)


class DirectionalSpeakerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("AM Directional Speaker")
        self.am_signal = None
        self.fs = None

        controls = ttk.Frame(root, padding=10)
        controls.grid(row=0, column=0, sticky="ns")

        def add_field(row, label, default):
            ttk.Label(controls, text=label).grid(row=row, column=0, sticky="w", pady=3)
            var = tk.StringVar(value=str(default))
            entry = ttk.Entry(controls, textvariable=var, width=12)
            entry.grid(row=row, column=1, pady=3, padx=(6, 0))
            return var

        self.mod_var = add_field(0, "Modulation (kHz)", 18)
        self.carrier_var = add_field(1, "Carrier (kHz)", 21)
        self.mod_index_var = add_field(2, "Modulation index (0-1)", 1.0)
        self.duration_var = add_field(3, "Duration (s)", 1.0)

        ttk.Button(controls, text="Generate", command=self.generate_and_plot).grid(
            row=4, column=0, columnspan=2, pady=(12, 4), sticky="ew")
        self.hear_btn = ttk.Button(controls, text="Hear", command=self.hear, state="disabled")
        self.hear_btn.grid(row=5, column=0, columnspan=2, pady=4, sticky="ew")
        self.export_btn = ttk.Button(controls, text="Export WAV...", command=self.export,
                                      state="disabled")
        self.export_btn.grid(row=6, column=0, columnspan=2, pady=4, sticky="ew")

        ttk.Label(controls, text="Results:", font=("", 10, "bold")).grid(
            row=7, column=0, columnspan=2, sticky="w", pady=(14, 2))
        self.info_text = tk.Text(controls, width=34, height=14, wrap="word", relief="sunken", borderwidth=1)
        self.info_text.grid(row=8, column=0, columnspan=2, sticky="nsew")
        self.info_text.configure(state="disabled")

        fig = plt.Figure(figsize=(8.5, 6.5))
        gs = fig.add_gridspec(2, 1, height_ratios=[1, 1])
        self.ax_wave = fig.add_subplot(gs[0])
        self.ax_spec = fig.add_subplot(gs[1])
        fig.tight_layout(pad=3.0)
        self.fig = fig

        self.canvas = FigureCanvasTkAgg(fig, master=root)
        self.canvas.get_tk_widget().grid(row=0, column=1, sticky="nsew")

        root.grid_columnconfigure(1, weight=1)
        root.grid_rowconfigure(0, weight=1)

        self.generate_and_plot()

    def _set_info(self, text):
        self.info_text.configure(state="normal")
        self.info_text.delete("1.0", "end")
        self.info_text.insert("1.0", text)
        self.info_text.configure(state="disabled")

    def generate_and_plot(self):
        try:
            f_mod = float(self.mod_var.get()) * 1000
            f_carrier = float(self.carrier_var.get()) * 1000
            mod_index = float(self.mod_index_var.get())
            duration = float(self.duration_var.get())
        except ValueError:
            messagebox.showerror("Invalid input", "All fields must be numbers.")
            return

        if f_mod <= 0 or f_carrier <= 0:
            messagebox.showerror("Invalid input", "Frequencies must be positive.")
            return
        if f_carrier <= f_mod:
            messagebox.showerror("Invalid input",
                                 "The carrier must be higher than the modulation frequency, "
                                 "otherwise the lower sideband folds through 0 Hz.")
            return
        if not (0 < mod_index <= 1):
            messagebox.showerror("Invalid input", "Modulation index must be in (0, 1].")
            return
        if duration <= 0 or f_mod * duration < 12:
            messagebox.showerror("Invalid input",
                                 "Duration must cover at least 12 modulation periods so the "
                                 "sidebands can be resolved.")
            return

        fs = choose_sample_rate(f_carrier, f_mod)
        t, signal = generate_am(f_carrier, f_mod, fs, duration, mod_index)
        self.am_signal = signal / np.max(np.abs(signal))
        self.fs = fs

        lower_sb = f_carrier - f_mod
        upper_sb = f_carrier + f_mod

        tc, modulation, wave = am_curve(f_carrier, f_mod, mod_index)
        t_ms = tc * 1e3
        ax = self.ax_wave
        ax.clear()
        ax.plot(t_ms, wave, color="tab:orange", linewidth=1.0, label="AM wave")
        ax.plot(t_ms, modulation, color="tab:red", linestyle=":", linewidth=2.0,
                label="amplitude modulation  1 + m·cos(2π·f_m·t)")
        ax.plot(t_ms, -modulation, color="tab:red", linestyle=":", linewidth=2.0)
        ax.set_ylim(-1.3 * (1 + mod_index), 1.3 * (1 + mod_index))
        ax.set_xlim(t_ms[0], t_ms[-1])
        ax.set_title("AM wave:  [1 + m·cos(2π·f_m·t)] · cos(2π·f_c·t)", fontsize=10)
        ax.set_xlabel("time (ms)")
        ax.set_ylabel("amplitude")
        ax.legend(loc="upper right", fontsize=8, ncol=2)

        fft_freqs, fft_amp = compute_spectrum(signal, fs)
        ax = self.ax_spec
        ax.clear()
        xmax = 1.15 * upper_sb
        mask = fft_freqs <= xmax
        ax.plot(fft_freqs[mask] / 1e3, fft_amp[mask], color="tab:purple", linewidth=1.5)
        aligns = ("right", "center", "left") if f_mod < 0.08 * xmax else ("center",) * 3
        for f, align in zip((lower_sb, f_carrier, upper_sb), aligns):
            ax.annotate(f"{f/1000:.2f} kHz", (f / 1e3, peak_near(fft_freqs, fft_amp, f)),
                        textcoords="offset points", xytext=(0, 4), ha=align, fontsize=9)
        ax.set_xlim(0, xmax / 1e3)
        ax.set_ylim(0, 1.25)
        ax.grid(True, linestyle=":", alpha=0.6)
        ax.set_title("Fourier transform of the wave", fontsize=10)
        ax.set_xlabel("frequency (kHz)")
        ax.set_ylabel("amplitude (carrier = 1)")

        self.fig.tight_layout(pad=3.0)
        self.canvas.draw()

        def hearing(f):
            return "audible" if f < AUDIBLE_LIMIT else "above hearing range"

        info = (
            f"Carrier:     {f_carrier/1000:.3f} kHz\n"
            f"Modulation:  {f_mod/1000:.3f} kHz\n"
            f"Index m:     {mod_index:g}\n"
            f"Sample rate: {fs/1000:.1f} kHz\n\n"
            f"Lines in the wave:\n"
            f"  {lower_sb/1000:.3f} kHz  lower sideband\n"
            f"      ({hearing(lower_sb)})\n"
            f"  {f_carrier/1000:.3f} kHz  carrier\n"
            f"      ({hearing(f_carrier)})\n"
            f"  {upper_sb/1000:.3f} kHz  upper sideband\n"
            f"      ({hearing(upper_sb)})\n"
        )
        self._set_info(info)
        self.hear_btn.configure(state="normal")
        self.export_btn.configure(state="normal")

    def hear(self):
        if self.am_signal is None:
            return
        self.hear_btn.configure(state="disabled", text="Playing...")
        threading.Thread(target=self._play_audio, daemon=True).start()

    def _play_audio(self):
        temp_path = None
        try:
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
                temp_path = temp_file.name
            export_wav(temp_path, self.am_signal, self.fs)
            subprocess.run(["afplay", temp_path], check=True)
        except Exception as exc:
            self.root.after(0, lambda: messagebox.showerror("Playback failed", str(exc)))
        finally:
            if temp_path and os.path.exists(temp_path):
                try:
                    os.remove(temp_path)
                except OSError:
                    pass
            self.root.after(0, lambda: self.hear_btn.configure(state="normal", text="Hear"))

    def export(self):
        if self.am_signal is None:
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".wav",
            filetypes=[("WAV files", "*.wav")],
            title="Export AM signal as WAV")
        if not path:
            return
        try:
            export_wav(path, self.am_signal, self.fs)
        except Exception as exc:
            messagebox.showerror("Export failed", str(exc))
            return
        messagebox.showinfo("Exported", f"Saved:\n{path}\n\nSample rate: {self.fs/1000:.1f} kHz")


def main():
    root = tk.Tk()
    DirectionalSpeakerApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()

