Compare commits

...

27 Commits

Author SHA1 Message Date
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
27 changed files with 162 additions and 118 deletions

View File

@@ -1,7 +1,7 @@
[bumpversion]
commit = True
tag = True
current_version = 1.14.0
current_version = 1.15.1
[bumpversion:file:setup.py]

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

@@ -0,0 +1,37 @@
name: build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.5, 3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
python -m pip install --upgrade pip
pip install pytest mock pycodestyle coverage pylint six
pip install -e .
- name: Lint
run: |
pycodestyle amodem/ scripts/
pylint --extension-pkg-whitelist=numpy --reports=no amodem --rcfile .pylintrc
- name: Test with pytest
run: |
coverage run --source amodem/ --omit="*/__main__.py" -m py.test -v
coverage report

2
.gitignore vendored
View File

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

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

@@ -8,7 +8,7 @@ import zlib
import pkg_resources
from . import async
from . import async_reader
from . import audio
from . import calib
from . import main
@@ -31,7 +31,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 +52,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,7 +77,7 @@ 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()
@@ -124,10 +124,13 @@ 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,
@@ -142,8 +145,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,7 +188,7 @@ def create_parser(description, interface_factory):
return p
class _Dummy(object):
class _Dummy:
def __enter__(self):
return self
@@ -198,6 +200,20 @@ def _version():
return pkg_resources.require('amodem')[0].version
def _config_log(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)
def _main():
fmt = ('Audio OFDM MODEM v{0:s}: '
'{1:.1f} kb/s ({2:d}-QAM x {3:d} carriers) '
@@ -213,31 +229,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 = _Dummy() # manually disable PortAudio
elif args.command == 'send' and args.output is not None:
interface = _Dummy() # redirected output
elif args.command == 'recv' and args.input is not None:
interface = _Dummy() # 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

@@ -8,7 +8,7 @@ import six # since `Queue` module was renamed to `queue` (in Python 3)
log = logging.getLogger()
class AsyncReader(object):
class AsyncReader:
def __init__(self, stream, bufsize):
self.stream = stream
self.queue = six.moves.queue.Queue()
@@ -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,7 @@ import time
log = logging.getLogger(__name__)
class Interface(object):
class Interface:
def __init__(self, config, debug=False):
self.debug = bool(debug)
self.config = config
@@ -53,7 +53,7 @@ class Interface(object):
return Stream(self, config=self.config, write=True)
class Stream(object):
class Stream:
timer = time.time

View File

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

View File

@@ -75,13 +75,15 @@ def take(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])
try:
while True:
yield tuple([next(iterable) for iterable in iterables])
except StopIteration:
pass
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

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)

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,7 +72,7 @@ def linear_regression(x, y):
return a, b
class MODEM(object):
class MODEM:
def __init__(self, symbols):
self.encode_map = {}

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

@@ -14,7 +14,7 @@ def _checksum_func(x):
return binascii.crc32(bytes(x)) & 0xFFFFFFFF
class Checksum(object):
class Checksum:
fmt = '>L' # unsigned longs (32-bit)
size = struct.calcsize(fmt)
@@ -29,12 +29,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 +87,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 = {}
@@ -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]
@@ -95,6 +96,7 @@ class Receiver(object):
self._constellation(symbols[:, i], train_symbols[:, i],
'$F_c = {0} Hz$'.format(freq), index=i)
assert error_rate == 0, error_rate
log.debug('Training verified')
def _bitstream(self, symbols, error_handler):
streams = []
@@ -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)

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,7 +1,7 @@
import time
class Reader(object):
class Reader:
wait = 0.2
timeout = 2.0
@@ -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

View File

@@ -1,7 +1,7 @@
import mock
import time
import pytest
from amodem import async
from amodem import async_reader
import logging
logging.basicConfig(format='%(message)s')
@@ -13,7 +13,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 +25,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

@@ -12,7 +12,7 @@ import mock
config = config.fastest()
class ProcessMock(object):
class ProcessMock:
def __init__(self):
self.buf = BytesIO()
self.stdin = self

View File

@@ -1,5 +1,6 @@
from amodem import stream
import subprocess as sp
import sys
script = br"""
import sys
@@ -14,7 +15,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,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]

View File

@@ -15,7 +15,7 @@ class PyTest(TestCommand):
setup(
name='amodem',
version='1.14.0',
version='1.15.1',
description='Audio Modem Communication Library',
author='Roman Zeyde',
author_email='dev@romanzey.de',
@@ -32,12 +32,10 @@ setup(
'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',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
'Topic :: Communications',

View File

@@ -1,5 +1,5 @@
[tox]
envlist = py27,py3
envlist = py3
[testenv]
deps=
pytest