# cwkbcopilot8_d-mod_002.py
#
# BASED ON cwkbcopilot8_d.py of January 5, 2025 by Chuck, AA0HW
# https://groups.google.com/g/i_cw/c/MeLizd5WhoQ/m/G4WTfsymBQAJ
#
# MOD: mostly rely on samples (not on time) and set all keyed and silence durations to normal (PARIS-based)
# https://groups.google.com/g/i_cw/c/MeLizd5WhoQ/m/6cK4DvCSDAAJ
#
# See (e.g.) "def set_wpm(self, wpm)" and "def generate_wave_for_string(self, string)"
# "Smooth envelope" at around line 185, is disabled at present
# _pylint: disable=unused-argument_ inserted (as comment without the under-scores) at some places to avoid some (unimportant) Warnings
# 
#
# (personal note: start of qjackctl: qjackctl -a ~/thomas/Documents/Jack/py-cwkb.xml
#  -- after that _activate_ the according Patchbay file and no new wiring
#     is needed)
#
# There are still issues with the timing, e.g: Additional silence before the start of a new character
# This additional silence duration is about 0 to 20 ms and differs for different CW speed settings.
# The silence duration _after_ a charcter, 3 DIT duration, is correct for all speeds (tested) -- the problem
# looks like there is a "DELAY"/"HESITATION" before the next character starts. 
# 
# 
#
# DF7TV, 2025-01-25
#
import jack
import numpy as np
import gi
import signal  # Import the signal module
from collections import deque


gi.require_version("Gtk", "3.0")
gi.require_version("Pango", "1.0")
from gi.repository import Gtk, Gdk, Pango, GLib

# Full Morse code map with letters, numbers, and punctuation
MORSE_CODE = {
    'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.',
    'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..',
    'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.',
    'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-',
    'Y': '-.--', 'Z': '--..',
    '1': '.----', '2': '..---', '3': '...--', '4': '....-', '5': '.....',
    '6': '-....', '7': '--...', '8': '---..', '9': '----.', '0': '-----',
    '.': '.-.-.-', ',': '--..--', '?': '..--..', '/': '-..-.', '@': '.--.-.',
    '-': '-....-', '(': '-.--.', ')': '-.--.-', '&': '.-...', '=': '-...-',
    ':': '---...', ';': '-.-.-.', '+': '.-.-.', "'": '.----.', '"': '.-..-.',
    '$': '...-..-', '!': '-.-.--'
}


# Define constants for calculations
SAMPLE_RATE = 44100.0 # in Hz, example
WPM         = 30.0 # in wpm, value set as target, example

DIT_KEYED_SAMPLES   =     round(1.2 * SAMPLE_RATE / WPM) # nearest integer number of samples corresponding to the keyed time of a DIT
DAH_KEYED_SAMPLES   = 3 * DIT_KEYED_SAMPLES
EOE_SILENCE_SAMPLES =     DIT_KEYED_SAMPLES # samples of silence at the end of an element 'EOE' (always present at the end of an element)
EOC_SILENCE_SAMPLES = 2 * DIT_KEYED_SAMPLES # ADDITIONAL samples of silence at the end of a character 'EOC'
EOW_SILENCE_SAMPLES = 4 * DIT_KEYED_SAMPLES # ADDITIONAL samples of silence at the end of a word 'EOW'

