Compare commits

...

236 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
Roman Zeyde
ccfe5c00cf equalizer: shorten prefix and training sequence to 500ms 2015-04-23 11:17:12 +03:00
Roman Zeyde
f476055cf2 detect: move TIMEOUT to config module 2015-04-23 09:43:58 +03:00
Roman Zeyde
fce906df0a cli: add gain option for sender 2015-04-13 12:03:51 +03:00
Roman Zeyde
129b9a4ad0 resample: use argparse instead of sys.argv 2015-04-13 12:03:51 +03:00
Roman Zeyde
0a4584f1b8 README: fix after CLI rename 2015-04-11 11:39:32 +03:00
Roman Zeyde
5d6b47574d __main__: implicit exit code handling 2015-04-11 11:15:45 +03:00
Roman Zeyde
ef7467efd7 travis: fix script name 2015-04-10 22:30:56 +03:00
Roman Zeyde
449d4eac0a CI: fix tox and travis 2015-04-10 22:24:47 +03:00
Roman Zeyde
66148650ed setup.py: fix entry_point to use amodem/__main__.py 2015-04-10 22:24:47 +03:00
Roman Zeyde
3a33338425 levinson: reuse previous vectors 2015-04-08 11:07:35 +03:00
Roman Zeyde
18462289f8 README: add waffle.io for issue tracking 2015-03-31 09:57:52 +03:00
Roman Zeyde
c5f8b48554 plot: fix imports 2015-03-27 09:57:13 +03:00
Roman Zeyde
7b4b2dd7ef sampling: fix lint warning 2015-03-27 09:49:04 +03:00
Roman Zeyde
6d317df465 scripts: fix lint warnings 2015-03-27 09:48:51 +03:00
Roman Zeyde
daca119c6f calib: fixup recv logging 2015-03-26 15:01:49 +02:00
Roman Zeyde
13cbd82d5a tox: use specific pylint rcfile 2015-03-26 15:01:49 +02:00
Roman Zeyde
37ee53d8e4 Revert "travis: test on nightly Python"
numpy has weird problems on nightly Python build.
This reverts commit 66cecb9be4.
2015-03-26 11:00:27 +02:00
Roman Zeyde
66cecb9be4 travis: test on nightly Python 2015-03-26 10:54:20 +02:00
Roman Zeyde
de426c6187 config: validate settings 2015-03-26 09:47:40 +02:00
Roman Zeyde
c637b3e914 cli: change command line switch --zip to --zlib 2015-03-16 07:44:40 +02:00
Roman Zeyde
5cf0fa4e27 calib: fix TypeError on config.frequencies 2015-03-08 10:33:51 +02:00
Roman Zeyde
eb6ace3cc3 README: fix carrier and audio sampling rate info. 2015-03-04 17:10:24 +02:00
Roman Zeyde
e94fd0e2ff README: remove whitespace. 2015-03-04 17:09:46 +02:00
Roman Zeyde
3b4b64253d detect: fix detection logging 2015-03-03 18:15:32 +02:00
Roman Zeyde
ddfdf2f7f4 config: add UT for bitrate verification 2015-02-24 19:01:30 +02:00
Roman Zeyde
9b2ebf05df config: use more readable initialization for MODEM settings 2015-02-24 18:57:04 +02:00
Roman Zeyde
c06e842eb7 config: use lower sample rate for slowest bitrates
this will use much less CPU.
2015-02-22 16:45:59 +02:00
Roman Zeyde
23ce7bba08 bump version 2015-02-22 16:25:56 +02:00
Roman Zeyde
03a600ddd2 recv: remove re-buffering from decoded data. 2015-02-19 19:02:25 +02:00
Roman Zeyde
1aa41db6cb recv: fixup main for-loop 2015-02-19 18:17:03 +02:00
Roman Zeyde
078e429340 recv: fixup bytes' issue 2015-02-19 18:06:36 +02:00
Roman Zeyde
da636212e8 recv: output.write() should get bytes (not bytearray) 2015-02-19 17:56:41 +02:00
Roman Zeyde
65c0892367 travis: add sanity test for CLI 2015-02-19 17:00:03 +02:00
Roman Zeyde
34a892e72c equalizer: remove unused dependencies 2015-02-19 15:28:20 +02:00
Roman Zeyde
a73b09c186 dsp: remove linalg.lstsq() dependency 2015-02-19 15:20:25 +02:00
Roman Zeyde
cac280cf3f dsp: remove buffering from MODEM.decode() 2015-02-19 14:57:39 +02:00
Roman Zeyde
52ee71fad1 scripts: add profiling test 2015-02-19 14:57:14 +02:00
Roman Zeyde
8fe7f1d716 config: bits_per_baud should be integer 2015-02-19 09:54:23 +02:00
Roman Zeyde
6b77534bc2 tox: log testcase names 2015-02-18 18:15:32 +02:00
Roman Zeyde
964b5e0df4 travis: log everything 2015-02-18 18:15:30 +02:00
Roman Zeyde
06cc8918f0 test_transfer: add sanity test for all supported rates. 2015-02-18 18:15:30 +02:00
Roman Zeyde
6a2e320808 equalizer: replace Least-Square solver by Levinson-Durbin recursion 2015-02-18 18:15:30 +02:00
Roman Zeyde
97e992ea56 audio: return self from Interface.load() 2015-02-18 18:15:30 +02:00
Roman Zeyde
61dc35c122 detect: refactor find_start() 2015-02-17 18:08:57 +02:00
Roman Zeyde
e06cb37e2b recv: reduce equalization filter size, due to better timing estimation 2015-02-17 17:35:42 +02:00
Roman Zeyde
d34d2fdbea test_transfer: verify it works after "signal flip". 2015-02-17 17:35:41 +02:00
Roman Zeyde
42ad312418 test_transfer: add 1% frequency drift test. 2015-02-17 17:35:41 +02:00
Roman Zeyde
e0718596e2 send: set gain (to prevent saturation) 2015-02-17 17:35:41 +02:00
Roman Zeyde
fdf6e7e882 sampling: use raised cosine window. 2015-02-17 17:35:41 +02:00
Roman Zeyde
90dd3e55f0 detect: find actual starting offset of the carrier 2015-02-17 17:35:41 +02:00
Roman Zeyde
b3619a75ba detect: remove phase logging 2015-02-17 17:35:41 +02:00
Roman Zeyde
1ddc693683 recv: count errors at prefix 2015-02-17 17:35:41 +02:00
Roman Zeyde
b3804a42be cli: support "dummy" audio interface
specify '-' to to skip loading PortAudio shared library.
2015-02-14 10:47:25 +02:00
Roman Zeyde
c0634a34d0 dsp: pre-compute polynome bit_length
since Python 2.6 has no .bit_length() method
2015-02-13 15:08:11 +02:00
Roman Zeyde
807c03a8e8 equalizer: use PRBS for equalization sequence 2015-02-13 14:47:28 +02:00
Roman Zeyde
e5ff6297b1 autocalib: fix amodem invocation 2015-02-11 17:21:28 +02:00
Roman Zeyde
07a3d5cc98 scripts: fix permissions 2015-02-11 17:21:26 +02:00
Roman Zeyde
d81ec630a5 dsp: move lfilter and IIR to tests 2015-02-11 17:21:26 +02:00
Roman Zeyde
4cebb06e11 setup.py: " -> ' 2015-02-11 10:29:45 +02:00
Roman Zeyde
a43e674fbe scripts: add auto-calibration script
should be used when sender and receiver run at the same computer
2015-02-11 10:29:33 +02:00
Roman Zeyde
ffc9ece45c scripts: add ALSA helpers 2015-02-11 10:01:53 +02:00
Roman Zeyde
e374a65920 scripts: rename existing filenames 2015-02-11 09:59:30 +02:00
Roman Zeyde
9709ffc523 travis: output textual report 2015-02-07 09:09:40 +02:00
Roman Zeyde
8295b0865d PEP8 fixes
imports order
2015-02-07 09:06:59 +02:00
Roman Zeyde
cbdf4d1616 PEP8 fixes
lambdas and imports
2015-02-07 09:02:26 +02:00
Roman Zeyde
a2b220c8e4 PEP8 fixes
lambdas and coverage
2015-02-07 08:52:27 +02:00
Roman Zeyde
5b94d7fd49 PEP8 fixes 2015-02-07 08:42:44 +02:00
Roman Zeyde
cbf14a5153 README: add package status 2015-02-07 08:30:58 +02:00
Roman Zeyde
d09391f43f README: move to restructured text format. 2015-02-06 18:31:10 +02:00
Roman Zeyde
6361f8a257 bump version 2015-02-06 11:29:01 +02:00
Roman Zeyde
b39334bfe8 recv: log SNR during demodulation 2015-02-04 18:08:58 +02:00
Roman Zeyde
1c13671a4c README: add version from PyPI 2015-02-04 18:08:58 +02:00
Roman Zeyde
6a35820155 async: remove AsyncWriter
we are not expecting real-time problems on the sender's side.
2015-02-04 18:08:57 +02:00
Roman Zeyde
1b64e2874a config: add more bitrates 2015-02-04 18:08:57 +02:00
Roman Zeyde
537db23b34 README: separate PyPI and GitHub versions. 2015-02-04 09:09:16 +02:00
Roman Zeyde
ef8a75f10e main: refactor send and recv 2015-02-04 08:16:12 +02:00
Roman Zeyde
53559ff8df recv: fix pylint warning 2015-02-03 17:44:06 +02:00
Roman Zeyde
1988144752 async: fix pylint warnings 2015-02-03 17:42:53 +02:00
Roman Zeyde
1d5d564f4d stream: use async I/O to avoid real-time problems. 2015-02-03 16:52:48 +02:00
Roman Zeyde
5b6d1881ab audio: don't specify framesPerBuffer.
from "Pa_OpenStream" documentation:

 @param framesPerBuffer The number of frames passed to the stream callback
 function, or the preferred block granularity for a blocking read/write stream.
 The special value paFramesPerBufferUnspecified (0) may be used to request that
 the stream callback will receive an optimal (and possibly varying) number of
 frames based on host requirements and the requested latency settings.
 Note: With some host APIs, the use of non-zero framesPerBuffer for a callback
 stream may introduce an additional layer of buffering which could introduce
 additional latency. PortAudio guarantees that the additional latency
 will be kept to the theoretical minimum however, it is strongly recommended
 that a non-zero framesPerBuffer value only be used when your algorithm
 requires a fixed number of frames per stream callback.
