#!/usr/bin/env python3

# Copyright BytesMedia, UK
# GNU Affero General Public License v3.0

import numpy
import soundfile as sf
import re
import feedparser
import dateparser

from argparse import ArgumentParser
from os import path

parser = ArgumentParser(description="Read an RSS or Atom feed and produce \
and audio file containing the corresponding morse code.  \
Either -i or -u is required.")
parser.add_argument("-a", "--amplitude", dest="amplitude", type=float, \
    help="amplitude on a scale of 0.0 to 1.0, default is 1.",
    default=1.0)
parser.add_argument("-d", "--duration", dest="duration", type=float, \
    help="unit (dit) duration in seconds, default is 0.1",
    default=0.1)
parser.add_argument("-f", "--format", dest="format", type=str, \
    help="output format of MP3, OGG, or FLAC, default is FLAC",
    default='FLAC')
parser.add_argument("--farnsworth", dest="farnsworth", type=float, \
    help="pseudo Farnsworth value, default is duration",
    default=None)
parser.add_argument("-i", "--input-file", dest="input", type=str, \
    help="full path to local feed file to read", default=False)
parser.add_argument("-n", "--lines", dest="lines", type=int, \
    help="maximum number of entries to read from feed.  defaults to all.",
    default=0)
parser.add_argument("-o", "--output", dest="output", type=str, \
    help="full path to MP3 file to be created," \
                    + " the default is 'headlines.mp3'", default=False)
parser.add_argument("-s", "--slew-duration", dest="slew", type=float, \
    help="Slew duration (soft start and end) in seconds.", default=0.01)
parser.add_argument("-u", "--url", dest="url", type=str, \
    help="URL of remote feed to fetch", default=None)
parser.add_argument("-v", "--verbose", dest="verbosity", action="count", \
    help="increase verbosity of reporting.", default=0)
options = parser.parse_args()

if options.format:
    format = options.format.upper()
    if format == 'MP3' or format == 'FLAC' or format == 'OGG':
        True
    else:
        print(f'Output format must be either MP3, OGG, or FLAC!')
        exit(1)
else:
    format = 'FLAC'

if options.verbosity:
    print("Format",format)

if options.input:
    p = path.abspath(options.input)
    if path.exists(p):
        try:
            f = feedparser.parse(p)
        except:
            print(f'File "{options.input}" does not exist!')
            exit(1)
    else:
        print(f'File "{options.input}" does not exist!')
        exit(1)
elif options.url:
    try:
        f = feedparser.parse(options.url)
    except:
        print(f'Could not fetch URL "{options.url}" ')
        exit(1)
else:
    print(f'Provide a feed to read in using either --input or --url!')
    exit(1)

if options.output:
    output = options.output
else:
    ext = format.lower()
    output = 'headlines.' + ext
    print(f'Writing to {output}')

def validate(s: str):
    s = re.sub(r'\[', '(', s)
    s = re.sub(r'\]', ')', s)
    s = s.lower()
    s = re.sub(r'[^0123456789abcdefghijklmnopqrstuvwyxzäæąáåćĉçđðéèęĝĥĵłńñóöøśŝšþüŭüźż\.,:\'\?\!/()\@&:=+-_\"\$]', ' ', s)
    s = re.sub(r'\s\s+', ' ', s)
    return(s)

duration   = 0.1        # base in seconds
if options.duration:
    duration = options.duration

# space between letters
if options.farnsworth:
    pseudoFarnsworth = float(options.farnsworth)
else:
    pseudoFarnsworth = duration

amplitude  = 1.0        # range [0.0, 1.0]
# too high a sample rate and the files are unnecessarily large,
# too low a sample rate and some browsers (Firefox) crackle and pop
sampleRate = 22000      # integer sample rate, Hz
frequency  = 440.0      # float sine frequency, Hz
slewDuration = 0.01     # slew length, s

if options.amplitude:
    if options.amplitude >= 0 and options.amplitude <= 1:
        amplitude = options.amplitude
    else:
        print(f'Amplitude "{options.amplitude}" is out of range.')
        print('Specify a value between 0 and 1, inclusive.')
        print('The default is 1.0')
        exit(1)

if options.slew:
    slewDuration = options.slew

if options.verbosity:
    print(f'Duration {duration}')
    print(f'pseudoFarnsworth {pseudoFarnsworth}')
    print(f'amplitude {amplitude}')
    print(f'frequency {frequency}')
    print(f'slew rate {slewDuration}')

# try to adjust to nearest cycle
ditDuration = round(frequency * duration * 2, 0) / frequency / 2
dahDuration = round(frequency * duration * 2 * 3, 0) / frequency / 2

# generate sine sample, with float32 array conversion
dit = ( numpy.sin(
    numpy.arange( sampleRate * ditDuration ) \
    * frequency / sampleRate \
    * numpy.pi * 2 ) ).astype( numpy.single )

dah = ( numpy.sin(
    numpy.arange( sampleRate * dahDuration ) \
    * frequency / sampleRate \
    * numpy.pi * 2 ) ).astype( numpy.single )

# how many samples in slew ramps, to soften starts and stops
slewSamples = int(sampleRate * slewDuration)

if slewSamples >= ( sampleRate * ditDuration ) // 2:
    raise ValueError("Slew duration is longer than dit duration.")

# Hanning window for the soft start and end
# https://numpy.org/doc/stable/reference/generated/numpy.hanning.html
hanningWindow = numpy.hanning(2 * slewSamples)

# make slewed dit
ditWithSlew = dit.copy()
ditWithSlew[:slewSamples] *= hanningWindow[:slewSamples]  # start
ditWithSlew[-slewSamples:] *= hanningWindow[slewSamples:] # end