class MorseCodeKeyboard:
    SILENCE_CACHE: dict = {}
    SILENCE_CACHE_NEW: dict = {}
    TONE_CACHE: dict = {}

    deque([])
    def __init__(self):
        self.client = jack.Client("MorseCodeKeyboard")
        self.output_port = self.client.outports.register("output")
        self.sample_rate = self.client.samplerate
        self.buffer_frames = self.client.blocksize
        self.frequency = 700  # Default frequency is 700 Hz
        self.set_wpm(30.0)  # Initialize with default WPM
        self.buffer = deque([])  # Optimized type-ahead buffer using deque
        self.current_wave = None
        self.current_frame = 0
        self.processed_chars = 0  # Track the number of processed characters

        self.client.set_process_callback(self.process)
        self.client.activate()

    def set_wpm(self, wpm):
        self.wpm = wpm
        self.dit_keyed_samples = round(1.2 * self.sample_rate / self.wpm) # nearest integer number of samples corresponding to the keyed time of a DIT
        self.dah_keyed_samples = self.dit_keyed_samples * 3
        self.eoe_silence_samples = self.dit_keyed_samples     # samples of silence at the end of an element 'EOE' (always present at the end of an element)
        self.eoc_silence_samples = self.dit_keyed_samples * 2 # ADDITIONAL samples of silence at the end of a character 'EOC'
        self.eow_silence_samples = self.dit_keyed_samples * 4 # ADDITIONAL samples of silence at the end of a word 'EOW'

        # Precompute all tones
        self.precompute_tones()

    def precompute_tones(self):
        durations_samples = [
            self.dit_keyed_samples,
            self.dah_keyed_samples,
            ]
        for duration in durations_samples:
            self.TONE_CACHE[duration] = self.generate_tone(duration)

        # Precompute all silences
        self.precompute_silences()

    def precompute_silences(self):
        durations_samples = [
            self.eoe_silence_samples,
            self.eoc_silence_samples,
            self.eow_silence_samples,
            ]
        for duration in durations_samples:
            self.SILENCE_CACHE[duration] = self.generate_silence(duration)

    def process(self, frames):
        buffer = self.output_port.get_buffer()
        output = np.zeros(frames, dtype=np.float32)  # Default to silence
        
        try:
            if self.current_wave is None and self.buffer:
                char = self.buffer.popleft()  # Efficiently move the first element
                self.current_wave = self.generate_wave_for_string(char)
                self.current_frame = 0
                GLib.idle_add(self.gui.update_text_color, self.processed_chars)  # Ensure GUI updates happen in the main thread
                self.processed_chars += 1  # Increment the number of processed characters

            if self.current_wave is not None:
                remaining_samples = len(self.current_wave) - self.current_frame
                chunk = min(frames, remaining_samples)
                output[:chunk] = self.current_wave[self.current_frame:self.current_frame + chunk]
                self.current_frame += chunk

                if self.current_frame >= len(self.current_wave):
                    self.current_wave = None

            buffer[:] = output
        except Exception as e:
            print(f"Error in process callback: {e}")

    def generate_wave_for_string(self, string):
        wave_parts = []  # Use a list to gather wave parts

        for char in string:
            if char == '.':
                wave_parts.append(self.TONE_CACHE[self.dit_keyed_samples])
                wave_parts.append(self.SILENCE_CACHE[self.eoe_silence_samples])
            elif char == '-':
                wave_parts.append(self.TONE_CACHE[self.dah_keyed_samples])
                wave_parts.append(self.SILENCE_CACHE[self.eoe_silence_samples])
            elif char == ' ':
                wave_parts.append(self.SILENCE_CACHE[self.eow_silence_samples])
            elif char in MORSE_CODE:
                morse_symbol = MORSE_CODE[char]
                for symbol in morse_symbol:
                    if symbol == '.':
                        wave_parts.append(self.TONE_CACHE[self.dit_keyed_samples])
                        wave_parts.append(self.SILENCE_CACHE[self.eoe_silence_samples])
                    elif symbol == '-':
                        wave_parts.append(self.TONE_CACHE[self.dah_keyed_samples])
                        wave_parts.append(self.SILENCE_CACHE[self.eoe_silence_samples])
        else:
            wave_parts.append(self.SILENCE_CACHE[self.eoc_silence_samples])

        # Combine all parts into a single wave
        wave = np.concatenate(wave_parts)
        return wave

    def generate_tone(self, keyed_samples):
        t = np.arange(0, keyed_samples)
        return 0.5 * np.sin(2 * np.pi * self.frequency * t/self.sample_rate).astype(np.float32)


    def generate_silence(self, silence_samples):
        return np.zeros(silence_samples, dtype=np.float32)
   
        
