Compare commits

...

119 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
Roman Zeyde
f30d28e39a Bump version: 1.13.0 → 1.13.1 2018-02-18 11:24:05 +02:00
Roman Zeyde
fff10853a9 version: use bumpversion for bumping 2018-02-18 11:23:41 +02:00
Roman Zeyde
147404645c pep8 -> pycodestyle 2018-02-18 11:05:44 +02:00
Roman Zeyde
0e29f9a606 pylint: fix warnings (mostly import-related) 2018-02-18 11:05:43 +02:00
Roman Zeyde
93a174142b Update supported Python version 2018-02-18 10:04:23 +02:00
Roman Zeyde
3210638003 framing: use '%08x' for CRC-32 logging 2018-02-18 10:04:23 +02:00
Roman Zeyde
ceaf893675 config: use '//' for Python 3 (instead of '/') 2018-02-18 10:04:23 +02:00
Roman Zeyde
6629cb6762 Merge pull request #27 from babetoduarte/master
Added basic comment descriptions to the scripts and to some of the so…
2017-10-15 12:05:53 -07:00
Jorge A. Duarte
555186c2d8 Fixed PEP-8 trailing whitespaces on doctrings. 2017-10-15 13:58:15 -05:00
Jorge A. Duarte
66acac3e35 Made PEP8 changes to several scripts and files. 2017-10-15 13:52:22 -05:00
Jorge A. Duarte
e1bdae2069 Made documentation changes as requested, according to PEP-257. 2017-10-15 13:23:57 -05:00
babetoduarte
1ff777d226 Added basic comment descriptions to the scripts and to some of the source files. There's still much to be done, but it's a start. 2017-10-14 17:03:27 -05:00
Roman Zeyde
40460e0291 README: remove unused badges 2017-10-05 17:53:48 +03:00
Roman Zeyde
43b68779a4 travis: add Python 3.6 2017-10-05 17:28:39 +03:00
Roman Zeyde
90aae24600 update LICENSE 2016-05-26 19:20:54 +03:00
Roman Zeyde
4c6315daf2 record: remove print statement 2016-05-26 19:20:08 +03:00
Roman Zeyde
f7a151534f travis: remove Python 3.2 due to broken coverage support 2016-02-06 17:55:27 +02:00
Roman Zeyde
e637e701df bump version 2015-09-19 12:13:19 +03:00
Roman Zeyde
5be6684fa6 travis: upgrade pytest version for Python 3.5 2015-09-19 12:05:58 +03:00
Roman Zeyde
0876be18e4 Merge branch 'py35' 2015-09-19 12:02:24 +03:00
Roman Zeyde
3b8f913fcb setup: Python 3.5 is supported 2015-09-19 12:01:52 +03:00
Roman Zeyde
45d4ccae76 use --assert=plain on Travis 2015-09-19 11:58:30 +03:00
Roman Zeyde
7cb05aaaf7 travis: test under Python 3.5 2015-09-19 11:45:50 +03:00
Roman Zeyde
3911f16bd7 tox: fix indent to tabs 2015-09-18 21:07:43 +03:00
Roman Zeyde
b5f8e07ae2 recv: define integration gain as member variable 2015-08-16 18:30:30 +03:00
Roman Zeyde
835841bf2e test_calib: test frequency change case 2015-08-16 18:21:40 +03:00
Roman Zeyde
c70b3c9dc7 calib: remove AttributeHolder 2015-08-16 17:55:40 +03:00
Roman Zeyde
544dd28ddd common: add docstrings 2015-08-16 17:55:40 +03:00
Roman Zeyde
c887dbf4e6 README: use screencasts instead of videos 2015-08-15 08:35:22 +03:00
Roman Zeyde
bf6282127c docs: link to readthedocs.org 2015-08-14 13:07:04 +03:00
Roman Zeyde
65f2559a19 README: fix whitespace 2015-08-13 10:07:07 +03:00
Roman Zeyde
1c6f8894a5 setup.py: fix pytest invocation 2015-08-11 10:43:28 +03:00
Roman Zeyde
c19d11744f remove requirements.txt 2015-08-11 10:39:00 +03:00
Roman Zeyde
4d60cac7ed version: meta-bump due to PyPI problems 2015-08-07 17:44:45 +03:00
Roman Zeyde
a3adda625b gitignore: ignore backup files 2015-07-29 18:03:56 +03:00
Roman Zeyde
a44f55a608 travis: fix tests runner 2015-07-29 17:59:09 +03:00
Roman Zeyde
e0a38bf5d7 README: simplify description 2015-07-29 17:44:47 +03:00
Roman Zeyde
6b67721374 tox: update to run on updated tests 2015-07-29 17:44:46 +03:00
Roman Zeyde
20efa6a688 tests: remove unused code 2015-07-29 17:44:46 +03:00
Roman Zeyde
327a7f9d0f tests: move into amodem package 2015-07-29 17:44:46 +03:00
Roman Zeyde
7b0ba1714f Merge pull request #18 from anduck/patch-1
changed input and output vice versa
2015-07-28 08:58:01 +03:00
anduck
ec76a1394c changed input and output vice versa 2015-07-27 22:45:34 +03:00
Roman Zeyde
bf7b59db11 bump version 2015-07-17 08:27:30 +03:00
Roman Zeyde
f51cf8c4db config: shorten start & stop silence periods 2015-07-17 08:22:49 +03:00
Roman Zeyde
aec1648ae7 __main__: fix PEP8 2015-07-01 14:23:16 +03:00
Roman Zeyde
2ad3ffced4 alsa: fix string format for Python 2.6 2015-07-01 14:20:16 +03:00
Roman Zeyde
318081fca4 __main__: fix dummy interface case 2015-07-01 14:17:49 +03:00
Roman Zeyde
f82a4f4a39 audio: add simple ALSA interface 2015-07-01 13:55:08 +03:00
Roman Zeyde
8d72621b9b __main__: fix format string 2015-06-29 12:23:52 +03:00
Roman Zeyde
2e6196416b __main__: add logging about zlib usage 2015-06-23 11:38:09 +03:00
Roman Zeyde
01c78bae8f __main__: fix imports 2015-06-23 11:37:52 +03:00
Roman Zeyde
c56f696e9e version: use separate file for versioning 2015-06-23 11:37:43 +03:00
Roman Zeyde
3fe515ea59 __main__: should not be executable 2015-06-23 11:15:41 +03:00
Roman Zeyde
55e7152da6 README: add downloads badge 2015-06-13 14:23:03 +03:00
Roman Zeyde
66c639b597 README: use shields.io for badges 2015-06-13 08:26:23 +03:00
Roman Zeyde
a4ebf68223 bump package version 2015-04-23 11:56:33 +03:00
47 changed files with 618 additions and 410 deletions

