diff --git a/data/sampleaudio/BabyElephantWalk60.mp3 b/data/sampleaudio/BabyElephantWalk60.mp3 new file mode 100644 index 0000000..ba83ed3 Binary files /dev/null and b/data/sampleaudio/BabyElephantWalk60.mp3 differ diff --git a/data/sampleaudio/CantinaBand3.mp3 b/data/sampleaudio/CantinaBand3.mp3 new file mode 100644 index 0000000..8fd4f5f Binary files /dev/null and b/data/sampleaudio/CantinaBand3.mp3 differ diff --git a/data/sampleaudio/CantinaBand60.mp3 b/data/sampleaudio/CantinaBand60.mp3 new file mode 100644 index 0000000..935ddea Binary files /dev/null and b/data/sampleaudio/CantinaBand60.mp3 differ diff --git a/data/sampleaudio/Fanfare60.mp3 b/data/sampleaudio/Fanfare60.mp3 new file mode 100644 index 0000000..13541b4 Binary files /dev/null and b/data/sampleaudio/Fanfare60.mp3 differ diff --git a/data/sampleaudio/ImperialMarch60.mp3 b/data/sampleaudio/ImperialMarch60.mp3 new file mode 100644 index 0000000..05d6c6e Binary files /dev/null and b/data/sampleaudio/ImperialMarch60.mp3 differ diff --git a/data/sampleaudio/PinkPanther30.mp3 b/data/sampleaudio/PinkPanther30.mp3 new file mode 100644 index 0000000..3a0b714 Binary files /dev/null and b/data/sampleaudio/PinkPanther30.mp3 differ diff --git a/data/sampleaudio/PinkPanther60.mp3 b/data/sampleaudio/PinkPanther60.mp3 new file mode 100644 index 0000000..894a7e3 Binary files /dev/null and b/data/sampleaudio/PinkPanther60.mp3 differ diff --git a/data/sampleaudio/SOURCE b/data/sampleaudio/SOURCE new file mode 100644 index 0000000..648d57e --- /dev/null +++ b/data/sampleaudio/SOURCE @@ -0,0 +1 @@ +https://www2.cs.uic.edu/~i101/SoundFiles/ \ No newline at end of file diff --git a/data/sampleaudio/StarWars3.mp3 b/data/sampleaudio/StarWars3.mp3 new file mode 100644 index 0000000..da6f7d8 Binary files /dev/null and b/data/sampleaudio/StarWars3.mp3 differ diff --git a/data/sampleaudio/StarWars60.mp3 b/data/sampleaudio/StarWars60.mp3 new file mode 100644 index 0000000..6284b4e Binary files /dev/null and b/data/sampleaudio/StarWars60.mp3 differ diff --git a/data/sampleaudio/taunt.mp3 b/data/sampleaudio/taunt.mp3 new file mode 100644 index 0000000..43e5d3e Binary files /dev/null and b/data/sampleaudio/taunt.mp3 differ diff --git a/fileradio.py b/fileradio.py new file mode 100644 index 0000000..66ae818 --- /dev/null +++ b/fileradio.py @@ -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) diff --git a/mediamtx.yml b/mediamtx.yml index 9965edc..8b8f226 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -38,10 +38,10 @@ udpMaxPayloadSize: 1472 externalAuthenticationURL: # Enable the HTTP API. -api: no +api: yes # Enable Prometheus-compatible metrics. -metrics: no +metrics: yes # Enable pprof-compatible endpoint to monitor performances. pprof: no diff --git a/microservice.py b/microservice.py index 8bdeeb4..9c8e49e 100755 --- a/microservice.py +++ b/microservice.py @@ -1,7 +1,12 @@ #! /usr/bin/env python3 +import os +import sys +import requests import SoapySDR as soapy from radio import Radio +from tuuube import Tuuube +from fileradio import FileRadio from flask import Flask, jsonify from flasgger import Swagger @@ -12,9 +17,29 @@ swag = Swagger(app) 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') -def report(): +def radio_report(): """List radio devices available to the system. --- responses: @@ -115,7 +140,7 @@ def configure(radio, frequency): def start_stream(radio): """Start the radio stream. Once the stream has been started, connect to the stream at: - rtsp://[host]:8554/radio/[radio]/stream + rtsp://[host]:8554/radio/[radio] --- parameters: - name: radio @@ -169,6 +194,55 @@ def radio_info(radio): return str(e), 400 +tubes = {} + +@app.route('/tuuube//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//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__': import subprocess @@ -181,6 +255,13 @@ if __name__ == '__main__': 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( host='0.0.0.0', threaded=True, @@ -189,10 +270,15 @@ if __name__ == '__main__': print('Stopping any currently streaming radios...') for radio in radios: - if radios[radio].is_streaming(): - radios[radio].end_stream() + if radios[radio].is_streaming(): + radios[radio].end_stream() radios = None + for tube in tubes: + if tubes[tube].is_streaming(): + tubes[tube].end_stream() + tubes = None + print('Killing RTSP relay...') rtsp_relay.kill() rtsp_relay.wait() # Necessary? diff --git a/radio.py b/radio.py index c36f949..24d0578 100644 --- a/radio.py +++ b/radio.py @@ -7,19 +7,19 @@ import subprocess import struct from soapyhelpers import * from samplerates import * +from streamer import Streamer, is_alive -class Radio: +class Radio(Streamer): + REST_PATH = 'radio' FORMAT = 'CS16' SAMPLES = 8192 - PORT = 8554 def __init__(self, name, device_info): + super().__init__() + self.name = name self.device_info = device_info - self.run = False - self.thread = None - self.device = soapy.Device(device_info) if self.device is None: raise RuntimeError("Failed to connect to radio device") @@ -83,31 +83,9 @@ class Radio: '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): self._init_stream() - def is_alive(subprocess): - return (subprocess.poll() is None) - while self.run: # Check that the child processes are still running if (not is_alive(self.demod)) or (not is_alive(self.playback)): @@ -136,8 +114,12 @@ class Radio: def _init_stream(self): self.playback = subprocess.Popen( [ - '/usr/bin/ffmpeg', '-f', 's16le', '-ar', str(self.output_rate), '-ac', '2', '-i', '-', - '-f', 'rtsp', f"rtsp://localhost{self._stream_path()}" + '/usr/bin/ffmpeg', + '-f', 's16le', + '-ar', str(self.output_rate), + '-ac', '2', + '-i', '-', + '-f', 'rtsp', self.stream_address() ], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, @@ -145,7 +127,12 @@ class Radio: ) 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 @@ -176,8 +163,6 @@ class Radio: self.playback.wait() self.playback = None - def _stream_path(self): - return f":{Radio.PORT}/radio/{self.name}" """ @@ -201,4 +186,4 @@ if __name__ == '__main__': print('Ending stream...') sdr.end_stream() - print('Stream ended.') \ No newline at end of file + print('Stream ended.') diff --git a/requirements.pip b/requirements.pip index f39a6ea..8400d11 100644 --- a/requirements.pip +++ b/requirements.pip @@ -3,3 +3,5 @@ flasgger==0.9.5 Flask==2.2.3 numpy==1.17.4 prefixed==0.7.0 +PyYAML==6.0 +requests==2.22.0 diff --git a/scripts/wav2mp3.sh b/scripts/wav2mp3.sh new file mode 100755 index 0000000..ccbbbb7 --- /dev/null +++ b/scripts/wav2mp3.sh @@ -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 diff --git a/setup.sh b/setup.sh index a8b1165..5828671 100755 --- a/setup.sh +++ b/setup.sh @@ -8,6 +8,10 @@ setup() { sudo xargs apt-get install -y < requirements.apt 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 ./scripts/SDRplay_RSP_API-Linux-3.07.1.run diff --git a/streamer.py b/streamer.py new file mode 100644 index 0000000..865542a --- /dev/null +++ b/streamer.py @@ -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) \ No newline at end of file diff --git a/tuuube.py b/tuuube.py new file mode 100755 index 0000000..1fd94d0 --- /dev/null +++ b/tuuube.py @@ -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) \ No newline at end of file