Compare commits

..

8 Commits

19 changed files with 272 additions and 1706 deletions

3
scripts/.gitignore vendored
View File

@ -1,2 +1,3 @@
*.wav
__pycache__/
__pycache__/
junk/

63
scripts/README.md Normal file
View File

@ -0,0 +1,63 @@
# Planned algorithm
From selcald:
One possible solution to this problem is to step back from trying to determine the frequencies of the individual tones and to instead verify that:
- There are two tones present
- The difference in frequency between the two tones matches a pair of known tones in the alphabet
The author of selcald seems awfully hung up on trying to ensure correct detection of the silent period
between two tones. I don't think this is worth actively trying to detect, as it is more important
to find two dominant tones for a period of ~1 sec, followed by another 1 sec with two dominant tones.
The silence period is irrelevant - according to the spec of 0.2 +/- 0.1 sec _at the transmitter_ it
could be impossibly short to reliably distinguish between successive `A*-A*` tones at the receiver.
## Terrible pseudocode
```
collect samples from audio source <wavfile, arecord>
low pass & decimate if necessary
<simultaneously>
run wide fft over a large sample window
detect peak bin (with some hysteresis)
if peak bin within tone band:
assume difference is due to doppler shift
gradually adjust expected freq(s) for tone(s) to match
record RMS energy into buffer
<simultaneously>
run correlation over small sample window
For each tone in the alphabet:
Cross correlate the audio with the adjusted tone
Record normalized tone energy into tone buffer
For tone record in tone buffer:
sort tones by amplitude
if:
- the two highest amplitude tones are within 3 dB of each other
- the two highest amplitude tones are at least 3 dB greater than the next nearest tone
- the sum of the two highest tones account for at least 60% of the modulation energy (-2.22 dB)
(as determined relative to stable RMS energy calc'd by wide fft)
(nominally 90% / -0.458 dB)
then:
record dominant two tones in tone buffer
if any two tones are dominant tones for the majority of previous 1 sec:
record dominant two tones in code buffer
reset tone detection counter (prohibit detecting codes for another 0.5 sec)
upon code record in code buffer:
if dominant code previously occured within 1.25 sec
emit SELCAL detection
reset code detection counter (prohibit detecting codes for another 0.5 sec)
```
## Usage
Either
```
./audio-capture.sh | ./live.py
```
or
```
./audio-pipe.sh ./samples/MA-ZC.wav | ./live.py
```

8
scripts/audio-capture.sh Executable file
View File

@ -0,0 +1,8 @@
#! /bin/sh
set -eux
sample_rate="44100"
channels="1"
arecord -t raw -c ${channels} -f S16_LE -r ${sample_rate}

9
scripts/audio-pipe.sh Executable file
View File

@ -0,0 +1,9 @@
#! /bin/sh
set -eux
audio_file=$1
sample_rate="44100"
channels="1"
ffmpeg -hide_banner -loglevel error -i ${audio_file} -f s16le -ac ${channels} -ar ${sample_rate} -

View File

@ -1,20 +1,40 @@
import numpy as np
from scipy.signal import butter, lfilter, decimate
def anti_alias(data, sample_rate, max_frequency):
def anti_alias(data, sample_rate, max_frequency, order=5):
FILTER_HEADROOM = 1.2
nyquist_rate = 2 * max_frequency
downsample_factor = 1
if sample_rate > nyquist_rate:
filtered_data = lowpass_filter(data, max_frequency, sample_rate)
filtered_data = lowpass_filter(
data,
max_frequency * FILTER_HEADROOM,
sample_rate,
order
)
downsample_factor = int(sample_rate / nyquist_rate)
filtered_data = decimate(filtered_data, downsample_factor)
sample_rate = sample_rate // downsample_factor
else:
filtered_data = data
return filtered_data, sample_rate
return filtered_data, sample_rate, downsample_factor
def smoothing_filter(data, window_size=256, mode='same'):
window = np.ones(window_size) / window_size
return np.convolve(data, window, mode=mode)
# Stolen from selcald
def note(freq, length, amp=1.0, rate=44100):
if freq == 0:
data = np.zeros(int(length * rate))
else:
t = np.linspace(0, length, int(length * rate))
data = np.sin(2 * np.pi * freq * t) * amp
return data
# These originally came from https://scipy.github.io/old-wiki/pages/Cookbook/ButterworthBandpass,
# but they've been copied around the internet so many times that ChatGPT now produces them verbatim.

153
scripts/live.py Executable file
View File

@ -0,0 +1,153 @@
#! /usr/bin/env python3
import sys
import threading
import numpy as np
import signal as sig
from tones import TONES
from filters import anti_alias, bandpass_filter, note, smoothing_filter
from scipy import signal
from scipy.signal import butter, lfilter, decimate
import pyqtgraph as pg
from pyqtgraph.Qt import QtGui, QtWidgets, mkQApp
import time
keep_running = True
def signal_handler(sig, frame):
global keep_running
print('SIGINT received. Stopping...')
keep_running = False
class DetectorGui(QtWidgets.QMainWindow):
def __init__(self, *args, **kwargs):
super(DetectorGui, self).__init__(*args, **kwargs)
layout = pg.GraphicsLayoutWidget(show=True)
self.setCentralWidget(layout)
self.setWindowTitle('SELCAL Detector')
self.resize(1280, 800)
self.plot = layout.addPlot()
legend_view = layout.addViewBox()
legend = pg.LegendItem(offset=(0, 0))
legend.setParentItem(legend_view)
color_map = pg.colormap.get('CET-C6s')
colors = color_map.getLookupTable(nPts=len(TONES))
t = np.linspace(0, 500, 100)
self.tone_data = {}
self.tone_lines = {}
self.tone_pens = {}
for tone,color in zip(TONES.keys(), colors):
self.tone_data[tone] = np.zeros(1000, dtype=np.float64) #np.array([], dtype=np.float64) #np.zeros(int(10000), dtype=np.float64)
self.tone_lines[tone] = self.plot.plot(self.tone_data[tone], pen=pg.mkPen(color=color), name=tone)
legend.addItem(self.tone_lines[tone], tone)
self.tone_pens[tone] = pg.mkPen(color=color)
self.plot.setLabel('left', 'Signal Correlation')
self.plot.setLabel('bottom', 'Time (samples)')
self.plot.showGrid(x=True, y=True)
legend_view.setFixedWidth(80)
layout.ci.layout.setColumnFixedWidth(1, 80)
self.show()
def set_position(self, pos):
pass
def push_tone(self, tone, value):
self.tone_data[tone] = np.roll(self.tone_data[tone], -1)
self.tone_data[tone][-1] = value
self.tone_lines[tone].setData(self.tone_data[tone])
def push_tones(self, tone, values):
s = time.perf_counter()
#self.tone_data[tone] = np.append(self.tone_data[tone], values)
a = time.perf_counter()
self.tone_data[tone] = np.roll(self.tone_data[tone], -len(values))
self.tone_data[tone][-len(values):] = values
self.tone_lines[tone].setData(self.tone_data[tone])
#self.tone_lines[tone].setData(values)
g = time.perf_counter()
#print(a-s, g-a)
#self.plot.plot(values, pen=self.tone_pens[tone])
mkQApp("Correlation matrix display")
gui = DetectorGui()
def read_audio_from_stdin(chunk_size, process_chunk):
global keep_running
while keep_running:
# 2 bytes per sample for int16
read_size = chunk_size * 2
data = sys.stdin.buffer.read(read_size)
# Break the loop if no more data is available
if not data:
break
# Convert the binary data to a numpy array of int16
audio_chunk = np.frombuffer(data, dtype=np.int16)
process_chunk(audio_chunk)
sample_rate = 44100
note_length = 0.5
guard_duration = 0.01
def process_audio_chunk(audio_chunk):
global gui
data = audio_chunk
sample_rate = 44100
s = time.perf_counter()
data, sample_rate, decimation = anti_alias(data, sample_rate, 4800, 8)
data = data / (np.max(data) + 0.0001)
pure_signals = {tone:note(freq, note_length, rate=sample_rate) for tone,freq in TONES.items()}
aa = time.perf_counter()
correlations = {tone:smoothing_filter(np.abs(signal.correlate(data, pure, mode='same')), 64) for tone,pure in pure_signals.items()}
corr = time.perf_counter()
guard_samples = int(guard_duration * sample_rate)
for tone,massage in correlations.items():
#gui.push_tones(tone, massage)
gui.push_tone(tone, np.mean(massage))
gui.push_tones(tone, massage[guard_samples:-guard_samples:100])
draw = time.perf_counter()
#print(aa-s, corr-aa, draw-corr)
if draw - s > 0.1:
print("FAILED")
if __name__ == '__main__':
sig.signal(sig.SIGINT, signal_handler)
chunk_duration = 0.1 # seconds
sample_rate = 44100
channels = 1
chunk_size = int(sample_rate * chunk_duration) * channels
reader_thread = threading.Thread(target=read_audio_from_stdin, args=(chunk_size, process_audio_chunk))
reader_thread.daemon = True
reader_thread.start()
pg.exec()
# Wait...
reader_thread.join()