6
.bumpversion.cfg Normal file
View File

@@ -0,0 +1,6 @@
[bumpversion]
commit = True
tag = True
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

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
*~
\#*\#
*.out
*.pyc
*.sublime-*
@@ -23,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,21 +0,0 @@
sudo: false
language: python
python:
- "2.6"
- "2.7"
- "3.2"
- "3.3"
- "3.4"
install:
- pip install .
- pip install coveralls pep8 mock
script:
- pep8 amodem/ scripts/ tests/
- echo "Hello World!" | amodem send -vv -l- -o- | amodem recv -vv -l- -i-
- coverage run --source=amodem --omit="*/__main__.py" -m py.test -vvs tests/
after_success:
- coverage report
- coveralls

View File

@@ -1,6 +1,6 @@
amodem -- Audio Modem Communication Library
Copyright (C) 2014, Roman Zeyde.
Copyright (C) 2014, Roman Zeyde, Google Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,53 +1,47 @@
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://pypip.in/py_versions/amodem/badge.svg?style=flat
.. image:: https://img.shields.io/pypi/pyversions/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: Python Versions
.. image:: https://pypip.in/license/amodem/badge.svg?style=flat
.. image:: https://img.shields.io/pypi/l/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: License
.. image:: https://pypip.in/version/amodem/badge.svg?style=flat
.. image:: https://img.shields.io/pypi/v/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: Package Version
.. image:: https://pypip.in/status/amodem/badge.svg?style=flat
.. image:: https://img.shields.io/pypi/status/amodem.svg
: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
-----------
This program can be used to transmit a specified file between 2 computers, using
a simple audio cable (for better SNR and higher speeds) or a simple headset,
allowing true air-gapped communication (via a speaker and a microphone).
This program can transmit a file between 2 computers, using a simple headset,
allowing true air-gapped communication (via a speaker and a microphone),
or an audio cable (for higher transmission speed).
The sender modulates an input binary data file into an audio signal,
The sender modulates the input data into an audio signal,
which is played to the sound card.
The receiver side records the transmitted audio,
which is demodulated concurrently into an output binary data file.
The receiver records the audio, and demodulates it back to the original data.
The process requires a single manual calibration step: the transmitter has to
find maximal output volume for its sound card, which will not saturate the
receiving microphone.
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
-----------------
The modem is using OFDM over an audio cable with the following parameters:
- Sampling rate: 8/16/32 kHz
- Baud rate: 1 kHz
- Symbol modulation: BPSK, 4-PSK, 16-QAM ,64-QAM, 256-QAM
- Symbol modulation: BPSK, 4-PSK, 16-QAM, 64-QAM, 256-QAM
- Carriers: 2-11 kHz (up to ten carriers)
This way, modem may achieve 80kbps bitrate = 10 kB/s (for best SNR).
@@ -59,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::
@@ -91,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
-----------
@@ -134,7 +134,7 @@ and send me the resulting ``audio.raw`` file for debugging::
~/receiver $ arecord --format=S16_LE --channels=1 --rate=32000 audio.raw
You can see a video of the `calibration process <http://www.youtube.com/watch?v=jRUj2Ifk-Po>`_.
You can see a screencast of the `calibration process <https://asciinema.org/a/25065?autoplay=1>`_.
Usage
-----
@@ -147,11 +147,11 @@ Prepare the sender (generate a random binary data file to be sent)::
Start the receiver (will wait for the sender to start)::
~/receiver $ amodem recv -vv -i data.rx
~/receiver $ amodem recv -vv -o data.rx
Start the sender (will modulate the data and start the transmission)::
~/sender $ amodem send -vv -o data.tx
~/sender $ amodem send -vv -i data.tx
A similar log should be emitted by the sender::
@@ -218,7 +218,27 @@ After the receiver has finished, verify the received file's hash::
~/receiver $ sha256sum data.rx
008df57d4f3ed6e7a25d25afd57d04fc73140e8df604685bd34fcab58f5ddc01 data.rx
You can see a video of the `data transfer process <http://www.youtube.com/watch?v=GZQUtHB8so4>`_.
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
-------------