# REMARK: This "envelope" may be used to have different shapes (raised cosine etc.) later
#
#
#        # Generate clean sine wave with proper amplitude
#        tone = np.sin(2 * np.pi * self.frequency * t)
#        
#         # Smooth envelope to prevent clicks
#         envelope_ms = 3  # Envelope duration in milliseconds
#         envelope_samples = int(self.sample_rate * envelope_ms / 1000.0)
#         envelope = np.ones(samples)
#         
#         if samples > 2 * envelope_samples:
#             envelope[:envelope_samples] = np.linspace(0, 1, envelope_samples)
#             envelope[-envelope_samples:] = np.linspace(1, 0, envelope_samples)
#        
#        return (0.3 * tone * envelope).astype(np.float32)  # Reduced amplitude to 0.3
#        return (0.3 * tone).astype(np.float32)  # Reduced amplitude to 0.3


    def close(self):
        try:
            self.client.deactivate()
        except jack.JackError:
            pass
        self.client.close()


class MorseCodeGUI(Gtk.Window):
    def __init__(self, morse_keyboard):
        super().__init__(title="Morse Code Keyboard")
        self.morse_keyboard = morse_keyboard
        self.morse_keyboard.gui = self  # Add reference to the GUI in the MorseCodeKeyboard

        self.set_default_size(600, 400)
        self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        self.add(self.box)

        # Text entry box with word wrapping
        self.scrolled_window = Gtk.ScrolledWindow()
        self.text_view = Gtk.TextView()
        self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)

        # Set left and right margins directly in the TextView
        self.text_view.set_left_margin(20)  # Add 20px space on the left
        self.text_view.set_right_margin(10)  # Add 10px space on the right

        self.font_family = 'Comic Sans MS'
        self.font_size = 32
        self.text_color = 'yellow'
        self.background_color = '#9c9c99'
        self.sent_text_color = 'black'  # Initializing sent_text_color with a default value

        self.css_provider = Gtk.CssProvider()

        # Ensure the sent_text tag is created
        buffer = self.text_view.get_buffer()
        self.sent_text_tag = buffer.create_tag("sent_text", foreground=self.sent_text_color)

        self.update_css()

        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            self.css_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

        self.text_view.connect("key-press-event", self.on_key_press)

        self.scrolled_window.add(self.text_view)
        self.box.pack_start(self.scrolled_window, True, True, 0)

        # Set margin to add space on left and right
        self.box.set_margin_start(10)  # Adjust left margin (in pixels)
        self.box.set_margin_end(10)    # Adjust right margin (in pixels)

        # WPM entry box
        self.wpm_entry = Gtk.Entry()
        self.wpm_entry.set_placeholder_text("Enter WPM (1-200)")
        self.wpm_entry.connect("activate", self.on_wpm_changed)
        self.box.pack_start(self.wpm_entry, False, False, 0)

        # Frequency entry box
        self.frequency_entry = Gtk.Entry()
        self.frequency_entry.set_placeholder_text("Enter Frequency (Hz)")
        self.frequency_entry.connect("activate", self.on_frequency_changed)
        self.box.pack_start(self.frequency_entry, False, False, 0)

        # Clear All Text button
        self.clear_button = Gtk.Button(label="Clear All Text")
        self.clear_button.connect("clicked", self.on_clear_clicked)
        self.box.pack_start(self.clear_button, False, False, 0)

        # Add Text Color, Sent Text Color, Background Color, and Font Chooser buttons
        self.text_color_button = Gtk.Button(label="Text Color")
        self.text_color_button.connect("clicked", self.on_text_color_clicked)
        self.box.pack_start(self.text_color_button, False, False, 0)

        self.sent_text_color_button = Gtk.Button(label="Sent Text Color")
        self.sent_text_color_button.connect("clicked", self.on_sent_text_color_clicked)
        self.box.pack_start(self.sent_text_color_button, False, False, 0)

        self.background_color_button = Gtk.Button(label="Background Color")
        self.background_color_button.connect("clicked", self.on_background_color_clicked)
        self.box.pack_start(self.background_color_button, False, False, 0)

        self.font_chooser_button = Gtk.Button(label="Font Chooser")
        self.font_chooser_button.connect("clicked", self.on_font_chooser_clicked)
        self.box.pack_start(self.font_chooser_button, False, False, 0)

        self.sent_text_color = "red"  # Default sent text color

        self.connect("destroy", self.on_destroy)

    def update_css(self):
        css = f"""
            textview {{
                font-family: '{self.font_family}';
                font-size: {self.font_size}px;
                color: {self.text_color};
                background-color: {self.background_color};
                border: 1px solid black;
                padding: 2px;
            }}
            textview text {{
                color: {self.text_color};
            }}
            .sent_text {{
                color: {self.sent_text_color};
            }}
        """
        self.css_provider.load_from_data(css.encode('utf-8'))
        style_context = self.text_view.get_style_context()
        style_context.add_provider(self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

        # Directly set the background color as a fallback
        rgba = Gdk.RGBA()
        rgba.parse(self.background_color)
        self.text_view.override_background_color(Gtk.StateFlags.NORMAL, rgba)

        # Update the sent_text tag color
        self.sent_text_tag.set_property("foreground", self.sent_text_color)

    def on_wpm_changed(self, entry):
        #pylint: disable=unused-argument 
        wpm_value = self.wpm_entry.get_text()
        try:
            self.morse_keyboard.set_wpm(int(wpm_value))
        except ValueError:
            pass

    def on_frequency_changed(self, entry):
        #pylint: disable=unused-argument 
        frequency_value = self.frequency_entry.get_text()
        try:
            self.morse_keyboard.frequency = float(frequency_value)
            self.morse_keyboard.precompute_tones()  # Recompute tones with the new frequency 

        except ValueError:
            pass

    def on_key_press(self, widget, event):
        #pylint: disable=unused-argument 
        key = chr(event.keyval)

        # Check if backspace is pressed
        if event.keyval == Gdk.KEY_BackSpace:
            buffer = self.text_view.get_buffer()
            start_iter, end_iter = buffer.get_bounds()
            if start_iter.get_offset() < end_iter.get_offset():
                end_iter.backward_char()
            buffer.delete(end_iter, buffer.get_end_iter())
            if self.morse_keyboard.buffer:
                self.morse_keyboard.buffer.pop()
            return True  # Consume event to prevent further action

        # Check if TAB is pressed to stop sending immediately
        if event.keyval == Gdk.KEY_Tab:
            self.morse_keyboard.buffer.clear()
            self.morse_keyboard.current_wave = None

            # Turn all text to sent text color
            buffer = self.text_view.get_buffer()
            start_iter, end_iter = buffer.get_bounds()
            buffer.apply_tag(self.sent_text_tag, start_iter, end_iter)

            self.morse_keyboard.processed_chars = buffer.get_char_count()  # Reset processed chars count
            return True  # Consume event to prevent further action

        # Prevent the default action of inserting the character
        if key in MORSE_CODE or key.upper() in MORSE_CODE or key == ' ':
            # Handle normal characters
            if key in MORSE_CODE:
                self.morse_keyboard.buffer.append(MORSE_CODE[key])
            elif key.upper() in MORSE_CODE:
                self.morse_keyboard.buffer.append(MORSE_CODE[key.upper()])
            elif key == ' ':
                self.morse_keyboard.buffer.append(' ')  # Ensure spaces are added to the buffer

            # Insert the character into the buffer explicitly
            buffer = self.text_view.get_buffer()
            buffer.insert(buffer.get_end_iter(), key)

            # Scroll to the end of the text view to keep the current text in view
            self.text_view.scroll_to_iter(buffer.get_end_iter(), 0.0, False, 0.0, 1.0)

            return True  # Consume the event

    def on_clear_clicked(self, widget):
        #pylint: disable=unused-argument 
        """Clear the text in the TextView."""
        buffer = self.text_view.get_buffer()
        buffer.set_text("")
        self.morse_keyboard.processed_chars = 0

    def on_destroy(self, *args):
        #pylint: disable=unused-argument 
        self.morse_keyboard.close()
        Gtk.main_quit()

    # Callback for Text Color button
    def on_text_color_clicked(self, widget):
        #pylint: disable=unused-argument 
        color_chooser = Gtk.ColorChooserDialog(title="Select Text Color", parent=self)
        response = color_chooser.run()

        if response == Gtk.ResponseType.OK:
            rgba = color_chooser.get_rgba()
            self.text_color = Gdk.RGBA.to_string(rgba)
            self.update_css()

        color_chooser.destroy()

    # Callback for Sent Text Color button
    def on_sent_text_color_clicked(self, widget):
        #pylint: disable=unused-argument 
        color_chooser = Gtk.ColorChooserDialog(title="Select Sent Text Color", parent=self)
        response = color_chooser.run()

        if response == Gtk.ResponseType.OK:
            rgba = color_chooser.get_rgba()
            self.sent_text_color = Gdk.RGBA.to_string(rgba)

            # Update the sent text tag to use the new color
            buffer = self.text_view.get_buffer()
            tag_table = buffer.get_tag_table()
            sent_text_tag = tag_table.lookup("sent_text")
            if sent_text_tag:
                sent_text_tag.set_property("foreground", self.sent_text_color)
            else:
                buffer.create_tag("sent_text", foreground=self.sent_text_color)

        color_chooser.destroy()

    # Callback for Background Color button
    def on_background_color_clicked(self, widget):
        #pylint: disable=unused-argument 
        color_chooser = Gtk.ColorChooserDialog(title="Select Background Color", parent=self)
        response = color_chooser.run()

        if response == Gtk.ResponseType.OK:
            rgba = color_chooser.get_rgba()
            self.background_color = Gdk.RGBA.to_string(rgba)
            self.update_css()

        color_chooser.destroy()

    # Callback for Font Chooser button
    def on_font_chooser_clicked(self, widget):
        #pylint: disable=unused-argument 
        font_chooser = Gtk.FontChooserDialog(title="Select Font", parent=self)
        response = font_chooser.run()

        if response == Gtk.ResponseType.OK:
            font_desc = Pango.FontDescription(font_chooser.get_font())
            self.font_family = font_desc.get_family()
            self.font_size = font_desc.get_size() // Pango.SCALE  # Pango size is in Pango units, divide by Pango.SCALE to get points
            self.update_css()

        font_chooser.destroy()

    def update_text_color(self, index):
        buffer = self.text_view.get_buffer()
        start_iter = buffer.get_start_iter()
        start_iter.set_offset(index)

        end_iter = start_iter.copy()
        end_iter.forward_char()
        
        # Clear all tags before applying new ones
        buffer.remove_all_tags(start_iter, end_iter)

        # Apply the sent_text tag to change the color of the text
        buffer.apply_tag(self.sent_text_tag, start_iter, end_iter)

def gtk_main_quit(*args):
    #pylint: disable=unused-argument 
    Gtk.main_quit()
if __name__ == "__main__":
    morse_keyboard = MorseCodeKeyboard()
    gui = MorseCodeGUI(morse_keyboard)
    gui.show_all()

    # Register the signal handler for SIGINT
    signal.signal(signal.SIGINT, gtk_main_quit)

    try:
        Gtk.main()
    except KeyboardInterrupt:
        print("Program interrupted. Cleaning up and exiting...")
    finally:
        morse_keyboard.close()




