Git browser: Morse/
This page presents code associated with the module/unit named above.
Morse/rss-to-morse.py
#!/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
# https://pubmed.ncbi.nlm.nih.gov/31031095/
frequency = 432.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)
Morse/rss-to-morse.sh
#!/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
documentroot=/var/www/techrights.org/htdocs
# rm -f ${documentroot}/morse/headlines.flac
closure() {
test -f ${tmpfile} || exit 1
echo "Erasing temporary file"
rm -f ${tmpfile}
}
cancel() {
echo "Cancelled."
closure
exit 2
}
umask 0022
tmpfile=$(mktemp -p ${documentroot}/morse morse-tmp.XXXXXXX)
set -e
# set -x
# set -v
rss-to-morse.py \
--format FLAC \
--amplitude 0.5 \
--duration 0.1 \
--farnsworth 1.0 \
--lines 10 \
--input ${documentroot}/feed.xml \
--output ${tmpfile}
cp ${tmpfile} ${documentroot}/morse/headlines.flac
chmod 644 ${documentroot}/morse/headlines.flac
closure
exit 0