125
amodem/__main__.py Executable file → Normal file
View File

@@ -1,17 +1,19 @@
#!/usr/bin/env python
# PYTHON_ARGCOMPLETE_OK
from amodem import main, calib, audio, async
from amodem.config import bitrates
import argparse
import contextlib
import logging
import os
import sys
import zlib
import logging
import argparse
# Python 3 has `buffer` attribute for byte-based I/O
_stdin = getattr(sys.stdin, 'buffer', sys.stdin)
_stdout = getattr(sys.stdout, 'buffer', sys.stdout)
import pkg_resources
from . import async_reader
from . import audio
from . import calib
from . import main
from .config import bitrates
try:
@@ -25,9 +27,10 @@ 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')
self.stream = stream
def read(self, size):
@@ -45,9 +48,10 @@ class Compressor(object):
return result
class Decompressor(object):
class Decompressor:
def __init__(self, stream):
self.obj = zlib.decompressobj()
log.info('Using zlib decompressor')
self.stream = stream
def write(self, data):
@@ -57,8 +61,10 @@ class Decompressor(object):
self.stream.write(self.obj.flush())
def FileType(mode, audio_interface=None):
def FileType(mode, interface_factory=None):
def opener(fname):
audio_interface = interface_factory() if interface_factory else None
assert 'r' in mode or 'w' in mode
if audio_interface is None and fname is None:
fname = '-'
@@ -67,49 +73,41 @@ def FileType(mode, audio_interface=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:
return c[args.command]
class _DummyContextManager(object):
def __enter__(self):
pass
def __exit__(self, *args):
pass
return None
def wrap(cls, stream, enable):
return cls(stream) if enable else stream
def create_parser(description, interface):
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(
@@ -122,17 +120,21 @@ def create_parser(description, interface):
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),
output_type=FileType('wb', interface_factory),
command='send'
)
@@ -140,8 +142,7 @@ def create_parser(description, interface):
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(
@@ -159,7 +160,7 @@ def create_parser(description, interface):
config=config, src=args.src, verbose=args.verbose,
volume_cmd=get_volume_cmd(args)
),
input_type=FileType('rb', interface),
input_type=FileType('rb', interface_factory),
output_type=FileType('wb'),
command='recv'
)
@@ -184,15 +185,13 @@ def create_parser(description, interface):
return p
def _main():
fmt = ('Audio OFDM MODEM: {0:.1f} kb/s ({1:d}-QAM x {2:d} carriers) '
'Fs={3:.1f} kHz')
description = fmt.format(config.modem_bps / 1e3, len(config.symbols),
config.Nfreq, config.Fs / 1e3)
interface = audio.Interface(config=config)
p = create_parser(description, interface)
def _version():
return pkg_resources.require('amodem')[0].version
def _config_log(args):
level = fmt = None
args = p.parse_args()
if args.verbose == 0:
level, fmt = 'INFO', '%(message)s'
elif args.verbose == 1:
@@ -205,17 +204,43 @@ def _main():
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) '
'Fs={4:.1f} kHz')
description = fmt.format(_version(),
config.modem_bps / 1e3, len(config.symbols),
config.Nfreq, config.Fs / 1e3)
interface = None
def interface_factory():
return interface
p = create_parser(description, interface_factory)
args = p.parse_args()
_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 == '-':
interface = _DummyContextManager()
if args.audio_library == 'ALSA':
from . import alsa # pylint: disable=import-outside-toplevel
interface = alsa.Interface(config)
elif args.audio_library == '-':
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)
with interface:
@@ -227,9 +252,9 @@ def _main():
else:
args.calib(config=config, args=args)
finally:
log.debug('Closing input and output')
args.src.close()
args.dst.close()
log.debug('Finished I/O')
if __name__ == '__main__':