2015-02-03 09:54:04 +02:00
Roman Zeyde
221c7c03b9 recv: fix reporting 2015-02-03 08:35:02 +02:00
Roman Zeyde
0e9d8c04da audio: refactor naming and I/O wait report 2015-02-03 08:35:02 +02:00
Roman Zeyde
66b8e377ac stream: move Dumper class from recv and use it in calibration too 2015-02-03 08:35:02 +02:00
Roman Zeyde
bfbb7f3588 Revert "rename README (remove .md extension)"
This reverts commit 32e01dd4e0.
2015-01-25 20:13:50 +02:00
Roman Zeyde
32e01dd4e0 rename README (remove .md extension) 2015-01-25 20:09:20 +02:00
Roman Zeyde
200ec3b0c2 scripts: add recording script for audio debugging. 2015-01-25 20:09:10 +02:00
Roman Zeyde
6a37dd74ec bump version. 2015-01-24 07:59:29 +02:00
Roman Zeyde
dc8e876f13 README: fix typos 2015-01-23 15:32:30 +02:00
Roman Zeyde
a208151fbb detect: more precision on ppm 2015-01-23 14:17:35 +02:00
Roman Zeyde
ef40b498bb Revert "travis: add pylint" - pylint is broken on travis.org's Python 2.6
This reverts commit 33385b294e.
2015-01-21 20:47:33 +02:00
Roman Zeyde
db3ae11bc0 equalizer: use low-level random API, for Python 2<->3 interoperability
Random.randrange() behaviour was changed at v3.2, see:
https://bugs.python.org/issue9025
2015-01-21 20:38:44 +02:00
Roman Zeyde
4c4027b84a config: increate sample buffer to 256ms. 2015-01-20 10:21:02 +02:00
Roman Zeyde
4193fa88d6 cli: format -> fmt 2015-01-20 10:19:58 +02:00
Roman Zeyde
5275f25d14 cli: simplify stdin/stdout logic 2015-01-20 10:19:45 +02:00
Roman Zeyde
33385b294e travis: add pylint 2015-01-20 10:16:47 +02:00
Roman Zeyde
128675955b tox: whitelist numpy 2015-01-20 10:04:57 +02:00
Roman Zeyde
c4d583612b calib: add tests for automation 2015-01-20 10:00:30 +02:00
Roman Zeyde
779ba09c46 equalizer: add remark. 2015-01-19 20:54:16 +02:00
Roman Zeyde
7e8f1e8994 recv: fix subplot on 1x2 figure 2015-01-19 20:50:46 +02:00
Roman Zeyde
3450c2c570 equalizer: use constant symbols' prefix (for analog debugging) 2015-01-19 20:45:40 +02:00
Roman Zeyde
95ed9e0eda cli: use "-c auto" for auto-calibration
currently, support only PulseAudio
2015-01-19 16:15:08 +02:00
Roman Zeyde
95bbfbfe98 cli: handle Ctrl+C during calibration. 2015-01-19 10:59:08 +02:00
Roman Zeyde
066c27843e calib: print special message for frequency changes. 2015-01-19 10:56:15 +02:00
Roman Zeyde
323145c44b audio: fix UTs 2015-01-17 21:07:27 +02:00
Roman Zeyde
f523b7579c calib: allow only KeyboardInterrupt exception. 2015-01-17 19:11:15 +02:00
Roman Zeyde
3b6a54150f audio: split library load from c-tor 2015-01-17 19:01:49 +02:00
Roman Zeyde
2d202cf587 cli: use automaitic calibration with PulseAudio 2015-01-17 18:30:43 +02:00
Roman Zeyde
ee7db32418 audio: fix for Python3 2015-01-17 18:27:10 +02:00
Roman Zeyde
81165799fd calib: fix logging messages 2015-01-17 18:27:01 +02:00
Roman Zeyde
c7251b641e calib: fix UTs 2015-01-17 16:03:41 +02:00
Roman Zeyde
9036069323 cli: add aliases for PulseAudio volume setting 2015-01-17 12:51:10 +02:00
Roman Zeyde
ff8427f5f9 calib: automatic microphone gain calibration
$ amodem-cli send -vv -c 'pactl set-sink-volume @DEFAULT_SINK@'
will set speaker level to 100%.

$ amodem-cli recv -vv -c 'pactl set-source-volume @DEFAULT_SOURCE@'
will use "binary search", to find the best microphone gain.
2015-01-17 12:45:25 +02:00
Roman Zeyde
35487966d8 audio: add PortAudio version string for debugging 2015-01-17 09:04:34 +02:00
Roman Zeyde
5c4849be1c detect: remove unused code
since we now compensate for frequency drift, these estimates may be too
pessimistic.
2015-01-16 12:46:29 +02:00
Roman Zeyde
cbcf2860b9 README: add a video tutorial 2015-01-16 12:39:14 +02:00
Roman Zeyde
d03205fe02 update README for newer version. 2015-01-16 11:53:47 +02:00
Roman Zeyde
603fc75db4 amodem: bump version 2015-01-16 11:36:55 +02:00
54 changed files with 1770 additions and 1037 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

4
.gitignore vendored
View File

@@ -1,4 +1,5 @@
*~
\#*\#
*.out
*.pyc
*.sublime-*
@@ -23,3 +24,6 @@ htmlcov
*_ext.so
/.tox
/dist
/deb_dist
*.html
.pytest_cache/

2
.pylintrc Normal file
View File

@@ -0,0 +1,2 @@
[MESSAGES CONTROL]
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,20 +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/ amodem-cli
- cd tests
- coverage run --source=amodem -m py.test
after_success:
- 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

185
README.md
View File