# make slewed dah
dahWithSlew = dah.copy()
dahWithSlew[:slewSamples] *= hanningWindow[:slewSamples]  # start
dahWithSlew[-slewSamples:] *= hanningWindow[slewSamples:] # end

# scale with amplitude
dit = ditWithSlew * amplitude
dah = dahWithSlew * amplitude

# intraletter gap
Gap = numpy.zeros_like(dit) # fancy "0 * dit" (:

# interletter gap, as pseudo farnsworth value
letterGap = ( numpy.sin(
    numpy.arange( sampleRate * pseudoFarnsworth ) \
    * frequency / sampleRate \
    * numpy.pi * 2 ) ).astype(numpy.single) * 3

# interword gap, as a pseudo farnsworth value
wordGap = ( numpy.sin(
    numpy.arange( sampleRate * pseudoFarnsworth ) \
    * frequency / sampleRate \
    * numpy.pi * 2 ) ).astype(numpy.single) * 3

letterGap = (0 * letterGap)                     # silence
wordGap = (0 * wordGap)                         # silence

morse = {
    "a" : ".-",      "ä" : ".-.-",   "æ" : ".-.-",    "ą" : ".-.-",
    "á" : ".--.-",   "å" : ".--.-",  "b" : "-...",    "c" : "-.-.",
    "ć" : "-.-..",   "ĉ" : "-.-..",  "ç" : "-.-..",   "ch" : "----",
    "d" : "-..",     "đ" : "..-..",  "ð" : "..-..",   "e" : ".",
    "é" : "..-..",   "è" : ".-..-",  "ę" : "..-..",   "f" : "..-.",
    "g" : "--.",     "ĝ": "--.-.",   "h" : "....",    "ĥ" : "----",
    "i" : "..",      "j" : ".---",   "ĵ" : ".---.",   "k" : "-.-",
    "l" : ".-..",    "ł" : ".-..-",  "m" : "--",      "n" : "-.",
    "ń" : "--.--",   "ñ" : "--.--",  "o" : "---",     "ó" : "---.",
    "ö" : "---.",    "ø" : "---.",   "p" : ".--.",    "q" : "--.-",
    "r" : ".-.",     "s" : "...",    "ś" : "...-...", "ŝ" : "...-.",
    "š" : "----",    "t" : "-",      "þ" : ".--..",   "u" : "..-",
    "ü" : "..--",    "ŭ" : "..--",   "ü" : "..--",    "v" : "...-",
    "w" : ".--",     "x" : "-..-",   "y" : "-.--",    "z" : "--..",
    "ź" : "--..-.",  "ż" : "--..-",

    # when are cut numbers usable?
    "1" : ".----",   "2" : "..---",  "3" : "...--",   "4" : "....-",
    "5" : ".....",   "6" : "-....",  "7" : "--...",   "8" : "---..",
    "9" : "----.",   "0" : "-----",

    "@" : ".--.-.",  " " : " ",      "." : ".-.-.-",  "," : "--..--",
    "?" : "..--..",  "'" : ".----.", "!" : "-.-.--",  "/" : "-..-.",
    "(" : "-.--.",   ")" : "_.__._", "&" : ".-...",   ":" : "---...",
    "=" : "_..._",   "+" : "._._.",  "-" : "_..._",   "_" : "..--.-",
    "\"" : ".-..-.", "$" : "..._.._",

    "aa" : ".-.-",
    "ar" : ".-.-.",
    "bt" : "-...-",
    "ct" : "-.-.-",
    "sk" : "...-.-",
}

letters = list()
letters.append('ct')
letters.append(' ')
letters.append(' ')
letters.extend(list('headlines for '))
letters.extend(list(validate(f.feed.title)))

d = f.feed.published.lower()
d = re.sub(r',', '', d)
d = re.sub(r'\d{4}\s+\d\d:\d\d:\d\d.*$', '', d)

letters.extend(list(' from ' + d))
letters.append('bt')
letters.append(' ')

e = sorted(f.entries,
           key=lambda x: dateparser.parse(x.updated, languages=['en']),
           reverse=True)

count = 1
while e:
    if( options.lines and count > options.lines ):
        break
    count = count + 1
    entry = e.pop(0)
    ventry = validate(entry.title)
    letters.extend(list(ventry))
    if e and count <= options.lines:
        letters.append('aa')
        letters.append(' ')

letters.append(' ')
letters.append('ar')

if options.verbosity > 1:
    print(f'Letters = {letters}')

sound = Gap
while letters:
    letter = letters.pop(0)
    if (letter == ' ' or letter == '\n'):
        sound = numpy.concatenate((sound,wordGap))
    else:
        try:
            m = list(morse[letter])
        except:
            print(f'Foul letter "{letter}"')
            continue
        while m:
            bit = m.pop(0)
            if (bit == '.'):
                sound = numpy.concatenate((sound,dit))
            elif (bit == '-'):
                sound = numpy.concatenate((sound,dah))

            if m:
                sound = numpy.concatenate((sound,Gap))
            else:
                if letters and letters[0] != ' ':
                    sound = numpy.concatenate((sound,letterGap))

if format == 'FLAC':
    print("Writing")
    sf.write(output, sound, sampleRate, format='FLAC',
             subtype='PCM_S8')
elif format == 'MP3':
    sf.write(output, sound, sampleRate, format='MP3')
elif format == 'OGG':
    # known bug: this segfaults:
    sf.write(output, sound, sampleRate, format='OGG')

exit(0)