70
amodem/alsa.py Normal file
View File

@@ -0,0 +1,70 @@
"""Code which adds Linux ALSA support for interfaces,
recording and playing.
"""
import subprocess
import logging
log = logging.getLogger(__name__)
class Interface:
RECORDER = 'arecord'
PLAYER = 'aplay'
def __init__(self, config):
self.config = config
rate = int(config.Fs)
bits_per_sample = config.bits_per_sample
assert bits_per_sample == 16
args = '-f S{0:d}_LE -c 1 -r {1:d} -T 100 -q -'
args = args.format(bits_per_sample, rate).split()
self.record_cmd = [self.RECORDER] + args
self.play_cmd = [self.PLAYER] + args
self.processes = []
def __enter__(self):
return self
def __exit__(self, *args):
for p in self.processes:
try:
p.wait()
except OSError:
log.warning('%s failed', p)
def launch(self, **kwargs):
log.debug('Launching subprocess: %s', kwargs)
p = subprocess.Popen(**kwargs)
self.processes.append(p)
return p
def recorder(self):
return Recorder(self)
def player(self):
return Player(self)
class Recorder:
def __init__(self, lib):
self.p = lib.launch(args=lib.record_cmd, stdout=subprocess.PIPE)
self.read = self.p.stdout.read
self.bufsize = 4096
def close(self):
self.p.kill()
class Player:
def __init__(self, lib):
self.p = lib.launch(args=lib.play_cmd, stdin=subprocess.PIPE)
self.write = self.p.stdin.write
def close(self):
self.p.stdin.close()
self.p.wait()

View File

@@ -1,14 +1,17 @@
import threading
import six # since `Queue` module was renamed to `queue` (in Python 3)
"""Asynchronous Reading capabilities for amodem."""
import logging
import threading
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,
@@ -26,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

@@ -1,3 +1,5 @@
"""Audio capabilities for amodem."""
import ctypes
import logging
import time
@@ -5,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
@@ -24,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)
@@ -33,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')
@@ -51,7 +57,7 @@ class Interface(object):
return Stream(self, config=self.config, write=True)
class Stream(object):
class Stream:
timer = time.time
@@ -79,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

