Compare commits
No commits in common. "main" and "9130_dummy_audio" have entirely different histories.
main
...
9130_dummy
29
fileradio.py
29
fileradio.py
@ -6,7 +6,7 @@ import os
|
||||
|
||||
|
||||
class FileRadio(Streamer):
|
||||
REST_PATH = "sample"
|
||||
REST_PATH = 'sample'
|
||||
|
||||
def __init__(self, path):
|
||||
super().__init__()
|
||||
@ -17,26 +17,22 @@ class FileRadio(Streamer):
|
||||
def _stream_thread(self):
|
||||
self.playback = subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/ffmpeg",
|
||||
"-re", # http://trac.ffmpeg.org/wiki/StreamingGuide#The-reflag
|
||||
"-stream_loop", # Loop the stream -
|
||||
"-1", # ...indefinitely
|
||||
"-i",
|
||||
self.path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"rtsp",
|
||||
self.stream_address("localhost"),
|
||||
'/usr/bin/ffmpeg',
|
||||
'-re', # http://trac.ffmpeg.org/wiki/StreamingGuide#The-reflag
|
||||
'-stream_loop', '-1', # Loop the stream indefinitely
|
||||
'-i', self.path,
|
||||
'-c', 'copy',
|
||||
'-f', 'rtsp',
|
||||
self.stream_address('localhost')
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
while self.run:
|
||||
if not is_alive(self.playback):
|
||||
print("Playback failed, aborting stream.", file=sys.stderr)
|
||||
print('Playback failed, aborting stream.', file=sys.stderr)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
@ -47,8 +43,9 @@ class FileRadio(Streamer):
|
||||
self.playback = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fr = FileRadio("./data/sampleaudio/taunt.mp3")
|
||||
|
||||
if __name__ == '__main__':
|
||||
fr = FileRadio('./data/sampleaudio/taunt.mp3')
|
||||
fr.start_stream()
|
||||
|
||||
while True:
|
||||
|
||||
25
filters.py
25
filters.py
@ -10,9 +10,8 @@
|
||||
import numpy, math, sys, time
|
||||
from numpy import fft
|
||||
|
||||
|
||||
def impulse(mask):
|
||||
"""Convert frequency domain mask to time-domain"""
|
||||
''' Convert frequency domain mask to time-domain '''
|
||||
# Negative side, a mirror of positive side
|
||||
negatives = mask[1:-1]
|
||||
negatives.reverse()
|
||||
@ -31,7 +30,7 @@ def impulse(mask):
|
||||
|
||||
|
||||
def lo_mask(sample_rate, tap_count, freq, dboct):
|
||||
"""Create a freq domain mask for a lowpass filter"""
|
||||
''' Create a freq domain mask for a lowpass filter '''
|
||||
order = dboct / 6
|
||||
max_freq = sample_rate / 2.0
|
||||
f2s = max_freq / (tap_count / 2.0)
|
||||
@ -46,7 +45,7 @@ def lo_mask(sample_rate, tap_count, freq, dboct):
|
||||
|
||||
|
||||
def hi_mask(sample_rate, tap_count, freq, dboct):
|
||||
"""Create a freq domain mask for a highpass filter"""
|
||||
''' Create a freq domain mask for a highpass filter '''
|
||||
order = dboct / 6
|
||||
max_freq = sample_rate / 2.0
|
||||
f2s = max_freq / (tap_count / 2.0)
|
||||
@ -61,7 +60,7 @@ def hi_mask(sample_rate, tap_count, freq, dboct):
|
||||
|
||||
|
||||
def combine_masks(mask1, mask2):
|
||||
"""Combine two filter masks"""
|
||||
''' Combine two filter masks '''
|
||||
assert len(mask1) == len(mask2)
|
||||
return [ mask1[i] * mask2[i] for i in range(0, len(mask1)) ]
|
||||
|
||||
@ -89,7 +88,7 @@ class filter:
|
||||
def feed(self, original):
|
||||
unfiltered = numpy.concatenate((self.buf, original))
|
||||
self.buf = unfiltered[-len(self.coefs):]
|
||||
filtered = numpy.convolve(unfiltered, self.coefs, mode="valid")
|
||||
filtered = numpy.convolve(unfiltered, self.coefs, mode='valid')
|
||||
assert len(filtered) == len(original) + 1
|
||||
return filtered[1:]
|
||||
|
||||
@ -112,9 +111,8 @@ class high_pass(filter):
|
||||
|
||||
class band_pass(filter):
|
||||
def __init__(self, sample_rate, lo, hi, dbo):
|
||||
tap_count = max(
|
||||
taps(sample_rate, lo, dbo, True), taps(sample_rate, hi, dbo, False)
|
||||
)
|
||||
tap_count = max(taps(sample_rate, lo, dbo, True),
|
||||
taps(sample_rate, hi, dbo, False))
|
||||
lomask = lo_mask(sample_rate, tap_count, hi, dbo)
|
||||
himask = hi_mask(sample_rate, tap_count, lo, dbo)
|
||||
mask = combine_masks(lomask, himask)
|
||||
@ -133,9 +131,8 @@ class deemphasis(filter):
|
||||
# slope in dB/octave of deemphasis filter
|
||||
dedbo = 10 / octaves
|
||||
|
||||
tap_count = max(
|
||||
taps(sample_rate, lo, dedbo, False), taps(sample_rate, hi, final_dbo, False)
|
||||
)
|
||||
tap_count = max(taps(sample_rate, lo, dedbo, False),
|
||||
taps(sample_rate, hi, final_dbo, False))
|
||||
|
||||
# Calculate deemphasis filter
|
||||
demask = lo_mask(sample_rate, tap_count, lo, dedbo)
|
||||
@ -158,10 +155,10 @@ class decimator(filter):
|
||||
# Gets the last n-th sample of every n (n = factor)
|
||||
# If e.g. gets 12 samples, gets s[4] and s[9], and
|
||||
# stoves s[10:] to the next round
|
||||
"""
|
||||
'''
|
||||
decimated = [ original[ self.factor * i + self.factor - 1 ] \
|
||||
for i in range(0, len(original) // self.factor) ]
|
||||
"""
|
||||
'''
|
||||
decimated = original[(self.factor - 1)::self.factor]
|
||||
self.buf2 = original[:-len(original) % self.factor]
|
||||
|
||||
|
||||
42
fm_demod.py
42
fm_demod.py
@ -19,22 +19,10 @@ from formats import FORMATS
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", help="Print additional informational output", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--format",
|
||||
choices=list(FORMATS.keys()),
|
||||
help="Input sample format",
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--sample-rate", metavar="rate", help="Source sample rate (Hz)", required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d", "--demod-rate", metavar="rate", help="Output sample rate (Hz)", required=True
|
||||
)
|
||||
parser.add_argument('-v', '--verbose', help='Print additional informational output', action='store_true')
|
||||
parser.add_argument('-f', '--format', choices=list(FORMATS.keys()), help='Input sample format', required=True)
|
||||
parser.add_argument('-s', '--sample-rate', metavar='rate', help='Source sample rate (Hz)', required=True)
|
||||
parser.add_argument('-d', '--demod-rate', metavar='rate', help='Output sample rate (Hz)', required=True)
|
||||
# TODO JMT: Output to file
|
||||
#parser.add_argument('-o', metavar='file', help='Specify an output file for demodulated audio. Omit for stdout or use \'-\'.')
|
||||
args = parser.parse_args()
|
||||
@ -44,10 +32,7 @@ OUTPUT_RATE = int(prefixed.Float(args.demod_rate))
|
||||
DECIMATION = INPUT_RATE / OUTPUT_RATE
|
||||
|
||||
if DECIMATION != math.floor(DECIMATION):
|
||||
print(
|
||||
f"The output rate must be an integer divisor of the input rate: {INPUT_RATE}/{OUTPUT_RATE} = {DECIMATION}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f'The output rate must be an integer divisor of the input rate: {INPUT_RATE}/{OUTPUT_RATE} = {DECIMATION}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
FORMAT = FORMATS[args.format].numpy
|
||||
@ -78,17 +63,15 @@ decimate2 = filters.decimator(DECIMATION)
|
||||
lo_r = filters.deemphasis(INPUT_RATE, 75, FM_BANDWIDTH, 120)
|
||||
|
||||
# Band-pass filter for stereo (L-R) modulated audio
|
||||
hi = filters.band_pass(
|
||||
INPUT_RATE, STEREO_CARRIER - FM_BANDWIDTH, STEREO_CARRIER + FM_BANDWIDTH, 120
|
||||
)
|
||||
hi = filters.band_pass(INPUT_RATE,
|
||||
STEREO_CARRIER - FM_BANDWIDTH, STEREO_CARRIER + FM_BANDWIDTH, 120)
|
||||
|
||||
# Filter to extract pilot signal
|
||||
pilot = filters.band_pass(
|
||||
INPUT_RATE, STEREO_CARRIER / 2 - 100, STEREO_CARRIER / 2 + 100, 120
|
||||
)
|
||||
pilot = filters.band_pass(INPUT_RATE,
|
||||
STEREO_CARRIER / 2 - 100, STEREO_CARRIER / 2 + 100, 120)
|
||||
|
||||
last_angle = 0.0
|
||||
remaining_data = b""
|
||||
remaining_data = b''
|
||||
|
||||
while True:
|
||||
# Ingest 0.1s worth of data
|
||||
@ -190,7 +173,8 @@ while True:
|
||||
last_deviation_avg = 0.0
|
||||
|
||||
# Translate rotation to frequency deviation
|
||||
STEREO_CARRIER /= 1 + (rotation * 1.05) / tau
|
||||
STEREO_CARRIER /= (1 + (rotation * 1.05) / tau)
|
||||
|
||||
|
||||
# Downsample, Low-pass/deemphasis demodulated L-R
|
||||
output_jstereo = lo_r.feed(output_jstereo)
|
||||
@ -212,4 +196,4 @@ while True:
|
||||
output[1::2] = output_right
|
||||
output = output.astype(int)
|
||||
|
||||
sys.stdout.buffer.write(struct.pack("<%dh" % len(output), *output))
|
||||
sys.stdout.buffer.write(struct.pack('<%dh' % len(output), *output))
|
||||
|
||||
10
formats.py
10
formats.py
@ -2,7 +2,6 @@ import numpy as np
|
||||
from SoapySDR import *
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormatSpec:
|
||||
name: str
|
||||
@ -10,10 +9,9 @@ class FormatSpec:
|
||||
numpy: np.dtype
|
||||
packing: str
|
||||
|
||||
|
||||
FORMATS = {
|
||||
"CU8": FormatSpec("CU8", SOAPY_SDR_CU8, np.uint8, "=%dB"),
|
||||
"CS8": FormatSpec("CS8", SOAPY_SDR_CS8, np.int8, "=%db"),
|
||||
"CS16": FormatSpec("CS16", SOAPY_SDR_CS16, np.int16, "=%dh"),
|
||||
"CF32": FormatSpec("CF32", SOAPY_SDR_CF32, np.float32, "=%df"),
|
||||
'CU8': FormatSpec('CU8', SOAPY_SDR_CU8, np.uint8, '=%dB'),
|
||||
'CS8': FormatSpec('CS8', SOAPY_SDR_CS8, np.int8, '=%db'),
|
||||
'CS16': FormatSpec('CS16', SOAPY_SDR_CS16, np.int16, '=%dh'),
|
||||
'CF32': FormatSpec('CF32', SOAPY_SDR_CF32, np.float32, '=%df'),
|
||||
}
|
||||
142
microservice.py
142
microservice.py
@ -17,30 +17,28 @@ swag = Swagger(app)
|
||||
|
||||
radios = {}
|
||||
|
||||
|
||||
@app.route("/report")
|
||||
@app.route('/report')
|
||||
def report():
|
||||
"""Get streams report from the RTSP relay.
|
||||
---
|
||||
responses:
|
||||
200:
|
||||
description: JSON with streams report.
|
||||
400:
|
||||
description: JSON with error message.
|
||||
description: JSON
|
||||
"""
|
||||
try:
|
||||
r = requests.get("http://localhost:9997/v1/paths/list")
|
||||
j = r.json()
|
||||
for item in j["items"]:
|
||||
del j["items"][item]["conf"]
|
||||
del j["items"][item]["confName"]
|
||||
for item in j['items']:
|
||||
del j['items'][item]['conf']
|
||||
del j['items'][item]['confName']
|
||||
|
||||
|
||||
return jsonify(j)
|
||||
except Exception as e:
|
||||
return _error_message_json(e.message), 500
|
||||
except:
|
||||
return "Shat the bed", 500
|
||||
|
||||
|
||||
@app.route("/radio/report")
|
||||
@app.route('/radio/report')
|
||||
def radio_report():
|
||||
"""List radio devices available to the system.
|
||||
---
|
||||
@ -52,8 +50,7 @@ def radio_report():
|
||||
devices = [dict(device) for device in soapy.Device.enumerate()]
|
||||
return jsonify(devices)
|
||||
|
||||
|
||||
@app.route("/radio/<radio>/connect")
|
||||
@app.route('/radio/<radio>/connect')
|
||||
def connect(radio):
|
||||
"""Connect to a radio device, by driver name or serial number.
|
||||
---
|
||||
@ -65,29 +62,29 @@ def connect(radio):
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with message successfully connected to a radio.
|
||||
description: Successfully connected to a radio.
|
||||
400:
|
||||
description: JSON with error message No radio device by that name is available.
|
||||
description: No radio device by that name is available.
|
||||
500:
|
||||
description: JSON with error message failed to connect to radio.
|
||||
description: Failed to connect to radio.
|
||||
"""
|
||||
if radio in radios:
|
||||
return _error_message_json("Radio device already connected"), 400
|
||||
return "Radio device already connected", 400
|
||||
|
||||
devices = [dict(device) for device in soapy.Device.enumerate()]
|
||||
for device in devices:
|
||||
if radio in device.values():
|
||||
try:
|
||||
radios[radio] = Radio(radio, device)
|
||||
return jsonify("message", "successfully connected radio"), 200
|
||||
return "", 200
|
||||
except Exception as e:
|
||||
radios.pop(radio)
|
||||
return _error_message_json(e.message), 500
|
||||
return str(e), 500
|
||||
|
||||
|
||||
return "Radio device not found", 400
|
||||
|
||||
|
||||
@app.route("/radio/<radio>/disconnect")
|
||||
@app.route('/radio/<radio>/disconnect')
|
||||
def disconnect(radio):
|
||||
"""Disconnect from a radio device.
|
||||
---
|
||||
@ -107,12 +104,11 @@ def disconnect(radio):
|
||||
"""
|
||||
if radio in radios:
|
||||
radios.pop(radio)
|
||||
return jsonify("message", "succesfully disconnected radio"), 200
|
||||
return "", 200
|
||||
else:
|
||||
return _error_message_json("Radio not connected"), 400
|
||||
return "Radio not connected", 400
|
||||
|
||||
|
||||
@app.route("/radio/<radio>/configure/<frequency>")
|
||||
@app.route('/radio/<radio>/configure/<frequency>')
|
||||
def configure(radio, frequency):
|
||||
"""Tune the radio to a frequency.
|
||||
You must connect to the radio before attempting to configure it.
|
||||
@ -131,17 +127,16 @@ def configure(radio, frequency):
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with radio configuration.
|
||||
description: JSON
|
||||
400:
|
||||
description: The specified radio is not connected.
|
||||
"""
|
||||
if radio in radios:
|
||||
return jsonify(radios[radio].configure(frequency))
|
||||
else:
|
||||
return _error_message_json("Radio not connected"), 400
|
||||
return "Radio not connected", 400
|
||||
|
||||
|
||||
@app.route("/radio/<radio>/start")
|
||||
@app.route('/radio/<radio>/start')
|
||||
def start_stream(radio):
|
||||
"""Start the radio stream.
|
||||
Once the stream has been started, connect to the stream at:
|
||||
@ -153,20 +148,14 @@ def start_stream(radio):
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with message successful start of radio stream.
|
||||
400:
|
||||
description: JSON with error message.
|
||||
"""
|
||||
try:
|
||||
radios[radio].start_stream()
|
||||
return jsonify("message", "successfully started radio stream"), 200
|
||||
return "", 200
|
||||
except Exception as e:
|
||||
return _error_message_json(e.message), 400
|
||||
return str(e), 400
|
||||
|
||||
|
||||
@app.route("/radio/<radio>/end")
|
||||
@app.route('/radio/<radio>/end')
|
||||
def end_stream(radio):
|
||||
"""Terminate the radio stream.
|
||||
---
|
||||
@ -176,21 +165,14 @@ def end_stream(radio):
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with message successful termination of radio stream.
|
||||
400:
|
||||
description: JSON with error message.
|
||||
"""
|
||||
try:
|
||||
radios[radio].end_stream()
|
||||
return jsonify("message", "successfully ended radio stream"), 200
|
||||
return "", 200
|
||||
except Exception as e:
|
||||
error_message = {"error_message": e.message}
|
||||
return _error_message_json(e.message), 400
|
||||
return str(e), 400
|
||||
|
||||
|
||||
@app.route("/radio/<radio>/info")
|
||||
@app.route('/radio/<radio>/info')
|
||||
def radio_info(radio):
|
||||
"""Get information about a radio.
|
||||
---
|
||||
@ -202,20 +184,19 @@ def radio_info(radio):
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with radio information.
|
||||
description: JSON
|
||||
400:
|
||||
description: JSON with error message.
|
||||
description: The specified radio is not connected.
|
||||
"""
|
||||
try:
|
||||
return jsonify(radios[radio].get_info())
|
||||
except Exception as e:
|
||||
return _error_message_json(e.message), 400
|
||||
return str(e), 400
|
||||
|
||||
|
||||
tubes = {}
|
||||
|
||||
|
||||
@app.route("/tuuube/<id>/start")
|
||||
@app.route('/tuuube/<id>/start')
|
||||
def start_tuuube_stream(id):
|
||||
"""Start streaming from a youtube source.
|
||||
Once the stream has been started, connect to the stream at:
|
||||
@ -233,23 +214,17 @@ def start_tuuube_stream(id):
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with message successful start of youtube stream.
|
||||
400:
|
||||
description: JSON with error message.
|
||||
"""
|
||||
if id not in tubes:
|
||||
tubes[id] = Tuuube(id)
|
||||
|
||||
try:
|
||||
tubes[id].start_stream()
|
||||
return jsonify("message", "successfully started youtube stream"), 200
|
||||
return "", 200
|
||||
except Exception as e:
|
||||
return _error_message_json(e.message), 400
|
||||
return str(e), 400
|
||||
|
||||
|
||||
@app.route("/tuuube/<id>/end")
|
||||
@app.route('/tuuube/<id>/end')
|
||||
def end_tuuube_stream(id):
|
||||
"""Terminate the youtube stream.
|
||||
---
|
||||
@ -259,50 +234,41 @@ def end_tuuube_stream(id):
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: JSON with message successful termination of youtube stream.
|
||||
400:
|
||||
description: JSON with error message.
|
||||
"""
|
||||
try:
|
||||
tubes[id].end_stream()
|
||||
return jsonify("message", "succesfully ended youtube stream"), 200
|
||||
return "", 200
|
||||
except Exception as e:
|
||||
return _error_message_json(e.message), 400
|
||||
return str(e), 400
|
||||
|
||||
|
||||
"""
|
||||
Helper function to return a JSON error message
|
||||
parameters: error_message - the error message to return
|
||||
returns: a JSON object with the error message
|
||||
"""
|
||||
|
||||
|
||||
def _error_message_json(error_message: str):
|
||||
error_message = {"error_message": error_message}
|
||||
return jsonify(error_message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
import subprocess
|
||||
|
||||
rtsp_relay = subprocess.Popen(
|
||||
["./dependencies/mediamtx/mediamtx", "./mediamtx.yml"],
|
||||
[
|
||||
'./dependencies/mediamtx/mediamtx',
|
||||
'./mediamtx.yml'
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
for path, _, files in os.walk("./data/sampleaudio"):
|
||||
for path, _, files in os.walk('./data/sampleaudio'):
|
||||
for file in files:
|
||||
name,ext = os.path.splitext(file)
|
||||
if ext == ".mp3":
|
||||
if ext == '.mp3':
|
||||
tubes[name] = FileRadio(f"{path}/{file}")
|
||||
tubes[name].start_stream()
|
||||
|
||||
app.run(host="0.0.0.0", threaded=True, debug=False)
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
threaded=True,
|
||||
debug=False
|
||||
)
|
||||
|
||||
print("Stopping any currently streaming radios...")
|
||||
print('Stopping any currently streaming radios...')
|
||||
for radio in radios:
|
||||
if radios[radio].is_streaming():
|
||||
radios[radio].end_stream()
|
||||
@ -313,6 +279,6 @@ if __name__ == "__main__":
|
||||
tubes[tube].end_stream()
|
||||
tubes = None
|
||||
|
||||
print("Killing RTSP relay...")
|
||||
print('Killing RTSP relay...')
|
||||
rtsp_relay.kill()
|
||||
rtsp_relay.wait() # Necessary?
|
||||
|
||||
123
radio.py
123
radio.py
@ -11,8 +11,8 @@ from streamer import Streamer, is_alive
|
||||
|
||||
|
||||
class Radio(Streamer):
|
||||
REST_PATH = "radio"
|
||||
FORMAT = "CS16"
|
||||
REST_PATH = 'radio'
|
||||
FORMAT = 'CS16'
|
||||
SAMPLES = 8192
|
||||
|
||||
def __init__(self, name, device_info):
|
||||
@ -33,7 +33,7 @@ class Radio(Streamer):
|
||||
frequency = int(prefixed.Float(frequency))
|
||||
bandwidth = 200000
|
||||
|
||||
sample_rates = preferred_sample_rates(self.capabilities["rx"]["sample-rates"])
|
||||
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]
|
||||
@ -46,43 +46,41 @@ class Radio(Streamer):
|
||||
self.device.setGainMode(soapy.SOAPY_SDR_RX, 0, True)
|
||||
|
||||
return {
|
||||
"frequency": self.device.getFrequency(soapy.SOAPY_SDR_RX, 0),
|
||||
"sample-rate": self.device.getSampleRate(soapy.SOAPY_SDR_RX, 0),
|
||||
"bandwidth": self.device.getBandwidth(soapy.SOAPY_SDR_RX, 0),
|
||||
"gain-mode": "auto"
|
||||
if self.device.getGainMode(soapy.SOAPY_SDR_RX, 0)
|
||||
else "manual",
|
||||
'frequency': self.device.getFrequency(soapy.SOAPY_SDR_RX, 0),
|
||||
'sample-rate': self.device.getSampleRate(soapy.SOAPY_SDR_RX, 0),
|
||||
'bandwidth': self.device.getBandwidth(soapy.SOAPY_SDR_RX, 0),
|
||||
'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(),
|
||||
'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),
|
||||
'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(),
|
||||
'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 _stream_thread(self):
|
||||
@ -91,7 +89,7 @@ class Radio(Streamer):
|
||||
while self.run:
|
||||
# Check that the child processes are still running
|
||||
if (not is_alive(self.demod)) or (not is_alive(self.playback)):
|
||||
print("DSP chain failed, aborting stream.", file=sys.stderr)
|
||||
print('DSP chain failed, aborting stream.', file=sys.stderr)
|
||||
break
|
||||
|
||||
result = self.device.readStream(self.stream, [self.buffer], Radio.SAMPLES)
|
||||
@ -101,18 +99,14 @@ class Radio(Streamer):
|
||||
elif result.ret < 0:
|
||||
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
|
||||
continue
|
||||
else:
|
||||
read_size = int(result.ret * 2)
|
||||
self.demod.stdin.write(
|
||||
struct.pack(
|
||||
FORMATS[Radio.FORMAT].packing % read_size,
|
||||
*self.buffer[:read_size],
|
||||
)
|
||||
struct.pack(FORMATS[Radio.FORMAT].packing % read_size,
|
||||
*self.buffer[:read_size])
|
||||
)
|
||||
|
||||
self._cleanup_stream()
|
||||
@ -120,44 +114,32 @@ class Radio(Streamer):
|
||||
def _init_stream(self):
|
||||
self.playback = subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/ffmpeg",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ar",
|
||||
str(self.output_rate),
|
||||
"-ac",
|
||||
"2",
|
||||
"-i",
|
||||
"-",
|
||||
"-f",
|
||||
"rtsp",
|
||||
self.stream_address(),
|
||||
'/usr/bin/ffmpeg',
|
||||
'-f', 's16le',
|
||||
'-ar', str(self.output_rate),
|
||||
'-ac', '2',
|
||||
'-i', '-',
|
||||
'-f', 'rtsp', self.stream_address()
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
self.demod = subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/python3",
|
||||
"fm_demod.py",
|
||||
"-f",
|
||||
"CS16",
|
||||
"-s",
|
||||
str(self.sample_rate),
|
||||
"-d",
|
||||
str(self.output_rate),
|
||||
'/usr/bin/python3', 'fm_demod.py',
|
||||
'-f', 'CS16',
|
||||
'-s', str(self.sample_rate),
|
||||
'-d', str(self.output_rate)
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=self.playback.stdin,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
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)
|
||||
result = self.device.activateStream(self.stream)
|
||||
|
||||
if result != 0:
|
||||
@ -182,25 +164,26 @@ class Radio(Streamer):
|
||||
self.playback = None
|
||||
|
||||
|
||||
|
||||
"""
|
||||
Quick and dirty test of the Radio class.
|
||||
"""
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
import time
|
||||
|
||||
sdr = Radio("demo", {"driver": "sdrplay"})
|
||||
sdr = Radio('demo', {'driver': 'sdrplay'})
|
||||
|
||||
print("Configuring...")
|
||||
sdr.configure("105.5M")
|
||||
print("Configured.")
|
||||
print('Configuring...')
|
||||
sdr.configure('105.5M')
|
||||
print('Configured.')
|
||||
|
||||
print("Starting stream...")
|
||||
print('Starting stream...')
|
||||
sdr.start_stream()
|
||||
print("Stream started.")
|
||||
print('Stream started.')
|
||||
|
||||
# Let the stream play for a while
|
||||
time.sleep(15)
|
||||
|
||||
print("Ending stream...")
|
||||
print('Ending stream...')
|
||||
sdr.end_stream()
|
||||
print("Stream ended.")
|
||||
print('Stream ended.')
|
||||
|
||||
@ -17,7 +17,6 @@ supported_ouput_rates = [
|
||||
192000, # Too much.
|
||||
]
|
||||
|
||||
|
||||
def score(pair, target_output=32000, target_ratio=10):
|
||||
"""
|
||||
Heuristic for scoring input & output sample rates. The criteria are:
|
||||
@ -37,38 +36,29 @@ def score(pair, target_output=32000, target_ratio=10):
|
||||
|
||||
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
|
||||
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)
|
||||
(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
|
||||
]
|
||||
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
|
||||
)
|
||||
return sorted(flatten_dict(supported_sample_rates(supported_input_rates)), key=score)
|
||||
|
||||
7
scripts/.gitignore
vendored
7
scripts/.gitignore
vendored
@ -1,7 +0,0 @@
|
||||
CubicSDR/
|
||||
mediamtx/
|
||||
SoapyRTLSDR/
|
||||
SoapySDRPlay/
|
||||
wxWidgets-3.2.1/
|
||||
liquid-dsp/
|
||||
SoapySDR/
|
||||
@ -1,7 +1,6 @@
|
||||
import SoapySDR as soapy
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class SoapyError(IntEnum):
|
||||
Timeout = soapy.SOAPY_SDR_TIMEOUT
|
||||
StreamError = soapy.SOAPY_SDR_STREAM_ERROR
|
||||
@ -11,7 +10,6 @@ class SoapyError(IntEnum):
|
||||
TimeError = soapy.SOAPY_SDR_TIME_ERROR
|
||||
Underflow = soapy.SOAPY_SDR_UNDERFLOW
|
||||
|
||||
|
||||
class SoapyFlag(IntEnum):
|
||||
EndBurst = soapy.SOAPY_SDR_END_BURST
|
||||
HasTime = soapy.SOAPY_SDR_HAS_TIME
|
||||
|
||||
14
streamer.py
14
streamer.py
@ -2,7 +2,7 @@ from threading import Thread
|
||||
import yaml
|
||||
|
||||
|
||||
with open("mediamtx.yml", "r") as config_file:
|
||||
with open('mediamtx.yml', 'r') as config_file:
|
||||
MEDIASERVER_CONFIG = yaml.safe_load(config_file)
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ def is_alive(subprocess):
|
||||
|
||||
|
||||
class Streamer:
|
||||
PROTOCOL = "rtsp"
|
||||
REST_PATH = "stream"
|
||||
PROTOCOL = 'rtsp'
|
||||
REST_PATH = 'stream'
|
||||
|
||||
def __init__(self):
|
||||
self.run = False
|
||||
@ -30,7 +30,7 @@ class Streamer:
|
||||
|
||||
def start_stream(self):
|
||||
if self.is_streaming():
|
||||
raise RuntimeError("Stream thread is already running")
|
||||
raise RuntimeError('Stream thread is already running')
|
||||
|
||||
self.run = True
|
||||
self.thread = Thread(target=self._stream_thread, daemon=True, args=())
|
||||
@ -38,14 +38,14 @@ class Streamer:
|
||||
|
||||
def end_stream(self):
|
||||
if self.thread is None:
|
||||
raise RuntimeError("No stream thread to terminate")
|
||||
raise RuntimeError('No stream thread to terminate')
|
||||
|
||||
self.run = False
|
||||
self.thread.join()
|
||||
self.thread = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pprint import pprint
|
||||
|
||||
if __name__ == '__main__':
|
||||
from pprint import pprint
|
||||
pprint(MEDIASERVER_CONFIG)
|
||||
53
tuuube.py
53
tuuube.py
@ -17,7 +17,7 @@ import os
|
||||
|
||||
|
||||
class Tuuube(Streamer):
|
||||
REST_PATH = "tuuube"
|
||||
REST_PATH = 'tuuube'
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__()
|
||||
@ -28,51 +28,43 @@ class Tuuube(Streamer):
|
||||
return f"/tmp/{self.name}.mp3"
|
||||
|
||||
def _stream_thread(self):
|
||||
if not os.path.exists(self.source_path()) or not os.path.isfile(
|
||||
self.source_path()
|
||||
):
|
||||
if not os.path.exists(self.source_path()) or not os.path.isfile(self.source_path()):
|
||||
ydl_opts = {
|
||||
"format": "bestaudio/best",
|
||||
"outtmpl": f"/tmp/{self.name}.%(ext)s", # yt_dlp will append %(ext) if not specified,
|
||||
"postprocessors": [ # resulting in `/tmp/file.mp3.mp3` :/
|
||||
{
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": "mp3",
|
||||
"preferredquality": "192",
|
||||
}
|
||||
],
|
||||
'format': 'bestaudio/best',
|
||||
'outtmpl': f'/tmp/{self.name}.%(ext)s', # yt_dlp will append %(ext) if not specified,
|
||||
'postprocessors': [{ # resulting in `/tmp/file.mp3.mp3` :/
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '192',
|
||||
}],
|
||||
}
|
||||
|
||||
try:
|
||||
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([f"https://www.youtube.com/watch?v={self.name}"])
|
||||
ydl.download([f'https://www.youtube.com/watch?v={self.name}'])
|
||||
except Exception as e:
|
||||
print(f"File sourcing failed, aborting stream. {e}", file=sys.stderr)
|
||||
print(f'File sourcing failed, aborting stream. {e}', file=sys.stderr)
|
||||
self.run = False
|
||||
return
|
||||
|
||||
self.playback = subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/ffmpeg",
|
||||
"-re",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
self.source_path(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"rtsp",
|
||||
self.stream_address("localhost"),
|
||||
'/usr/bin/ffmpeg',
|
||||
'-re',
|
||||
'-stream_loop', '-1',
|
||||
'-i', self.source_path(),
|
||||
'-c', 'copy',
|
||||
'-f', 'rtsp',
|
||||
self.stream_address('localhost')
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
while self.run:
|
||||
if not is_alive(self.playback):
|
||||
print("Playback failed, aborting stream.", file=sys.stderr)
|
||||
print('Playback failed, aborting stream.', file=sys.stderr)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
@ -83,8 +75,9 @@ class Tuuube(Streamer):
|
||||
self.playback = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tube = Tuuube("BaW_jenozKc")
|
||||
|
||||
if __name__ == '__main__':
|
||||
tube = Tuuube('BaW_jenozKc')
|
||||
tube.start_stream()
|
||||
|
||||
while True:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user