Compare commits

...

63 Commits

Author SHA1 Message Date
Roman Zeyde
b944ae2989 Bump version: 1.15.7 → 1.16.0 2024-12-20 21:13:09 +02:00
Roman Zeyde
4322c39e46 Fix .bumpversion.cfg to use pyproject.toml 2024-12-20 21:04:56 +02:00
Roman Zeyde
73fb5bff62 Switch to pyproject.toml 2024-12-14 20:24:28 +02:00
Roman Zeyde
1f3ca560e8 Allow setting calibration tx gain 2024-12-12 20:27:42 +02:00
Roman Zeyde
297c66c284 Use contextlib.nullcontext (Python 3.7+) 2024-12-12 18:52:10 +02:00
Roman Zeyde
451551d37e Bump version: 1.15.5 → 1.15.6 2024-10-25 14:06:58 +03:00
Roman Zeyde
1d5fb11481 Add amodem.tests to source distribution
Also, fix a few pylint issues.
2024-10-24 21:48:10 +03:00
Roman Zeyde
a8e8bbbf02 Bump version: 1.15.4 → 1.15.5 2024-10-21 21:59:40 +03:00
Roman Zeyde
d015118d76 Update supported Python versions 2024-10-21 21:59:26 +03:00
Roman Zeyde
9410a988b1 Test on Python 3.13 2024-10-21 21:53:39 +03:00
Roman Zeyde
2a03d9f72c Fix a small pylint issue 2024-10-21 21:53:39 +03:00
Roman Zeyde
2925becfac Test on Python 3.12 (#67) 2023-11-11 21:26:03 +02:00
Roman Zeyde
30d6af680a Bump CI actions (#68) 2023-11-11 21:09:28 +02:00
Roman Zeyde
3284447d1c Fix division by 0 during calibration 2023-08-19 14:58:01 +03:00
Roman Zeyde
8557faba6e Fix lint nits 2023-07-15 21:48:13 +03:00
Roman Zeyde
d8059b5ff0 Require the user to specify send/recv subcommand 2023-07-15 21:40:01 +03:00
Roman Zeyde
250713a853 Bump version: 1.15.3 → 1.15.4 2022-12-25 21:45:15 +02:00
Roman Zeyde
f4473145c3 Merge pull request #60 from romanz/fix-test
Fix pytest usage
2022-12-24 21:54:47 +02:00
Roman Zeyde
bb68779d75 Update supported Python versions 2022-12-24 21:25:13 +02:00
Roman Zeyde
2452ed56e8 Fix pytest usage
0c5ba9480b (r93932858)
2022-12-24 21:23:05 +02:00
Roman Zeyde
55b446e362 Merge pull request #59 from mgorny/np-bool
Replace deprecated `np.bool` to fix NumPy 1.24 compatibility
2022-12-24 21:20:55 +02:00
Michał Górny
a99fb6d083 Replace deprecated np.bool to fix NumPy 1.24 compatibility
Replace the deprecated `np.bool` type with `bool`.  According
to the documentation, the former "has been an alias of the builtin"
"for a long time".  Using these aliases has been deprecated
in NumPy 1.20, and they were removed entirely in 1.24.

See:
https://numpy.org/doc/stable/release/1.20.0-notes.html#using-the-aliases-of-builtin-types-like-np-int-is-deprecated
https://numpy.org/doc/stable/release/1.24.0-notes.html#expired-deprecations
2022-12-22 15:55:07 +01:00
Roman Zeyde
0c5ba9480b Fix py.test in CI 2022-10-28 10:11:59 +03:00
Roman Zeyde
034a2c9ebe No need to specify encoding in binary I/O 2022-10-27 21:51:07 +03:00
Roman Zeyde
8553f74f88 Use f-string interpolation 2022-10-27 21:47:02 +03:00
Roman Zeyde
ffeda35d49 Support Python 3.10 2022-10-27 21:18:37 +03:00
Roman Zeyde
883ecb4334 Bump version: 1.15.2 → 1.15.3 2021-07-20 22:11:58 +03:00
Roman Zeyde
96b87ec5be A few more fixes after dropping Python 2 support 2021-06-19 21:45:54 +03:00
Roman Zeyde
ceffe7fac0 Drop six dependency 2021-06-19 21:41:38 +03:00
Roman Zeyde
e8cf356248 Drop Python 3.5 and test on 3.9 2021-06-19 21:38:14 +03:00
Roman Zeyde
ee5c543737 Bump version: 1.15.1 → 1.15.2 2021-06-19 19:38:26 +03:00
Roman Zeyde
1311c58005 Ignore 'consider-using-with' pylint warning 2021-06-19 14:31:38 +03:00
Roman Zeyde
9c6ef2884e Drop izip compatibility helper 2021-06-19 14:12:26 +03:00
Roman Zeyde
21fe42e68d Increase leading and trailing silence defaults
Leading silence should help ignore initial A/D artifacts.
Trailing silence fixes buffering issues (e.g. #49).
2021-06-19 14:07:01 +03:00
Roman Zeyde
9352088389 Bump version: 1.15.0 → 1.15.1 2020-07-03 14:56:12 +03:00
Roman Zeyde
db47cda390 Remove travis, coveralls, landscape & waffle badges 2020-07-03 14:55:12 +03:00
Roman Zeyde
4cd3def507 Fix lint errors and adapt to latest Python 2020-07-03 14:50:59 +03:00
Roman Zeyde
9192a8ff67 Switch to GitHub actions 2020-07-03 14:23:12 +03:00
Roman Zeyde
3b88a23dfb Document PortAudio DLL usage on Windows 2020-07-03 13:16:10 +03:00
Roman Zeyde
10c06f7646 Ignore .pytest_cache/ 2019-03-08 15:11:47 +02:00
Roman Zeyde
df9cbfdf13 Allow more silence before transmission 2019-03-08 15:10:02 +02:00
Roman Zeyde
02f45de4f1 Bump version: 1.14.0 → 1.15.0 2018-08-13 22:28:38 +03:00
Roman Zeyde
6d6bd44dd8 Update Python 3.x versions 2018-08-13 22:22:46 +03:00
Roman Zeyde
4598cfc7f6 Fix 'no-else-return' pylint warning 2018-08-13 22:10:26 +03:00
Roman Zeyde
ff6c9968e8 Don't inherit from object on modern Python 3 2018-08-13 16:04:06 +03:00
Roman Zeyde
0bd691320e Drop Python 2 support 2018-08-13 16:03:41 +03:00
Roman Zeyde
4527eaa931 Refactor log configuration 2018-08-13 15:17:54 +03:00
Roman Zeyde
1dd59f9f8f Merge remote-tracking branch 'jwoillez/jwoillez' 2018-07-23 10:39:16 +03:00
Roman Zeyde
c3cefee85f Remove Python 3.3 from Travis 2018-07-22 22:07:35 +03:00
Julien Woillez
2ab3c62d0d Added debug messages to check timing of recv(). 2018-07-22 19:46:53 +02:00
Julien Woillez
601a14297f Created defaultInterpolator to speed up recv(). 2018-07-22 19:46:53 +02:00
Julien Woillez
71dab0e7bc De-duplicate equalizer_length configuration. 2018-07-22 19:46:53 +02:00
Julien Woillez
53df2d5934 Speed up carrier detection with numpy. 2018-07-22 19:46:53 +02:00
Roman Zeyde
ac2e66bddd Add HN link 2018-06-18 22:11:30 +03:00
ellipticcurv3
0534b6891a More packages needed to install 2018-05-23 12:37:21 +02:00
Roman Zeyde
4f0bc6883b amodem: show the configuration during initialization 2018-05-02 09:39:31 +03:00
Roman Zeyde
4846cdaf8f README: explain audio redirection 2018-05-02 09:29:53 +03:00
Roman Zeyde
0e171a58f2 amodem: disable PortAudio when I/O is redirected 2018-05-02 09:25:49 +03:00
Roman Zeyde
20f90edf8d async: rename to async_reader
async is becoming a reserved word in Python 3.7
2018-05-02 09:13:37 +03:00
Roman Zeyde
72397e541c amodem: fix CLI argument help string 2018-05-02 09:09:34 +03:00
Roman Zeyde
6d17cc132f git: ignore deb_dist/ 2018-03-11 09:47:03 +02:00
Roman Zeyde
9dbb3826c7 Bump version: 1.13.1 → 1.14.0 2018-03-11 08:50:09 +02:00
Roman Zeyde
c991b2264e setup: update package status to stable :) 2018-03-11 08:48:21 +02:00
40 changed files with 292 additions and 280 deletions

View File

@@ -1,7 +1,6 @@
[bumpversion]
commit = True
tag = True
current_version = 1.13.1
[bumpversion:file:setup.py]
current_version = 1.16.0
[bumpversion:file:pyproject.toml]

36
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
python -m pip install --upgrade pip setuptools
pip install pytest pytest-cov mock pycodestyle coverage pylint
pip install -e .
- name: Lint
run: |
pycodestyle amodem/ scripts/
pylint --extension-pkg-whitelist=numpy --reports=no amodem --rcfile .pylintrc
- name: Test with pytest
run: |
pytest -v --cov=amodem

2
.gitignore vendored
View File

@@ -24,4 +24,6 @@ htmlcov
*_ext.so
/.tox
/dist
/deb_dist
*.html
.pytest_cache/

View File

@@ -1,2 +1,2 @@
[MESSAGES CONTROL]
disable=invalid-name, missing-docstring, too-many-instance-attributes, too-few-public-methods, logging-format-interpolation
disable=invalid-name, missing-docstring, too-many-instance-attributes, too-few-public-methods, logging-format-interpolation, consider-using-with, redefined-outer-name

View File

@@ -1,22 +0,0 @@
sudo: false
language: python
python:
- "2.7"
- "3.3"
- "3.4"
- "3.5"
- "3.6"
install:
- pip install .
- pip install pytest>=2.7.3 --upgrade
- pip install coveralls pycodestyle mock
script:
- pycodestyle amodem/ scripts/
- echo "Hello World!" | amodem send -vv -l- -o- | amodem recv -vv -l- -i-
- coverage run --source=amodem --omit="*/__main__.py" -m py.test -vvs
after_success:
- coverage report
- coveralls

View File

@@ -1,16 +1,6 @@
Audio Modem Communication Library
=================================
.. image:: https://travis-ci.org/romanz/amodem.svg?branch=master
:target: https://travis-ci.org/romanz/amodem
:alt: Build Status
.. image:: https://coveralls.io/repos/romanz/amodem/badge.svg?branch=master
:target: https://coveralls.io/r/romanz/amodem?branch=master
:alt: Code Coverage
.. image:: https://landscape.io/github/romanz/amodem/master/landscape.svg?style=flat
:target: https://landscape.io/github/romanz/amodem/master
:alt: Code Health
.. image:: https://img.shields.io/pypi/pyversions/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: Python Versions
@@ -24,9 +14,6 @@ Audio Modem Communication Library
:target: https://pypi.python.org/pypi/amodem/
:alt: Development Status
.. image:: https://badge.waffle.io/romanz/amodem.svg?label=ready&title=ready
:target: https://waffle.io/romanz/amodem
:alt: 'Ready'
Description
-----------
@@ -45,6 +32,8 @@ find the optimal output volume for its sound card, which will not saturate the
receiving microphone and provide good enough Signal-to-Noise ratio
for the demodulation to succeed.
HackerNews discussion: https://news.ycombinator.com/item?id=17333257
Technical Details
-----------------
@@ -64,9 +53,9 @@ on each 250 byte data frame.
Installation
------------
Make sure that ``numpy`` and ``PortAudio v19`` packages are installed (on Debian)::
Make sure that all the required packages are installed (on Debian)::
$ sudo apt-get install python-numpy portaudio19-dev
$ sudo apt-get install python-numpy python-pip portaudio19-dev git
Get the latest released version from PyPI::
@@ -96,6 +85,12 @@ For validation, run::
optional arguments:
-h, --help show this help message and exit
On, Windows you may download the `portaudio` library from `MinGW <https://packages.msys2.org/base/mingw-w64-portaudio>`_.
Then, you should specify the DLL using the following command-line flag::
-l AUDIO_LIBRARY, --audio-library AUDIO_LIBRARY
File name of PortAudio shared library.
Calibration
-----------
@@ -225,6 +220,26 @@ After the receiver has finished, verify the received file's hash::
You can see a screencast of the `data transfer process <https://asciinema.org/a/25066?autoplay=1>`_.
I/O redirection
---------------
The audio can be written/read to an intermediate PCM file (instead of the speaker/microphone) using::
$ echo 123 | amodem send -o /tmp/file.pcm
Sending 0.800 seconds of training audio
Starting modulation
Sent 0.004 kB @ 0.113 seconds
$ amodem recv -i /tmp/file.pcm
Waiting for carrier tone: 2.0 kHz
Carrier detected at ~150.0 ms @ 2.0 kHz
Carrier coherence: 100.000%
Carrier symbols amplitude : 1.000
Frequency error: 0.000 ppm
Starting demodulation
123
Received 0.004 kB @ 0.011 seconds = 0.376 kB/s
Visualization
-------------
Make sure that ``matplotlib`` package is installed, and run (at the receiver side)::

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python
# PYTHON_ARGCOMPLETE_OK
import argparse
import contextlib
import logging
import os
import sys
@@ -8,18 +9,13 @@ import zlib
import pkg_resources
from . import async
from . import async_reader
from . import audio
from . import calib
from . import main
from .config import bitrates
# Python 3 has `buffer` attribute for byte-based I/O
_stdin = getattr(sys.stdin, 'buffer', sys.stdin)
_stdout = getattr(sys.stdout, 'buffer', sys.stdout)
try:
import argcomplete
except ImportError:
@@ -31,7 +27,7 @@ bitrate = os.environ.get('BITRATE', 1)
config = bitrates.get(int(bitrate))
class Compressor(object):
class Compressor:
def __init__(self, stream):
self.obj = zlib.compressobj()
log.info('Using zlib compressor')
@@ -52,7 +48,7 @@ class Compressor(object):
return result
class Decompressor(object):
class Decompressor:
def __init__(self, stream):
self.obj = zlib.decompressobj()
log.info('Using zlib decompressor')
@@ -77,27 +73,27 @@ def FileType(mode, interface_factory=None):
assert audio_interface is not None
if 'r' in mode:
s = audio_interface.recorder()
return async.AsyncReader(stream=s, bufsize=s.bufsize)
return async_reader.AsyncReader(stream=s, bufsize=s.bufsize)
if 'w' in mode:
return audio_interface.player()
if fname == '-':
if 'r' in mode:
return _stdin
return sys.stdin.buffer
if 'w' in mode:
return _stdout
return sys.stdout.buffer
return open(fname, mode)
return open(fname, mode) # pylint: disable=unspecified-encoding
return opener
def get_volume_cmd(args):
volume_controllers = [
dict(test='pactl --version',
send='pactl set-sink-volume @DEFAULT_SINK@',
recv='pactl set-source-volume @DEFAULT_SOURCE@')
]
volume_controllers = [{
'test': 'pactl --version',
'send': 'pactl set-sink-volume @DEFAULT_SINK@',
'recv': 'pactl set-source-volume @DEFAULT_SOURCE@'
}]
if args.calibrate == 'auto':
for c in volume_controllers:
if os.system(c['test']) == 0:
@@ -111,7 +107,7 @@ def wrap(cls, stream, enable):
def create_parser(description, interface_factory):
p = argparse.ArgumentParser(description=description)
subparsers = p.add_subparsers()
subparsers = p.add_subparsers(required=True)
# Modulator
sender = subparsers.add_parser(
@@ -124,14 +120,18 @@ def create_parser(description, interface_factory):
sender.add_argument(
'-g', '--gain', type=float, default=1.0,
help='Modulator gain (defaults to 1)')
sender.add_argument(
'--silence', type=float, default=0.0,
help='Extra silence before sending the data (in seconds)')
sender.set_defaults(
main=lambda config, args: main.send(
config, src=wrap(Compressor, args.src, args.zlib), dst=args.dst,
gain=args.gain
gain=args.gain, extra_silence=args.silence
),
calib=lambda config, args: calib.send(
config=config, dst=args.dst,
volume_cmd=get_volume_cmd(args)
volume_cmd=get_volume_cmd(args),
gain=args.gain,
),
input_type=FileType('rb'),
output_type=FileType('wb', interface_factory),
@@ -142,8 +142,7 @@ def create_parser(description, interface_factory):
receiver = subparsers.add_parser(
'recv', help='demodulate audio signal into binary data.')
receiver.add_argument(
'-i', '--input', help='input file (use "-" for stdin).'
' if not specified, `arecord` tool will be used.')
'-i', '--input', help='input file (use "-" for stdin).')
receiver.add_argument(
'-o', '--output', help='output file (use "-" for stdout).')
receiver.add_argument(
@@ -186,18 +185,26 @@ def create_parser(description, interface_factory):
return p
class _Dummy(object):
def __enter__(self):
return self
def __exit__(self, *args):
pass
def _version():
return pkg_resources.require('amodem')[0].version
def _config_log(args):
level = fmt = None
if args.verbose == 0:
level, fmt = 'INFO', '%(message)s'
elif args.verbose == 1:
level, fmt = 'DEBUG', '%(message)s'
elif args.verbose >= 2:
level, fmt = ('DEBUG', '%(asctime)s %(levelname)-10s '
'%(message)-100s '
'%(filename)s:%(lineno)d')
if args.quiet:
level, fmt = 'WARNING', '%(message)s'
logging.basicConfig(level=level, format=fmt)
def _main():
fmt = ('Audio OFDM MODEM v{0:s}: '
'{1:.1f} kb/s ({2:d}-QAM x {3:d} carriers) '
@@ -213,31 +220,25 @@ def _main():
p = create_parser(description, interface_factory)
args = p.parse_args()
if args.verbose == 0:
level, fmt = 'INFO', '%(message)s'
elif args.verbose == 1:
level, fmt = 'DEBUG', '%(message)s'
elif args.verbose >= 2:
level, fmt = ('DEBUG', '%(asctime)s %(levelname)-10s '
'%(message)-100s '
'%(filename)s:%(lineno)d')
if args.quiet:
level, fmt = 'WARNING', '%(message)s'
logging.basicConfig(level=level, format=fmt)
_config_log(args)
# Parsing and execution
log.debug(description)
log.info(description)
args.pylab = None
if getattr(args, 'plot', False):
import pylab # pylint: disable=import-error
import pylab # pylint: disable=import-error,import-outside-toplevel
args.pylab = pylab
if args.audio_library == 'ALSA':
from . import alsa
from . import alsa # pylint: disable=import-outside-toplevel
interface = alsa.Interface(config)
elif args.audio_library == '-':
interface = _Dummy()
interface = contextlib.nullcontext() # manually disable PortAudio
elif args.command == 'send' and args.output is not None:
interface = contextlib.nullcontext() # redirected output
elif args.command == 'recv' and args.input is not None:
interface = contextlib.nullcontext() # redirected input
else:
interface = audio.Interface(config)
interface.load(args.audio_library)

View File

@@ -9,7 +9,7 @@ import logging
log = logging.getLogger(__name__)
class Interface(object):
class Interface:
RECORDER = 'arecord'
PLAYER = 'aplay'
@@ -50,7 +50,7 @@ class Interface(object):
return Player(self)
class Recorder(object):
class Recorder:
def __init__(self, lib):
self.p = lib.launch(args=lib.record_cmd, stdout=subprocess.PIPE)
self.read = self.p.stdout.read
@@ -60,7 +60,7 @@ class Recorder(object):
self.p.kill()
class Player(object):
class Player:
def __init__(self, lib):
self.p = lib.launch(args=lib.play_cmd, stdin=subprocess.PIPE)
self.write = self.p.stdin.write

View File

@@ -3,15 +3,15 @@
import logging
import threading
import six # since `Queue` module was renamed to `queue` (in Python 3)
from queue import Queue
log = logging.getLogger()
class AsyncReader(object):
class AsyncReader:
def __init__(self, stream, bufsize):
self.stream = stream
self.queue = six.moves.queue.Queue()
self.queue = Queue()
self.stop = threading.Event()
args = (stream, bufsize, self.queue, self.stop)
self.thread = threading.Thread(target=AsyncReader._thread,
@@ -29,7 +29,7 @@ class AsyncReader(object):
queue.put(buf)
total += len(buf)
log.debug('AsyncReader thread stopped (read %d bytes)', total)
except BaseException:
except BaseException: # pylint: disable=broad-except
log.exception('AsyncReader thread failed')
queue.put(None)

View File

@@ -7,7 +7,11 @@ import time
log = logging.getLogger(__name__)
class Interface(object):
class AudioError(Exception):
pass
class Interface:
def __init__(self, config, debug=False):
self.debug = bool(debug)
self.config = config
@@ -26,7 +30,7 @@ class Interface(object):
def call(self, name, *args, **kwargs):
assert self.lib is not None
func_name = 'Pa_{0}'.format(name)
func_name = f'Pa_{name}'
if self.debug:
log.debug('API: %s%s', name, args)
func = getattr(self.lib, func_name)
@@ -35,7 +39,7 @@ class Interface(object):
def _error_check(self, res):
if res != 0:
raise Exception(res, self._error_string(res))
raise AudioError(res, self._error_string(res))
def __enter__(self):
self.call('Initialize')
@@ -53,7 +57,7 @@ class Interface(object):
return Stream(self, config=self.config, write=True)
class Stream(object):
class Stream:
timer = time.time
@@ -81,7 +85,7 @@ class Stream(object):
assert read != write # don't support full duplex
direction = 'Input' if read else 'Output'
api_name = 'GetDefault{0}Device'.format(direction)
api_name = f'GetDefault{direction}Device'
index = interface.call(api_name, restype=ctypes.c_int)
self.params = Stream.Parameters(
device=index, # choose default device

View File

@@ -18,7 +18,7 @@ def volume_controller(cmd):
def controller(level):
assert 0 < level <= 1
percent = 100 * level
args = '{0} {1:.0f}%'.format(cmd, percent)
args = f'{cmd} {percent:.0f}%'
log.debug('Setting volume to %7.3f%% -> "%s"', percent, args)
subprocess.check_call(args=args, shell=True)
return controller if cmd else (lambda level: None)
@@ -66,19 +66,19 @@ def detector(config, src, frame_length=200):
max_index = np.argmax(coeffs)
freq = config.frequencies[max_index]
rms = abs(coeffs[max_index])
coherency = rms / total
coherency = rms / total if total > 0 else 0.0
flags = [total > 0.1, peak < 1.0, coherency > 0.99]
success = all(flags)
if success:
msg = 'good signal'
else:
msg = 'too {0} signal'.format(errors[flags.index(False)])
msg = f'too {errors[flags.index(False)]} signal'
yield dict(
freq=freq, rms=rms, peak=peak, coherency=coherency,
total=total, success=success, msg=msg
)
yield {
'freq': freq, 'rms': rms, 'peak': peak, 'coherency': coherency,
'total': total, 'success': success, 'msg': msg
}
def volume_calibration(result_iterator, volume_ctl):
@@ -107,8 +107,7 @@ def volume_calibration(result_iterator, volume_ctl):
def iter_window(iterable, size):
# pylint: disable=stop-iteration-return
block = []
while True:
item = next(iterable)
for item in iterable:
block.append(item)
block = block[-size:]
if len(block) == size:
@@ -136,7 +135,7 @@ def recv(config, src, verbose=False, volume_cmd=None, dump_audio=None):
log.info('verbose: %s', verbose)
if verbose:
fields = ['total', 'rms', 'coherency', 'peak']
fmt += ', '.join('{0}={{{0}:.4f}}'.format(f) for f in fields)
fmt += ', '.join(f'{f}={{{f}:.4f}}' for f in fields)
for state in recv_iter(config, src, volume_cmd, dump_audio):
log.info(fmt.format(**state))

View File

@@ -73,15 +73,7 @@ def take(iterable, n):
return np.array(list(itertools.islice(iterable, n)))
def izip(iterables):
""" "Python 3" zip re-implementation for Python 2. """
# pylint: disable=stop-iteration-return
iterables = [iter(iterable) for iterable in iterables]
while True:
yield tuple([next(iterable) for iterable in iterables])
class Dummy(object):
class Dummy:
""" Dummy placeholder object for testing and mocking. """
def __getattr__(self, name):

View File

@@ -3,7 +3,7 @@
import numpy as np
class Configuration(object):
class Configuration:
Fs = 32000.0 # sampling frequency [Hz]
Tsym = 0.001 # symbol duration [seconds]
Npoints = 64
@@ -14,8 +14,8 @@ class Configuration(object):
latency = 0.1
# sender config
silence_start = 0.25
silence_stop = 0.25
silence_start = 0.5
silence_stop = 0.5
# receiver config
skip_start = 0.1

View File

@@ -13,7 +13,7 @@ from . import common
log = logging.getLogger(__name__)
class Detector(object):
class Detector:
COHERENCE_THRESHOLD = 0.9
@@ -85,12 +85,10 @@ class Detector(object):
signal = np.concatenate([zeroes, carrier])
signal = (2 ** 0.5) * signal / dsp.norm(signal)
coeffs = []
for i in range(len(buf) - len(signal)):
b = buf[i:i+len(signal)]
norm_b = dsp.norm(b)
c = (np.abs(np.dot(b, signal)) / norm_b) if norm_b else 0.0
coeffs.append(c)
corr = np.abs(np.correlate(buf, signal))
norm_b = np.sqrt(np.correlate(np.abs(buf)**2, np.ones(len(signal))))
coeffs = np.zeros_like(corr)
coeffs[norm_b > 0.0] = corr[norm_b > 0.0] / norm_b[norm_b > 0.0]
index = np.argmax(coeffs)
log.info('Carrier coherence: %.3f%%', coeffs[index] * 100)
@@ -115,5 +113,5 @@ class Detector(object):
freq_err = a / (self.Tsym * self.freq)
log.info('Frequency error: %.3f ppm', freq_err * 1e6)
self.plt.title('Frequency drift: {0:.3f} ppm'.format(freq_err * 1e6))
self.plt.title(f'Frequency drift: {freq_err * 1e6:.3f} ppm')
return amplitude, freq_err

View File

@@ -5,7 +5,7 @@ import numpy as np
from . import common
class FIR(object):
class FIR:
def __init__(self, h):
self.h = np.array(h)
self.x_state = [0] * len(self.h)
@@ -19,7 +19,7 @@ class FIR(object):
self.x_state = x_
class Demux(object):
class Demux:
def __init__(self, sampler, omegas, Nsym):
self.Nsym = Nsym
self.filters = [exp_iwt(-w, Nsym) / (0.5*self.Nsym) for w in omegas]
@@ -33,8 +33,7 @@ class Demux(object):
frame = self.sampler.take(size=self.Nsym)
if len(frame) == self.Nsym:
return np.dot(self.filters, frame)
else:
raise StopIteration
raise StopIteration
__next__ = next
@@ -73,14 +72,14 @@ def linear_regression(x, y):
return a, b
class MODEM(object):
class MODEM:
def __init__(self, symbols):
self.encode_map = {}
symbols = np.array(list(symbols))
bits_per_symbol = np.log2(len(symbols))
bits_per_symbol = np.round(bits_per_symbol)
N = (2 ** bits_per_symbol)
N = 2 ** bits_per_symbol
assert N == len(symbols)
bits_per_symbol = int(bits_per_symbol)

View File

@@ -9,7 +9,7 @@ from . import sampling
from . import levinson
class Equalizer(object):
class Equalizer:
def __init__(self, config):
self.carriers = config.carriers
@@ -46,9 +46,9 @@ class Equalizer(object):
return np.array(list(itertools.islice(symbols, size)))
prefix = [1]*200 + [0]*50
equalizer_length = 200
silence_length = 50
prefix = [1]*equalizer_length + [0]*silence_length
def train(signal, expected, order, lookahead=0):

View File

@@ -10,11 +10,10 @@ log = logging.getLogger(__name__)
def _checksum_func(x):
''' The result will be unsigned on Python 2/3. '''
return binascii.crc32(bytes(x)) & 0xFFFFFFFF
return binascii.crc32(bytes(x))
class Checksum(object):
class Checksum:
fmt = '>L' # unsigned longs (32-bit)
size = struct.calcsize(fmt)
@@ -29,12 +28,11 @@ class Checksum(object):
if received != expected:
log.warning('Invalid checksum: %08x != %08x', received, expected)
raise ValueError('Invalid checksum')
else:
log.debug('Good checksum: %08x', received)
log.debug('Good checksum: %08x', received)
return payload
class Framer(object):
class Framer:
block_size = 250
prefix_fmt = '>B'
prefix_len = struct.calcsize(prefix_fmt)
@@ -88,7 +86,7 @@ def chain_wrapper(func):
return wrapped
class BitPacker(object):
class BitPacker:
byte_size = 8
def __init__(self):

View File

@@ -10,12 +10,12 @@ from . import framing, common, stream, detect, sampling
log = logging.getLogger(__name__)
def send(config, src, dst, gain=1.0):
def send(config, src, dst, gain=1.0, extra_silence=0.0):
sender = _send.Sender(dst, config=config, gain=gain)
Fs = config.Fs
# pre-padding audio with silence (priming the audio sending queue)
sender.write(np.zeros(int(Fs * config.silence_start)))
sender.write(np.zeros(int(Fs * (config.silence_start + extra_silence))))
sender.start()
@@ -59,10 +59,11 @@ def recv(config, src, dst, dump_audio=None, pylab=None):
gain = 1.0 / amplitude
log.debug('Gain correction: %.3f', gain)
sampler = sampling.Sampler(signal, sampling.Interpolator(), freq=freq)
sampler = sampling.Sampler(signal, sampling.defaultInterpolator,
freq=freq)
receiver.run(sampler, gain=1.0/amplitude, output=dst)
return True
except BaseException:
except BaseException: # pylint: disable=broad-except
log.exception('Decoding failed')
return False
finally:

View File

@@ -13,7 +13,7 @@ from . import equalizer
log = logging.getLogger(__name__)
class Receiver(object):
class Receiver:
def __init__(self, config, pylab=None):
self.stats = {}
@@ -43,9 +43,9 @@ class Receiver(object):
self.plt.subplot(1, 2, 2)
self.plt.plot(np.abs(S))
self.plt.plot(equalizer.prefix)
errors = (bits != equalizer.prefix)
errors = bits != equalizer.prefix
if any(errors):
msg = 'Incorrect prefix: {0} errors'.format(sum(errors))
msg = f'Incorrect prefix: {sum(errors)} errors'
raise ValueError(msg)
log.debug('Prefix OK')
@@ -70,6 +70,7 @@ class Receiver(object):
self.plt.plot(np.arange(order+lookahead), coeffs)
equalization_filter = dsp.FIR(h=coeffs)
log.debug('Training completed')
# Pre-load equalization filter with the signal (+lookahead)
equalized = list(equalization_filter(signal))
equalized = equalized[prefix+lookahead:-postfix+lookahead]
@@ -80,7 +81,7 @@ class Receiver(object):
equalizer_length = equalizer.equalizer_length
symbols = self.equalizer.demodulator(equalized, equalizer_length)
sliced = np.array(symbols).round()
errors = np.array(sliced - train_symbols, dtype=np.bool)
errors = np.array(sliced - train_symbols, dtype=bool)
error_rate = errors.sum() / errors.size
errors = np.array(symbols - train_symbols)
@@ -93,8 +94,9 @@ class Receiver(object):
for (i, freq), snr in zip(enumerate(self.frequencies), SNRs):
log.debug('%5.1f kHz: SNR = %5.2f dB', freq / 1e3, snr)
self._constellation(symbols[:, i], train_symbols[:, i],
'$F_c = {0} Hz$'.format(freq), index=i)
f'$F_c = {freq} Hz$', index=i)
assert error_rate == 0, error_rate
log.debug('Training verified')
def _bitstream(self, symbols, error_handler):
streams = []
@@ -109,7 +111,7 @@ class Receiver(object):
bits = self.modem.decode(S, freq_handler) # list of bit tuples
streams.append(bits) # bit stream per frequency
return common.izip(streams), symbol_list
return zip(*streams), symbol_list
def _demodulate(self, sampler, symbols):
symbol_list = []
@@ -156,6 +158,7 @@ class Receiver(object):
)
def run(self, sampler, gain, output):
log.debug('Receiving')
symbols = dsp.Demux(sampler, omegas=self.omegas, Nsym=self.Nsym)
self._prefix(symbols, gain=gain)
@@ -185,7 +188,7 @@ class Receiver(object):
symbol_list = np.array(self.stats['symbol_list'])
for i, freq in enumerate(self.frequencies):
self._constellation(symbol_list[i], self.modem.symbols,
'$F_c = {0} Hz$'.format(freq), index=i)
f'$F_c = {freq} Hz$', index=i)
self.plt.show()
def _constellation(self, y, symbols, title, index=None):

View File

@@ -5,7 +5,7 @@ import numpy as np
from . import common
class Interpolator(object):
class Interpolator:
def __init__(self, resolution=1024, width=128):
@@ -30,7 +30,10 @@ class Interpolator(object):
assert len(self.filt) == resolution
class Sampler(object):
defaultInterpolator = Interpolator()
class Sampler:
def __init__(self, src, interp=None, freq=1.0):
self.freq = freq
self.equalizer = lambda x: x # LTI equalization filter

View File

@@ -10,7 +10,7 @@ from . import dsp
log = logging.getLogger(__name__)
class Sender(object):
class Sender:
def __init__(self, fd, config, gain=1.0):
self.gain = gain
self.offset = 0

View File

@@ -1,11 +1,11 @@
import time
class Reader(object):
class Reader:
wait = 0.2
timeout = 2.0
bufsize = (8 << 10)
bufsize = 8 << 10
def __init__(self, fd, data_type=None, eof=False):
self.fd = fd
@@ -24,8 +24,7 @@ class Reader(object):
self.total += len(data)
block.extend(data)
return block
else:
raise StopIteration()
raise StopIteration()
finish_time = time.time() + self.timeout
while time.time() <= finish_time:
@@ -45,7 +44,7 @@ class Reader(object):
__next__ = next
class Dumper(object):
class Dumper:
def __init__(self, src, dst):
self.src = src
self.dst = dst

0
amodem/tests/__init__.py Normal file
View File

View File

@@ -1,7 +1,7 @@
from amodem import alsa, config
import mock
from .. import alsa, config
def test_alsa():
interface = alsa.Interface(config=config.fastest())

View File

@@ -1,8 +1,11 @@
import mock
import time
import pytest
from amodem import async
import logging
import time
import mock
import pytest
from .. import async_reader
logging.basicConfig(format='%(message)s')
@@ -13,7 +16,7 @@ def test_async_reader():
return b'\x00' * n
s = mock.Mock()
s.read = _read
r = async.AsyncReader(s, 1)
r = async_reader.AsyncReader(s, 1)
n = 5
assert r.read(n) == b'\x00' * n
@@ -25,6 +28,6 @@ def test_async_reader():
def test_async_reader_error():
s = mock.Mock()
s.read.side_effect = IOError()
r = async.AsyncReader(s, 1)
r = async_reader.AsyncReader(s, 1)
with pytest.raises(IOError):
r.read(3)

View File

@@ -1,8 +1,8 @@
from amodem import audio, config
import mock
import pytest
from .. import audio, config
def test():
length = 1024
@@ -31,4 +31,4 @@ def test():
s.close()
with pytest.raises(Exception):
interface._error_check(1)
interface._error_check(1) # pylint: disable=protected-access

View File

@@ -1,18 +1,17 @@
from amodem import calib
from amodem import common
from amodem import config
from io import BytesIO
import numpy as np
import random
import pytest
import mock
import numpy as np
import pytest
from .. import calib, common, config
config = config.fastest()
class ProcessMock(object):
class ProcessMock:
def __init__(self):
self.buf = BytesIO()
self.stdin = self

View File

@@ -1,7 +1,7 @@
from amodem import common
from amodem import config
import numpy as np
from .. import common, config
def iterlist(x, *args, **kwargs):
x = np.array(x)
@@ -48,12 +48,6 @@ def test_dumps_loads():
assert all(x == y)
def test_izip():
x = range(10)
y = range(-10, 0)
assert list(common.izip([x, y])) == list(zip(x, y))
def test_configs():
default = config.Configuration()
fastest = config.fastest()

View File

@@ -1,4 +1,4 @@
from amodem import config
from .. import config
def test_bitrates():

View File

@@ -1,13 +1,8 @@
import numpy as np
import pytest
from amodem import dsp
from amodem import recv
from amodem import detect
from amodem import equalizer
from amodem import sampling
from amodem import config
from amodem import common
from .. import common, config, detect, dsp, equalizer, recv, sampling
config = config.fastest()
@@ -17,7 +12,7 @@ def test_detect():
x = np.cos(2 * np.pi * config.Fc * t)
detector = detect.Detector(config, pylab=common.Dummy())
samples, amp, freq_err = detector.run(x)
_samples, amp, freq_err = detector.run(x)
assert abs(1 - amp) < 1e-12
assert abs(freq_err) < 1e-12
@@ -39,11 +34,11 @@ def test_prefix():
sampler = sampling.Sampler(signal)
return dsp.Demux(sampler=sampler, omegas=[omega], Nsym=config.Nsym)
r = recv.Receiver(config, pylab=common.Dummy())
r._prefix(symbols_stream(signal))
r._prefix(symbols_stream(signal)) # pylint: disable=protected-access
with pytest.raises(ValueError):
silence = 0 * signal
r._prefix(symbols_stream(silence))
r._prefix(symbols_stream(silence)) # pylint: disable=protected-access
def test_find_start():

View File

@@ -1,12 +1,11 @@
from amodem import dsp
from amodem import sampling
from amodem import config
import utils
import numpy as np
import random
import itertools
import numpy as np
from .. import dsp, sampling, config
from . import utils
config = config.fastest()
@@ -69,7 +68,7 @@ def quantize(q, s):
def test_overflow():
q = dsp.MODEM(config.symbols)
r = np.random.RandomState(seed=0)
for i in range(10000):
for _ in range(10000):
s = 10*(r.normal() + 1j * r.normal())
quantize(q, s)

View File

@@ -1,10 +1,9 @@
from numpy.random import RandomState
import numpy as np
import utils
from amodem import equalizer
from amodem import dsp
from amodem import config
from . import utils
from .. import config, dsp, equalizer
config = config.fastest()

View File

@@ -1,9 +1,10 @@
from amodem import framing
import random
import itertools
import random
import pytest
from .. import framing
def concat(iterable):
return bytearray(itertools.chain.from_iterable(iterable))

View File

@@ -1,8 +1,8 @@
from amodem import sampling
from amodem import common
from io import BytesIO
import numpy as np
from io import BytesIO
from .. import common, sampling
def test_resample():

View File

@@ -1,5 +1,7 @@
from amodem import stream
import subprocess as sp
import sys
from .. import stream
script = br"""
import sys
@@ -14,7 +16,7 @@ while True:
def test_read():
p = sp.Popen(args=['python', '-'], stdin=sp.PIPE, stdout=sp.PIPE)
p = sp.Popen(args=[sys.executable, '-'], stdin=sp.PIPE, stdout=sp.PIPE)
p.stdin.write(script)
p.stdin.close()
f = stream.Reader(p.stdout)

View File

@@ -1,15 +1,13 @@
from amodem import main
from amodem import common
from amodem import sampling
from amodem import config
import utils
from io import BytesIO
import logging
import os
import numpy as np
import os
from io import BytesIO
import pytest
import logging
from .. import common, config, main, sampling
from . import utils
logging.basicConfig(level=logging.DEBUG, # useful for debugging
format='%(asctime)s %(levelname)-12s %(message)s')
@@ -49,13 +47,18 @@ def run(size, chan=None, df=0, success=True, cfg=None):
assert rx_data == tx_data
@pytest.fixture(params=[0, 1, 3, 10, 42, 123])
@pytest.fixture(params=[0, 1, 3, 10, 16, 17, 42, 123])
def small_size(request):
return request.param
def test_small(small_size):
run(small_size, chan=lambda x: x)
@pytest.fixture(params=list(config.bitrates.values()))
def all_configs(request):
return request.param
def test_small(small_size, all_configs):
run(small_size, chan=lambda x: x, cfg=all_configs)
def test_flip():

View File

@@ -1,7 +1,7 @@
import numpy as np
class IIR(object):
class IIR:
def __init__(self, b, a):
self.b = np.array(b) / a[0]
self.a = np.array(a[1:]) / a[0]

37
pyproject.toml Normal file
View File

@@ -0,0 +1,37 @@
[project]
name = "amodem"
version = "1.16.0"
authors = [
{ name="Roman Zeyde", email="dev@romanzey.de" },
]
description = "Audio Modem Communication Library"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.7"
dependencies = [
"numpy",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: System :: Networking",
"Topic :: Communications",
]
[project.scripts]
amodem = "amodem.__main__:_main"
[project.urls]
Homepage = "https://github.com/romanz/amodem"
Issues = "https://github.com/romanz/amodem/issues"

View File

@@ -1,46 +0,0 @@
#!/usr/bin/env python
from setuptools import setup
from setuptools.command.test import test as TestCommand
class PyTest(TestCommand):
def finalize_options(self):
self.test_args = []
self.test_suite = True
def run_tests(self):
import sys
import pytest
sys.exit(pytest.main(['.']))
setup(
name='amodem',
version='1.13.1',
description='Audio Modem Communication Library',
author='Roman Zeyde',
author_email='roman.zeyde@gmail.com',
license='MIT',
url='http://github.com/romanz/amodem',
packages=['amodem'],
tests_require=['pytest'],
cmdclass={'test': PyTest},
install_requires=['numpy', 'six'],
platforms=['POSIX'],
classifiers=[
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'License :: OSI Approved :: MIT License',
'Operating System :: POSIX',
'Programming Language :: Python :: 2.7',
'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',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
'Topic :: Communications',
],
entry_points={'console_scripts': ['amodem = amodem.__main__:_main']},
)

View File

@@ -1,5 +1,5 @@
[tox]
envlist = py27,py3
envlist = py3
[testenv]
deps=
pytest
@@ -7,9 +7,8 @@ deps=
pycodestyle
coverage
pylint
six
commands=
pycodestyle amodem/ scripts/
pylint --extension-pkg-whitelist=numpy --reports=no amodem --rcfile .pylintrc
coverage run --source amodem/ --omit="*/__main__.py" -m py.test -v
coverage run --source amodem/ --omit="*/__main__.py" -m pytest -v
coverage report