View File

@ -5,85 +5,30 @@ import sys
import numpy as np
from scipy import signal
from scipy.io import wavfile
from scipy.signal import butter, lfilter
tones = {}
with open('tones.csv', newline='') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
tones[row['designator']] = float(row['frequency'])
'''
def freq_of_key(midi_key):
return 440.0 * (2 ** ((midi_key - 69)/12))
tones = {}
for c in range(65, 90):
tones[c] = freq_of_key(c)
'''
# Shamelessly lifted from
# https://scipy.github.io/old-wiki/pages/Cookbook/ButterworthBandpass
def butter_bandpass(lowcut, highcut, fs, order=5):
nyq = 0.5 * fs
low = lowcut / nyq
high = highcut / nyq
b, a = butter(order, [low, high], btype='band')
return b, a
def butter_bandpass_filter(data, lowcut, highcut, fs, order=5):
b, a = butter_bandpass(lowcut, highcut, fs, order=order)
y = lfilter(b, a, data)
return y
# tone synthesis
def note(freq, cycles, amp=32767.0, rate=44100):
len = cycles * (1.0/rate)
t = np.linspace(0, len, int(len * rate))
if freq == 0:
data = np.zeros(int(len * rate))
else:
data = np.sin(2 * np.pi * freq * t) * amp
return data.astype(int)
def decimate_from_sample_rate(sample_rate):
if sample_rate == 44100:
return 4 # rate = 11025, Fmax = 5512.5 Hz
elif sample_rate == 48000:
return 5 # rate = 9600, Fmax = 4800 Hz
elif sample_rate == 22050:
return 2 # rate = 11025, Fmax = 5512.5 Hz
elif sample_rate == 11025:
return 1 # rate = 11025, Fmax = 5512.5 Hz
else:
raise ValueError("Sample rate not supported")
from tones import TONES
from filters import bandpass_filter, note, smoothing_filter, anti_alias
pure_sample_length = 0.1
if __name__ == '__main__':
# TODO JMT: What is this?
FLT_LEN = 2000
file_name = sys.argv[1]
sample_rate, data = wavfile.read(file_name)
print(f"{file_name}: {len(data)} samples @ {sample_rate} Hz")
decimate = decimate_from_sample_rate(sample_rate)
if decimate > 1:
data = signal.decimate(data, decimate)
sample_rate = sample_rate / decimate
if len(data.shape) == 2:
data = data.mean(axis=1)
print(f'Length after decimation: {len(data)} samples')
# Coarse normalisation
data = data / np.max(data)
data = butter_bandpass_filter(data, 270, 1700, sample_rate, order=8)
# TODO JMT: Find out why the correlation step fails when max frequency <= 2 * nyquist rate
data, sample_rate, decimation = anti_alias(data, sample_rate, 4800)
print(f'Length after decimation: {len(data)} samples (/{decimation}, {sample_rate} Hz)')
pure_signals = {tone:note(freq, FLT_LEN, rate=sample_rate) for tone,freq in tones.items()}
pure_signals = {tone:note(freq, pure_sample_length, rate=sample_rate) for tone,freq in TONES.items()}
correlations = {tone:np.abs(signal.correlate(data, pure, mode='same')) for tone,pure in pure_signals.items()}
massaged = {tone:smoothing_filter(correlation) for tone,correlation in correlations.items()}
N = FLT_LEN // 8 # Rolling average length
cumsum_convolution = np.ones(N)/N
massaged = {tone:np.convolve(correlation, cumsum_convolution, mode='valid') for tone,correlation in correlations.items()}
# Only import if we're actually plotting, these imports are pretty heavy.
import pyqtgraph as pg
@ -100,7 +45,7 @@ if __name__ == '__main__':
legend.setParentItem(legend_view)
color_map = pg.colormap.get('CET-C6s')
colors = color_map.getLookupTable(nPts=len(tones))
colors = color_map.getLookupTable(nPts=len(TONES))
for (tone, correlation), color in zip(massaged.items(), colors):
line = plot.plot(correlation, pen=pg.mkPen(color=color), fillLevel=0.1, name=tone)

View File

