master
Max 2 years ago
parent 5c5bd11a0c
commit e540ac9c54
  1. 243
      op25/gr-op25_repeater/apps/README-hls
  2. 3
      op25/gr-op25_repeater/apps/audio.py
  3. 57
      op25/gr-op25_repeater/apps/create_image.py
  4. 15
      op25/gr-op25_repeater/apps/ffmpeg.liq
  5. 42
      op25/gr-op25_repeater/apps/ffmpeg.sh
  6. 21
      op25/gr-op25_repeater/apps/rx.py
  7. 8
      op25/gr-op25_repeater/apps/sockaudio.py
  8. BIN
      op25/gr-op25_repeater/www/images/status.png
  9. 50
      op25/gr-op25_repeater/www/www-static/live.html
  10. 3
      op25/gr-op25_repeater/www/www-static/live.m3u8

@ -0,0 +1,243 @@
OP25 HTTP live streaming December 2020
=====================================================================
These hacks ("OP25-hls hacks") add a new option for audio reception
and playback in OP25; namely, via an HTTP live stream to any remote
client using a standard Web browser*. The web server software used
(nginx) is industrial-strength and immediately scalable to dozens or
hundreds of simultaneous remote users with zero added effort. More
than one upstream source (in parallel) can be served simultaneously.
OP25's liquidsoap script is hacked to pipe output PCM audio data
to ffmpeg, which also reads the www/images/status.png image file
that makes up the video portion of the encoded live stream. The
image png file is kept updated by rx.py.
The selection of ffmpeg codecs ("libx264" for video and "aac" for
audio) allows us directly to send the encoded data stream from
ffmpeg to the web server (nginx) utilizing RTMP. Individual
MPEG-TS segments are stored as files in nginx web server URL-space,
and served to web clients via standard HTTP GET requests. The
hls.js package is used at the client.
The entire effort mostly involved assembling existing off-the-shelf
building blocks. The ffmpeg package was built manually from source
to enable the "libx264" codec, and a modified nginx config was
used.
*the web browser must support the "MediaSource Extensions" API.
All recent broswer versions should qualify.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1. nginx installation
The libnginx-mod-rtmp package must be installed (in addition to
nginx itself). You can copy the sample nginx configuration at the
end of this README file to /etc/nginx/nginx.conf, followed by
restarting the web server
sudo systemctl stop nginx
sudo systemctl start nginx
With this configuration the web server should listen on HTTP port
8081 and RTMP port 1935.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2. ffmpeg installation
git clone https://code.videolan.org/videolan/x264.git
git clone https://git.ffmpeg.org/ffmpeg.git
cd x264
./configure
make
sudo make install
cd ../ffmpeg
./configure --enable-shared --enable-libx264 --enable-gpl
make
sudo make install
To confirm the installation run the "ffmpeg" command and
verify the presence of "--enable-shared" and "--enable-libx264"
in the "configuration:" section of the output.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3. liquidsoap installation
Both packages "liquidsoap" and "liquidsoap-plugin-all" were
installed, but not tested whether the plugins are required for
this application.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4. nginx setup
with the custom config installed as per step 1, copy the files
from op25/gr_op25_repeater/www to /var/www/html as follows:
live.html
live.m3u8
hls.js
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5. liquidsoap setup
in the op25/gr_op25_repeater/apps directory, note the ffmpeg.liq
script. Overall the filtering and buffering options should be
similar to those in op25.liq. The default version of ffmpeg.liq
should be OK for most uses.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6. operation
With OP25 rx.py started using the options -V -w (and -2 if using
TDMA) and with op25.liq started (both from the apps directory),
you should be able to connect to http://hostip:8081/live.html
and click the Play button to begin.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7. troubleshooting
A. with the op25.liq script running ffmpeg should start sending
rtmp data over port 1935 to nginx. You should see files
start being populated in /var/www/html/hls/ .
B. If /var/www/html/hls is empty, check ffmpeg message output
for possible errors, and also check the nginx access and
error logs. Note that the /var/www/html/hls directory should
start receiving files a few seconds after op25.liq is started
(regardless of whether OP25 is actively receiving a station,
or is not receiving).
C. js debug can be enabled for hls.js by editing that file as
follows; locate the lines of code and change the "debug"
setting to "true"
var hlsDefaultConfig = _objectSpread(_objectSpread({
autoStartLoad: true,
// used by stream-controller
startPosition: -1,
// used by stream-controller
defaultAudioCodec: void 0,
// used by stream-controller
debug: true, ///// <<<=== change this line from
///// "false" to "true"
D. after reloading the page and with the web browser js console
opened (and with all message types enabled), debug messages
should now start appearing in the console. As before another
place to look for messages is in the nginx access and error
logs.
E. if you are doing heavy client-side debugging it may be helpful
to obtain a copy of the hls.js distribution and to populate
the hls.js.map file (with updated hls.js) in /var/www/html.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
8. notes
A. due to the propagation delay inherent in the streaming
process, there is a latency of several seconds from when
the transmissions are receieved before they are played in
the remote web browser. OP25 attempts to keep the video
and audio synchronized but the usual user controls (hold,
lockout, etc). are not available (in this release) because
the several-second delay could cause the commands to operate
on stale talkgroup data (without additional work).
B. in keeping with the current OP25 liquidsoap setup, the audio
stream is converted to mono prior to streaming. It might be
possible to retain the stereo data (in cases where the L and
R channels contain separate information), but this has not
been tested. The op25.liq script would need to be changed to
use "output" instead of "mean(output)" and the ffmpeg script
would need to change "-ac 1" to "-ac 2". In addition the
options stereo=true and channels=2 would need to be set in the
%wav specification parameters.
C. multiple independent streams can be served simultaneously by
invoking a separate ffmpeg.sh script for each stream and by
changing the last component of the rtmp URL to a unique
value; for example:
rtmp://localhost/live/stream2
A unified (parameterized) version of ffmpeg.sh could also be
used.
Also, new versions of live.html and live.m3u8 in /var/www/html
(reflecting the above modification) would need to be added.
D. note that pausing and seeking etc. in the media feed isn't
possible when doing live streaming.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
########################################################################
####### tested on ubuntu 18.04 #######
####### sample nginx conf file - copy everything below this line #######
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 768;
# multi_accept on;
}
# RTMP configuration
rtmp {
server {
listen 1935; # Listen on standard RTMP port
chunk_size 4000;
application live {
live on;
# Turn on HLS
hls on;
hls_path /var/www/html/hls/;
hls_fragment 3;
hls_playlist_length 60;
# disable consuming the stream from nginx as rtmp
deny play all;
}
}
}
http {
sendfile off;
tcp_nopush on;
#aio on;
directio 512;
default_type application/octet-stream;
server {
listen 8081;
location / {
# Disable cache
add_header 'Cache-Control' 'no-cache';
# CORS setup
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length';
# allow CORS preflight requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
# include /etc/nginx/mime.types;
types {
text/html html;
text/css css;
application/javascript js;
application/dash+xml mpd;
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
root /var/www/html;
}
}
}

@ -40,13 +40,14 @@ parser.add_option("-u", "--wireshark-port", type="int", default=23456, help="Wir
parser.add_option("-2", "--two-channel", action="store_true", default=False, help="single or two channel audio")
parser.add_option("-x", "--audio-gain", type="float", default="1.0", help="audio gain (default = 1.0)")
parser.add_option("-s", "--stdout", action="store_true", default=False, help="write to stdout instead of audio device")
parser.add_option("-S", "--silence", action="store_true", default=False, help="suppress output of zeros after timeout")
(options, args) = parser.parse_args()
if len(args) != 0:
parser.print_help()
sys.exit(1)
audio_handler = socket_audio(options.host_ip, options.wireshark_port, options.audio_output, options.two_channel, options.audio_gain, options.stdout)
audio_handler = socket_audio(options.host_ip, options.wireshark_port, options.audio_output, options.two_channel, options.audio_gain, options.stdout, silent_flag=options.silence)
if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)

@ -0,0 +1,57 @@
#!/usr/bin/env python
#
# (c) Copyright 2020, OP25
#
# This file is part of OP25 and part of GNU Radio
#
# OP25 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 3, or (at your option)
# any later version.
#
# OP25 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 OP25; see the file COPYING. If not, write to the Free
# Software Foundation, Inc., 51 Franklin Street, Boston, MA
# 02110-1301, USA.
""" generate named image file consisting of multi-line text """
from PIL import Image, ImageDraw, ImageFont
import os
_TTF_FILE = '/usr/share/fonts/truetype/freefont/FreeSerif.ttf'
def create_image(textlist=["Blank"], imgfile="test.png", bgcolor='red', fgcolor='black', windowsize=(400,300)):
global _TTF_FILE
width=windowsize[0]
height=windowsize[1]
margin = 4
if not os.access(_TTF_FILE, os.R_OK):
font = ImageFont.load_default()
else:
font = ImageFont.truetype(_TTF_FILE, 16)
img = Image.new('RGB', (width, height), bgcolor)
draw = ImageDraw.Draw(img)
cursor = 0
for line in textlist:
w,h = draw.textsize(line, font)
# TODO: overwidth check needed?
if cursor+h >= height:
break
draw.text((margin, cursor), line,'black',font)
cursor += h + margin // 2
img.save(imgfile)
if __name__ == '__main__':
s = []
s.append('Starting...')
create_image(textlist=s, bgcolor='#c0c0c0')

@ -0,0 +1,15 @@
#!/usr/bin/liquidsoap
# Example liquidsoap hls streaming from op25 to nginx
# (c) 2019, 2020 gnorbury@bondcar.com, wllmbecks@gmail.com
# (c) 2020 KA1RBI
set("log.stdout", true)
set("log.file", false)
set("log.level", 1)
set("frame.audio.samplerate", 8000)
input = mksafe(input.external(buffer=0.02, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 2 -s -S"))
output.external(%wav(stereo=false, channels=1, samplesize=16, header=false, samplerate=8000), fallible=false, flush=true, "./ffmpeg.sh", mean(input))

@ -0,0 +1,42 @@
#! /bin/sh
# Copyright (c) 2020 OP25
#
# This file is part of OP25 and part of GNU Radio
#
# OP25 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 3, or (at your option)
# any later version.
#
# OP25 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 OP25; see the file COPYING. If not, write to the Free
# Software Foundation, Inc., 51 Franklin Street, Boston, MA
# 02110-1301, USA.
#
# this script should not be run directly - run ffmpeg.liq instead
#
# requires ffmpeg configured with --enable-libx264
#
ffmpeg \
-ar 8000 \
-ac 1 \
-acodec pcm_s16le \
-f s16le \
-i pipe:0 \
-f image2 \
-loop 1 \
-i ../www/images/status.png \
-vcodec libx264 \
-pix_fmt yuv420p \
-f flv \
-acodec aac \
-b:a 48k \
rtmp://localhost/live/stream

@ -70,6 +70,8 @@ from gr_gnuplot import setup_correlation
from terminal import op25_terminal
from sockaudio import audio_thread
from create_image import create_image
#speeds = [300, 600, 900, 1200, 1440, 1800, 1920, 2400, 2880, 3200, 3600, 3840, 4000, 4800, 6000, 6400, 7200, 8000, 9600, 14400, 19200]
speeds = [4800, 6000]
@ -130,6 +132,7 @@ class p25_rx_block (gr.top_block):
self.last_change_freq = 0
self.last_change_freq_at = time.time()
self.last_freq_params = {'freq' : 0.0, 'tgid' : None, 'tag' : "", 'tdma' : None}
self.next_status_png = time.time()
self.src = None
if (not options.input) and (not options.audio) and (not options.audio_if) and (not options.args.startswith('udp:')):
@ -676,6 +679,23 @@ class p25_rx_block (gr.top_block):
msg = gr.message().make_from_string(json.dumps(d), -4, 0, 0)
self.input_q.insert_tail(msg)
def make_status_png(self):
PNG_UPDATE_INTERVAL = 1.0
output_file = '../www/images/status.png'
tmp_output_file = '../www/images/tmp-status.png'
if time.time() < self.next_status_png:
return
self.next_status_png = time.time() + PNG_UPDATE_INTERVAL
if self.trunk_rx is None:
return ## possible race cond - just ignore
status_str = 'OP25-hls hacks (c) Copyright 2020, KA1RBI\n'
status_str += 'F %f TG %s %s at %s\n' % ( self.last_freq_params['freq'] / 1000000.0, self.last_freq_params['tgid'], self.last_freq_params['tag'], time.asctime())
status_str += self.trunk_rx.to_string()
status = status_str.split('\n')
status = [s for s in status if not s.startswith('tbl-id')]
create_image(status, imgfile=tmp_output_file, bgcolor="#c0c0c0", windowsize=(640,480))
os.rename(tmp_output_file, output_file)
def process_qmsg(self, msg):
# return true = end top block
RX_COMMANDS = 'skip lockout hold'.split()
@ -696,6 +716,7 @@ class p25_rx_block (gr.top_block):
msg = gr.message().make_from_string(js, -4, 0, 0)
self.input_q.insert_tail(msg)
self.process_ajax()
self.make_status_png()
elif s == 'set_freq':
freq = msg.arg1()
self.last_freq_params['freq'] = freq

@ -369,7 +369,7 @@ class stdout_wrapper(object):
# Main class that receives UDP audio samples and sends them to a PCM subsystem (currently ALSA or STDOUT)
class socket_audio(object):
def __init__(self, udp_host, udp_port, pcm_device, two_channels = False, audio_gain = 1.0, dest_stdout = False, **kwds):
def __init__(self, udp_host, udp_port, pcm_device, two_channels = False, audio_gain = 1.0, dest_stdout = False, silent_flag=False, **kwds):
self.keep_running = True
self.two_channels = two_channels
self.audio_gain = audio_gain
@ -377,6 +377,7 @@ class socket_audio(object):
self.sock_a = None
self.sock_b = None
self.pcm = None
self.silent_flag = silent_flag
if dest_stdout:
pcm_device = "stdout"
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0) # reopen stdout with buffering disabled
@ -417,7 +418,10 @@ class socket_audio(object):
# Check for select() polling timeout and pcm self-check
if (not readable) and (not writable) and (not exceptional):
rc = self.pcm.check()
if self.silent_flag:
rc = 0 # suppress additional zeros to preserve timing
else:
rc = self.pcm.check()
if isinstance(rc, ctypes.c_int):
rc = rc.value
continue

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -0,0 +1,50 @@
<html>
<head>
<title>OP25 Live</title>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<script src="http://localhost:8081/hls.js"></script>
<script>
var videoSrc = 'http://localhost:8081/live.m3u8';
var hls;
function onload1() {
attach_hls();
}
function attach_hls() {
var video = document.getElementById('video');
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
video.play();
});
}
// hls.js is not supported on platforms that do not have Media Source
// Extensions (MSE) enabled.
//
// When the browser has built-in HLS support (check using `canPlayType`),
// we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
// element through the `src` property. This is using the built-in support
// of the plain video element, without using hls.js.
//
// Note: it would be more normal to wait on the 'canplay' event below however
// on Safari (where you are most likely to find built-in HLS support) the
// video.src URL must be on the user-driven white-list before a 'canplay'
// event will be emitted; the last video event that can be reliably
// listened-for when the URL is not on the white-list is 'loadedmetadata'.
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
video.addEventListener('loadedmetadata', function() {
video.play();
});
}
} // end of attach_hls()
</script>
</head>
<body onload="javascript:onload1();">
<video id="video" controls height=600 width=800></video>
<br>
<hr>
</body>
</html>

@ -0,0 +1,3 @@
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=68800,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=400x300,NAME="240"
http://localhost:8081/hls/stream.m3u8
Loading…
Cancel
Save