@@ -1,13 +1,16 @@
"""Calibration capabilities for amodem."""
import itertools
import logging
import subprocess
import numpy as np
from . import common
from . import dsp
from . import sampling
from . import stream
import numpy as np
import itertools
import logging
import subprocess
log = logging.getLogger(__name__)
@@ -15,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)
@@ -63,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 common.AttributeHolder(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):
@@ -90,7 +93,7 @@ def volume_calibration(result_iterator, volume_ctl):
for index, result in enumerate(itertools.chain([None], result_iterator)):
if index % iters_per_update == 0:
if index > 0: # skip dummy (first result)
sign = 1 if (result.total < target_level) else -1
sign = 1 if (result['total'] < target_level) else -1
level = level + step * sign
level = min(max(level, min_level), max_level)
step = step * 0.5
@@ -102,21 +105,16 @@ 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:
yield block
def recv(config, src, verbose=False, volume_cmd=None, dump_audio=None):
fmt = '{0.freq:6.0f} Hz: {0.msg:20s}'
if verbose:
fields = ['total', 'rms', 'coherency', 'peak']
fmt += ', '.join('{0}={{0.{0}:.4f}}'.format(f) for f in fields)
def recv_iter(config, src, volume_cmd=None, dump_audio=None):
volume_ctl = volume_controller(volume_cmd)
if dump_audio:
@@ -125,6 +123,19 @@ def recv(config, src, verbose=False, volume_cmd=None, dump_audio=None):
result_iterator = volume_calibration(result_iterator, volume_ctl)
for _prev, curr, _next in iter_window(result_iterator, size=3):
# don't log errors during frequency changes
if _prev.success and _next.success and _prev.freq != _next.freq:
curr.msg = curr.msg if curr.success else 'frequency change'
log.info(fmt.format(curr))
if _prev['success'] and _next['success']:
if _prev['freq'] != _next['freq']:
if not curr['success']:
curr['msg'] = 'frequency change'
yield curr
def recv(config, src, verbose=False, volume_cmd=None, dump_audio=None):
fmt = '{freq:6.0f} Hz: {msg:20s}'
log.info('verbose: %s', verbose)
if verbose:
fields = ['total', 'rms', 'coherency', 'peak']
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

@@ -1,28 +1,38 @@
""" Common package functionality.
Commom utilities and procedures for amodem.
"""
import itertools
import logging
import numpy as np
import logging
log = logging.getLogger(__name__)
scaling = 32000.0 # out of 2**15
def load(fileobj):
""" Load signal from file object. """
return loads(fileobj.read())
def loads(data):
""" Load signal from memory buffer. """
x = np.frombuffer(data, dtype='int16')
x = x / scaling
return x
def dumps(sym):
""" Dump signal to memory buffer. """
sym = sym.real * scaling
return sym.astype('int16').tostring()
def iterate(data, size, func=None, truncate=True, index=False):
""" Iterate over a signal, taking each time *size* elements. """
offset = 0
data = iter(data)
@@ -40,6 +50,9 @@ def iterate(data, size, func=None, truncate=True, index=False):
def split(iterable, n):
""" Split an iterable of n-tuples into n iterables of scalars.
The k-th iterable will be equivalent to (i[k] for i in iter).
"""
def _gen(it, index):
for item in it:
yield item[index]
@@ -49,37 +62,22 @@ def split(iterable, n):
def icapture(iterable, result):
""" Appends each yielded item to result. """
for i in iter(iterable):
result.append(i)
yield i
def take(iterable, n):
""" Take n elements from iterable, and return them as a numpy array. """
return np.array(list(itertools.islice(iterable, n)))
# "Python 3" zip re-implementation for Python 2
def izip(iterables):
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):
return self
def __call__(self, *args, **kwargs):
return self
class AttributeHolder(object):
def __init__(self, d):
self.__dict__.update(d)
def __repr__(self):
items = sorted(self.__dict__.items())
args = ', '.join('{0}={1}'.format(k, v) for k, v in items)
return '{0}({1})'.format(self.__class__.__name__, args)

View File

