From 1381d0e4bd17338bac24e8dfe66b82ece96c6da6 Mon Sep 17 00:00:00 2001 From: Bigsk Date: Thu, 23 Feb 2023 21:02:28 +0800 Subject: [PATCH] v0.0.2 rebase --- .gitignore | 5 +- README.md | 33 +++++++++- pycw/__init__.py | 18 ++++++ pycw/__main__.py | 43 +++++++++++++ pycw/__version__.py | 8 +++ pycw/morse.py | 151 ++++++++++++++++++++++++++++++++++++++++++++ pycw/synth.py | 53 ++++++++++++++++ setup.py | 53 ++++++++++++++++ 8 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 pycw/__init__.py create mode 100644 pycw/__main__.py create mode 100644 pycw/__version__.py create mode 100644 pycw/morse.py create mode 100644 pycw/synth.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 385555c..d9970ce 100644 --- a/.gitignore +++ b/.gitignore @@ -498,7 +498,8 @@ fabric.properties .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -720,5 +721,5 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/README.md b/README.md index 8aea519..c8689a6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # pycw -Python Morse Code Generator \ No newline at end of file +Python Morse Code Generator + +Generate Morse Code (CW) audio files in Python. + +## Usage + +``` +optional arguments: + -h, --help show this help message and exit + -i INPUT, --input INPUT + Input text file (defaults to stdin) + -t TEXT, --text TEXT Input text. Overrides --input. + -s SPEED, --speed SPEED + Speed, in words per minute (default: 12) + -n TONE, --tone TONE Tone frequency, in Hz (default: 800) + -v VOLUME, --volume VOLUME + Volume (default: 1.0) + -r SAMPLE_RATE, --sample_rate SAMPLE_RATE + Sample rate (default: 44100) + -o OUTPUT, --output OUTPUT + Name of the output file +``` + +Or `import pycw` and then use functions in your code, for example: + +``` +import pycw + +pycw.output_wave("Intro.wav", "CQ CQ CQ DE BG5AWO BG5AWO BG5AWO PSE K", 20) +``` + +Then you can get a output file called `Intro.wav` in your working directory. diff --git a/pycw/__init__.py b/pycw/__init__.py new file mode 100644 index 0000000..ff496b0 --- /dev/null +++ b/pycw/__init__.py @@ -0,0 +1,18 @@ +from . import synth + +from .morse import DIT, DAH, MORSE_TABLE +from .morse import generate +from .morse import stream_wave +from .morse import output_wave +from .morse import normalize_text + +__all__ = [ + "synth", + "DIT", + "DAH", + "MORSE_TABLE", + "generate", + "stream_wave", + "output_wave", + "normalize_text" +] \ No newline at end of file diff --git a/pycw/__main__.py b/pycw/__main__.py new file mode 100644 index 0000000..0e122fe --- /dev/null +++ b/pycw/__main__.py @@ -0,0 +1,43 @@ +import os + +from argparse import ArgumentParser + +from . import output_wave + +def main(): + parser = ArgumentParser( + description='Generate Morse Code (CW) audio files in Python.' + ) + parser.add_argument('-i', '--input', default="-", + help='Input text file (defaults to stdin)') + parser.add_argument('-t', '--text', + help='Input text. Overrides --input.') + parser.add_argument('-s', '--speed', type=int, default=12, + help='Speed, in words per minute (default: 12)') + parser.add_argument('-n', '--tone', type=int, default=800, + help='Tone frequency, in Hz (default: 800)') + parser.add_argument('-v', '--volume', type=float, default=1.0, + help='Volume (default: 1.0)') + parser.add_argument('-r', '--sample_rate', type=int, default=44100, + help='Sample rate (default: 44100)') + parser.add_argument('-o', '--output', + help='Name of the output file') + args = parser.parse_args() + + if not args.input: + with open(args.input, "r") as fb: + input_text = fb.read() + else: + input_text = args.text + + if input_text == "-" or input_text is None: + os.system("pycw -h") + else: + output_wave( + args.output, input_text, + args.speed, args.tone, args.volume, + args.sample_rate + ) + +if __name__ == '__main__': + main() diff --git a/pycw/__version__.py b/pycw/__version__.py new file mode 100644 index 0000000..0f7278d --- /dev/null +++ b/pycw/__version__.py @@ -0,0 +1,8 @@ +__title__ = "pycw" +__description__ = "Generate Morse Code (CW) audio files in Python." +__url__ = "https://github.com/bigsk05/pycw" +__version__ = '0.0.2' +__author__ = "Ian Xia" +__author_email__ = "i@ianxia.com" +__license__ = "Apache 2.0" +__copyright__ = "Copyright Ian Xia" \ No newline at end of file diff --git a/pycw/morse.py b/pycw/morse.py new file mode 100644 index 0000000..fb1e906 --- /dev/null +++ b/pycw/morse.py @@ -0,0 +1,151 @@ +import re +import wave + +import numpy + +from .synth import generate_silence, generate_sin_wave + +DEFAULT_SAMPLE_RATE = 44100 +DEFAULT_VOLUME = 1.0 +DEFAULT_TONE = 800 + +DIT = object() +DAH = object() +SYMBOL_SPACE = object() +LETTER_SPACE = object() +WORD_SPACE = object() + +MORSE_TABLE = { + 'a': (DIT, DAH), + 'b': (DAH, DIT, DIT, DIT), + 'c': (DAH, DIT, DAH, DIT), + 'd': (DAH, DIT, DIT), + 'e': (DIT, ), + 'f': (DIT, DIT, DAH, DIT), + 'g': (DAH, DAH, DIT), + 'h': (DIT, DIT, DIT, DIT), + 'i': (DIT, DIT), + 'j': (DIT, DAH, DAH, DAH), + 'k': (DAH, DIT, DAH), + 'l': (DIT, DAH, DIT, DIT), + 'm': (DAH, DAH), + 'n': (DAH, DIT), + 'o': (DAH, DAH, DAH), + 'p': (DIT, DAH, DAH, DIT), + 'q': (DAH, DAH, DIT, DAH), + 'r': (DIT, DAH, DIT), + 's': (DIT, DIT, DIT), + 't': (DAH, ), + 'u': (DIT, DIT, DAH), + 'v': (DIT, DIT, DIT, DAH), + 'w': (DIT, DAH, DAH), + 'x': (DAH, DIT, DIT, DAH), + 'y': (DAH, DIT, DAH, DAH), + 'z': (DAH, DAH, DIT, DIT), + '0': (DAH, DAH, DAH, DAH, DAH), + '1': (DIT, DAH, DAH, DAH, DAH), + '2': (DIT, DIT, DAH, DAH, DAH), + '3': (DIT, DIT, DIT, DAH, DAH), + '4': (DIT, DIT, DIT, DIT, DAH), + '5': (DIT, DIT, DIT, DIT, DIT), + '6': (DAH, DIT, DIT, DIT, DIT), + '7': (DAH, DAH, DIT, DIT, DIT), + '8': (DAH, DAH, DAH, DIT, DIT), + '9': (DAH, DAH, DAH, DAH, DIT), + '.': (DIT, DAH, DIT, DAH, DIT, DAH), + ',': (DAH, DAH, DIT, DIT, DAH, DAH), + '/': (DAH, DIT, DIT, DAH, DIT), + '?': (DIT, DIT, DAH, DAH, DIT, DIT), + '=': (DAH, DIT, DIT, DIT, DAH), + "'": (DIT, DAH, DAH, DAH, DAH, DIT), + '!': (DAH, DIT, DAH, DIT, DAH, DAH), + '(': (DAH, DIT, DAH, DAH, DIT), + ')': (DAH, DIT, DAH, DAH, DIT, DAH), + '&': (DIT, DAH, DIT, DIT, DIT), + ':': (DAH, DAH, DAH, DIT, DIT, DIT), + ';': (DAH, DIT, DAH, DIT, DAH, DIT), + '+': (DIT, DAH, DIT, DAH, DIT), + '-': (DAH, DIT, DIT, DIT, DIT, DAH), + '_': (DIT, DIT, DAH, DAH, DIT, DAH), + '"': (DIT, DAH, DIT, DIT, DAH, DIT), + '$': (DIT, DIT, DIT, DAH, DIT, DIT, DAH), +} + + +def generate( + text: str, wpm: int, tone: int = DEFAULT_TONE, + volume: float = DEFAULT_VOLUME, sample_rate: int = DEFAULT_SAMPLE_RATE + ) -> numpy.array: + text = normalize_text(text) + samples = list(_generate_samples(text, wpm, tone, volume, sample_rate)) + return numpy.concatenate(samples) + + +def stream_wave( + fp: wave.Wave_write, text: str, wpm: int, tone: int = DEFAULT_TONE, + volume: float = DEFAULT_VOLUME, sample_rate: int = DEFAULT_SAMPLE_RATE + ) -> None: + text = normalize_text(text) + for sample in _generate_samples(text, wpm, tone, volume, sample_rate): + fp.writeframes(sample) + + +def output_wave( + file: str, text: str, wpm: int, tone: int = DEFAULT_TONE, + volume: float = DEFAULT_VOLUME, sample_rate: int = DEFAULT_SAMPLE_RATE + ) -> None: + text = normalize_text(text) + with wave.open(file, "wb") as fp: + fp.setnchannels(1) + fp.setsampwidth(2) + fp.setframerate(sample_rate) + for sample in _generate_samples(text, wpm, tone, volume, sample_rate): + fp.writeframes(sample) + + +def _generate_samples( + text: str, wpm: int, tone: int = DEFAULT_TONE, + volume: float = DEFAULT_VOLUME, sample_rate: int = DEFAULT_SAMPLE_RATE + ) -> numpy.array: + dit_duration = 1.2 / wpm + dah_duration = dit_duration * 3 + symbol_space_duration = dit_duration + letter_space_duration = (dit_duration * 3) - symbol_space_duration + word_space_duration = (dit_duration * 7) - letter_space_duration + + audio_params = { + 'attack': dit_duration / 10, + 'release': dit_duration / 10, + 'volume': volume, + 'sample_rate': sample_rate + } + + samples = { + DIT: generate_sin_wave(tone, dit_duration, **audio_params), + DAH: generate_sin_wave(tone, dah_duration, **audio_params), + SYMBOL_SPACE: generate_silence(symbol_space_duration), + LETTER_SPACE: generate_silence(letter_space_duration), + WORD_SPACE: generate_silence(word_space_duration), + } + + def _encode_letter(letter: str): + if letter == ' ': + yield samples[WORD_SPACE] + return + + symbols = MORSE_TABLE.get(letter) + if not symbols: + raise ValueError('Unsupported symbol: {}'.format(repr(letter))) + + for symbol in symbols: + yield samples[symbol] + yield samples[SYMBOL_SPACE] + yield samples[LETTER_SPACE] + + for letter in normalize_text(text): + yield from _encode_letter(letter) + + +def normalize_text(text: str) -> str: + text = re.sub(r'\s+', ' ', text.lower().strip()) + return text \ No newline at end of file diff --git a/pycw/synth.py b/pycw/synth.py new file mode 100644 index 0000000..c93d644 --- /dev/null +++ b/pycw/synth.py @@ -0,0 +1,53 @@ +import numpy + +DEFAULT_SAMPLE_RATE = 44100 +DEFAULT_VOLUME = 1.0 + +def generate_sin_wave( + frequency: int, + duration: float, + volume: float = DEFAULT_VOLUME, + attack: int = 0, + release: int = 0, + sample_rate: int = int(DEFAULT_SAMPLE_RATE) + ): + samples = volume * numpy.sin( + 2 * numpy.pi * + numpy.arange(sample_rate * duration) * + frequency / sample_rate, + ).astype(numpy.float32) + + if (attack + release) > duration: + raise ValueError('Attack + release times cannot be > total time') + + if attack > 0: + attack_samples_num = int(attack * sample_rate) + attack_samples = samples[:attack_samples_num] + other_samples = samples[attack_samples_num:] + attack_envelope = ( + numpy.arange( + attack_samples_num + ) / attack_samples_num + ) + samples = numpy.concatenate(( + (attack_samples * attack_envelope), other_samples + )) + + if release > 0: + release_samples_num = int(release * sample_rate) + release_samples = samples[-release_samples_num:] + other_samples = samples[:-release_samples_num] + release_envelope = ( + numpy.arange( + release_samples_num - 1, -1, -1 + ) / release_samples_num + ) + samples = numpy.concatenate(( + other_samples, (release_samples * release_envelope) + )) + + return (samples * (2**15 - 1) / numpy.max(numpy.abs(samples))).astype(numpy.int16) + + +def generate_silence(duration: float, sample_rate: int = DEFAULT_SAMPLE_RATE): + return numpy.zeros(int(duration * sample_rate)).astype(numpy.int16) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2b934b7 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +import os +import sys + +from setuptools import setup, find_packages + +about = {} +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, "pycw", "__version__.py"), "r") as f: + exec(f.read(), about) + +with open("README.md", "r") as f: + readme = f.read() + +if sys.argv[-1] == "publish": + os.system("python setup.py sdist bdist_wheel") + os.system("twine upload dist/*") + sys.exit() + +setup( + name=about["__title__"], + version=about["__version__"], + description=about["__description__"], + packages=find_packages(), + url=about["__url__"], + license=about["__license__"], + author=about["__author__"], + author_email=about["__author_email__"], + long_description_content_type="text/markdown", + long_description=readme, + install_requires=[ + 'numpy', + ], + classifiers=[ + 'License :: OSI Approved :: Apache Software License', + # 'Programming Language :: Python :: 3', + # 'Programming Language :: Python :: 3.0', + # 'Programming Language :: Python :: 3.1', + # 'Programming Language :: Python :: 3.2', + # 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3 :: Only', + ], + entry_points={ + 'console_scripts': ['pycw=pycw.__main__:main'], + }, + package_data={'': ['README.md']}, + include_package_data=True, + zip_safe=False) \ No newline at end of file