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