@@ -1,7 +1,9 @@
"""Configuration class."""
import numpy as np
class Configuration(object):
class Configuration:
Fs = 32000.0 # sampling frequency [Hz]
Tsym = 0.001 # symbol duration [seconds]
Npoints = 64
@@ -12,8 +14,8 @@ class Configuration(object):
latency = 0.1
# sender config
silence_start = 1.0
silence_stop = 1.0
silence_start = 0.5
silence_stop = 0.5
# receiver config
skip_start = 0.1
@@ -49,7 +51,7 @@ class Configuration(object):
])
# QAM constellation
Nx = 2 ** int(np.ceil(bits_per_symbol / 2))
Nx = 2 ** int(np.ceil(bits_per_symbol // 2))
Ny = self.Npoints // Nx
symbols = [complex(x, y) for x in range(Nx) for y in range(Ny)]
symbols = np.array(symbols)

View File

@@ -1,16 +1,19 @@
"""Signal detection capabilities for amodem."""
import collections
import itertools
import logging
import numpy as np
from . import dsp
from . import equalizer
from . import common
import numpy as np
import logging
import itertools
import collections
log = logging.getLogger(__name__)
class Detector(object):
class Detector:
COHERENCE_THRESHOLD = 0.9
@@ -82,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)
@@ -112,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

@@ -1,9 +1,11 @@
"""Digital Signal Processing capabilities for amodem."""
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)
@@ -17,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]
@@ -31,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
@@ -53,14 +54,13 @@ def coherence(x, omega):
n = len(x)
Hc = exp_iwt(-omega, n) / np.sqrt(0.5*n)
norm_x = norm(x)
if norm_x:
return np.dot(Hc, x) / norm_x
else:
if not norm_x:
return 0.0
return np.dot(Hc, x) / norm_x
def linear_regression(x, y):
''' Find (a,b) such that y = a*x + b. '''
""" Find (a,b) such that y = a*x + b. """
x = np.array(x)
y = np.array(y)
mean_x = np.mean(x)
@@ -72,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)
@@ -98,7 +98,7 @@ class MODEM(object):
yield self.encode_map[bits_tuple]
def decode(self, symbols, error_handler=None):
''' Maximum-likelihood decoding, using naive nearest-neighbour. '''
""" Maximum-likelihood decoding, using naive nearest-neighbour. """
symbols_vec = self.symbols
_dec = self.decode_list
for received in symbols:
@@ -111,7 +111,7 @@ class MODEM(object):
def prbs(reg, poly, bits):
''' Simple pseudo-random number generator. '''
""" Simple pseudo-random number generator. """
mask = (1 << bits) - 1
size = 0 # effective register size (in bits)

View File

@@ -1,12 +1,15 @@
"""Audio equalizing capabilities for amodem."""
import itertools
import numpy as np
from . import dsp
from . import sampling
from . import levinson
import numpy as np
import itertools
class Equalizer(object):
class Equalizer:
def __init__(self, config):
self.carriers = config.carriers
@@ -43,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

@@ -1,19 +1,19 @@
from . import common
import binascii
import functools
import itertools
import binascii
import struct
import logging
import struct
from . import common
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)
@@ -26,12 +26,13 @@ class Checksum(object):
payload = data[self.size:]
expected = _checksum_func(payload)
if received != expected:
log.warning('Invalid checksum: %04x != %04x', received, expected)
log.warning('Invalid checksum: %08x != %08x', received, expected)
raise ValueError('Invalid checksum')
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)
@@ -85,7 +86,7 @@ def chain_wrapper(func):
return wrapped
class BitPacker(object):
class BitPacker:
byte_size = 8
def __init__(self):

View File

