diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c3dea67 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ec785a9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pycw.iml b/.idea/pycw.iml new file mode 100644 index 0000000..061a460 --- /dev/null +++ b/.idea/pycw.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pycw/__init__.py b/pycw/__init__.py new file mode 100644 index 0000000..b7c2876 --- /dev/null +++ b/pycw/__init__.py @@ -0,0 +1,20 @@ +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 + +__version__ = '0.0.1' + +__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..d5cd88e --- /dev/null +++ b/pycw/__main__.py @@ -0,0 +1,41 @@ +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 == "-": + print('usage: -h to learn more') + else: + output_wave( + args.output, input_text, + args.speed, args.tone, args.volume, + args.sample_rate + ) + +if __name__ == '__main__': + main() 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..1ace65b --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +from setuptools import setup, find_packages + +version = '0.0.1' + +longdesc = """\ +PyCW +####### + +Generate Morse Code (CW) audio files in Python. + +Repository and documentation: https://github.com/bigsk05/pycw +""" + + +setup( + name='pycw', + version=version, + packages=find_packages(), + url='https://github.com/bigsk05/pycw', + license='Apache Software License', + author='Ian Xia', + author_email='i@ianxia.com', + description='Generate Morse Code (CW) audio files in Python', + long_description=longdesc, + 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'], + }, + package_data={'': ['README.md']}, + include_package_data=True, + zip_safe=False) \ No newline at end of file