Added ability to control either the SDRPlay or nooelec radio
This commit is contained in:
parent
a4e4a11d86
commit
802f3c0051
@ -147,6 +147,27 @@ def end_stream(radio):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return str(e), 400
|
return str(e), 400
|
||||||
|
|
||||||
|
@app.route('/radio/<radio>/info')
|
||||||
|
def radio_info(radio):
|
||||||
|
"""Get information about a radio.
|
||||||
|
---
|
||||||
|
parameters:
|
||||||
|
- name: radio
|
||||||
|
description: Radio device driver name, or serial number.
|
||||||
|
in: path
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: JSON
|
||||||
|
400:
|
||||||
|
description: The specified radio is not connected.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return jsonify(radios[radio].get_info())
|
||||||
|
except Exception as e:
|
||||||
|
return str(e), 400
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
60
radio.py
60
radio.py
@ -6,6 +6,7 @@ import sys
|
|||||||
import subprocess
|
import subprocess
|
||||||
import struct
|
import struct
|
||||||
from soapyhelpers import *
|
from soapyhelpers import *
|
||||||
|
from samplerates import *
|
||||||
|
|
||||||
|
|
||||||
class Radio:
|
class Radio:
|
||||||
@ -23,16 +24,22 @@ class Radio:
|
|||||||
if self.device is None:
|
if self.device is None:
|
||||||
raise RuntimeError("Failed to connect to radio device")
|
raise RuntimeError("Failed to connect to radio device")
|
||||||
|
|
||||||
|
self.capabilities = self._get_capabilities()
|
||||||
|
|
||||||
def configure(self, frequency):
|
def configure(self, frequency):
|
||||||
if self.is_streaming():
|
if self.is_streaming():
|
||||||
raise RuntimeError("Cannot configure radio while a stream is active")
|
raise RuntimeError("Cannot configure radio while a stream is active")
|
||||||
|
|
||||||
frequency = int(prefixed.Float(frequency))
|
frequency = int(prefixed.Float(frequency))
|
||||||
sample_rate = 384000
|
|
||||||
bandwidth = 200000
|
bandwidth = 200000
|
||||||
|
|
||||||
|
sample_rates = preferred_sample_rates(self.capabilities['rx']['sample-rates'])
|
||||||
|
if len(sample_rates) == 0:
|
||||||
|
raise RuntimeError("No suitable sample rates are available")
|
||||||
|
self.sample_rate, self.output_rate = sample_rates[0]
|
||||||
|
|
||||||
self.device.setFrequency(soapy.SOAPY_SDR_RX, 0, frequency)
|
self.device.setFrequency(soapy.SOAPY_SDR_RX, 0, frequency)
|
||||||
self.device.setSampleRate(soapy.SOAPY_SDR_RX, 0, sample_rate)
|
self.device.setSampleRate(soapy.SOAPY_SDR_RX, 0, self.sample_rate)
|
||||||
self.device.setBandwidth(soapy.SOAPY_SDR_RX, 0, bandwidth)
|
self.device.setBandwidth(soapy.SOAPY_SDR_RX, 0, bandwidth)
|
||||||
|
|
||||||
# Set automatic gain
|
# Set automatic gain
|
||||||
@ -45,8 +52,39 @@ class Radio:
|
|||||||
'gain-mode': 'auto' if self.device.getGainMode(soapy.SOAPY_SDR_RX, 0) else 'manual',
|
'gain-mode': 'auto' if self.device.getGainMode(soapy.SOAPY_SDR_RX, 0) else 'manual',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_info(self):
|
||||||
|
return {
|
||||||
|
'name': self.name,
|
||||||
|
'device': self.device_info,
|
||||||
|
'capabilities': self.capabilities,
|
||||||
|
'stream-path': self._stream_path(),
|
||||||
|
'streaming': self.is_streaming(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_capabilities(self):
|
||||||
|
def get_direction_capabilities(direction):
|
||||||
|
return {
|
||||||
|
'antennas': self.device.listAntennas(direction, 0),
|
||||||
|
'gains': self.device.listGains(direction, 0),
|
||||||
|
'frequencies': self.device.listFrequencies(direction, 0),
|
||||||
|
'sample-rates': self.device.listSampleRates(direction, 0),
|
||||||
|
'bandwidths': self.device.listBandwidths(direction, 0),
|
||||||
|
'sensors': self.device.listSensors(direction, 0),
|
||||||
|
'formats': self.device.getStreamFormats(direction, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rx': get_direction_capabilities(soapy.SOAPY_SDR_RX),
|
||||||
|
'tx': get_direction_capabilities(soapy.SOAPY_SDR_TX),
|
||||||
|
'clock-sources': self.device.listClockSources(),
|
||||||
|
'time-sources': self.device.listTimeSources(),
|
||||||
|
'register-interfaces': self.device.listRegisterInterfaces(),
|
||||||
|
'gpios': self.device.listGPIOBanks(),
|
||||||
|
'uarts': self.device.listUARTs(),
|
||||||
|
}
|
||||||
|
|
||||||
def is_streaming(self):
|
def is_streaming(self):
|
||||||
return (self.thread and self.thread.is_alive())
|
return True if (self.thread and self.thread.is_alive()) else False
|
||||||
|
|
||||||
def start_stream(self):
|
def start_stream(self):
|
||||||
if self.is_streaming():
|
if self.is_streaming():
|
||||||
@ -82,8 +120,10 @@ class Radio:
|
|||||||
continue
|
continue
|
||||||
elif result.ret < 0:
|
elif result.ret < 0:
|
||||||
error = SoapyError(result.ret)
|
error = SoapyError(result.ret)
|
||||||
|
if error is not SoapyError.Timeout:
|
||||||
print("Stream read failed, aborting stream:", error, file=sys.stderr)
|
print("Stream read failed, aborting stream:", error, file=sys.stderr)
|
||||||
break
|
break
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
read_size = int(result.ret * 2)
|
read_size = int(result.ret * 2)
|
||||||
self.demod.stdin.write(
|
self.demod.stdin.write(
|
||||||
@ -96,8 +136,8 @@ class Radio:
|
|||||||
def _init_stream(self):
|
def _init_stream(self):
|
||||||
self.playback = subprocess.Popen(
|
self.playback = subprocess.Popen(
|
||||||
[
|
[
|
||||||
'/usr/bin/ffmpeg', '-f', 's16le', '-ar', '32000', '-ac', '2', '-i', '-',
|
'/usr/bin/ffmpeg', '-f', 's16le', '-ar', str(self.output_rate), '-ac', '2', '-i', '-',
|
||||||
'-f', 'rtsp', f"rtsp://localhost:{Radio.PORT}/radio/{self.name}"
|
'-f', 'rtsp', f"rtsp://localhost:{self._stream_path()}"
|
||||||
],
|
],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
@ -105,7 +145,7 @@ class Radio:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.demod = subprocess.Popen(
|
self.demod = subprocess.Popen(
|
||||||
['/usr/bin/python3', 'fm_demod.py', '-f', 'CS16', '-s', '384k', '-d', '32k'],
|
['/usr/bin/python3', 'fm_demod.py', '-f', 'CS16', '-s', str(self.sample_rate), '-d', str(self.output_rate)],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=self.playback.stdin,
|
stdout=self.playback.stdin,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL
|
||||||
@ -113,7 +153,10 @@ class Radio:
|
|||||||
|
|
||||||
self.buffer = np.array([0] * Radio.SAMPLES * 2, FORMATS[Radio.FORMAT].numpy)
|
self.buffer = np.array([0] * Radio.SAMPLES * 2, FORMATS[Radio.FORMAT].numpy)
|
||||||
self.stream = self.device.setupStream(soapy.SOAPY_SDR_RX, FORMATS[Radio.FORMAT].soapy)
|
self.stream = self.device.setupStream(soapy.SOAPY_SDR_RX, FORMATS[Radio.FORMAT].soapy)
|
||||||
self.device.activateStream(self.stream)
|
result = self.device.activateStream(self.stream)
|
||||||
|
|
||||||
|
if result != 0:
|
||||||
|
raise RuntimeError(f"Error activating stream: {result}")
|
||||||
|
|
||||||
def _cleanup_stream(self):
|
def _cleanup_stream(self):
|
||||||
self.run = False
|
self.run = False
|
||||||
@ -133,6 +176,9 @@ class Radio:
|
|||||||
self.playback.wait()
|
self.playback.wait()
|
||||||
self.playback = None
|
self.playback = None
|
||||||
|
|
||||||
|
def _stream_path(self):
|
||||||
|
return f"{Radio.PORT}/radio/{self.name}"
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Quick and dirty test of the Radio class.
|
Quick and dirty test of the Radio class.
|
||||||
|
|||||||
119
samplerates.py
Normal file
119
samplerates.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
from pprint import pprint
|
||||||
|
|
||||||
|
"""
|
||||||
|
Not all output rates are going to be supported by the system sound card.
|
||||||
|
Choosing from a limited subset of preferred output rates is how we guarantee
|
||||||
|
that the system will be able to play the output audio.
|
||||||
|
Sample rates stolen from:
|
||||||
|
https://en.wikipedia.org/wiki/Sampling_(signal_processing)
|
||||||
|
"""
|
||||||
|
supported_ouput_rates = [
|
||||||
|
8000, # Telephone, P25 audio (sufficient for speech, some consonants unintelligble)
|
||||||
|
16000, # Modern telephone/VoIP, good quality speech
|
||||||
|
32000, # FM radio
|
||||||
|
44100, # CD audio quality
|
||||||
|
48000,
|
||||||
|
50000, # Uncommon but supported
|
||||||
|
88200,
|
||||||
|
96000, # DVD/Blu-ray audio
|
||||||
|
192000, # Too much.
|
||||||
|
]
|
||||||
|
|
||||||
|
def score(pair, target_output=32000, target_ratio=10):
|
||||||
|
"""
|
||||||
|
Heuristic for scoring input & output sample rates. The criteria are:
|
||||||
|
- closest to 44.1 kHz is better;
|
||||||
|
- closer to a ratio of 10 is better,
|
||||||
|
- additionally penalising ratios lower than 10.
|
||||||
|
|
||||||
|
Lower scores are better.
|
||||||
|
|
||||||
|
This should result in selected sample rates that give good audio quality
|
||||||
|
without wasting CPU cycles on a needlessly high input rate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Give the worst score possible for impossible pairs.
|
||||||
|
if pair[0] % pair[1] != 0:
|
||||||
|
return float("inf")
|
||||||
|
|
||||||
|
ratio = pair[0] // pair[1]
|
||||||
|
|
||||||
|
return abs(pair[1] - target_output)/2500 \
|
||||||
|
+ max(0, target_output - pair[1])/2500 \
|
||||||
|
+ abs(ratio - target_ratio)**0.8 \
|
||||||
|
+ max(0, target_ratio - ratio)**2
|
||||||
|
|
||||||
|
def flatten(l):
|
||||||
|
return [item for sublist in l for item in sublist]
|
||||||
|
|
||||||
|
def flatten_dict(d):
|
||||||
|
return [(key,value) for key,rates in d.items() for value in rates]
|
||||||
|
|
||||||
|
def get_pairs(input_rate):
|
||||||
|
return [
|
||||||
|
(input_rate, rate)
|
||||||
|
for rate in supported_ouput_rates
|
||||||
|
if (input_rate % rate == 0)
|
||||||
|
]
|
||||||
|
|
||||||
|
def supported_sample_rates(supported_input_rates):
|
||||||
|
return {
|
||||||
|
in_rate: [out_rate for out_rate in supported_ouput_rates if in_rate % out_rate == 0]
|
||||||
|
for in_rate in supported_input_rates
|
||||||
|
}
|
||||||
|
|
||||||
|
def preferred_sample_rates(supported_input_rates):
|
||||||
|
return sorted(flatten_dict(supported_sample_rates(supported_input_rates)), key=score)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
nesdr_sample_rates = [
|
||||||
|
250000,
|
||||||
|
1024000,
|
||||||
|
1536000,
|
||||||
|
1792000,
|
||||||
|
1920000,
|
||||||
|
2048000,
|
||||||
|
2160000,
|
||||||
|
2560000,
|
||||||
|
2880000,
|
||||||
|
3200000
|
||||||
|
]
|
||||||
|
|
||||||
|
sdrplay_sample_rates = [
|
||||||
|
62500,
|
||||||
|
96000,
|
||||||
|
125000,
|
||||||
|
192000,
|
||||||
|
250000,
|
||||||
|
384000,
|
||||||
|
500000,
|
||||||
|
768000,
|
||||||
|
1000000,
|
||||||
|
2000000,
|
||||||
|
2048000,
|
||||||
|
3000000,
|
||||||
|
4000000,
|
||||||
|
5000000,
|
||||||
|
6000000,
|
||||||
|
7000000,
|
||||||
|
8000000,
|
||||||
|
9000000,
|
||||||
|
10000000
|
||||||
|
]
|
||||||
|
|
||||||
|
print('nesdr')
|
||||||
|
pprint(preferred_sample_rates(nesdr_sample_rates))
|
||||||
|
#for rate in nesdr_sample_rates:
|
||||||
|
# print(rate, ' \t', get_pairs(rate))
|
||||||
|
#supported = flatten([get_pairs(rate) for rate in nesdr_sample_rates])
|
||||||
|
#supported.sort(key=score)
|
||||||
|
#pprint(supported)
|
||||||
|
|
||||||
|
print('sdrplay')
|
||||||
|
pprint(preferred_sample_rates(sdrplay_sample_rates))
|
||||||
|
#for rate in sdrplay_sample_rates:
|
||||||
|
# print(rate, ' \t', get_pairs(rate))
|
||||||
|
#supported = flatten([get_pairs(rate) for rate in sdrplay_sample_rates])
|
||||||
|
#supported.sort(key=score)
|
||||||
|
#pprint(supported)
|
||||||
Loading…
Reference in New Issue
Block a user