@@ -1,185 +0,0 @@
# Audio Modem Communication Library
[![Build Status](https://travis-ci.org/romanz/amodem.svg?branch=master)](https://travis-ci.org/romanz/amodem)
[![Coverage Status](https://coveralls.io/repos/romanz/amodem/badge.png?branch=master)](https://coveralls.io/r/romanz/amodem?branch=master)
[![Code Health](https://landscape.io/github/romanz/amodem/master/landscape.svg)](https://landscape.io/github/romanz/amodem/master)
[![Supported Python versions](https://pypip.in/py_versions/amodem/badge.svg)](https://pypi.python.org/pypi/amodem/)
[![License](https://pypip.in/license/amodem/badge.svg)](https://pypi.python.org/pypi/amodem/)
# 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).
The sender modulates an input binary data file into an 32kHz audio,
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 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.
The modem is using OFDM over an audio cable with the following parameters:
- Sampling rate: 32 kHz
- Baud rate: 1 kHz
- Symbol modulation: BPSK, 4-PSK, 16-QAM ,64-QAM
- Carriers: 2-11 kHz
This way, modem may achieve 60kbps bitrate = 7.5 kB/s.
A simple CRC-32 checksum is used for data integrity verification
on each 250 byte data frame.
# Installation
Make sure that `numpy` and `PortAudio v19` packages are installed (on Debian):
$ sudo apt-get install python-numpy portaudio19-dev
Clone and install latest version:
$ git clone https://github.com/romanz/amodem.git
$ pip install --user -e amodem
For graphs and visualization (optional), install `matplotlib` Python package.
For validation, run:
$ export BITRATE=16 # explicitly select high MODEM bit rate (assuming good SNR).
$ amodem-cli -h
usage: amodem-cli [-h] {send,recv} ...
Audio OFDM MODEM: 48.0 kb/s (64-QAM x 8 carriers) Fs=32.0 kHz
positional arguments:
{send,recv}
send modulate binary data into audio signal.
recv demodulate audio signal into binary data.
optional arguments:
-h, --help show this help message and exit
# Calibration
Connect the audio cable between the sender and the receiver, and run the
following scripts:
- On the sender's side:
```
~/sender $ export BITRATE=48 # explicitly select high MODEM bit rate (assuming good SNR).
~/sender $ amodem-cli send --calibrate
```
- On the receiver's side:
```
~/receiver $ export BITRATE=48 # explicitly select high MODEM bit rate (assuming good SNR).
~/receiver $ amodem-cli recv --calibrate
```
If BITRATE is not set, the MODEM will use 1 kbps settings (single frequency with BPSK modulation).
Change the sender computer's output audio level, until
all frequencies are received well:
```
1000 Hz: good signal
2000 Hz: good signal
3000 Hz: good signal
4000 Hz: good signal
5000 Hz: good signal
6000 Hz: good signal
7000 Hz: good signal
8000 Hz: good signal
```
If the signal is "too weak", increase the sender's output audio level.
If the signal is "too strong", decrease the sender's output audio level.
If the signal is "too noisy", the SNR is probably too low: decrease the
background noise or increase the signal (without causing saturation).
# Usage
- Prepare the sender (generate a random binary data file to be sent):
```
~/sender $ dd if=/dev/urandom of=data.tx bs=16kB count=1 status=none
~/sender $ sha256sum data.tx
008df57d4f3ed6e7a25d25afd57d04fc73140e8df604685bd34fcab58f5ddc01 data.tx
```
- Start the receiver (will wait for the sender to start):
```
~/receiver $ amodem-cli recv -vv -i data.rx
```
- Start the sender (will modulate the data and start the transmission):
```
~/sender $ amodem-cli send -vv -o data.tx
```
- A similar log should be emitted by the sender:
```
2014-10-23 09:46:36,116 DEBUG MODEM settings: {'F0': 1000.0, 'Nfreq': 8, 'Fs': 32000.0, 'Npoints': 64, 'Tsym': 0.001} amodem:126
2014-10-23 09:46:36,116 DEBUG Running: ['aplay', '-', '-q', '-f', 'S16_LE', '-c', '1', '-r', '32000'] wave.py:20
2014-10-23 09:46:36,665 INFO Sending 2.150 seconds of training audio send.py:69
2014-10-23 09:46:36,665 INFO Starting modulation: <48.000 kbps, 64-QAM, 8 carriers> send.py:74
2014-10-23 09:46:37,735 DEBUG Sent 6.0 kB send.py:56
2014-10-23 09:46:38,794 DEBUG Sent 12.0 kB send.py:56
2014-10-23 09:46:39,440 INFO Sent 16.384 kB @ 2.754 seconds send.py:79
```
- A similar log should be emitted by the receiver:
```
2014-10-23 09:46:36,116 DEBUG MODEM settings: {'F0': 1000.0, 'Nfreq': 8, 'Fs': 32000.0, 'Npoints': 64, 'Tsym': 0.001} amodem:126
2014-10-23 09:46:36,238 DEBUG Running: ['arecord', '-', '-q', '-f', 'S16_LE', '-c', '1', '-r', '32000'] wave.py:20
2014-10-23 09:46:36,408 DEBUG Skipping 0.128 seconds recv.py:275
2014-10-23 09:46:36,409 INFO Waiting for carrier tone: 1.0 kHz recv.py:282
2014-10-23 09:46:37,657 INFO Carrier detected at ~886.0 ms @ 1.0 kHz: coherence=99.996%, amplitude=0.475 recv.py:40
2014-10-23 09:46:37,657 DEBUG Buffered 1000 ms of audio recv.py:64
2014-10-23 09:46:37,660 DEBUG Carrier starts at 9.531 ms recv.py:73
2014-10-23 09:46:38,119 DEBUG Prefix OK recv.py:108
2014-10-23 09:46:38,153 DEBUG Current phase on carrier: -0.497 recv.py:121
2014-10-23 09:46:38,153 DEBUG Frequency error: 0.02 ppm recv.py:123
2014-10-23 09:46:38,682 DEBUG 1.0 kHz: SNR = 34.20 dB recv.py:165
2014-10-23 09:46:38,715 DEBUG 2.0 kHz: SNR = 35.05 dB recv.py:165
2014-10-23 09:46:38,766 DEBUG 3.0 kHz: SNR = 35.52 dB recv.py:165
2014-10-23 09:46:38,803 DEBUG 4.0 kHz: SNR = 35.65 dB recv.py:165
2014-10-23 09:46:38,837 DEBUG 5.0 kHz: SNR = 35.03 dB recv.py:165
2014-10-23 09:46:38,869 DEBUG 6.0 kHz: SNR = 35.05 dB recv.py:165
2014-10-23 09:46:38,907 DEBUG 7.0 kHz: SNR = 34.80 dB recv.py:165
2014-10-23 09:46:38,943 DEBUG 8.0 kHz: SNR = 33.74 dB recv.py:165
2014-10-23 09:46:38,977 INFO Starting demodulation: <48.000 kbps, 64-QAM, 8 carriers> recv.py:197
2014-10-23 09:46:39,619 DEBUG Got 6.0 kB, realtime: 64.18%, drift: +0.02 ppm recv.py:215
2014-10-23 09:46:40,538 DEBUG Got 12.0 kB, realtime: 78.03%, drift: +0.02 ppm recv.py:215
2014-10-23 09:46:41,306 DEBUG EOF frame detected framing.py:60
2014-10-23 09:46:41,306 DEBUG Demodulated 16.520 kB @ 2.329 seconds (84.6% realtime) recv.py:244
2014-10-23 09:46:41,306 INFO Received 16.384 kB @ 2.329 seconds = 7.034 kB/s recv.py:247
```
- After the receiver has finished, verify the received file's hash:
```
~/receiver $ sha256sum data.rx
008df57d4f3ed6e7a25d25afd57d04fc73140e8df604685bd34fcab58f5ddc01 data.rx
```
# Visualization
Make sure that `matplotlib` package is installed, and run (at the receiver side):
```
~/receiver $ amodem-cli recv --plot -o data.rx
```
# Donations
Want to donate? Feel free.
Send to [1C1snTrkHAHM5XnnfuAtiTBaA11HBxjJyv](https://blockchain.info/address/1C1snTrkHAHM5XnnfuAtiTBaA11HBxjJyv).
Thanks :)

248
README.rst Normal file
View File

@@ -0,0 +1,248 @@
Audio Modem Communication Library
=================================
.. image:: https://img.shields.io/pypi/pyversions/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: Python Versions
.. image:: https://img.shields.io/pypi/l/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: License
.. image:: https://img.shields.io/pypi/v/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: Package Version
.. image:: https://img.shields.io/pypi/status/amodem.svg
:target: https://pypi.python.org/pypi/amodem/
:alt: Development Status
Description
-----------
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 the input data into an audio signal,
which is played to the sound card.
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 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
- Carriers: 2-11 kHz (up to ten carriers)
This way, modem may achieve 80kbps bitrate = 10 kB/s (for best SNR).
A simple CRC-32 checksum is used for data integrity verification
on each 250 byte data frame.
Installation
------------
Make sure that all the required packages are installed (on Debian)::
$ sudo apt-get install python-numpy python-pip portaudio19-dev git
Get the latest released version from PyPI::
$ pip install --user amodem
Or, try the latest (unstable) development version from GitHub::
$ git clone https://github.com/romanz/amodem.git
$ cd amodem
$ pip install --user -e .
For graphs and visualization (optional), install `matplotlib` Python package.
For validation, run::
$ export BITRATE=48 # explicitly select high MODEM bit rate (assuming good SNR).
$ amodem -h
usage: amodem [-h] {send,recv} ...
Audio OFDM MODEM: 48.0 kb/s (64-QAM x 8 carriers) Fs=32.0 kHz
positional arguments:
{send,recv}
send modulate binary data into audio signal.
recv demodulate audio signal into binary data.
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
-----------
Connect the audio cable between the sender and the receiver, and run the
following scripts:
On the sender's side::
~/sender $ export BITRATE=48 # explicitly select high MODEM bit rate (assuming good SNR).
~/sender $ amodem send --calibrate
On the receiver's side::
~/receiver $ export BITRATE=48 # explicitly select high MODEM bit rate (assuming good SNR).
~/receiver $ amodem recv --calibrate
If BITRATE is not set, the MODEM will use 1 kbps settings (single frequency with BPSK modulation).
Change the sender computer's output audio level, until
all frequencies are received well::
3000 Hz: good signal
4000 Hz: good signal
5000 Hz: good signal
6000 Hz: good signal
7000 Hz: good signal
8000 Hz: good signal
9000 Hz: good signal
10000 Hz: good signal
If the signal is "too weak", increase the sender's output audio level.
If the signal is "too strong", decrease the sender's output audio level.
If the signal is "too noisy", it may be that the noise level is too high
or that the analog signal is being distorted.
Please run the following command during the calibration session,
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 screencast of the `calibration process <https://asciinema.org/a/25065?autoplay=1>`_.
Usage
-----
Prepare the sender (generate a random binary data file to be sent)::
~/sender $ dd if=/dev/urandom of=data.tx bs=60KB count=1 status=none
~/sender $ sha256sum data.tx
008df57d4f3ed6e7a25d25afd57d04fc73140e8df604685bd34fcab58f5ddc01 data.tx
Start the receiver (will wait for the sender to start)::
~/receiver $ amodem recv -vv -o data.rx
Start the sender (will modulate the data and start the transmission)::
~/sender $ amodem send -vv -i data.tx
A similar log should be emitted by the sender::
2015-02-06 18:12:46,222 DEBUG Audio OFDM MODEM: 48.0 kb/s (64-QAM x 8 carriers) Fs=32.0 kHz
2015-02-06 18:12:46,222 INFO PortAudio V19-devel (built Feb 25 2014 21:09:53) loaded
2015-02-06 18:12:48,297 INFO Sending 2.150 seconds of training audio
2015-02-06 18:12:48,297 INFO Starting modulation
2015-02-06 18:12:49,303 DEBUG Sent 6.000 kB
2015-02-06 18:12:50,296 DEBUG Sent 12.000 kB
2015-02-06 18:12:51,312 DEBUG Sent 18.000 kB
2015-02-06 18:12:52,290 DEBUG Sent 24.000 kB
2015-02-06 18:12:53,299 DEBUG Sent 30.000 kB
2015-02-06 18:12:54,299 DEBUG Sent 36.000 kB
2015-02-06 18:12:55,306 DEBUG Sent 42.000 kB
2015-02-06 18:12:56,296 DEBUG Sent 48.000 kB
2015-02-06 18:12:57,311 DEBUG Sent 54.000 kB
2015-02-06 18:12:58,293 DEBUG Sent 60.000 kB
2015-02-06 18:12:58,514 INFO Sent 60.000 kB @ 10.201 seconds
2015-02-06 18:12:59,506 DEBUG Closing input and output
A similar log should be emitted by the receiver::
2015-02-06 18:12:44,848 DEBUG Audio OFDM MODEM: 48.0 kb/s (64-QAM x 8 carriers) Fs=32.0 kHz
2015-02-06 18:12:44,849 INFO PortAudio V19-devel (built Feb 25 2014 21:09:53) loaded
2015-02-06 18:12:44,929 DEBUG AsyncReader thread started
2015-02-06 18:12:44,930 DEBUG Skipping 0.100 seconds
2015-02-06 18:12:45,141 INFO Waiting for carrier tone: 3.0 kHz
2015-02-06 18:12:47,846 INFO Carrier detected at ~2265.0 ms @ 3.0 kHz
2015-02-06 18:12:47,846 DEBUG Buffered 1000 ms of audio
2015-02-06 18:12:48,025 DEBUG Carrier starts at 2264.000 ms
2015-02-06 18:12:48,029 DEBUG Carrier symbols amplitude : 0.573
2015-02-06 18:12:48,030 DEBUG Current phase on carrier: 0.061
2015-02-06 18:12:48,030 DEBUG Frequency error: -0.009 ppm
2015-02-06 18:12:48,030 DEBUG Frequency correction: 0.009 ppm
2015-02-06 18:12:48,030 DEBUG Gain correction: 1.746
2015-02-06 18:12:48,198 DEBUG Prefix OK
2015-02-06 18:12:48,866 DEBUG 3.0 kHz: SNR = 34.82 dB
2015-02-06 18:12:48,866 DEBUG 4.0 kHz: SNR = 36.39 dB
2015-02-06 18:12:48,867 DEBUG 5.0 kHz: SNR = 37.88 dB
2015-02-06 18:12:48,867 DEBUG 6.0 kHz: SNR = 38.58 dB
2015-02-06 18:12:48,867 DEBUG 7.0 kHz: SNR = 38.86 dB
2015-02-06 18:12:48,867 DEBUG 8.0 kHz: SNR = 38.63 dB
2015-02-06 18:12:48,867 DEBUG 9.0 kHz: SNR = 38.07 dB
2015-02-06 18:12:48,868 DEBUG 10.0 kHz: SNR = 37.22 dB
2015-02-06 18:12:48,869 INFO Starting demodulation
2015-02-06 18:12:49,689 DEBUG Got 6.000 kB, SNR: 41.19 dB, drift: -0.01 ppm
2015-02-06 18:12:50,659 DEBUG Got 12.000 kB, SNR: 41.05 dB, drift: -0.00 ppm
2015-02-06 18:12:51,639 DEBUG Got 18.000 kB, SNR: 40.96 dB, drift: -0.00 ppm
2015-02-06 18:12:52,610 DEBUG Got 24.000 kB, SNR: 41.47 dB, drift: -0.01 ppm
2015-02-06 18:12:53,610 DEBUG Got 30.000 kB, SNR: 41.06 dB, drift: -0.00 ppm
2015-02-06 18:12:54,589 DEBUG Got 36.000 kB, SNR: 41.37 dB, drift: -0.00 ppm
2015-02-06 18:12:55,679 DEBUG Got 42.000 kB, SNR: 41.13 dB, drift: -0.00 ppm
2015-02-06 18:12:56,650 DEBUG Got 48.000 kB, SNR: 41.31 dB, drift: -0.00 ppm
2015-02-06 18:12:57,631 DEBUG Got 54.000 kB, SNR: 41.23 dB, drift: +0.00 ppm
2015-02-06 18:12:58,605 DEBUG Got 60.000 kB, SNR: 41.31 dB, drift: +0.00 ppm
2015-02-06 18:12:58,857 DEBUG EOF frame detected
2015-02-06 18:12:58,857 DEBUG Demodulated 61.205 kB @ 9.988 seconds (97.9% realtime)
2015-02-06 18:12:58,858 INFO Received 60.000 kB @ 9.988 seconds = 6.007 kB/s
2015-02-06 18:12:58,876 DEBUG Closing input and output
2015-02-06 18:12:58,951 DEBUG AsyncReader thread stopped (read 896000 bytes)
After the receiver has finished, verify the received file's hash::
~/receiver $ sha256sum data.rx
008df57d4f3ed6e7a25d25afd57d04fc73140e8df604685bd34fcab58f5ddc01 data.rx
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)::
~/receiver $ amodem recv --plot -o data.rx

View File

@@ -1,191 +0,0 @@
#!/usr/bin/env python
# PYTHON_ARGCOMPLETE_OK
import os
import sys
import zlib
import logging
import argparse
if sys.version_info.major == 2:
_stdin = sys.stdin
_stdout = sys.stdout
else:
_stdin = sys.stdin.buffer
_stdout = sys.stdout.buffer
try:
import argcomplete
except ImportError:
argcomplete = None
log = logging.getLogger('__name__')
from amodem import recv, send, calib, audio
from amodem.config import bitrates
bitrate = os.environ.get('BITRATE', 1)
config = bitrates.get(int(bitrate))
class Compressor(object):
def __init__(self, stream):
self.obj = zlib.compressobj()
self.stream = stream
def read(self, size):
while True:
data = self.stream.read(size)
if data:
result = self.obj.compress(data)
if not result: # compression is too good :)
continue # try again (since falsy data = EOF)
elif self.obj:
result = self.obj.flush()
self.obj = None
else:
result = '' # EOF marker
return result
class Decompressor(object):
def __init__(self, stream):
self.obj = zlib.decompressobj()
self.stream = stream
def write(self, data):
self.stream.write(self.obj.decompress(bytes(data)))
def flush(self):
self.stream.write(self.obj.flush())
def FileType(mode, audio_interface=None):
def opener(fname):
assert 'r' in mode or 'w' in mode
if audio_interface is None and fname is None:
fname = '-'
if fname is None:
assert audio_interface is not None
if 'r' in mode:
return audio_interface.recorder()
if 'w' in mode:
return audio_interface.player()
if fname == '-':
if 'r' in mode:
return _stdin
if 'w' in mode:
return _stdout
return open(fname, mode)
return opener
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('libportaudio.so', config=config)
p = argparse.ArgumentParser(description=description)
subparsers = p.add_subparsers()
def wrap(cls, stream, enable):
return cls(stream) if enable else stream
# Modulator
sender = subparsers.add_parser(
'send', help='modulate binary data into audio signal.')
sender.add_argument(
'-i', '--input', help='input file (use "-" for stdin).')
sender.add_argument(
'-o', '--output', help='output file (use "-" for stdout).'
' if not specified, `aplay` tool will be used.')
sender.add_argument(
'-c', '--calibrate', default=False, action='store_true')
sender.set_defaults(
main=lambda config, args: send.main(
config, src=wrap(Compressor, args.src, args.zip), dst=args.dst
),
calib=lambda config, args: calib.send(
config, dst=args.dst
),
input_type=FileType('rb'),
output_type=FileType('wb', interface)
)
# Demodulator
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.')
receiver.add_argument(
'-o', '--output', help='output file (use "-" for stdout).')
receiver.add_argument(
'-c', '--calibrate', default=False, action='store_true')
receiver.add_argument(
'-d', '--dump', type=FileType('wb'),
help='Filename to save recorded audio')
receiver.add_argument(
'--plot', action='store_true', default=False,
help='plot results using pylab module')
receiver.set_defaults(
main=lambda config, args: recv.main(
config, src=args.src, dst=wrap(Decompressor, args.dst, args.zip),
pylab=args.pylab, dump_audio=args.dump
),
calib=lambda config, args: calib.recv(
config, src=args.src, verbose=args.verbose
),
input_type=FileType('rb', interface),
output_type=FileType('wb')
)
for sub in subparsers.choices.values():
sub.add_argument('-z', '--zip', default=False, action='store_true')
g = sub.add_mutually_exclusive_group()
g.add_argument('-v', '--verbose', default=0, action='count')
g.add_argument('-q', '--quiet', default=False, action='store_true')
if argcomplete:
argcomplete.autocomplete(p)
with interface:
args = p.parse_args()
if args.verbose == 0:
level, format = 'INFO', '%(message)s'
elif args.verbose == 1:
level, format = 'DEBUG', '%(message)s'
elif args.verbose >= 2:
level, format = ('DEBUG', '%(asctime)s %(levelname)-10s '
'%(message)-100s '
'%(filename)s:%(lineno)d')
if args.quiet:
level, format = 'WARNING', '%(message)s'
logging.basicConfig(level=level, format=format)
# Parsing and execution
log.debug(description)
args.pylab = None
if getattr(args, 'plot', False):
import pylab
args.pylab = pylab
args.src = args.input_type(args.input)
args.dst = args.output_type(args.output)
if args.calibrate:
args.calib(config=config, args=args)
else:
return args.main(config=config, args=args)
if __name__ == '__main__':
success = main()
sys.exit(0 if success else 1)

261
amodem/__main__.py Normal file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python
# PYTHON_ARGCOMPLETE_OK
import argparse
import contextlib
import logging
import os
import sys
import zlib
import pkg_resources
from . import async_reader
from . import audio
from . import calib
from . import main
from .config import bitrates
try:
import argcomplete
except ImportError:
argcomplete = None
log = logging.getLogger('__name__')
bitrate = os.environ.get('BITRATE', 1)
config = bitrates.get(int(bitrate))
class Compressor:
def __init__(self, stream):
self.obj = zlib.compressobj()
log.info('Using zlib compressor')
self.stream = stream
def read(self, size):
while True:
data = self.stream.read(size)
if data:
result = self.obj.compress(data)
if not result: # compression is too good :)
continue # try again (since falsy data = EOF)
elif self.obj:
result = self.obj.flush()
self.obj = None
else:
result = '' # EOF marker
return result
class Decompressor:
def __init__(self, stream):
self.obj = zlib.decompressobj()
log.info('Using zlib decompressor')
self.stream = stream
def write(self, data):
self.stream.write(self.obj.decompress(bytes(data)))
def flush(self):
self.stream.write(self.obj.flush())
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 = '-'
if fname is None:
assert audio_interface is not None
if 'r' in mode:
s = audio_interface.recorder()
return async_reader.AsyncReader(stream=s, bufsize=s.bufsize)
if 'w' in mode:
return audio_interface.player()
if fname == '-':
if 'r' in mode:
return sys.stdin.buffer
if 'w' in mode:
return sys.stdout.buffer
return open(fname, mode) # pylint: disable=unspecified-encoding
return opener
def get_volume_cmd(args):
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]
return None
def wrap(cls, stream, enable):
return cls(stream) if enable else stream
def create_parser(description, interface_factory):
p = argparse.ArgumentParser(description=description)
subparsers = p.add_subparsers(required=True)
# Modulator
sender = subparsers.add_parser(
'send', help='modulate binary data into audio signal.')
sender.add_argument(
'-i', '--input', help='input file (use "-" for stdin).')
sender.add_argument(
'-o', '--output', help='output file (use "-" for stdout).'
' if not specified, `aplay` tool will be used.')
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, extra_silence=args.silence
),
calib=lambda config, args: calib.send(
config=config, dst=args.dst,
volume_cmd=get_volume_cmd(args),
gain=args.gain,
),
input_type=FileType('rb'),
output_type=FileType('wb', interface_factory),
command='send'
)
# Demodulator
receiver = subparsers.add_parser(
'recv', help='demodulate audio signal into binary data.')
receiver.add_argument(
'-i', '--input', help='input file (use "-" for stdin).')
receiver.add_argument(
'-o', '--output', help='output file (use "-" for stdout).')
receiver.add_argument(
'-d', '--dump', type=FileType('wb'),
help='Filename to save recorded audio')
receiver.add_argument(
'--plot', action='store_true', default=False,
help='plot results using pylab module')
receiver.set_defaults(
main=lambda config, args: main.recv(
config, src=args.src, dst=wrap(Decompressor, args.dst, args.zlib),
pylab=args.pylab, dump_audio=args.dump
),
calib=lambda config, args: calib.recv(
config=config, src=args.src, verbose=args.verbose,
volume_cmd=get_volume_cmd(args)
),
input_type=FileType('rb', interface_factory),
output_type=FileType('wb'),
command='recv'
)
calibration_help = ('Run calibration '
'(specify "auto" for automatic gain control)')
for sub in subparsers.choices.values():
sub.add_argument('-c', '--calibrate', nargs='?', default=False,
metavar='SYSTEM', help=calibration_help)
sub.add_argument('-l', '--audio-library', default='libportaudio.so',
help='File name of PortAudio shared library.')
sub.add_argument('-z', '--zlib', default=False, action='store_true',
help='Use zlib to compress/decompress data.')
g = sub.add_mutually_exclusive_group()
g.add_argument('-v', '--verbose', default=0, action='count')
g.add_argument('-q', '--quiet', default=False, action='store_true')
if argcomplete:
argcomplete.autocomplete(p)
return p
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) '
'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.info(description)
args.pylab = None
if getattr(args, 'plot', False):
import pylab # pylint: disable=import-error,import-outside-toplevel
args.pylab = pylab
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:
args.src = args.input_type(args.input)
args.dst = args.output_type(args.output)
try:
if args.calibrate is False:
args.main(config=config, args=args)
else:
args.calib(config=config, args=args)
finally:
args.src.close()
args.dst.close()
log.debug('Finished I/O')
if __name__ == '__main__':
_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()

52
amodem/async_reader.py Normal file
View File

@@ -0,0 +1,52 @@
"""Asynchronous Reading capabilities for amodem."""
import logging
import threading
from queue import Queue
log = logging.getLogger()
class AsyncReader:
def __init__(self, stream, bufsize):
self.stream = stream
self.queue = Queue()
self.stop = threading.Event()
args = (stream, bufsize, self.queue, self.stop)
self.thread = threading.Thread(target=AsyncReader._thread,
args=args, name='AsyncReader')
self.thread.start()
self.buf = b''
@staticmethod
def _thread(src, bufsize, queue, stop):
total = 0
try:
log.debug('AsyncReader thread started')
while not stop.isSet():
buf = src.read(bufsize)
queue.put(buf)
total += len(buf)
log.debug('AsyncReader thread stopped (read %d bytes)', total)
except BaseException: # pylint: disable=broad-except
log.exception('AsyncReader thread failed')
queue.put(None)
def read(self, size):
while len(self.buf) < size:
buf = self.queue.get()
if buf is None:
raise IOError('cannot read from stream')
self.buf += buf
result = self.buf[:size]
self.buf = self.buf[size:]
return result
def close(self):
if self.stream is not None:
self.stop.set()
self.thread.join()
self.stream.close()
self.stream = None

View File

@@ -1,22 +1,36 @@
"""Audio capabilities for amodem."""
import ctypes
import logging
import time
log = logging.getLogger(__name__)
class Interface(object):
def __init__(self, name, config, debug=False):
class AudioError(Exception):
pass
class Interface:
def __init__(self, config, debug=False):
self.debug = bool(debug)
self.lib = ctypes.CDLL(name)
self.config = config
self.streams = []
assert self._error_string(0) == 'Success'
self.lib = None
def load(self, name):
self.lib = ctypes.CDLL(name)
assert self._error_string(0) == b'Success'
version = self.call('GetVersionText', restype=ctypes.c_char_p)
log.info('%s loaded', version)
return self
def _error_string(self, code):
return self.call('GetErrorText', code, restype=ctypes.c_char_p)
def call(self, name, *args, **kwargs):
func_name = 'Pa_{0}'.format(name)
assert self.lib is not None
func_name = f'Pa_{name}'
if self.debug:
log.debug('API: %s%s', name, args)
func = getattr(self.lib, func_name)
@@ -25,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')
@@ -37,13 +51,15 @@ class Interface(object):
self.call('Terminate')
def recorder(self):
return Stream(lib=self, config=self.config, read=True)
return Stream(self, config=self.config, read=True)
def player(self):
return Stream(lib=self, config=self.config, write=True)
return Stream(self, config=self.config, write=True)
class Stream(object):
class Stream:
timer = time.time
class Parameters(ctypes.Structure):
_fields_ = [
@@ -54,53 +70,63 @@ class Stream(object):
('hostApiSpecificStreamInfo', ctypes.POINTER(None))
]
def __init__(self, lib, config, read=False, write=False):
self.lib = lib
def __init__(self, interface, config, read=False, write=False):
self.interface = interface
self.stream = ctypes.POINTER(ctypes.c_void_p)()
self.user_data = ctypes.c_void_p(None)
self.stream_callback = ctypes.c_void_p(None)
self.bytes_per_sample = config.sample_size
self.latency = float(config.latency) # in seconds
self.bufsize = int(self.latency * config.Fs * self.bytes_per_sample)
assert config.bits_per_sample == 16 # just to make sure :)
read = bool(read)
write = bool(write)
assert read != write
assert read != write # don't support full duplex
direction = 'Input' if read else 'Output'
api_name = 'GetDefault{0}Device'.format(direction)
index = lib.call(api_name, restype=ctypes.c_int)
api_name = f'GetDefault{direction}Device'
index = interface.call(api_name, restype=ctypes.c_int)
self.params = Stream.Parameters(
device=index, # choose default device
channelCount=1, # mono audio
sampleFormat=0x00000008, # 16-bit samples (paInt16)
suggestedLatency=0.1, # 100ms should be good enough
suggestedLatency=self.latency,
hostApiSpecificStreamInfo=None)
self.lib.call(
self.interface.call(
'OpenStream',
ctypes.byref(self.stream),
ctypes.byref(self.params) if read else None,
ctypes.byref(self.params) if write else None,
ctypes.c_double(config.Fs),
ctypes.c_ulong(config.samples_per_buffer),
ctypes.c_ulong(0), # (paFramesPerBufferUnspecified)
ctypes.c_ulong(0), # no flags (paNoFlag)
self.stream_callback,
self.user_data)
self.lib.streams.append(self)
self.lib.call('StartStream', self.stream)
self.interface.streams.append(self)
self.interface.call('StartStream', self.stream)
self.start_time = self.timer()
self.io_time = 0
def close(self):
if self.stream:
self.lib.call('StopStream', self.stream)
self.lib.call('CloseStream', self.stream)
self.interface.call('StopStream', self.stream)
self.interface.call('CloseStream', self.stream)
self.stream = None
def read(self, size):
assert size % self.bytes_per_sample == 0
buf = ctypes.create_string_buffer(size)
frames = ctypes.c_ulong(size // self.bytes_per_sample)
self.lib.call('ReadStream', self.stream, buf, frames)
t0 = self.timer()
self.interface.call('ReadStream', self.stream, buf, frames)
t1 = self.timer()
self.io_time += (t1 - t0)
if self.interface.debug:
io_wait = self.io_time / (t1 - self.start_time)
log.debug('I/O wait: %.1f%%', io_wait * 100)
return buf.raw
def write(self, data):
@@ -108,4 +134,4 @@ class Stream(object):
assert len(data) % self.bytes_per_sample == 0
buf = ctypes.c_char_p(data)
frames = ctypes.c_ulong(len(data) // self.bytes_per_sample)
self.lib.call('WriteStream', self.stream, buf, frames)
self.interface.call('WriteStream', self.stream, buf, frames)

View File

@@ -1,32 +1,45 @@
import numpy as np
"""Calibration capabilities for amodem."""
import itertools
import logging
import subprocess
log = logging.getLogger(__name__)
import numpy as np
from . import common
from . import dsp
from . import sampling
from . import stream
ALLOWED_EXCEPTIONS = (IOError, KeyboardInterrupt)
log = logging.getLogger(__name__)
def send(config, dst):
def volume_controller(cmd):
def controller(level):
assert 0 < level <= 1
percent = 100 * level
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)
def send(config, dst, volume_cmd=None, gain=1.0, limit=None):
volume_ctl = volume_controller(volume_cmd)
volume_ctl(1.0) # full scale output volume
calibration_symbols = int(1.0 * config.Fs)
t = np.arange(0, calibration_symbols) * config.Ts
signals = [np.sin(2 * np.pi * f * t) for f in config.frequencies]
signals = [gain * np.sin(2 * np.pi * f * t) for f in config.frequencies]
signals = [common.dumps(s) for s in signals]
try:
for signal in itertools.cycle(signals):
dst.write(signal)
except ALLOWED_EXCEPTIONS:
pass
for signal in itertools.islice(itertools.cycle(signals), limit):
dst.write(signal)
def frame_iter(config, src, frame_length):
frame_size = frame_length * config.Nsym * config.sample_size
omegas = 2 * np.pi * config.frequencies / config.Fs
omegas = 2 * np.pi * np.array(config.frequencies) / config.Fs
while True:
data = src.read(frame_size)
@@ -48,41 +61,81 @@ def frame_iter(config, src, frame_length):
def detector(config, src, frame_length=200):
states = [True]
errors = ['weak', 'strong', 'noisy']
try:
for coeffs, peak, total in frame_iter(config, src, frame_length):
max_index = np.argmax(coeffs)
freq = config.frequencies[max_index]
rms = abs(coeffs[max_index])
coherency = rms / total
flags = [rms > 0.1, peak < 1.0, coherency > 0.99]
for coeffs, peak, total in frame_iter(config, src, frame_length):
max_index = np.argmax(coeffs)
freq = config.frequencies[max_index]
rms = abs(coeffs[max_index])
coherency = rms / total if total > 0 else 0.0
flags = [total > 0.1, peak < 1.0, coherency > 0.99]
states.append(all(flags))
states = states[-2:]
message = 'good signal'
error = not any(states)
if error:
message = 'too {0} signal'.format(errors[flags.index(False)])
yield common.AttributeHolder(dict(
freq=freq, rms=rms, peak=peak, coherency=coherency,
total=total, error=error, message=message
))
except ALLOWED_EXCEPTIONS:
pass
def recv(config, src, verbose=False):
fmt = '{0.freq:6.0f} Hz: {0.message:s}'
if verbose:
fields = ['peak', 'total', 'rms', 'coherency']
fmt += ''.join(', {0}={{0.{0}:.4f}}'.format(f) for f in fields)
for result in detector(config=config, src=src):
msg = fmt.format(result)
if not result.error:
log.info(msg)
success = all(flags)
if success:
msg = 'good signal'
else:
log.error(msg)
msg = f'too {errors[flags.index(False)]} signal'
yield {
'freq': freq, 'rms': rms, 'peak': peak, 'coherency': coherency,
'total': total, 'success': success, 'msg': msg
}
def volume_calibration(result_iterator, volume_ctl):
min_level = 0.01
max_level = 1.0
level = 0.5
step = 0.25
target_level = 0.4 # not too strong, not too weak
iters_per_update = 10 # update every 2 seconds
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
level = level + step * sign
level = min(max(level, min_level), max_level)
step = step * 0.5
volume_ctl(level) # should run "before" first actual iteration
if index > 0: # skip dummy (first result)
yield result
def iter_window(iterable, size):
# pylint: disable=stop-iteration-return
block = []
for item in iterable:
block.append(item)
block = block[-size:]
if len(block) == size:
yield block
def recv_iter(config, src, volume_cmd=None, dump_audio=None):
volume_ctl = volume_controller(volume_cmd)
if dump_audio:
src = stream.Dumper(src, dump_audio)
result_iterator = detector(config=config, src=src)
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']:
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,39 +1,48 @@
"""Configuration class."""
import numpy as np
class Configuration(object):
class Configuration:
Fs = 32000.0 # sampling frequency [Hz]
Tsym = 0.001 # symbol duration [seconds]
Nfreq = 8 # number of frequencies used
Npoints = 64
F0 = 1e3
frequencies = [1e3, 8e3] # use 1..8 kHz carriers
# audio config
bits_per_sample = 16
sample_size = bits_per_sample // 8
samples_per_buffer = 4096
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
timeout = 60.0
def __init__(self, **kwargs):
self.__dict__.update(**kwargs)
self.sample_size = self.bits_per_sample // 8
assert self.sample_size * 8 == self.bits_per_sample
self.Ts = 1.0 / self.Fs
self.Fsym = 1 / self.Tsym
self.frequencies = self.F0 + np.arange(self.Nfreq) * self.Fsym
self.Nsym = int(self.Tsym / self.Ts)
self.baud = int(1.0 / self.Tsym)
assert self.baud * self.Tsym == 1
if len(self.frequencies) != 1:
first, last = self.frequencies
self.frequencies = np.arange(first, last + self.baud, self.baud)
self.Nfreq = len(self.frequencies)
self.carrier_index = 0
self.Fc = self.frequencies[self.carrier_index]
self.Nsym = int(self.Tsym / self.Ts)
self.baud = int(1.0 / self.Tsym)
bits_per_symbol = np.log2(self.Npoints)
assert int(bits_per_symbol) == bits_per_symbol
bits_per_symbol = int(np.log2(self.Npoints))
assert 2 ** bits_per_symbol == self.Npoints
self.bits_per_baud = bits_per_symbol * self.Nfreq
self.modem_bps = self.baud * self.bits_per_baud
self.carriers = np.array([
@@ -42,25 +51,40 @@ 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)
symbols = symbols - symbols[-1]/2
self.symbols = symbols / np.max(np.abs(symbols))
# MODEM configurations for various bitrates [kbps]
bitrates = {
1: Configuration(F0=8e3, Npoints=2, Nfreq=1),
2: Configuration(F0=8e3, Npoints=4, Nfreq=1),
4: Configuration(F0=8e3, Npoints=16, Nfreq=1),
8: Configuration(F0=8e3, Npoints=16, Nfreq=2),
16: Configuration(F0=6e3, Npoints=16, Nfreq=4),
32: Configuration(F0=3e3, Npoints=16, Nfreq=8),
48: Configuration(F0=3e3, Npoints=64, Nfreq=8),
54: Configuration(F0=3e3, Npoints=64, Nfreq=9),
60: Configuration(F0=2e3, Npoints=64, Nfreq=10),
1: Configuration(Fs=8e3, Npoints=2, frequencies=[2e3]),
2: Configuration(Fs=8e3, Npoints=4, frequencies=[2e3]),
4: Configuration(Fs=8e3, Npoints=16, frequencies=[2e3]),
8: Configuration(Fs=8e3, Npoints=16, frequencies=[1e3, 2e3]),
12: Configuration(Fs=16e3, Npoints=16, frequencies=[3e3, 5e3]),
16: Configuration(Fs=16e3, Npoints=16, frequencies=[2e3, 5e3]),
20: Configuration(Fs=16e3, Npoints=16, frequencies=[2e3, 6e3]),
24: Configuration(Fs=16e3, Npoints=16, frequencies=[1e3, 6e3]),
28: Configuration(Fs=32e3, Npoints=16, frequencies=[3e3, 9e3]),
32: Configuration(Fs=32e3, Npoints=16, frequencies=[2e3, 9e3]),
36: Configuration(Fs=32e3, Npoints=64, frequencies=[4e3, 9e3]),
42: Configuration(Fs=32e3, Npoints=64, frequencies=[4e3, 10e3]),
48: Configuration(Fs=32e3, Npoints=64, frequencies=[3e3, 10e3]),
54: Configuration(Fs=32e3, Npoints=64, frequencies=[2e3, 10e3]),
60: Configuration(Fs=32e3, Npoints=64, frequencies=[2e3, 11e3]),
64: Configuration(Fs=32e3, Npoints=256, frequencies=[3e3, 10e3]),
72: Configuration(Fs=32e3, Npoints=256, frequencies=[2e3, 10e3]),
80: Configuration(Fs=32e3, Npoints=256, frequencies=[2e3, 11e3]),
}
fastest = lambda: bitrates[max(bitrates)]
slowest = lambda: bitrates[min(bitrates)]
def fastest():
return bitrates[max(bitrates)]
def slowest():
return bitrates[min(bitrates)]

View File

@@ -1,24 +1,26 @@
import numpy as np
import logging
import itertools
import collections
"""Signal detection capabilities for amodem."""
log = logging.getLogger(__name__)
import collections
import itertools
import logging
import numpy as np
from . import dsp
from . import equalizer
from . import common
log = logging.getLogger(__name__)
class Detector(object):
class Detector:
COHERENCE_THRESHOLD = 0.9
CARRIER_DURATION = sum(equalizer.prefix)
CARRIER_THRESHOLD = int(0.9 * CARRIER_DURATION)
SEARCH_WINDOW = int(0.1 * CARRIER_DURATION)
TIMEOUT = 10.0 # [seconds]
START_PATTERN_LENGTH = SEARCH_WINDOW // 4
def __init__(self, config, pylab):
self.freq = config.Fc
@@ -26,7 +28,7 @@ class Detector(object):
self.Nsym = config.Nsym
self.Tsym = config.Tsym
self.maxlen = config.baud # 1 second of symbols
self.max_offset = self.TIMEOUT * config.Fs
self.max_offset = config.timeout * config.Fs
self.plt = pylab
def _wait(self, samples):
@@ -54,14 +56,9 @@ class Detector(object):
length = (self.CARRIER_THRESHOLD - 1) * self.Nsym
begin = offset - length
x = np.concatenate(tuple(bufs)[-self.CARRIER_THRESHOLD:-1])
Hc = dsp.exp_iwt(-self.omega, len(x))
amplitude = np.abs(np.dot(Hc, x) / (0.5 * len(x)))
start_time = begin * self.Tsym / self.Nsym
log.info('Carrier detected at ~%.1f ms @ %.1f kHz:'
' coherence=%.3f%%, amplitude=%.3f',
start_time * 1e3, self.freq / 1e3,
np.abs(dsp.coherence(x, self.omega)) * 100, amplitude)
log.info('Carrier detected at ~%.1f ms @ %.1f kHz',
start_time * 1e3, self.freq / 1e3)
log.debug('Buffered %d ms of audio', len(bufs))
@@ -71,7 +68,7 @@ class Detector(object):
bufs.append(np.array(trailing))
buf = np.concatenate(bufs)
offset = self.find_start(buf, duration=self.CARRIER_DURATION)
offset = self.find_start(buf)
start_time += (offset / self.Nsym - self.SEARCH_WINDOW) * self.Tsym
log.debug('Carrier starts at %.3f ms', start_time * 1e3)
@@ -81,14 +78,21 @@ class Detector(object):
amplitude, freq_err = self.estimate(buf[:prefix_length])
return itertools.chain(buf, samples), amplitude, freq_err
def find_start(self, buf, duration):
filt = dsp.FIR(dsp.exp_iwt(self.omega, self.Nsym))
p = np.abs(list(filt(buf))) ** 2
p = np.cumsum(p)[self.Nsym-1:]
p = np.concatenate([[0], p])
length = (duration - 1) * self.Nsym
correlations = np.abs(p[length:] - p[:-length])
offset = np.argmax(correlations)
def find_start(self, buf):
carrier = dsp.exp_iwt(self.omega, self.Nsym)
carrier = np.tile(carrier, self.START_PATTERN_LENGTH)
zeroes = carrier * 0.0
signal = np.concatenate([zeroes, carrier])
signal = (2 ** 0.5) * signal / dsp.norm(signal)
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)
offset = index + len(zeroes)
return offset
def estimate(self, buf, skip=5):
@@ -98,7 +102,7 @@ class Detector(object):
symbols = np.array(symbols[skip:-skip])
amplitude = np.mean(np.abs(symbols))
log.debug('Carrier symbols amplitude : %.3f', amplitude)
log.info('Carrier symbols amplitude : %.3f', amplitude)
phase = np.unwrap(np.angle(symbols)) / (2 * np.pi)
indices = np.arange(len(phase))
@@ -108,9 +112,6 @@ class Detector(object):
self.plt.plot(indices, a * indices + b)
freq_err = a / (self.Tsym * self.freq)
last_phase = a * indices[-1] + b
log.debug('Current phase on carrier: %.3f', last_phase)
log.debug('Frequency error: %.2f ppm', freq_err * 1e6)
self.plt.title('Frequency drift: {0:.3f} ppm'.format(freq_err * 1e6))
log.info('Frequency error: %.3f ppm', freq_err * 1e6)
self.plt.title(f'Frequency drift: {freq_err * 1e6:.3f} ppm')
return amplitude, freq_err

View File

@@ -1,10 +1,11 @@
"""Digital Signal Processing capabilities for amodem."""
import numpy as np
from numpy import linalg
from . import common
class FIR(object):
class FIR:
def __init__(self, h):
self.h = np.array(h)
self.x_state = [0] * len(self.h)
@@ -18,33 +19,7 @@ class FIR(object):
self.x_state = x_
class IIR(object):
def __init__(self, b, a):
self.b = np.array(b) / a[0]
self.a = np.array(a[1:]) / a[0]
self.x_state = [0] * len(self.b)
self.y_state = [0] * (len(self.a) + 1)
def __call__(self, x):
x_, y_ = self.x_state, self.y_state
for v in x:
x_ = [v] + x_[:-1]
y_ = y_[:-1]
num = np.dot(x_, self.b)
den = np.dot(y_, self.a)
y = num - den
y_ = [y] + y_
yield y
self.x_state, self.y_state = x_, y_
def lfilter(b, a, x):
f = IIR(b=b, a=a)
y = list(f(x))
return np.array(y)
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]
@@ -58,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
@@ -72,36 +46,40 @@ def norm(x):
return np.sqrt(np.dot(x.conj(), x).real)
def rms(x):
return np.mean(np.abs(x) ** 2, axis=0) ** 0.5
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)
ones = np.ones(len(x))
M = np.array([x, ones]).T
a, b = linalg.lstsq(M, y)[0]
mean_x = np.mean(x)
mean_y = np.mean(y)
x_ = x - mean_x
y_ = y - mean_y
a = np.dot(y_, x_) / np.dot(x_, x_)
b = mean_y - a * mean_x
return a, b
class MODEM(object):
buf_size = 16
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)
@@ -120,14 +98,28 @@ 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 syms in common.iterate(symbols, self.buf_size, truncate=False):
for received in syms:
error = np.abs(symbols_vec - received)
index = np.argmin(error)
decoded, bits = _dec[index]
if error_handler:
error_handler(received=received, decoded=decoded)
yield bits
for received in symbols:
error = np.abs(symbols_vec - received)
index = np.argmin(error)
decoded, bits = _dec[index]
if error_handler:
error_handler(received=received, decoded=decoded)
yield bits
def prbs(reg, poly, bits):
""" Simple pseudo-random number generator. """
mask = (1 << bits) - 1
size = 0 # effective register size (in bits)
while (poly >> size) > 1:
size += 1
while True:
yield reg & mask
reg = reg << 1
if reg >> size:
reg = reg ^ poly

View File

@@ -1,16 +1,15 @@
import numpy as np
from numpy.linalg import lstsq
from amodem import dsp
from amodem import sampling
"""Audio equalizing capabilities for amodem."""
import itertools
import random
_constellation = [1, 1j, -1, -1j]
import numpy as np
from . import dsp
from . import sampling
from . import levinson
class Equalizer(object):
class Equalizer:
def __init__(self, config):
self.carriers = config.carriers
@@ -18,10 +17,18 @@ class Equalizer(object):
self.Nfreq = config.Nfreq
self.Nsym = config.Nsym
def train_symbols(self, length, seed=0):
r = random.Random(seed)
choose = lambda: [r.choice(_constellation) for j in range(self.Nfreq)]
return np.array([choose() for _ in range(length)])
def train_symbols(self, length, constant_prefix=16):
r = dsp.prbs(reg=1, poly=0x1100b, bits=2)
constellation = [1, 1j, -1, -1j]
symbols = []
for _ in range(length):
symbols.append([constellation[next(r)] for _ in range(self.Nfreq)])
symbols = np.array(symbols)
# Constant symbols (for analog debugging)
symbols[:constant_prefix, :] = 1
return symbols
def modulator(self, symbols):
gain = 1.0 / len(self.carriers)
@@ -39,27 +46,21 @@ class Equalizer(object):
return np.array(list(itertools.islice(symbols, size)))
equalizer_length = 200
silence_length = 50
prefix = [1]*equalizer_length + [0]*silence_length
def train(signal, expected, order, lookahead=0):
signal = [np.zeros(order-1), signal, np.zeros(lookahead)]
signal = np.concatenate(signal)
length = len(expected)
padding = np.zeros(lookahead)
assert len(signal) == len(expected)
x = np.concatenate([signal, padding])
y = np.concatenate([padding, expected])
A = []
b = []
for i in range(length - order):
offset = order + i
row = signal[offset-order:offset+lookahead]
A.append(np.array(row, ndmin=2))
b.append(expected[i])
A = np.concatenate(A, axis=0)
b = np.array(b)
h = lstsq(A, b)[0]
h = h[::-1].real
return h
prefix = [1]*400 + [0]*50
equalizer_length = 500
silence_length = 100
N = order + lookahead # filter length
Rxx = np.zeros(N)
Rxy = np.zeros(N)
for i in range(N):
Rxx[i] = np.dot(x[i:], x[:len(x)-i])
Rxy[i] = np.dot(y[i:], x[:len(x)-i])
return levinson.solver(t=Rxx, y=Rxy)

View File

@@ -1,17 +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__)
_checksum_func = lambda x: binascii.crc32(bytes(x)) & 0xFFFFFFFF
# (so the result will be unsigned on Python 2/3)
def _checksum_func(x):
return binascii.crc32(bytes(x))
class Checksum(object):
class Checksum:
fmt = '>L' # unsigned longs (32-bit)
size = struct.calcsize(fmt)
@@ -24,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)
@@ -83,7 +86,7 @@ def chain_wrapper(func):
return wrapped
class BitPacker(object):
class BitPacker:
byte_size = 8
def __init__(self):
@@ -113,8 +116,7 @@ def _to_bytes(bits):
yield [converter.to_byte[chunk]]
@chain_wrapper
def decode(bits, framer=None):
def decode_frames(bits, framer=None):
framer = framer or Framer()
for frame in framer.decode(_to_bytes(bits)):
yield frame
yield bytes(frame)

30
amodem/levinson.py Normal file
View File

@@ -0,0 +1,30 @@
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.
See http://en.wikipedia.org/wiki/Levinson_recursion for details.
"""
N = len(t)
assert len(y) == N
t0 = np.array([1.0 / t[0]])
f = [t0] # forward vectors
b = [t0] # backward vectors
for n in range(1, N):
prev_f = f[-1]
prev_b = b[-1]
ef = sum(t[n-i] * prev_f[i] for i in range(n))
eb = sum(t[i+1] * prev_b[i] for i in range(n))
f_ = np.concatenate([prev_f, [0]])
b_ = np.concatenate([[0], prev_b])
det = 1.0 - ef * eb
f.append((f_ - ef * b_) / det)
b.append((b_ - eb * f_) / det)
x = []
for n in range(N):
x = np.concatenate([x, [0]])
ef = sum(t[n-i] * x[i] for i in range(n))
x = x + (y[n] - ef) * b[n]
return x

71
amodem/main.py Normal file
View File

@@ -0,0 +1,71 @@
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
log = logging.getLogger(__name__)
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 + extra_silence))))
sender.start()
training_duration = sender.offset
log.info('Sending %.3f seconds of training audio', training_duration / Fs)
reader = stream.Reader(src, eof=True)
data = itertools.chain.from_iterable(reader)
bits = framing.encode(data)
log.info('Starting modulation')
sender.modulate(bits=bits)
data_duration = sender.offset - training_duration
log.info('Sent %.3f kB @ %.3f seconds',
reader.total / 1e3, data_duration / Fs)
# post-padding audio with silence
sender.write(np.zeros(int(Fs * config.silence_stop)))
return True
def recv(config, src, dst, dump_audio=None, pylab=None):
if dump_audio:
src = stream.Dumper(src, dump_audio)
reader = stream.Reader(src, data_type=common.loads)
signal = itertools.chain.from_iterable(reader)
log.debug('Skipping %.3f seconds', config.skip_start)
common.take(signal, int(config.skip_start * config.Fs))
pylab = pylab or common.Dummy()
detector = detect.Detector(config=config, pylab=pylab)
receiver = _recv.Receiver(config=config, pylab=pylab)
try:
log.info('Waiting for carrier tone: %.1f kHz', config.Fc / 1e3)
signal, amplitude, freq_error = detector.run(signal)
freq = 1 / (1.0 + freq_error) # receiver's compensated frequency
log.debug('Frequency correction: %.3f ppm', (freq - 1) * 1e6)
gain = 1.0 / amplitude
log.debug('Gain correction: %.3f', gain)
sampler = sampling.Sampler(signal, sampling.defaultInterpolator,
freq=freq)
receiver.run(sampler, gain=1.0/amplitude, output=dst)
return True
except BaseException: # pylint: disable=broad-except
log.exception('Decoding failed')
return False
finally:
dst.flush()
receiver.report()

View File

@@ -1,21 +1,19 @@
import numpy as np
import logging
import itertools
import functools
import itertools
import logging
import time
log = logging.getLogger(__name__)
import numpy as np
from . import stream
from . import dsp
from . import sampling
from . import common
from . import framing
from . import equalizer
from . import detect
log = logging.getLogger(__name__)
class Receiver(object):
class Receiver:
def __init__(self, config, pylab=None):
self.stats = {}
@@ -26,32 +24,36 @@ class Receiver(object):
self.Nsym = config.Nsym
self.Tsym = config.Tsym
self.iters_per_update = 100 # [ms]
self.iters_per_report = 1000 # [ms]
self.modem_bitrate = config.modem_bps
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))
S = S[:, self.carrier_index] * gain
sliced = np.round(np.abs(S))
self.plt.figure()
self.plt.subplot(121)
self.plt.subplot(1, 2, 1)
self._constellation(S, sliced, 'Prefix')
bits = np.array(sliced, dtype=int)
self.plt.subplot(122)
self.plt.subplot(1, 2, 2)
self.plt.plot(np.abs(S))
self.plt.plot(equalizer.prefix)
if any(bits != equalizer.prefix):
raise ValueError('Incorrect prefix')
errors = bits != equalizer.prefix
if any(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
@@ -60,7 +62,7 @@ class Receiver(object):
coeffs = equalizer.train(
signal=signal[prefix:-postfix],
expected=train_signal,
expected=np.concatenate([train_signal, np.zeros(lookahead)]),
order=order, lookahead=lookahead
)
@@ -68,6 +70,8 @@ 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]
self._verify_training(equalized, train_symbols)
@@ -77,31 +81,26 @@ 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)
rms = lambda x: (np.mean(np.abs(x) ** 2, axis=0) ** 0.5)
noise_rms = rms(errors)
signal_rms = rms(train_symbols)
noise_rms = dsp.rms(errors)
signal_rms = dsp.rms(train_symbols)
SNRs = 20.0 * np.log10(signal_rms / noise_rms)
self.plt.figure()
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 _demodulate(self, sampler, symbols):
def _bitstream(self, symbols, error_handler):
streams = []
symbol_list = []
errors = {}
def error_handler(received, decoded, freq):
errors.setdefault(freq, []).append(received / decoded)
generators = common.split(symbols, n=len(self.omegas))
for freq, S in zip(self.frequencies, generators):
equalized = []
@@ -110,51 +109,68 @@ class Receiver(object):
freq_handler = functools.partial(error_handler, freq=freq)
bits = self.modem.decode(S, freq_handler) # list of bit tuples
streams.append(bits) # stream per frequency
streams.append(bits) # bit stream per frequency
return zip(*streams), symbol_list
def _demodulate(self, sampler, symbols):
symbol_list = []
errors = {}
noise = {}
def _handler(received, decoded, freq):
errors.setdefault(freq, []).append(received / decoded)
noise.setdefault(freq, []).append(received - decoded)
stream, symbol_list = self._bitstream(symbols, _handler)
self.stats['symbol_list'] = symbol_list
self.stats['rx_bits'] = 0
self.stats['rx_start'] = time.time()
log.info('Starting demodulation')
for i, block in enumerate(common.izip(streams), 1):
for bits in block:
for i, block_of_bits in enumerate(stream, 1):
for bits in block_of_bits:
self.stats['rx_bits'] = self.stats['rx_bits'] + len(bits)
yield bits
if i % self.iters_per_update == 0:
self._update_sampler(i, errors, sampler)
self._update_sampler(errors, sampler)
def _update_sampler(self, iter_index, errors, sampler):
if i % self.iters_per_report == 0:
self._report_progress(noise, sampler)
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()
duration = time.time() - self.stats['rx_start']
sampler.freq -= 0.01 * err * self.Tsym
sampler.freq -= self.freq_err_gain * err
sampler.offset -= err
def _report_progress(self, noise, sampler):
e = np.array([e for v in noise.values() for e in v])
noise.clear()
log.debug(
'Got %10.3f kB, realtime: %6.2f%%, drift: %+5.2f ppm',
'Got %10.3f kB, SNR: %5.2f dB, drift: %+5.2f ppm',
self.stats['rx_bits'] / 8e3,
duration * 100.0 / (iter_index * self.Tsym),
-10 * np.log10(np.mean(np.abs(e) ** 2)),
(1.0 - sampler.freq) * 1e6
)
def run(self, sampler, gain, output):
log.debug('Receiving')
symbols = dsp.Demux(sampler, omegas=self.omegas, Nsym=self.Nsym)
self._prefix(symbols, gain=gain)
filt = self._train(sampler, order=20, lookahead=20)
filt = self._train(sampler, order=10, lookahead=10)
sampler.equalizer = lambda x: list(filt(x))
bitstream = self._demodulate(sampler, symbols)
bitstream = itertools.chain.from_iterable(bitstream)
data = framing.decode(bitstream)
for chunk in common.iterate(data=data, size=256,
truncate=False, func=bytearray):
output.write(chunk)
self.output_size += len(chunk)
for frame in framing.decode_frames(bitstream):
output.write(frame)
self.output_size += len(frame)
def report(self):
if self.stats:
@@ -172,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):
@@ -192,47 +208,3 @@ class Receiver(object):
self.plt.axis('equal')
self.plt.axis(np.array([-1, 1, -1, 1])*1.1)
self.plt.title(title)
class Dumper(object):
def __init__(self, src, dst):
self.src = src
self.dst = dst
def read(self, size):
data = self.src.read(size)
self.dst.write(data)
return data
def main(config, src, dst, dump_audio=None, pylab=None):
if dump_audio:
src = Dumper(src, dump_audio)
reader = stream.Reader(src, data_type=common.loads)
signal = itertools.chain.from_iterable(reader)
log.debug('Skipping %.3f seconds', config.skip_start)
common.take(signal, int(config.skip_start * config.Fs))
pylab = pylab or common.Dummy()
detector = detect.Detector(config=config, pylab=pylab)
receiver = Receiver(config=config, pylab=pylab)
try:
log.info('Waiting for carrier tone: %.1f kHz', config.Fc / 1e3)
signal, amplitude, freq_error = detector.run(signal)
freq = 1 / (1.0 + freq_error) # receiver's compensated frequency
log.debug('Frequency correction: %.3f ppm', (freq - 1) * 1e6)
gain = 1.0 / amplitude
log.debug('Gain correction: %.3f', gain)
sampler = sampling.Sampler(signal, sampling.Interpolator(), freq=freq)
receiver.run(sampler, gain=1.0/amplitude, output=dst)
return True
except Exception:
log.exception('Decoding failed')
return False
finally:
dst.flush()
receiver.report()

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):
@@ -14,7 +14,7 @@ class Interpolator(object):
N = resolution * width
u = np.arange(-N, N, dtype=float)
window = (1 + np.cos(0.5 * np.pi * u / N)) / 2.0 # (Hann window)
window = np.cos(0.5 * np.pi * u / N) ** 2.0 # raised cosine
h = np.sinc(u / resolution) * window
self.filt = []
@@ -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
@@ -57,26 +60,26 @@ class Sampler(object):
def _take(self, size):
frame = np.zeros(size)
count = 0
try:
for frame_index in range(size):
offset = self.offset
# offset = k + (j / self.resolution)
k = int(offset) # integer part
j = int((offset - k) * self.resolution) # fractional part
coeffs = self.filt[j] # choose correct filter phase
end = k + self.width
# process input until all buffer is full with samples
for frame_index in range(size):
offset = self.offset
# offset = k + (j / self.resolution)
k = int(offset) # integer part
j = int((offset - k) * self.resolution) # fractional part
coeffs = self.filt[j] # choose correct filter phase
end = k + self.width
# process input until all buffer is full with samples
try:
while self.index < end:
self.buff[:-1] = self.buff[1:]
self.buff[-1] = next(self.src) # throws StopIteration
self.index += 1
except StopIteration:
break
self.offset += self.freq
# apply interpolation filter
frame[frame_index] = np.dot(coeffs, self.buff)
count = frame_index + 1
except StopIteration:
pass
self.offset += self.freq
# apply interpolation filter
frame[frame_index] = np.dot(coeffs, self.buff)
count = frame_index + 1
return self.equalizer(frame[:count])

View File

@@ -1,18 +1,18 @@
import numpy as np
import logging
import itertools
import logging
log = logging.getLogger(__name__)
import numpy as np
from . import common
from . import stream
from . import framing
from . import equalizer
from . import dsp
log = logging.getLogger(__name__)
class Sender(object):
def __init__(self, fd, config):
class Sender:
def __init__(self, fd, config, gain=1.0):
self.gain = gain
self.offset = 0
self.fd = fd
self.modem = dsp.MODEM(config.symbols)
@@ -24,7 +24,7 @@ class Sender(object):
self.equalizer = equalizer.Equalizer(config)
def write(self, sym):
sym = np.array(sym)
sym = np.array(sym) * self.gain
data = common.dumps(sym)
self.fd.write(data)
self.offset += len(sym)
@@ -48,30 +48,3 @@ class Sender(object):
if i % self.iters_per_report == 0:
total_bits = i * Nfreq * self.modem.bits_per_symbol
log.debug('Sent %10.3f kB', total_bits / 8e3)
def main(config, src, dst):
sender = Sender(dst, config=config)
Fs = config.Fs
# pre-padding audio with silence
sender.write(np.zeros(int(Fs * config.silence_start)))
sender.start()
training_duration = sender.offset
log.info('Sending %.3f seconds of training audio', training_duration / Fs)
reader = stream.Reader(src, eof=True)
data = itertools.chain.from_iterable(reader)
bits = framing.encode(data)
log.info('Starting modulation')
sender.modulate(bits=bits)
data_duration = sender.offset - training_duration
log.info('Sent %.3f kB @ %.3f seconds',
reader.total / 1e3, data_duration / Fs)
# post-padding audio with silence
sender.write(np.zeros(int(Fs * config.silence_stop)))
return True

View File

@@ -1,11 +1,11 @@
import time
class Reader(object):
class Reader:
wait = 0.2
timeout = 2.0
bufsize = 4096
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:
@@ -43,3 +42,14 @@ class Reader(object):
raise IOError('timeout')
__next__ = next
class Dumper:
def __init__(self, src, dst):
self.src = src
self.dst = dst
def read(self, size):
data = self.src.read(size)
self.dst.write(data)
return data

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

@@ -0,0 +1,33 @@
import logging
import time
import mock
import pytest
from .. import async_reader
logging.basicConfig(format='%(message)s')
def test_async_reader():
def _read(n):
time.sleep(n * 0.1)
return b'\x00' * n
s = mock.Mock()
s.read = _read
r = async_reader.AsyncReader(s, 1)
n = 5
assert r.read(n) == b'\x00' * n
r.close()
assert r.stream is None
r.close()
def test_async_reader_error():
s = mock.Mock()
s.read.side_effect = IOError()
r = async_reader.AsyncReader(s, 1)
with pytest.raises(IOError):
r.read(3)

View File

@@ -1,22 +1,21 @@
from amodem import audio, config
import mock
import pytest
from .. import audio, config
def test():
length = 1024
data = b'\x12\x34' * length
with mock.patch('ctypes.CDLL') as cdll:
lib = mock.Mock()
lib.Pa_GetErrorText = lambda code: 'Error' if code else 'Success'
lib.Pa_GetErrorText = lambda code: b'Error' if code else b'Success'
lib.Pa_GetDefaultOutputDevice.return_value = 1
lib.Pa_GetDefaultInputDevice.return_value = 2
lib.Pa_OpenStream.return_value = 0
cdll.return_value = lib
interface = audio.Interface(
name='portaudio', config=config.fastest(), debug=True
)
interface = audio.Interface(config=config.fastest(), debug=True)
assert interface.load(name='portaudio') is interface
with interface:
s = interface.player()
assert s.params.device == 1
@@ -32,4 +31,4 @@ def test():
s.close()
with pytest.raises(Exception):
interface._error_check(1)
interface._error_check(1) # pylint: disable=protected-access

159
amodem/tests/test_calib.py Normal file
View File

@@ -0,0 +1,159 @@
from io import BytesIO
import random
import mock
import numpy as np
import pytest
from .. import calib, common, config
config = config.fastest()
class ProcessMock:
def __init__(self):
self.buf = BytesIO()
self.stdin = self
self.stdout = self
self.bytes_per_sample = 2
def write(self, data):
assert self.buf.tell() < 10e6
self.buf.write(data)
def read(self, n):
return self.buf.read(n)
def test_success():
p = ProcessMock()
calib.send(config, p, gain=0.5, limit=32)
p.buf.seek(0)
calib.recv(config, p)
def test_too_strong():
p = ProcessMock()
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'
def test_too_weak():
p = ProcessMock()
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'
def test_too_noisy():
r = random.Random(0) # generate random binary signal
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'
def test_errors():
class WriteError(ProcessMock):
def write(self, data):
raise KeyboardInterrupt()
p = WriteError()
with pytest.raises(KeyboardInterrupt):
calib.send(config, p, limit=32)
assert p.buf.tell() == 0
class ReadError(ProcessMock):
def read(self, n):
raise KeyboardInterrupt()
p = ReadError()
with pytest.raises(KeyboardInterrupt):
calib.recv(config, p, verbose=True)
assert p.buf.tell() == 0
@pytest.fixture(params=[0] + [sign * mag for sign in (+1, -1)
for mag in (0.1, 1, 10, 100, 1e3, 2e3)])
def freq_err(request):
return request.param * 1e-6
def test_drift(freq_err):
freq = config.Fc * (1 + freq_err / 1e6)
t = np.arange(int(1.0 * config.Fs)) * config.Ts
frame_length = 100
rms = 0.5
signal = rms * np.cos(2 * np.pi * freq * t)
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
iters += 1
assert iters > 0
assert iters == config.baud / frame_length
def test_volume():
with mock.patch('subprocess.check_call') as check_call:
ctl = calib.volume_controller('volume-control')
ctl(0.01)
ctl(0.421)
ctl(0.369)
ctl(1)
assert check_call.mock_calls == [
mock.call(shell=True, args='volume-control 1%'),
mock.call(shell=True, args='volume-control 42%'),
mock.call(shell=True, args='volume-control 37%'),
mock.call(shell=True, args='volume-control 100%')
]
with pytest.raises(AssertionError):
ctl(0)
with pytest.raises(AssertionError):
ctl(-0.5)
with pytest.raises(AssertionError):
ctl(12.3)
def test_send_max_volume():
with mock.patch('subprocess.check_call') as check_call:
calib.send(config, dst=BytesIO(), volume_cmd='ctl', limit=1)
assert check_call.mock_calls == [mock.call(shell=True, args='ctl 100%')]
def test_recv_binary_search():
buf = BytesIO()
gains = [0.5, 0.25, 0.38, 0.44, 0.41, 0.39, 0.40, 0.40]
for gain in gains:
calib.send(config, buf, gain=gain, limit=2)
buf.seek(0)
dump = BytesIO()
with mock.patch('subprocess.check_call') as check_call:
calib.recv(config, src=buf, volume_cmd='ctl', dump_audio=dump)
assert dump.getvalue() == buf.getvalue()
gains.append(gains[-1])
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,6 +1,7 @@
from amodem import common
import numpy as np
from .. import common, config
def iterlist(x, *args, **kwargs):
x = np.array(x)
@@ -47,15 +48,9 @@ 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()
slowest = config.slowest()
assert slowest.modem_bps <= default.modem_bps
assert fastest.modem_bps >= default.modem_bps

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,9 +12,9 @@ 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-16
assert abs(freq_err) < 1e-12
x = np.cos(2 * np.pi * (2*config.Fc) * t)
with pytest.raises(ValueError):
@@ -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():
@@ -56,6 +51,6 @@ def test_find_start():
for offset in range(32):
bufs = [prefix, [0] * offset, carrier, postfix]
buf = np.concatenate(bufs)
start = detector.find_start(buf, length)
start = detector.find_start(buf)
expected = offset + len(prefix)
assert expected == start

View File

@@ -1,14 +1,13 @@
import numpy as np
from numpy.linalg import norm
from amodem import dsp
from amodem import sampling
from amodem import config
config = config.fastest()
import random
import itertools
import numpy as np
from .. import dsp, sampling, config
from . import utils
config = config.fastest()
def test_linreg():
x = np.array([1, 3, 2, 8, 4, 6, 9, 7, 0, 5])
@@ -21,11 +20,11 @@ def test_linreg():
def test_filter():
x = range(10)
y = dsp.lfilter(b=[1], a=[1], x=x)
y = utils.lfilter(b=[1], a=[1], x=x)
assert (np.array(x) == y).all()
x = [1] + [0] * 10
y = dsp.lfilter(b=[0.5], a=[1, -0.5], x=x)
y = utils.lfilter(b=[0.5], a=[1, -0.5], x=x)
assert list(y) == [0.5 ** (i+1) for i in range(len(x))]
@@ -50,7 +49,9 @@ def test_qam():
decoded = list(q.decode(S))
assert decoded == bits
noise = lambda A: A*(r.uniform(-1, 1) + 1j*r.uniform(-1, 1))
def noise(A):
return A*(r.uniform(-1, 1) + 1j*r.uniform(-1, 1))
noised_symbols = [(s + noise(1e-3)) for s in S]
decoded = list(q.decode(noised_symbols))
assert decoded == bits
@@ -67,6 +68,25 @@ 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)
def test_prbs():
r = list(itertools.islice(dsp.prbs(reg=1, poly=0x7, bits=2), 4))
assert r == [1, 2, 3, 1]
r = list(itertools.islice(dsp.prbs(reg=1, poly=0x7, bits=1), 4))
assert r == [1, 0, 1, 1]
r = list(itertools.islice(dsp.prbs(reg=1, poly=0xd, bits=3), 8))
assert r == [1, 2, 4, 5, 7, 3, 6, 1]
r = list(itertools.islice(dsp.prbs(reg=1, poly=0xd, bits=2), 8))
assert r == [1, 2, 0, 1, 3, 3, 2, 1]
period = 2 ** 16 - 1
r = list(itertools.islice(dsp.prbs(reg=1, poly=0x1100b, bits=16), period))
r.sort()
assert r == list(range(1, 2 ** 16))

View File

@@ -1,15 +1,16 @@
from numpy.linalg import norm
from numpy.random import RandomState
import numpy as np
from amodem import dsp
from amodem import equalizer
from amodem import config
from . import utils
from .. import config, dsp, equalizer
config = config.fastest()
def assert_approx(x, y, e=1e-12):
assert norm(x - y) < e * norm(x)
x = x.flatten()
y = y.flatten()
assert dsp.norm(x - y) < e * dsp.norm(x)
def test_training():
@@ -24,14 +25,14 @@ def test_commutation():
x = np.random.RandomState(seed=0).normal(size=1000)
b = [1, 1j, -1, -1j]
a = [1, 0.1]
y = dsp.lfilter(x=x, b=b, a=a)
y1 = dsp.lfilter(x=dsp.lfilter(x=x, b=b, a=[1]), b=[1], a=a)
y2 = dsp.lfilter(x=dsp.lfilter(x=x, b=[1], a=a), b=b, a=[1])
y = utils.lfilter(x=x, b=b, a=a)
y1 = utils.lfilter(x=utils.lfilter(x=x, b=b, a=[1]), b=[1], a=a)
y2 = utils.lfilter(x=utils.lfilter(x=x, b=[1], a=a), b=b, a=[1])
assert_approx(y, y1)
assert_approx(y, y2)
z = dsp.lfilter(x=y, b=a, a=[1])
z_ = dsp.lfilter(x=x, b=b, a=[1])
z = utils.lfilter(x=y, b=a, a=[1])
z_ = utils.lfilter(x=x, b=b, a=[1])
assert_approx(z, z_)
@@ -46,19 +47,20 @@ def test_modem():
def test_signal():
length = 100
length = 120
x = np.sign(RandomState(0).normal(size=length))
x[-20:] = 0 # make sure the signal has bounded support
den = np.array([1, -0.6, 0.1])
num = np.array([0.5])
y = dsp.lfilter(x=x, b=num, a=den)
y = utils.lfilter(x=x, b=num, a=den)
lookahead = 2
h = equalizer.train(
signal=y, expected=x, order=len(den), lookahead=lookahead)
assert norm(h[:lookahead]) < 1e-12
assert dsp.norm(h[:lookahead]) < 1e-12
h = h[lookahead:]
assert_approx(h, den / num)
x_ = dsp.lfilter(x=y, b=h, a=[1])
x_ = utils.lfilter(x=y, b=h, a=[1])
assert_approx(x_, x)

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))
@@ -31,22 +33,22 @@ def test_framer(data):
def test_main(data):
encoded = framing.encode(data)
decoded = framing.decode(encoded)
assert bytearray(decoded) == data
decoded = framing.decode_frames(encoded)
assert concat(decoded) == data
def test_fail():
encoded = list(framing.encode(''))
encoded[-1] = not encoded[-1]
with pytest.raises(ValueError):
list(framing.decode(encoded))
concat(framing.decode_frames(encoded))
def test_missing():
f = framing.Framer()
with pytest.raises(ValueError):
list(f.decode(b''))
concat(f.decode(b''))
with pytest.raises(ValueError):
list(f.decode(b'\x01'))
concat(f.decode(b'\x01'))
with pytest.raises(ValueError):
list(f.decode(b'\xff'))
concat(f.decode(b'\xff'))

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,35 +1,23 @@
import os
from io import BytesIO
import logging
import os
import numpy as np
from amodem import send
from amodem import recv
from amodem import common
from amodem import dsp
from amodem import sampling
from amodem import config
config = config.fastest()
import logging
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s %(levelname)-12s %(message)s')
import pytest
from .. import common, config, main, sampling
from . import utils
class Args(object):
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def __getattr__(self, name):
return None
logging.basicConfig(level=logging.DEBUG, # useful for debugging
format='%(asctime)s %(levelname)-12s %(message)s')
def run(size, chan=None, df=0, success=True):
def run(size, chan=None, df=0, success=True, cfg=None):
if cfg is None:
cfg = config.fastest()
tx_data = os.urandom(size)
tx_audio = BytesIO()
send.main(config=config, src=BytesIO(tx_data), dst=tx_audio)
main.send(config=cfg, src=BytesIO(tx_data), dst=tx_audio, gain=0.5)
data = tx_audio.getvalue()
data = common.loads(data)
@@ -42,26 +30,44 @@ def run(size, chan=None, df=0, success=True):
data = common.dumps(data)
rx_audio = BytesIO(data)
rx_data = BytesIO()
d = BytesIO()
result = recv.main(config=config, src=rx_audio, dst=rx_data,
dump_audio=d)
dump = BytesIO()
try:
result = main.recv(config=cfg, src=rx_audio, dst=rx_data,
dump_audio=dump, pylab=None)
finally:
rx_audio.close()
rx_data = rx_data.getvalue()
assert data.startswith(d.getvalue())
assert data.startswith(dump.getvalue())
assert result == success
if success:
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():
run(16, chan=lambda x: -x)
def test_large_drift():
run(1, df=+0.01)
run(1, df=-0.01)
def test_error():
@@ -70,7 +76,7 @@ def test_error():
@pytest.fixture(params=[sign * mag for sign in (+1, -1)
for mag in (0.1, 1, 10, 100, 1e3, 2e3)])
for mag in (0.1, 1, 10, 100, 1e3, 10e3)])
def freq_err(request):
return request.param * 1e-6
@@ -80,11 +86,11 @@ def test_timing(freq_err):
def test_lowpass():
run(1024, chan=lambda x: dsp.lfilter(b=[0.9], a=[1.0, -0.1], x=x))
run(1024, chan=lambda x: utils.lfilter(b=[0.9], a=[1.0, -0.1], x=x))
def test_highpass():
run(1024, chan=lambda x: dsp.lfilter(b=[0.9], a=[1.0, 0.1], x=x))
run(1024, chan=lambda x: utils.lfilter(b=[0.9], a=[1.0, 0.1], x=x))
def test_attenuation():
@@ -103,3 +109,12 @@ def test_medium_noise():
def test_large():
run(54321, chan=lambda x: x)
@pytest.fixture(params=sorted(config.bitrates.keys()))
def rate(request):
return request.param
def test_rate(rate):
run(1, cfg=config.bitrates[rate])

27
amodem/tests/utils.py Normal file
View File

@@ -0,0 +1,27 @@
import numpy as np
class IIR:
def __init__(self, b, a):
self.b = np.array(b) / a[0]
self.a = np.array(a[1:]) / a[0]
self.x_state = [0] * len(self.b)
self.y_state = [0] * (len(self.a) + 1)
def __call__(self, x):
x_, y_ = self.x_state, self.y_state
for v in x:
x_ = [v] + x_[:-1]
y_ = y_[:-1]
num = np.dot(x_, self.b)
den = np.dot(y_, self.a)
y = num - den
y_ = [y] + y_
yield y
self.x_state, self.y_state = x_, y_
def lfilter(b, a, x):
f = IIR(b=b, a=a)
y = list(f(x))
return np.array(y)

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,2 +0,0 @@
numpy
argcomplete

2
scripts/autocalib.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
amodem-cli send -vv -c auto | amodem-cli recv -vv -c auto

2
scripts/play.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
aplay -f S16_LE -c 1 -r 32000 $*

View File

@@ -1,6 +1,16 @@
#!/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
from amodem.config import Configuration
import sys
def spectrogram(t, x, Fs, NFFT=256):
@@ -11,10 +21,8 @@ def spectrogram(t, x, Fs, NFFT=256):
pylab.specgram(x, NFFT=NFFT, Fs=Fs, noverlap=NFFT/2,
cmap=pylab.cm.gist_heat)
if __name__ == '__main__':
import sys
from amodem import common
from amodem.config import Configuration
def main():
config = Configuration()
for fname in sys.argv[1:]:
@@ -25,3 +33,7 @@ if __name__ == '__main__':
spectrogram(t, x, config.Fs)
pylab.show()
if __name__ == '__main__':
main()

15
scripts/profile.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
set -x -u
SRC=`tempfile`
DST=`tempfile`
AUDIO=`tempfile`
dd if=/dev/urandom of=$SRC bs=1kB count=1000
export BITRATE=80
time python -m cProfile -o send.prof amodem-cli send -l- -vv -i $SRC -o $AUDIO 2> send.log
echo -e "sort cumtime\nstats" | python -m pstats send.prof > send.prof.txt
time python -m cProfile -o recv.prof amodem-cli recv -l- -vv -i $AUDIO -o $DST 2> recv.log
echo -e "sort cumtime\nstats" | python -m pstats recv.prof > recv.prof.txt
diff $SRC $DST || echo "ERROR!"
rm $SRC $DST $AUDIO

35
scripts/record.py Executable file
View File

@@ -0,0 +1,35 @@
#!/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
def run(args):
config = Configuration()
with open(args.filename, 'wb') as dst:
interface = audio.Interface(config=config)
with interface.load(args.audio_library):
src = interface.recorder()
size = int(config.sample_size * config.Fs) # one second of audio
while True:
dst.write(src.read(size))
def main():
p = argparse.ArgumentParser()
p.add_argument('-l', '--audio-library', default='libportaudio.so')
p.add_argument('filename')
try:
run(args=p.parse_args())
except KeyboardInterrupt:
return
if __name__ == '__main__':
main()

2
scripts/record.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
arecord -f S16_LE -c 1 -r 32000 $*

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env python
from amodem.sampling import resample
import sys
resample(src=sys.stdin, dst=sys.stdout, df=float(sys.argv[1]))

22
scripts/resample.py Executable file
View File

@@ -0,0 +1,22 @@
#!/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
def main():
p = argparse.ArgumentParser()
p.add_argument('df', type=float)
args = p.parse_args()
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.6",
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'],
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",
],
scripts=['amodem-cli'],
)

View File

@@ -1,80 +0,0 @@
from amodem import calib
from amodem import common
from amodem import config
config = config.fastest()
from io import BytesIO
import numpy as np
import pytest
class ProcessMock(object):
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):
self.buf.write(data)
if self.buf.tell() > 1e6:
raise KeyboardInterrupt
def read(self, n):
return self.buf.read(n)
def test_success():
p = ProcessMock()
calib.send(config, p)
p.buf.seek(0)
calib.recv(config, p)
def test_errors():
class WriteError(ProcessMock):
def write(self, data):
raise IOError()
p = WriteError()
calib.send(config, p)
assert p.buf.tell() == 0
class ReadError(ProcessMock):
def read(self, n):
raise KeyboardInterrupt()
p = ReadError()
calib.recv(config, p, verbose=True)
assert p.buf.tell() == 0
@pytest.fixture(params=[0] + [sign * mag for sign in (+1, -1)
for mag in (0.1, 1, 10, 100, 1e3, 2e3)])
def freq_err(request):
return request.param * 1e-6
def test_drift(freq_err):
freq = config.Fc * (1 + freq_err / 1e6)
t = np.arange(int(1.0 * config.Fs)) * config.Ts
frame_length = 100
rms = 0.5
signal = rms * np.cos(2 * np.pi * freq * t)
src = BytesIO(common.dumps(signal))
iters = 0
for r in calib.detector(config, src, frame_length=frame_length):
assert not r.error
assert abs(r.rms - rms) < 1e-3
assert abs(r.total - rms) < 1e-3
iters += 1
assert iters > 0
assert iters == config.baud / frame_length

10
tox.ini
View File

@@ -1,14 +1,14 @@
[tox]
envlist = py27,py34
envlist = py3
[testenv]
deps=
pytest
mock
pep8
pycodestyle
coverage
pylint
commands=
pep8 amodem/ scripts/ tests/ amodem-cli
pylint --disable no-member --report=no amodem
coverage run --source amodem/ -m py.test 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