v0.0.2 rebase

This commit is contained in:
Bigsk 2023-02-23 21:02:28 +08:00
parent e7c23331a3
commit 1381d0e4bd
8 changed files with 361 additions and 3 deletions

3
.gitignore vendored
View File

@ -500,6 +500,7 @@ fabric.properties
# Icon must end with two \r
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/

View File

@ -1,3 +1,34 @@
# pycw
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.

18
pycw/__init__.py Normal file
View File

@ -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"
]

43
pycw/__main__.py Normal file
View File

@ -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()

8
pycw/__version__.py Normal file
View File

@ -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"

151
pycw/morse.py Normal file
View File

@ -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

53
pycw/synth.py Normal file
View File

@ -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)

53
setup.py Normal file
View File

@ -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)