@ -1,339 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{description}
Copyright (C) {year} {fullname}
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
{signature of Ty Coon}, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@ -1,308 +0,0 @@
selcald
=======
Selcal decoder daemon
---------------------
![Cross-correlation Waterfall](doc/fsek.png "Cross-correlation waterfall diagram for SELCAL FSEK")
A Linux/BSD daemon that monitors an audio stream and looks for selcal
(Selective Calling; see <https://en.wikipedia.org/wiki/SELCAL>) calls and
emits a timestamp, followed by the selcal code received. The daemon is
intended to be as simple and lightweight as possible, and should rely
on existing frameworks such as fftw where possible.
[Selective Calling (SELCAL)][1]
--------------------------
SELCAL is a technique that allows a ground radio operator to alert an
aircrew that the operator wishes to communicate with that aircraft.
Because of the background noise level experienced on HF radio frequencies,
aircrews usually prefer to turn down the audio level of their HF receiver
until alerted via SELCAL of a message specifically intended for their
aircraft. When the ground station operator wishes to communicate with an
aircraft, he enters into the SELCAL encoder the 4-letter code of that aircraft,
which is usually included in its flight plan, and transmits that code over the
assigned radio channel. All aircraft monitoring that channel receive the
SELCAL broadcast, but only those (preferably only one) that have been
programmed with that 4-letter code will respond by sounding a chime or
otherwise alerting the crew. The crew will then set their volume control
higher to listen to the voice traffic and, using ICAO recommended radio
procedures, assure that the message is intended for them.
[Selcal Specification][2]
--------------------
The official specification for the selcal system is found in
"ARINC Characteristic 714-6-1990", published on August 15, 1990. The key
attributes of selcal codes are as follows:
### General
Selective calling is accomplished by the coder of the ground transmitter
sending coded tone pulses to the aircraft receiver and decoder. Each
transmitted code is made up of two consecutive tone pulses, with each pulse
containing two simultaneously-transmitted tones.
### Transmitted Code
When the ground operator desires to call a particular aircraft, he depresses
the buttons corresponding to the code assigned to that aircraft. The coder
then keys the transmitter on the air causing to be transmitted two
consecutive tone pulses of 1.0 +/- 0.25 sec. duration, separated by an
interval of 0.2 +/- 0.1 sec. which makes up the code. Each tone pulse
consists of two simultaneously-transmitted tones. The call should consist
of one transmitted code without repetition.
### Stability
The frequency of transmitted codes should be held to +/- 0.15% tolerance to
insure proper operation of the airborne decoder.
**NOTE:** The specification does not indicate the required frequency accuracy of
the receiver. Given that [research][3] seems to [show][4] that doppler spreads of
5-20 Hz over polar paths are possible, it seems that as a practical matter,
the receiver frequency tolerances have to be more relaxed than the transmitter
frequency tolerances. In addition, practical experience has shown that various
ground stations do not appear to follow the +/- 0.15% tolerance regardless. An
initial estimate of a receiver tolerance of 2-2.5% for the tones should be
sufficient to mitigate transmitter, receiver, and sound card frequency errors.
This issue more or less disappears if the source of audio is an AM receiver,
rather than a SSB one, since the SELCALs are sent as SC-USB, they can be
demodulated with an AM receiver with no frequency error, other than the transmitter
and ionospheric contributions. Based on a cursory analysis of the live
data available, it seems that it is quite easy to have a combination of
receiver frequency and soundcard clock errors that sum to 50 Hz or more.
This easily puts the tones out of any reasonable detection band.
### Distortion
Overall audio distortion present on the transmitted RF signal should not
exceed 15%.
### Percent Modulation
The RF signals transmitted by the ground radio station should contain within
3 dB of equal amounts of the two modulating tones. The combination of tones
should result in a modulation envelope having a nominal modulation percentage
of 90% and in no case less than 60%.
### Transmitted Tones
Tone codes are made up of various combinations of the following tones and
are designated by letter as indicated:
Note: The tones are spaced by 10^(0.045)-1 (approximately 10.9%)
| Designation | Nominal Frequency (Hz) | Minimum | Maximum | Width |
| ----------- | ---------------------- | -------- | -------- | ----- |
| A | 312.60 | 312.13 | 313.07 | 0.94 |
| B | 346.70 | 346.18 | 347.22 | 1.04 |
| C | 384.60 | 384.02 | 385.18 | 1.15 |
| D | 426.60 | 425.96 | 427.24 | 1.28 |
| E | 473.20 | 472.49 | 473.91 | 1.42 |
| F | 524.80 | 524.01 | 525.59 | 1.57 |
| G | 582.10 | 581.23 | 582.97 | 1.75 |
| H | 645.70 | 644.73 | 646.67 | 1.94 |
| J | 716.10 | 715.03 | 717.17 | 2.15 |
| K | 794.30 | 793.11 | 795.49 | 2.38 |
| L | 881.00 | 879.68 | 882.32 | 2.64 |
| M | 977.20 | 975.73 | 978.67 | 2.93 |
| P | 1,083.90 | 1,082.27 | 1,085.53 | 3.25 |
| Q | 1,202.30 | 1,200.50 | 1,204.10 | 3.61 |
| R | 1,333.50 | 1,331.50 | 1,335.50 | 4.00 |
| S | 1,479.10 | 1,476.88 | 1,481.32 | 4.44 |
### Received Tones
| Designation | Nominal Frequency | Low | High | Width | Guard |
| ----------- | ----------------- | --- | ---- | ----- | ----- |
| A | 312.60 | 306.35 | 318.85 | 12.50 | 20.91 |
| B | 346.70 | 339.77 | 353.63 | 13.87 | 23.27 |
| C | 384.60 | 376.91 | 392.29 | 15.38 | 25.78 |
| D | 426.60 | 418.07 | 435.13 | 17.06 | 28.60 |
| E | 473.20 | 463.74 | 482.66 | 18.93 | 31.64 |
| F | 524.80 | 514.30 | 535.30 | 20.99 | 35.16 |
| G | 582.10 | 570.46 | 593.74 | 23.28 | 39.04 |
| H | 645.70 | 632.79 | 658.61 | 25.83 | 43.16 |
| J | 716.10 | 701.78 | 730.42 | 28.64 | 47.99 |
| K | 794.30 | 778.41 | 810.19 | 31.77 | 53.19 |
| L | 881.00 | 863.38 | 898.62 | 35.24 | 59.04 |
| M | 977.20 | 957.66 | 996.74 | 39.09 | 65.48 |
| P | 1,083.90 | 1,062.22 | 1,105.58 | 43.36 | 72.68 |
| Q | 1,202.30 | 1,178.25 | 1,226.35 | 48.09 | 80.48 |
| R | 1,333.50 | 1,306.83 | 1,360.17 | 53.34 | 89.35 |
| S | 1,479.10 | 1,449.52 | 1,508.68 | 59.16 | -- |
### Table of Tone Frequencies and Derivation of the Frequencies
fN = 10^((N-1) x 0.045 + 2). For the first tone, N=12, second N=13, etc.
| Designation | Log Frequency | Frequency (Hz) |
| ----------- | ------------- | -------------- |
| A | 2.495 | 312.6 |
| B | 2.540 | 346.7 |
| C | 2.585 | 384.6 |
| D | 2.630 | 426.6 |
| E | 2.675 | 473.2 |
| F | 2.720 | 524.8 |
| G | 2.765 | 582.1 |
| H | 2.810 | 645.7 |
| J | 2.855 | 716.1 |
| K | 2.900 | 794.3 |
| L | 2.945 | 881.0 |
| M | 2.990 | 977.2 |
| P | 3.035 | 1083.9 |
| Q | 3.080 | 1202.3 |
| R | 3.125 | 1333.5 |
| S | 3.170 | 1479.1 |
Challenges for SSB Receivers
----------------------------
It is important to note that "real" selcal receivers use AM demodulators to
receive the suppressed carrier (SC) transmissions from the ground stations, and
so for them, the audio tones received will be effectively the same as those
transmitted by the ground stations. The tones are then decoded and used to
either signal the user of the call, or to automatically unmute a separate SSB
receiver.
For hobbyist uses, there is typically only one receiver, and it is a SSB
receiver. This adds additional errors to the received tones, based on offsets
in the tuned frequency and the carrier injection (BFO) frequency. One possible
solution to this problem is to step back from trying to determine the
frequencies of the individual tones and to instead verify that:
1. There are two tones present
2. The difference in frequency between the two tones matches a pair of known
tones in the alphabet
### Table of Tone Frequency Differences
| Tone | Frequency | A | B | C | D | E | F | G | H | J | K | L | M | P | Q | R | S |
| --------- | --------- | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ | ------ |
| | | 312.6 | 346.7 | 384.6 | 426.6 | 473.2 | 524.8 | 582.1 | 645.7 | 716.1 | 794.3 | 881.0 | 977.2 | 1083.9 | 1202.3 | 1333.5 | 1479.1 |
| A | 312.6 | 0.0 | | | | | | | | | | | | | | | |
| B | 346.7 | 34.1 | 0.0 | | | | | | | | | | | | | | |
| C | 384.6 | 72.0 | 37.9 | 0.0 | | | | | | | | | | | | | |
| D | 426.6 | 114.0 | 79.9 | 42.0 | 0.0 | | | | | | | | | | | | |
| E | 473.2 | 160.6 | 126.5 | 88.6 | 46.6 | 0.0 | | | | | | | | | | | |
| F | 524.8 | 212.2 | 178.1 | 140.2 | 98.2 | 51.6 | 0.0 | | | | | | | | | | |
| G | 582.1 | 269.5 | 235.4 | 197.5 | 155.5 | 108.9 | 57.3 | 0.0 | | | | | | | | | |
| H | 645.7 | 333.1 | 299.0 | 261.1 | 219.1 | 172.5 | 120.9 | 63.6 | 0.0 | | | | | | | | |
| J | 716.1 | 403.5 | 369.4 | 331.5 | 289.5 | 242.9 | 191.3 | 134.0 | 70.4 | 0.0 | | | | | | | |
| K | 794.3 | 481.7 | 447.6 | 409.7 | 367.7 | 321.1 | 269.5 | 212.2 | 148.6 | 78.2 | 0.0 | | | | | | |
| L | 881.0 | 568.4 | 534.3 | 496.4 | 454.4 | 407.8 | 356.2 | 298.9 | 235.3 | 164.9 | 86.7 | 0.0 | | | | | |
| M | 977.2 | 664.6 | 630.5 | 592.6 | 550.6 | 504.0 | 452.4 | 395.1 | 331.5 | 261.1 | 182.9 | 96.2 | 0.0 | | | | |
| P | 1083.9 | 771.3 | 737.2 | 699.3 | 657.3 | 610.7 | 559.1 | 501.8 | 438.2 | 367.8 | 289.6 | 202.9 | 106.7 | 0.0 | | | |
| Q | 1202.3 | 889.7 | 855.6 | 817.7 | 775.7 | 729.1 | 677.5 | 620.2 | 556.6 | 486.2 | 408.0 | 321.3 | 225.1 | 118.4 | 0.0 | | |
| R | 1333.5 | 1020.9 | 986.8 | 948.9 | 906.9 | 860.3 | 808.7 | 751.4 | 687.8 | 617.4 | 539.2 | 452.5 | 356.3 | 249.6 | 131.2 | 0.0 | |
| S | 1479.1 | 1166.5 | 1132.4 | 1094.5 | 1052.5 | 1005.9 | 954.3 | 897.0 | 833.4 | 763.0 | 684.8 | 598.1 | 501.9 | 395.2 | 276.8 | 145.6 | 0.0 |
Signal Processing
-----------------
Detection of the selcal tones is quite similar to DTMF tone detection, and
this has been well documented. There are several approaches available:
1. Bandpass filter bank and energy detectors (i.e. analog implementation approach)
2. Discrete FFT and energy detection in bins containing selcal tones
3. Goertzl algorithm for fast DFT (see <https://en.wikipedia.org/wiki/Goertzel_algorithm>)
4. Chirp-Z transform for DFT (see <https://en.wikipedia.org/wiki/Bluestein%27s_FFT_algorithm>)
5. MUSIC algorithm (see <https://en.wikipedia.org/wiki/Multiple_signal_classification>)
6. ESPRIT algorithm (see <https://en.wikipedia.org/wiki/Estimation_of_signal_parameters_via_rotational_invariance_techniques>)
7. Wavelet transform and convolution (seems highly advanced)
8. Q Transform (handles geometric spacing of tones/bins much better)
The implementation should take into account characteristics of the HF radio medium:
* Often poor signal/noise ratio
* Frequent ionospheric and auroral fading and flutter
* Slight doppler due to relative ionospheric motion
These imply that unlike DTMF decoder implementations, a series of measurements should
be made during the signal and a final decision determined from statistical analysis
of the raw measurements. Based on the highest baseband frequency of approximately
1500 Hz, this means that any audio sampling rate above 4000 samples/second should work
fine. Looking at the post-detection stage, we can see that the gap between the two tone
groups is specified as 200 mS +/- 100 mS, so in the worst case, the gap could be 100 mS.
Following Nyquist and sampling theory, this means that in that 100 mS period, we need to
check for the presence or absence of signal at least twice, or once every 50 mS. The actual
numbers will vary based on audio sampling rates, but for the typical 44100 samples/second
rate of modern sound cards, this leads us to use a block size of 2048 (using a nice round
binary number) and a post-processing sample rate of 2048/44100 or 46.4 mS. This also means
that we need to window this sample size appropriately and choose an algorithm for tone
detection that is "comfortable" with this block size.
After considerable trolling through the Internet, and discarding many approaches
(see above) that are oriented toward detecting _unknown_ tones, it seems that
theoretically, the ideal approach is to use [cross-correlation][5] of the input signal with
_known_ signals to detect their presence, otherwise known as [matched filtering][6].
The figure below shows the cross-correlation between the received SELCAL "JRAE" and each
of the 16 tones in the alphabet. The lines are ordered from tone A on the top to tone
S on the bottom, and 2.5 to 3.0 seconds of time run from left to right.
![16 Channel matched filter](doc/16channel.png "Receving SELCAL JRAE on 16 channel matched filter receiver")
Some care must be taken in selecting the block size, as it is a tradeoff between
integrating more signal power to improve cross-correlation detection, and added
difficulty in detecting the silent period between the tone groups. As mentioned
earlier, presumably for direct detection of the shortest possible silent period,
blocks should be less than 50 mS, but this would result in requiring detection
based on only 15 cycles (312.6 Hz * 0.05 sec) for the lowest frequency tone.
![Cross-correlation demonstration](doc/cross-correlation.png "Demonstration of cross-correlation")
The normal source of sampled audio is the soundcard interface.
After some digging, it seems that PortAudio would be a good choice for an audio interface
API, since it provides both a degree of platform independence and isolates the application
from the various underlying audio frameworks (i.e. ALSA, Pulseaudio, SndKit, etc.).
In general, lower sampling rates are preferred due to the reduced processing load,
as are fixed point DSP implementations versus floating point implementations.
### Pseudocode
Clear the result buffer
Clear the tones
Set state to idle
Set the threshold to 50%
While true
Capture one block of audio (~100 mS)
Decimate it by 10
For each tone in the alphabet
Cross correlate the audio with the tone
If the correlation is greater than the threshold
Save the result in the buffer
Else
Add the correlation to the threshold and average
If there are > 800 mS of results in the buffer
If the last result was silence (no tones)
For each tone in the alphabet
If there is a majority of results in the buffer
Then the current tone is detected
If two tones are detected
If the state is idle
Set the state to two tones
Save the two tones
If the state is two tones
Return the four tones
Clear the result buffer
Clear the tones
Set the state to idle
References
----------
[^1]: http://www.asri.aero/our-services/selcal/ "Aviation Spectrum Resources Inc. website, retrieved 3, Nov 2013"
[^2]: http://store.aviation-ia.com/cf/store/catalog_detail.cfm?item_id=126 "ARINC Characteristic 714-6-1990, chapter 2; August 15, 1990"
[^3]: https://lra.le.ac.uk/bitstream/2381/7403/3/propagation%20of%20HF%20radio%20waves.pdf "Propagation of HF radio waves over northerly paths: measurements, simulation and systems aspects, Warrington et al"
[^4]: http://onlinelibrary.wiley.com/doi/10.1002/2013RS005264/full "Observations of Doppler and delay spreads on HF signals received over polar cap and trough paths at various stages of the solar cycle, Stocker, A. J., E. M. Warrington, and D. R. Siddle (2013), Radio Sci., 48, 638645, doi:10.1002/2013RS005264"
[^5]: https://en.wikipedia.org/wiki/Autocorrelation "Autocorrelation"
[^6]: https://en.wikipedia.org/wiki/Matched_filter "Matched filter"

View File

@ -1,49 +0,0 @@
from __future__ import print_function
class Alphabet:
TONES = dict({'Alpha': 312.6,
'Bravo': 346.7,
'Charlie': 384.6,
'Delta': 426.6,
'Echo': 473.2,
'Foxtrot': 524.8,
'Golf': 582.1,
'Hotel': 645.7,
'Juliette': 716.1,
'Kilo': 794.3,
'Lima': 881.0,
'Mike': 977.2,
'Papa': 1083.9,
'Quebec': 1202.3,
'Romeo': 1333.5,
'Sierra': 1479.1, })
def frequency(self, name='Alpha'):
try:
return Alphabet.TONES[name]
except KeyError:
for tone in Alphabet.TONES:
if str(tone[0]).upper() == str(name).upper():
return Alphabet.TONES[tone]
return None
def tone(self, frequency=TONES['Alpha'], accuracy=0.02):
for tone in Alphabet.TONES:
if abs((frequency / Alphabet.TONES[tone]) - 1) <= accuracy:
return tone
return None
if __name__ == "__main__":
x = Alphabet()
print(x.frequency())
print(x.frequency('Sierra'))
print(x.frequency('Foobar'))
print(x.frequency('H'))
print(x.tone(312.6))
print(x.tone(1479.1))
print(x.tone(500.0))
print(x.tone(500.0, 0.05))
print(x.tone(714.7))

View File

@ -1,251 +0,0 @@
# Run with "ipython -i --matplotlib=qt analyze.py <file>.wav"
#
from __future__ import print_function
import sys
import numpy as np
# import pandas as pd
from scipy import signal
import matplotlib.pyplot as plt
from scipy.io.wavfile import read
from scipy.signal import butter, lfilter
from math import log10
FRAME = 0.1 # Frame time in seconds
Alpha = 312.6
Bravo = 346.7
Charlie = 384.6
Delta = 426.6
Echo = 473.2
Foxtrot = 524.8
Golf = 582.1
Hotel = 645.7
Juliette = 716.1
Kilo = 794.3
Lima = 881.0
Mike = 977.2
Papa = 1083.9
Quebec = 1202.3
Romeo = 1333.5
Sierra = 1479.1
FLT_LEN = 2000 # Samples
# Shamelessly lifted from
# https://scipy.github.io/old-wiki/pages/Cookbook/ButterworthBandpass
def butter_bandpass(lowcut, highcut, fs, order=5):
nyq = 0.5 * fs
low = lowcut / nyq
high = highcut / nyq
b, a = butter(order, [low, high], btype='band')
return b, a
def butter_bandpass_filter(data, lowcut, highcut, fs, order=5):
b, a = butter_bandpass(lowcut, highcut, fs, order=order)
y = lfilter(b, a, data)
return y
# tone synthesis
def note(freq, cycles, amp=32767.0, rate=44100):
len = cycles * (1.0/rate)
t = np.linspace(0, len, int(len * rate))
if freq == 0:
data = np.zeros(int(len * rate))
else:
data = np.sin(2 * np.pi * freq * t) * amp
return data.astype(int)
# analyze wav file
def analyze(file_name):
try:
sig_rate, sig_noise = read(file_name)
except Exception:
print('Error opening {}'.format(file_name))
return
print('file: ', file_name, ' rate: ', sig_rate, ' len: ', len(sig_noise))
if sig_rate == 44100:
decimate = 4 # rate = 11025, Fmax = 5512.5 Hz
elif sig_rate == 48000:
decimate = 5 # rate = 9600, Fmax = 4800 Hz
elif sig_rate == 22050:
decimate = 2 # rate = 11025, Fmax = 5512.5 Hz
elif sig_rate == 11025:
decimate = 1 # rate = 11025, Fmax = 5512.5 Hz
else:
print('Sample rate {} not supported.'.format(sig_rate))
return
if decimate > 1:
sig_noise = signal.decimate(sig_noise, decimate)
sig_rate = sig_rate / decimate
print('length after decimation: ', len(sig_noise))
sig_noise = butter_bandpass_filter(sig_noise, 270, 1700, sig_rate, order=8)
sigA = note(Alpha, FLT_LEN, rate=sig_rate)
sigB = note(Bravo, FLT_LEN, rate=sig_rate)
sigC = note(Charlie, FLT_LEN, rate=sig_rate)
sigD = note(Delta, FLT_LEN, rate=sig_rate)
sigE = note(Echo, FLT_LEN, rate=sig_rate)
sigF = note(Foxtrot, FLT_LEN, rate=sig_rate)
sigG = note(Golf, FLT_LEN, rate=sig_rate)
sigH = note(Hotel, FLT_LEN, rate=sig_rate)
sigJ = note(Juliette, FLT_LEN, rate=sig_rate)
sigK = note(Kilo, FLT_LEN, rate=sig_rate)
sigL = note(Lima, FLT_LEN, rate=sig_rate)
sigM = note(Mike, FLT_LEN, rate=sig_rate)
sigP = note(Papa, FLT_LEN, rate=sig_rate)
sigQ = note(Quebec, FLT_LEN, rate=sig_rate)
sigR = note(Romeo, FLT_LEN, rate=sig_rate)
sigS = note(Sierra, FLT_LEN, rate=sig_rate)
corrA = np.abs(signal.correlate(sig_noise, sigA, mode='same'))
print('A: {}'.format(log10(corrA.sum())))
corrB = np.abs(signal.correlate(sig_noise, sigB, mode='same'))
print('B: {}'.format(log10(corrB.sum())))
corrC = np.abs(signal.correlate(sig_noise, sigC, mode='same'))
print('C: {}'.format(log10(corrC.sum())))
corrD = np.abs(signal.correlate(sig_noise, sigD, mode='same'))
print('D: {}'.format(log10(corrD.sum())))
corrE = np.abs(signal.correlate(sig_noise, sigE, mode='same'))
print('E: {}'.format(log10(corrE.sum())))
corrF = np.abs(signal.correlate(sig_noise, sigF, mode='same'))
print('F: {}'.format(log10(corrF.sum())))
corrG = np.abs(signal.correlate(sig_noise, sigG, mode='same'))
print('G: {}'.format(log10(corrG.sum())))
corrH = np.abs(signal.correlate(sig_noise, sigH, mode='same'))
print('H: {}'.format(log10(corrH.sum())))
corrJ = np.abs(signal.correlate(sig_noise, sigJ, mode='same'))
print('J: {}'.format(log10(corrJ.sum())))
corrK = np.abs(signal.correlate(sig_noise, sigK, mode='same'))
print('K: {}'.format(log10(corrK.sum())))
corrL = np.abs(signal.correlate(sig_noise, sigL, mode='same'))
print('L: {}'.format(log10(corrL.sum())))
corrM = np.abs(signal.correlate(sig_noise, sigM, mode='same'))
print('M: {}'.format(log10(corrM.sum())))
corrP = np.abs(signal.correlate(sig_noise, sigP, mode='same'))
print('P: {}'.format(log10(corrP.sum())))
corrQ = np.abs(signal.correlate(sig_noise, sigQ, mode='same'))
print('Q: {}'.format(log10(corrQ.sum())))
corrR = np.abs(signal.correlate(sig_noise, sigR, mode='same'))
print('R: {}'.format(log10(corrR.sum())))
corrS = np.abs(signal.correlate(sig_noise, sigS, mode='same'))
print('S: {}'.format(log10(corrS.sum())))
fig, (ax_A, ax_B, ax_C, ax_D, ax_E, ax_F, ax_G, ax_H, ax_J, ax_K,
ax_L, ax_M, ax_P, ax_Q, ax_R, ax_S) = plt.subplots(16, 1,
sharex=True,
sharey=True)
# ax_sig.plot(sig_noise)
# ax_sig.set_title('Signal with noise')
# ax_sig.axis('off')
# ax_sig.margins(0, 0.1)
ax_A.plot(corrA)
ax_A.axhline(np.average(corrA), ls=':')
# ax_A.set_title(label='Alpha')
ax_A.axis('off')
ax_B.plot(corrB)
ax_B.axhline(np.average(corrB), ls=':')
# ax_B.set_title(label='Bravo')
ax_B.axis('off')
ax_C.plot(corrC)
ax_C.axhline(np.average(corrC), ls=':')
# ax_C.set_title(label='Charlie')
ax_C.axis('off')
ax_D.plot(corrD)
ax_D.axhline(np.average(corrD), ls=':')
# ax_D.set_title(label='Delta')
ax_D.axis('off')
ax_E.plot(corrE)
ax_E.axhline(np.average(corrE), ls=':')
# ax_E.set_title(label='Echo')
ax_E.axis('off')
ax_F.plot(corrF)
ax_F.axhline(np.average(corrF), ls=':')
# ax_F.set_title(label='Foxtrot')
ax_F.axis('off')
ax_G.plot(corrG)
ax_G.axhline(np.average(corrG), ls=':')
# ax_G.set_title(label='Golf')
ax_G.axis('off')
ax_H.plot(corrH)
ax_H.axhline(np.average(corrH), ls=':')
# ax_H.set_title(label='Hotel')
ax_H.axis('off')
ax_J.plot(corrJ)
ax_J.axhline(np.average(corrJ), ls=':')
# ax_J.set_title(label='Juliette')
ax_J.axis('off')
ax_K.plot(corrK)
ax_K.axhline(np.average(corrK), ls=':')
# ax_K.set_title(label='Kilo')
ax_K.axis('off')
ax_L.plot(corrL)
ax_L.axhline(np.average(corrL), ls=':')
# ax_L.set_title(label='Lima')
ax_L.axis('off')
ax_M.plot(corrM)
ax_M.axhline(np.average(corrM), ls=':')
# ax_M.set_title(label='Mike')
ax_M.axis('off')
ax_P.plot(corrP)
ax_P.axhline(np.average(corrP), ls=':')
# ax_P.set_title(label='Papa')
ax_P.axis('off')
ax_Q.plot(corrQ)
ax_Q.axhline(np.average(corrQ), ls=':')
# ax_Q.set_title(label='Quebec')
ax_Q.axis('off')
ax_R.plot(corrR)
ax_R.axhline(np.average(corrR), ls=':')
# ax_R.set_title(label='Romeo')
ax_R.axis('off')
ax_S.plot(corrS)
ax_S.axhline(np.average(corrS), ls=':')
# ax_S.set_title(label='Sierra')
ax_S.axis('off')
fig.set_tight_layout(True)
plt.show()
if __name__ == "__main__":
analyze(sys.argv[1])

View File

@ -1,187 +0,0 @@
'''
Calculate the optimum bin sizes to fit the selcal tone assignments
'''
from __future__ import print_function
import bidict
import math
class Tones(object):
"""
The selcal tone definitions
"""
RED = bidict.bidict(
# Designation: Frequency (Hz)
Alpha=312.6,
Bravo=346.7,
Charlie=384.6,
Delta=426.6,
Echo=473.2,
Foxtrot=524.8,
Golf=582.1,
Hotel=645.7,
Juliet=716.1,
Kilo=794.3,
Lima=881.0,
Mike=977.2,
Papa=1083.9,
Quebec=1202.3,
Romeo=1333.5,
Sierra=1479.1,)
def freq(self, desig):
"""
:return: The frequency assigned to the given designator
"""
return Tones.RED[desig]
def tone(self, freq):
"""
:return: The designator for the given frequency
"""
try:
return Tones.RED[:freq]
except Exception:
pass
class SelcalParams:
"""
Contains the specifications and parameters for the
set of 16 selcal tones. The specified frequencies,
tolerances, and associated DFT bins are attributes.
"""
def __init__(self, samplerate):
self.samplerate = samplerate * 1.0
self.selcal_tones = [312.6, 346.7, 384.6, 426.6,
473.2, 524.8, 582.1, 645.7,
716.1, 794.3, 881.0, 977.2,
1083.9, 1202.3, 1333.5, 1479.1, ]
self.log_tone_step = 0.045
self.log_tone_tolerance = 0.0015
self.tone_lower = {}
self.tone_upper = {}
self.tone_step = {}
self.tone_window = {}
self.bin_center = {}
self.bin_error = {}
for tone in range(len(self.selcal_tones)):
self.tone_lower[tone] = \
self.selcal_tones[tone] * (1 - self.log_tone_tolerance)
self.tone_upper[tone] = \
self.selcal_tones[tone] * (1 + self.log_tone_tolerance)
self.tone_window[tone] = \
self.tone_upper[tone] - self.tone_lower[tone]
for tone in range(12, 28):
index = tone - 12
lower = math.pow(10, (2.0 + ((tone - 1) * self.log_tone_step)))
upper = math.pow(10, (2.0 + (tone * self.log_tone_step)))
self.tone_step[index] = (upper - lower)
def calc_bin_error(self, binsize):
"""
For a given sample block size, calculate the freqency error between
the derived DFT frequency bins and the selcal tones. Return an
arbitrary score for the net frequency error across all of the selcal
tones. Currently, this is set to a log10 value, so more negative
scores are better.
"""
self.samplesize = binsize
self.binsize = (self.samplerate / self.samplesize)
self.bintime = (self.samplesize / self.samplerate)
avg_err = 0.0
for tone in range(len(self.selcal_tones)):
self.bin_center[tone] = \
((1.0 * int(self.selcal_tones[tone] / self.binsize)) + 0.5) \
* self.binsize
bin_err = abs(self.selcal_tones[tone] - self.bin_center[tone])
self.bin_error[tone] = bin_err
if bin_err > 0:
avg_err = avg_err + math.log10(bin_err)
return(avg_err)
def print_bin_error(self, samplerate, samplesize):
"""
Given a sample rate and size, print out the frequency error for
each DFT bin versus the selcal tone frequency.
"""
binsize = (samplerate / samplesize)
print("=== Rate: ",
samplerate,
" size : ",
samplesize,
" time: ",
samplesize * 1.0 / samplerate,
" ===")
for tone in range(len(self.selcal_tones)):
self.bin_center[tone] = \
((1.0 * int(self.selcal_tones[tone] / binsize)) + 0.5) \
* binsize
bin_err = self.selcal_tones[tone] - self.bin_center[tone]
print("Tone: ",
tone,
" freq: ",
self.selcal_tones[tone],
" bin: ",
self.bin_center[tone],
" err: ",
bin_err,
" tol: ",
self.selcal_tones[tone] * self.log_tone_tolerance)
print()
def search_err(self, upper_bound):
"""
Search sample sizes starting from a given upper bound to a
set lower bound, recursively. Return when the upper bound
equals the lower bound.
"""
lower_bound = int(self.samplerate / 40)
if upper_bound > lower_bound:
min_err = 10000.0
min_size = upper_bound
for size in range(lower_bound, upper_bound):
err = params.calc_bin_error(size)
if err < min_err:
min_err = err
min_size = size
print("rate: ",
rate,
" size ",
min_size,
" time ",
(min_size * 1.0 / rate),
" error ",
min_err)
self.search_err(min_size)
if __name__ == '__main__':
_samplerates = [44100, 32000, 22050, 16000, 11025, 8000, 4000]
for rate in _samplerates:
params = SelcalParams(rate)
upper_bound = int(rate / 9)
params.search_err(upper_bound)
print()
# rate: 44100 size 4580 time 0.103854875283 error -3.60366543025
params.print_bin_error(44100, 4580)
# rate: 32000 size 3553 time 0.11103125 error -3.36174781256
params.print_bin_error(32000, 3553)
# rate: 22050 size 2290 time 0.103854875283 error -3.60366543025
params.print_bin_error(22050, 2290)
# rate: 16000 size 1662 time 0.103875 error -2.49558719312
params.print_bin_error(16000, 1662)
# rate: 11025 size 1145 time 0.103854875283 error -3.60366543025
params.print_bin_error(11025, 1145)
# rate: 8000 size 831 time 0.103875 error -2.49558719312
params.print_bin_error(8000, 831)
# rate: 4000 size 361 time 0.09025 error -1.53481794027
params.print_bin_error(4000, 361)

View File

@ -1,84 +0,0 @@
# Run with "ipython -i --matplotlib=qt correlate.py"
#
from __future__ import print_function
import numpy as np
import pandas as pd
from scipy import signal
import matplotlib.pyplot as plt
from scipy.io.wavfile import write
RATE = 44100
Alpha = 312.6
# See http://www.dsprelated.com/showarticle/908.php
def voss(nrows, ncols=16):
"""Generates pink noise using the Voss-McCartney algorithm.
nrows: number of values to generate
rcols: number of random sources to add
returns: NumPy array
"""
array = np.empty((nrows, ncols))
array.fill(np.nan)
array[0, :] = np.random.random(ncols)
array[:, 0] = np.random.random(nrows)
# the total number of changes is nrows
n = nrows
cols = np.random.geometric(0.5, n)
cols[cols >= ncols] = 0
rows = np.random.randint(nrows, size=n)
array[rows, cols] = np.random.random(n)
df = pd.DataFrame(array)
df.fillna(method='ffill', axis=0, inplace=True)
total = df.sum(axis=1)
return (total.values - (ncols/2))
# tone synthesis
def note(freq, len, amp=1, rate=RATE):
t = np.linspace(0, len, int(len * rate))
if freq == 0:
data = np.zeros(int(len * rate))
else:
data = np.sin(2*np.pi*freq*t)*amp
return data
sig = note(Alpha, 0.10)
sig_tx = np.concatenate((note(0, 0.50),
note(Alpha, 0.20),
note(0, 0.10),
note(Alpha, 0.75),
note(0, 0.10),
note(Alpha, 0.10),
note(0, 0.10),))
# sig_noise = 0.33 * sig_tx + 0.66 * np.random.randn(len(sig_tx))
sig_noise = 0.50 * sig_tx + 0.50 * voss(len(sig_tx))
corr = np.abs(signal.correlate(sig_noise, sig, mode='same'))
fig, (ax_orig, ax_sig, ax_noise, ax_corr) = plt.subplots(4, 1, sharex=True)
ax_orig.plot(sig)
ax_orig.set_title('Reference')
ax_sig.plot(sig_tx)
ax_sig.set_title('Signal without noise')
ax_noise.plot(sig_noise)
ax_noise.set_title('Signal with noise')
ax_corr.plot(corr)
ax_corr.axhline(np.average(corr), ls=':')
ax_corr.set_title('Cross-correlated with Reference')
ax_orig.margins(0, 0.1)
fig.set_tight_layout(True)
plt.show()
write('test.wav', 44100, sig_noise)

View File

@ -1,47 +0,0 @@
# Run with "ipython -i --matplotlib=qt match_frequency.py"
#
from __future__ import print_function
import numpy as np
# import pandas as pd
from scipy import signal
import matplotlib.pyplot as plt
RATE = 44100
# tone synthesis
def tone(freq, cycles, amp=1, rate=RATE):
len = cycles * (1.0/rate)
t = np.linspace(0, len, int(len * rate))
if freq == 0:
data = np.zeros(int(len * rate))
else:
data = np.sin(2 * np.pi * freq * t) * amp
return data
fig, ax = plt.subplots(1, 1, sharex=True)
for carrier in [312, 473, 716, 1084, 1479]:
print("carrier = ", carrier)
freqs = range(carrier-20, carrier+22, 2)
sig = tone(carrier * 1.0, 2000) # Reference tone
response = []
for freq in freqs:
sig_tx = tone(freq * 1.0, 2000) # Test tone
resp = np.abs(signal.correlate(sig, sig_tx, mode='same'))
response.append(resp.sum())
ax.semilogy(freqs, response, label='tone = {}'.format(carrier))
ax.set_title('Matched filter response')
ax.axvline(626.67, ls=':') # Guardband markers
ax.axvline(695.01, ls=':')
ax.legend(loc='best')
ax.margins(0, 0.1)
fig.set_tight_layout(True)
plt.show()

View File

@ -1,48 +0,0 @@
# Run with "ipython -i --matplotlib=qt correlate.py"
#
from __future__ import print_function
import numpy as np
# import pandas as pd
from scipy import signal
import matplotlib.pyplot as plt
RATE = 44100
# tone synthesis
def tone(freq, cycles, amp=1, rate=RATE):
len = cycles * (1.0/freq)
t = np.linspace(0, len, len * rate)
if freq is 0:
data = np.zeros(int(len * rate))
else:
data = np.sin(2 * np.pi * freq * t) * amp
return data
freqs = range(600, 722, 2) # Frequency range
fig, ax = plt.subplots(1, 1, sharex=True)
for length in [32, 64, 100, 128, 256]:
print("len = ", length)
sig = tone(660.0, length) # Reference tone
response = []
for freq in freqs:
sig_tx = tone(freq * 1.0, length) # Test tone
resp = np.abs(signal.correlate(sig, sig_tx, mode='same'))
response.append(resp.sum())
ax.semilogy(freqs, response, label='len = {}'.format(length))
ax.set_title('Matched filter response')
ax.axvline(626.67, ls=':') # Guardband markers
ax.axvline(695.01, ls=':')
ax.legend(loc='best')
ax.margins(0, 0.1)
fig.set_tight_layout(True)
fig.show()

View File

@ -1,190 +0,0 @@
# Run with "ipython --matplotlib=qt receiver.py <file>.wav"
#
from __future__ import print_function
import sys
import numpy as np
from scipy import signal
from scipy.io.wavfile import read
from scipy.signal import butter, lfilter
from math import log10
from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
FRAME_TIME = 0.1 # Frame time in seconds
TONES = [312.6,
346.7,
384.6,
426.6,
473.2,
524.8,
582.1,
645.7,
716.1,
794.3,
881.0,
977.2,
1083.9,
1202.3,
1333.5,
1479.1]
ALPHABET = ['Alpha',
'Bravo',
'Charlie',
'Delta',
'Echo',
'Foxtrot',
'Golf',
'Hotel',
'Juliette',
'Kilo',
'Lima',
'Mike',
'Papa',
'Quebec',
'Romeo',
'Sierra']
FILTER_LEN = 1000 # Samples
# Shamelessly lifted from
# https://scipy.github.io/old-wiki/pages/Cookbook/ButterworthBandpass
def butter_bandpass(lowcut, highcut, fs, order=5):
nyq = 0.5 * fs
low = lowcut / nyq
high = highcut / nyq
b, a = butter(order, [low, high], btype='band')
return b, a
def butter_bandpass_filter(data, lowcut, highcut, fs, order=5):
b, a = butter_bandpass(lowcut, highcut, fs, order=order)
y = lfilter(b, a, data)
return y
# tone synthesis
def note(freq, cycles, amp=32767.0, rate=44100):
len = cycles * (1.0/rate)
t = np.linspace(0, len, int(len * rate))
if freq == 0:
data = np.zeros(int(len * rate))
else:
data = np.sin(2 * np.pi * freq * t) * amp
return data.astype(int)
# analyze wav file by chunks
def receiver(file_name):
try:
sig_rate, sig_noise = read(file_name)
except Exception:
print('Error opening {}'.format(file_name))
return
print('file: ', file_name, ' rate: ', sig_rate, ' len: ', len(sig_noise))
if sig_rate == 44100:
decimate = 4 # rate = 11025, Fmax = 5512.5 Hz
elif sig_rate == 48000:
decimate = 5 # rate = 9600, Fmax = 4800 Hz
elif sig_rate == 22050:
decimate = 2 # rate = 11025, Fmax = 5512.5 Hz
elif sig_rate == 11025:
decimate = 1 # rate = 11025, Fmax = 5512.5 Hz
else:
print('Sample rate {} not supported.'.format(sig_rate))
return
if decimate > 1:
sig_noise = signal.decimate(sig_noise, decimate)
sig_rate = sig_rate / decimate
print('length after decimation: ', len(sig_noise))
frame_len = int(sig_rate * FRAME_TIME)
frames = int((len(sig_noise) / frame_len) + 1)
sig_noise = butter_bandpass_filter(sig_noise,
270,
1700,
sig_rate,
order=8)
template = []
for tone in range(0, len(TONES)):
template.append(note(TONES[tone], frame_len, rate=sig_rate))
# See http://stackoverflow.com/questions/23507217/
# python-plotting-2d-data-on-to-3d-axes
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
y = np.arange(len(TONES))
print(' Index A B C D E F G', end='')
print(' H J K L M P Q R', end='')
print(' S Avg')
x = range(0, frames)
X, Y = np.meshgrid(y, x)
Z = np.zeros((len(x), len(y)))
for frame in range(0, frames):
beg = frame * frame_len
end = (frame+1) * frame_len
corr = np.zeros(len(TONES))
for tone in range(0, len(TONES)):
corr[tone] = log10(np.abs(signal.correlate(sig_noise[beg:end],
template[tone],
mode='same')).sum())
Z[frame, tone] = corr[tone]
max1 = 0.0
for tone in range(0, len(TONES)):
if corr[tone] > max1:
max1 = corr[tone]
max1idx = tone
max2 = 0.0
for tone in range(0, len(TONES)):
if tone != max1idx and corr[tone] > max2:
max2 = corr[tone]
max2idx = tone
print('{0:6d}: '.format(frame), end='')
avg = np.mean(corr)
for tone in range(0, len(TONES)):
if tone == max1idx or tone == max2idx:
print('[{0:2.2f}]'.format(corr[tone]), end='')
else:
if corr[tone] > avg:
print(' {0:2.2f} '.format(corr[tone]), end='')
else:
print(' . ', end='')
print(' {0:2.2f}'.format(avg))
ax.plot_surface(X, Y, Z, rstride=1, cstride=1000, color='w', shade=True,
lw=.5)
# ax.plot_wireframe(X, Y, Z, rstride=1, cstride=1000, lw=.5)
ax.set_title(file_name)
ax.set_xlabel("Tone")
ax.set_ylabel("Frame")
ax.set_zlabel("Log Correlation")
ax.set_zlim(10.0, 15.0)
ax.set_ylim(0, frames)
ax.view_init(30, -130)
plt.show()
if __name__ == "__main__":
receiver(sys.argv[1])

View File

@ -1,97 +0,0 @@
# Run with "ipython -i --matplotlib=qt spectrum.py <file>.wav"
#
from __future__ import print_function
import sys
import numpy as np
# import pandas as pd
from scipy import signal
import matplotlib.pyplot as plt
from scipy.io.wavfile import read
from scipy.signal import butter, lfilter
Tones = dict({'Alpha': 312.6,
'Bravo': 346.7,
'Charlie': 384.6,
'Delta': 426.6,
'Echo': 473.2,
'Foxtrot': 524.8,
'Golf': 582.1,
'Hotel': 645.7,
'Juliette': 716.1,
'Kilo': 794.3,
'Lima': 881.0,
'Mike': 977.2,
'Papa': 1083.9,
'Quebec': 1202.3,
'Romeo': 1333.5,
'Sierra': 1479.1, })
# Shamelessly lifted from
# https://scipy.github.io/old-wiki/pages/Cookbook/ButterworthBandpass
def butter_bandpass(lowcut, highcut, fs, order=5):
nyq = 0.5 * fs
low = lowcut / nyq
high = highcut / nyq
b, a = butter(order, [low, high], btype='band')
return b, a
def butter_bandpass_filter(data, lowcut, highcut, fs, order=5):
b, a = butter_bandpass(lowcut, highcut, fs, order=order)
y = lfilter(b, a, data)
return y
# wav file spectrum
def spectrum(file_name):
try:
sig_rate, sig_noise = read(file_name)
except Exception:
print('Error opening {}'.format(file_name))
return
print('file: ', file_name, ' rate: ', sig_rate, ' len: ', len(sig_noise))
if sig_rate == 44100:
decimate = 12 # rate = 3675, Fmax = 1837.5 Hz
elif sig_rate == 48000:
decimate = 10 # rate = 4800, Fmax = 2400 Hz
elif sig_rate == 22050:
decimate = 6 # rate = 3675, Fmax = 1837.5 Hz
elif sig_rate == 11025:
decimate = 3 # rate = 3675, Fmax = 1837.5 Hz
else:
print('Sample rate {} not supported.'.format(sig_rate))
return
if decimate > 1:
sig_noise = signal.decimate(sig_noise, decimate)
sig_rate = sig_rate/decimate
print('length after decimation: ', len(sig_noise))
sig_noise = butter_bandpass_filter(sig_noise, 270, 1700, sig_rate, order=8)
sig_f, welch_spec = signal.welch(sig_noise, sig_rate, nperseg=2048,
nfft=65536, scaling='spectrum')
plt.title(file_name)
plt.xlabel('frequency [Hz]')
plt.ylabel('PSD')
plt.xlim(270, 1700)
plt.ylim([1e-2, 1e7])
for tone in Tones:
plt.axvline(Tones[tone], color='g')
plt.axvspan(Tones[tone]*0.98,
Tones[tone]*1.02, facecolor='r', alpha=0.5)
plt.grid()
# plt.semilogy(sig_f, welch_spec)
plt.loglog(sig_f, welch_spec)
plt.axhline(np.average(welch_spec), color='r')
plt.show()
if __name__ == "__main__":
spectrum(sys.argv[1])

View File

@ -1,33 +0,0 @@
"""Inspired by http://stackoverflow.com/questions/24974032/reading-realtime
-audio-data-into-numpy-array."""
from __future__ import print_function
import pyaudio
import numpy
import scipy.io.wavefile as wav
RATE = 16000
RECORD_SECONDS = 2.5
CHUNKSIZE = 1024
# initialize portaudio
p = pyaudio.PyAudio()
stream = p.open(format=pyaudio.paInt16,
channels=1,
rate=RATE,
input=True,
frames_per_buffer=CHUNKSIZE)
frames = [] # A python-list of chunks(numpy.ndarray)
for _ in range(0, int(RATE / CHUNKSIZE * RECORD_SECONDS)):
data = stream.read(CHUNKSIZE)
frames.append(numpy.fromstring(data, dtype=numpy.int16))
# Convert the list of numpy-arrays into a 1D array (column-wise)
numpydata = numpy.hstack(frames)
# close stream
stream.stop_stream()
stream.close()
p.terminate()
wav.write('out.wav', RATE, numpydata)