9130: Extend API to support streaming dummy audio sources #1
BIN
data/sampleaudio/BabyElephantWalk60.mp3
Normal file
BIN
data/sampleaudio/BabyElephantWalk60.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/CantinaBand3.mp3
Normal file
BIN
data/sampleaudio/CantinaBand3.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/CantinaBand60.mp3
Normal file
BIN
data/sampleaudio/CantinaBand60.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/Fanfare60.mp3
Normal file
BIN
data/sampleaudio/Fanfare60.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/ImperialMarch60.mp3
Normal file
BIN
data/sampleaudio/ImperialMarch60.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/PinkPanther30.mp3
Normal file
BIN
data/sampleaudio/PinkPanther30.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/PinkPanther60.mp3
Normal file
BIN
data/sampleaudio/PinkPanther60.mp3
Normal file
Binary file not shown.
1
data/sampleaudio/SOURCE
Normal file
1
data/sampleaudio/SOURCE
Normal file
@ -0,0 +1 @@
|
|||||||
|
https://www2.cs.uic.edu/~i101/SoundFiles/
|
||||||
BIN
data/sampleaudio/StarWars3.mp3
Normal file
BIN
data/sampleaudio/StarWars3.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/StarWars60.mp3
Normal file
BIN
data/sampleaudio/StarWars60.mp3
Normal file
Binary file not shown.
BIN
data/sampleaudio/taunt.mp3
Normal file
BIN
data/sampleaudio/taunt.mp3
Normal file
Binary file not shown.
52
fileradio.py
Normal file
52
fileradio.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from streamer import Streamer, is_alive
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class FileRadio(Streamer):
|
||||||
|
REST_PATH = 'sample'
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.basename = os.path.basename(self.path)
|
||||||
|
self.name, self.ext = os.path.splitext(self.basename)
|
||||||
|
|
||||||
|
def _stream_thread(self):
|
||||||
|
self.playback = subprocess.Popen(
|
||||||
|
[
|
||||||
|
'/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
|
||||||
|
)
|
||||||
|
|
||||||
|
while self.run:
|
||||||
|
if not is_alive(self.playback):
|
||||||
|
print('Playback failed, aborting stream.', file=sys.stderr)
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
self.run = False
|
||||||
|
|
||||||
|
self.playback.kill()
|
||||||
|
self.playback.wait()
|
||||||
|
self.playback = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
fr = FileRadio('./data/sampleaudio/taunt.mp3')
|
||||||
|
fr.start_stream()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
@ -38,10 +38,10 @@ udpMaxPayloadSize: 1472
|
|||||||
externalAuthenticationURL:
|
externalAuthenticationURL:
|
||||||
|
|
||||||
# Enable the HTTP API.
|
# Enable the HTTP API.
|
||||||
api: no
|
api: yes
|
||||||
|
|
||||||
# Enable Prometheus-compatible metrics.
|
# Enable Prometheus-compatible metrics.
|
||||||
metrics: no
|
metrics: yes
|
||||||
|
|
||||||
# Enable pprof-compatible endpoint to monitor performances.
|
# Enable pprof-compatible endpoint to monitor performances.
|
||||||
pprof: no
|
pprof: no
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
#! /usr/bin/env python3
|
#! /usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
import SoapySDR as soapy
|
import SoapySDR as soapy
|
||||||
from radio import Radio
|
from radio import Radio
|
||||||
|
from tuuube import Tuuube
|
||||||
|
from fileradio import FileRadio
|
||||||
|
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
from flasgger import Swagger
|
from flasgger import Swagger
|
||||||
@ -12,9 +17,29 @@ swag = Swagger(app)
|
|||||||
|
|
||||||
radios = {}
|
radios = {}
|
||||||
|
|
||||||
|
@app.route('/report')
|
||||||
|
def report():
|
||||||
|
"""Get streams report from the RTSP relay.
|
||||||
|
---
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
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']
|
||||||
|
|
||||||
|
|
||||||
|
return jsonify(j)
|
||||||
|
except:
|
||||||
|
return "Shat the bed", 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/radio/report')
|
@app.route('/radio/report')
|
||||||
def report():
|
def radio_report():
|
||||||
"""List radio devices available to the system.
|
"""List radio devices available to the system.
|
||||||
---
|
---
|
||||||
responses:
|
responses:
|
||||||
@ -115,7 +140,7 @@ def configure(radio, frequency):
|
|||||||
def start_stream(radio):
|
def start_stream(radio):
|
||||||
"""Start the radio stream.
|
"""Start the radio stream.
|
||||||
Once the stream has been started, connect to the stream at:
|
Once the stream has been started, connect to the stream at:
|
||||||
rtsp://[host]:8554/radio/[radio]/stream
|
rtsp://[host]:8554/radio/[radio]
|
||||||
---
|
---
|
||||||
parameters:
|
parameters:
|
||||||
- name: radio
|
- name: radio
|
||||||
@ -169,6 +194,55 @@ def radio_info(radio):
|
|||||||
return str(e), 400
|
return str(e), 400
|
||||||
|
|
||||||
|
|
||||||
|
tubes = {}
|
||||||
|
|
||||||
|
@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:
|
||||||
|
rtsp://[host]:8554/tuuube/[id]
|
||||||
|
---
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
description:
|
||||||
|
The youtube video ID. That is the part following the `watch?v=` in the URL. For example, `dQw4w9WgXcQ`.\n
|
||||||
|
Other good options are - \n
|
||||||
|
`BaW_jenozKc`, yt_dlp package test video.\n
|
||||||
|
`b2je8uBlgFM`, stereo audio test.\n
|
||||||
|
`LDU_Txk06tM`, crab rave, a commonly used audio fidelity test.\n
|
||||||
|
`sPT_epMLkwQ`, Kilsyth CFA major factory fire dispatch radio traffic.
|
||||||
|
in: path
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
"""
|
||||||
|
if id not in tubes:
|
||||||
|
tubes[id] = Tuuube(id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tubes[id].start_stream()
|
||||||
|
return "", 200
|
||||||
|
except Exception as e:
|
||||||
|
return str(e), 400
|
||||||
|
|
||||||
|
@app.route('/tuuube/<id>/end')
|
||||||
|
def end_tuuube_stream(id):
|
||||||
|
"""Terminate the youtube stream.
|
||||||
|
---
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
description: The youtube video ID.
|
||||||
|
in: path
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tubes[id].end_stream()
|
||||||
|
return "", 200
|
||||||
|
except Exception as e:
|
||||||
|
return str(e), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@ -181,6 +255,13 @@ if __name__ == '__main__':
|
|||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for path, _, files in os.walk('./data/sampleaudio'):
|
||||||
|
for file in files:
|
||||||
|
name,ext = os.path.splitext(file)
|
||||||
|
if ext == '.mp3':
|
||||||
|
tubes[name] = FileRadio(f"{path}/{file}")
|
||||||
|
tubes[name].start_stream()
|
||||||
|
|
||||||
app.run(
|
app.run(
|
||||||
host='0.0.0.0',
|
host='0.0.0.0',
|
||||||
threaded=True,
|
threaded=True,
|
||||||
@ -193,6 +274,11 @@ if __name__ == '__main__':
|
|||||||
radios[radio].end_stream()
|
radios[radio].end_stream()
|
||||||
radios = None
|
radios = None
|
||||||
|
|
||||||
|
for tube in tubes:
|
||||||
|
if tubes[tube].is_streaming():
|
||||||
|
tubes[tube].end_stream()
|
||||||
|
tubes = None
|
||||||
|
|
||||||
print('Killing RTSP relay...')
|
print('Killing RTSP relay...')
|
||||||
rtsp_relay.kill()
|
rtsp_relay.kill()
|
||||||
rtsp_relay.wait() # Necessary?
|
rtsp_relay.wait() # Necessary?
|
||||||
|
|||||||
49
radio.py
49
radio.py
@ -7,19 +7,19 @@ import subprocess
|
|||||||
import struct
|
import struct
|
||||||
from soapyhelpers import *
|
from soapyhelpers import *
|
||||||
from samplerates import *
|
from samplerates import *
|
||||||
|
from streamer import Streamer, is_alive
|
||||||
|
|
||||||
|
|
||||||
class Radio:
|
class Radio(Streamer):
|
||||||
|
REST_PATH = 'radio'
|
||||||
FORMAT = 'CS16'
|
FORMAT = 'CS16'
|
||||||
SAMPLES = 8192
|
SAMPLES = 8192
|
||||||
PORT = 8554
|
|
||||||
|
|
||||||
def __init__(self, name, device_info):
|
def __init__(self, name, device_info):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.device_info = device_info
|
self.device_info = device_info
|
||||||
self.run = False
|
|
||||||
self.thread = None
|
|
||||||
|
|
||||||
self.device = soapy.Device(device_info)
|
self.device = soapy.Device(device_info)
|
||||||
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")
|
||||||
@ -83,31 +83,9 @@ class Radio:
|
|||||||
'uarts': self.device.listUARTs(),
|
'uarts': self.device.listUARTs(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def is_streaming(self):
|
|
||||||
return True if (self.thread and self.thread.is_alive()) else False
|
|
||||||
|
|
||||||
def start_stream(self):
|
|
||||||
if self.is_streaming():
|
|
||||||
raise RuntimeError('Stream thread is already running')
|
|
||||||
|
|
||||||
self.run = True
|
|
||||||
self.thread = Thread(target=self._stream_thread, daemon=True, args=())
|
|
||||||
self.thread.start()
|
|
||||||
|
|
||||||
def end_stream(self):
|
|
||||||
if self.thread is None:
|
|
||||||
raise RuntimeError('No stream thread to terminate')
|
|
||||||
|
|
||||||
self.run = False
|
|
||||||
self.thread.join()
|
|
||||||
self.thread = None
|
|
||||||
|
|
||||||
def _stream_thread(self):
|
def _stream_thread(self):
|
||||||
self._init_stream()
|
self._init_stream()
|
||||||
|
|
||||||
def is_alive(subprocess):
|
|
||||||
return (subprocess.poll() is None)
|
|
||||||
|
|
||||||
while self.run:
|
while self.run:
|
||||||
# Check that the child processes are still running
|
# Check that the child processes are still running
|
||||||
if (not is_alive(self.demod)) or (not is_alive(self.playback)):
|
if (not is_alive(self.demod)) or (not is_alive(self.playback)):
|
||||||
@ -136,8 +114,12 @@ class Radio:
|
|||||||
def _init_stream(self):
|
def _init_stream(self):
|
||||||
self.playback = subprocess.Popen(
|
self.playback = subprocess.Popen(
|
||||||
[
|
[
|
||||||
'/usr/bin/ffmpeg', '-f', 's16le', '-ar', str(self.output_rate), '-ac', '2', '-i', '-',
|
'/usr/bin/ffmpeg',
|
||||||
'-f', 'rtsp', f"rtsp://localhost{self._stream_path()}"
|
'-f', 's16le',
|
||||||
|
'-ar', str(self.output_rate),
|
||||||
|
'-ac', '2',
|
||||||
|
'-i', '-',
|
||||||
|
'-f', 'rtsp', self.stream_address()
|
||||||
],
|
],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
@ -145,7 +127,12 @@ class Radio:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.demod = subprocess.Popen(
|
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,
|
stdin=subprocess.PIPE,
|
||||||
stdout=self.playback.stdin,
|
stdout=self.playback.stdin,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL
|
||||||
@ -176,8 +163,6 @@ 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}"
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -3,3 +3,5 @@ flasgger==0.9.5
|
|||||||
Flask==2.2.3
|
Flask==2.2.3
|
||||||
numpy==1.17.4
|
numpy==1.17.4
|
||||||
prefixed==0.7.0
|
prefixed==0.7.0
|
||||||
|
PyYAML==6.0
|
||||||
|
requests==2.22.0
|
||||||
|
|||||||
8
scripts/wav2mp3.sh
Executable file
8
scripts/wav2mp3.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
path="${arg%/*}"
|
||||||
|
file="${arg##*/}"
|
||||||
|
filename="${file%%.*}"
|
||||||
|
ffmpeg -i $path/$file -vn -ar 44100 -ac 2 -b:a 100k $path/$filename.mp3
|
||||||
|
done
|
||||||
4
setup.sh
4
setup.sh
@ -8,6 +8,10 @@ setup() {
|
|||||||
sudo xargs apt-get install -y < requirements.apt
|
sudo xargs apt-get install -y < requirements.apt
|
||||||
sudo pip install -r requirements.pip
|
sudo pip install -r requirements.pip
|
||||||
|
|
||||||
|
# Requires yt-dlp, but a very recent version (as youtube broke their API for the pip version)
|
||||||
|
# https://stackoverflow.com/a/75504772
|
||||||
|
python3 -m pip install --force-reinstall https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz
|
||||||
|
|
||||||
# Install device driver
|
# Install device driver
|
||||||
./scripts/SDRplay_RSP_API-Linux-3.07.1.run
|
./scripts/SDRplay_RSP_API-Linux-3.07.1.run
|
||||||
|
|
||||||
|
|||||||
51
streamer.py
Normal file
51
streamer.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from threading import Thread
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
with open('mediamtx.yml', 'r') as config_file:
|
||||||
|
MEDIASERVER_CONFIG = yaml.safe_load(config_file)
|
||||||
|
|
||||||
|
|
||||||
|
def is_alive(subprocess):
|
||||||
|
return True if (subprocess and subprocess.poll() is None) else False
|
||||||
|
|
||||||
|
|
||||||
|
class Streamer:
|
||||||
|
PROTOCOL = 'rtsp'
|
||||||
|
REST_PATH = 'stream'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.run = False
|
||||||
|
self.thread = None
|
||||||
|
self.name = None
|
||||||
|
|
||||||
|
def stream_path(self):
|
||||||
|
return f"{MEDIASERVER_CONFIG['rtspAddress']}/{type(self).REST_PATH}/{self.name}"
|
||||||
|
|
||||||
|
def stream_address(self, host):
|
||||||
|
return f"{Streamer.PROTOCOL}://{host}{self.stream_path()}"
|
||||||
|
|
||||||
|
def is_streaming(self):
|
||||||
|
return True if (self.thread and self.thread.is_alive()) else False
|
||||||
|
|
||||||
|
def start_stream(self):
|
||||||
|
if self.is_streaming():
|
||||||
|
raise RuntimeError('Stream thread is already running')
|
||||||
|
|
||||||
|
self.run = True
|
||||||
|
self.thread = Thread(target=self._stream_thread, daemon=True, args=())
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def end_stream(self):
|
||||||
|
if self.thread is None:
|
||||||
|
raise RuntimeError('No stream thread to terminate')
|
||||||
|
|
||||||
|
self.run = False
|
||||||
|
self.thread.join()
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from pprint import pprint
|
||||||
|
pprint(MEDIASERVER_CONFIG)
|
||||||
85
tuuube.py
Executable file
85
tuuube.py
Executable file
@ -0,0 +1,85 @@
|
|||||||
|
#! /usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
We aren't using either the apt or the pip repositories for the youtube_dl
|
||||||
|
as there is a known bug affecting those two versions. The youtube API has changed
|
||||||
|
since their release, causing downloads to fail.
|
||||||
|
Make sure you use the ./setup.sh script to obtain the latest github release of
|
||||||
|
yt_dlp, as this version carries the latest fixes.
|
||||||
|
"""
|
||||||
|
#import youtube_dl
|
||||||
|
import yt_dlp as youtube_dl
|
||||||
|
from streamer import Streamer, is_alive
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Tuuube(Streamer):
|
||||||
|
REST_PATH = 'tuuube'
|
||||||
|
|
||||||
|
def __init__(self, name):
|
||||||
|
super().__init__()
|
||||||
|
self.name = name
|
||||||
|
self.playback = None
|
||||||
|
|
||||||
|
def source_path(self):
|
||||||
|
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()):
|
||||||
|
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',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
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)
|
||||||
|
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')
|
||||||
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
while self.run:
|
||||||
|
if not is_alive(self.playback):
|
||||||
|
print('Playback failed, aborting stream.', file=sys.stderr)
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
self.run = False
|
||||||
|
|
||||||
|
self.playback.kill()
|
||||||
|
self.playback.wait()
|
||||||
|
self.playback = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
tube = Tuuube('BaW_jenozKc')
|
||||||
|
tube.start_stream()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print(tube.is_streaming())
|
||||||
|
time.sleep(1)
|
||||||
Loading…
Reference in New Issue
Block a user