@@ -2,9 +2,9 @@ import numpy as np
def solver(t, y):
''' Solve Mx = y for x, where M[i,j] = t[|i-j|], in O(N^2) steps.
""" Solve Mx = y for x, where M[i,j] = t[|i-j|], in O(N^2) steps.
See http://en.wikipedia.org/wiki/Levinson_recursion for details.
'''
"""
N = len(t)
assert len(y) == N

View File

@@ -1,6 +1,8 @@
import numpy as np
import logging
import itertools
import logging
import numpy as np
from . import send as _send
from . import recv as _recv
from . import framing, common, stream, detect, sampling
@@ -8,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()
@@ -57,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

@@ -1,18 +1,19 @@
import functools
import itertools
import logging
import time
import numpy as np
from . import dsp
from . import common
from . import framing
from . import equalizer
import numpy as np
import logging
import itertools
import functools
import time
log = logging.getLogger(__name__)
class Receiver(object):
class Receiver:
def __init__(self, config, pylab=None):
self.stats = {}
@@ -28,6 +29,7 @@ class Receiver(object):
self.equalizer = equalizer.Equalizer(config)
self.carrier_index = config.carrier_index
self.output_size = 0 # number of bytes written to output stream
self.freq_err_gain = 0.01 * self.Tsym # integration feedback gain
def _prefix(self, symbols, gain=1.0):
S = common.take(symbols, len(equalizer.prefix))
@@ -41,17 +43,17 @@ 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')
def _train(self, sampler, order, lookahead):
Nfreq = len(self.frequencies)
equalizer_length = equalizer.equalizer_length
train_symbols = self.equalizer.train_symbols(equalizer_length)
train_signal = self.equalizer.modulator(train_symbols) * Nfreq
train_signal = (self.equalizer.modulator(train_symbols) *
len(self.frequencies))
prefix = postfix = equalizer.silence_length * self.Nsym
signal_length = equalizer_length * self.Nsym + prefix + postfix
@@ -68,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]
@@ -78,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)
@@ -91,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 = []
@@ -107,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 = []
@@ -137,10 +141,10 @@ class Receiver(object):
def _update_sampler(self, errors, sampler):
err = np.array([e for v in errors.values() for e in v])
err = np.mean(np.angle(err))/(2*np.pi) if len(err) else 0
err = np.mean(np.angle(err))/(2*np.pi) if err.size else 0
errors.clear()
sampler.freq -= 0.01 * err * self.Tsym
sampler.freq -= self.freq_err_gain * err
sampler.offset -= err
def _report_progress(self, noise, sampler):
@@ -154,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)
@@ -183,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

@@ -1,11 +1,11 @@
#!/usr/bin/env python
import numpy as np
import itertools
from amodem import common
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

@@ -1,15 +1,16 @@
import itertools
import logging
import numpy as np
from . import common
from . import equalizer
from . import dsp
import numpy as np
import logging
import itertools
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

40
amodem/tests/test_alsa.py Normal file
View File

@@ -0,0 +1,40 @@
import mock
from .. import alsa, config
def test_alsa():
interface = alsa.Interface(config=config.fastest())
interface.launch = mock.Mock()
with interface:
r = interface.recorder()
r.read(2)
r.close()
p = mock.call(
args='arecord -f S16_LE -c 1 -r 32000 -T 100 -q -'.split(),
stdout=-1)
assert interface.launch.mock_calls == [p, p.stdout.read(2), p.kill()]
interface.launch = mock.Mock()
with interface:
p = interface.player()
p.write('\x00\x00')
p.close()
p = mock.call(
args='aplay -f S16_LE -c 1 -r 32000 -T 100 -q -'.split(),
stdin=-1)
assert interface.launch.mock_calls == [
p, p.stdin.write('\x00\x00'), p.stdin.close(), p.wait()
]
def test_alsa_subprocess():
interface = alsa.Interface(config=config.fastest())
with mock.patch('subprocess.Popen') as popen:
with interface:
p = interface.launch(args=['foobar'])
p.wait.side_effect = OSError('invalid command')
assert interface.processes == [p]
assert popen.mock_calls == [mock.call(args=['foobar'])]

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,32 +1,23 @@
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
self.stdout = self
self.bytes_per_sample = 2
def launch(self, *args, **kwargs):
return self
__call__ = launch
def kill(self):
pass
def write(self, data):
assert self.buf.tell() < 10e6
self.buf.write(data)
@@ -47,8 +38,8 @@ def test_too_strong():
calib.send(config, p, gain=1.001, limit=32)
p.buf.seek(0)
for r in calib.detector(config, src=p):
assert not r.success
assert r.msg == 'too strong signal'
assert not r['success']
assert r['msg'] == 'too strong signal'
def test_too_weak():
@@ -56,8 +47,8 @@ def test_too_weak():
calib.send(config, p, gain=0.01, limit=32)
p.buf.seek(0)
for r in calib.detector(config, src=p):
assert not r.success
assert r.msg == 'too weak signal'
assert not r['success']
assert r['msg'] == 'too weak signal'
def test_too_noisy():
@@ -65,8 +56,8 @@ def test_too_noisy():
signal = np.array([r.choice([-1, 1]) for i in range(int(config.Fs))])
src = BytesIO(common.dumps(signal * 0.5))
for r in calib.detector(config, src=src):
assert not r.success
assert r.msg == 'too noisy signal'
assert not r['success']
assert r['msg'] == 'too noisy signal'
def test_errors():
@@ -102,9 +93,9 @@ def test_drift(freq_err):
src = BytesIO(common.dumps(signal))
iters = 0
for r in calib.detector(config, src, frame_length=frame_length):
assert r.success is True
assert abs(r.rms - rms) < 1e-3
assert abs(r.total - rms) < 1e-3
assert r['success'] is True
assert abs(r['rms'] - rms) < 1e-3
assert abs(r['total'] - rms) < 1e-3
iters += 1
assert iters > 0
@@ -154,3 +145,15 @@ def test_recv_binary_search():
fmt = 'ctl {0:.0f}%'
expected = [mock.call(shell=True, args=fmt.format(100 * g)) for g in gains]
assert check_call.mock_calls == expected
def test_recv_freq_change():
p = ProcessMock()
calib.send(config, p, gain=0.5, limit=2)
offset = p.buf.tell() // 16
p.buf.seek(offset)
messages = [state['msg'] for state in calib.recv_iter(config, p)]
assert messages == [
'good signal', 'good signal', 'good signal',
'frequency change',
'good signal', 'good signal', 'good signal']

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,20 +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_holder():
d = {'x': 1, 'y': 2.3}
a = common.AttributeHolder(d)
assert a.x == d['x']
assert a.y == d['y']
assert repr(a) == 'AttributeHolder(x=1, y=2.3)'
def test_configs():
default = config.Configuration()
fastest = config.fastest()

View File

@@ -0,0 +1,12 @@
from .. import config
def test_bitrates():
for rate, cfg in sorted(config.bitrates.items()):
assert rate * 1000 == cfg.modem_bps
def test_slowest():
c = config.slowest()
assert c.Npoints == 2
assert list(c.symbols) == [-1j, 1j]

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,13 +1,15 @@
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))
r = random.Random(0)
blob = bytearray(r.randrange(0, 256) for i in range(64 * 1024))

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():
@@ -20,6 +20,6 @@ def test_resample():
def test_coeffs():
I = sampling.Interpolator(width=4, resolution=16)
err = I.filt[0] - [0, 0, 0, 1, 0, 0, 0, 0]
interp = sampling.Interpolator(width=4, resolution=16)
err = interp.filt[0] - [0, 0, 0, 1, 0, 0, 0, 0]
assert np.max(np.abs(err)) < 1e-10

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)
@@ -29,7 +31,6 @@ def test_read():
j += 1
try:
for buf in f:
pass
next(f)
except IOError as e:
assert e.args == ('timeout',)

View File

@@ -1,28 +1,18 @@
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')
class Args(object):
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def __getattr__(self, name):
return None
def run(size, chan=None, df=0, success=True, reader=None, cfg=None):
def run(size, chan=None, df=0, success=True, cfg=None):
if cfg is None:
cfg = config.fastest()
tx_data = os.urandom(size)
@@ -43,8 +33,6 @@ def run(size, chan=None, df=0, success=True, reader=None, cfg=None):
rx_data = BytesIO()
dump = BytesIO()
if reader:
rx_audio = reader(rx_audio)
try:
result = main.recv(config=cfg, src=rx_audio, dst=rx_data,
dump_audio=dump, pylab=None)
@@ -59,13 +47,18 @@ def run(size, chan=None, df=0, success=True, reader=None, 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,3 +0,0 @@
numpy
six
argcomplete

View File

@@ -1,4 +1,11 @@
#!/usr/bin/env python
"""Script that exposes pylab's spectogram plotting
capabilities to the command line. It implements this
for amodem.config Configurations.
"""
import pylab
import numpy as np
from amodem import common
@@ -27,5 +34,6 @@ def main():
pylab.show()
if __name__ == '__main__':
main()

View File

@@ -1,4 +1,9 @@
#!/usr/bin/env python
"""Script that records audio through an interface
and stores it into an amodem.config Configuration.
"""
import argparse
from amodem import audio
from amodem.config import Configuration
@@ -7,7 +12,6 @@ from amodem.config import Configuration
def run(args):
config = Configuration()
with open(args.filename, 'wb') as dst:
print dst
interface = audio.Interface(config=config)
with interface.load(args.audio_library):
src = interface.recorder()

View File

@@ -1,4 +1,10 @@
#!/usr/bin/env python
"""Script that exposes the amodem.resample() function
to the command line, taking parameters via standard
inputs and returning results via standard outputs.
"""
from amodem.sampling import resample
import argparse
import sys
@@ -11,5 +17,6 @@ def main():
resample(src=sys.stdin, dst=sys.stdout, df=args.df)
if __name__ == '__main__':
main()

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(['tests']))
setup(
name='amodem',
version='1.10',
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.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: System :: Networking',
'Topic :: Communications',
],
entry_points={'console_scripts': ['amodem = amodem.__main__:_main']},
)

View File

@@ -1,6 +0,0 @@
from amodem import config
def test_bitrates():
for rate, cfg in sorted(config.bitrates.items()):
assert rate * 1000 == cfg.modem_bps

11
tox.ini
View File

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