From 5d2dd6c946c2b64aa5367b74f2fc0daba6de1905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Mon, 9 Dec 2024 14:55:52 +0100 Subject: [PATCH] Initial commit --- Dockerfile | 69 +++++++++++++++++++++ README.md | 24 ++++++++ entrypoint.sh | 57 ++++++++++++++++++ novnc/defaults.json | 6 ++ novnc/tokenfile | 2 + novnc/webaudio.js | 142 ++++++++++++++++++++++++++++++++++++++++++++ pulse/client.conf | 1 + pulse/default.pa | 3 + 8 files changed, 304 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 entrypoint.sh create mode 100644 novnc/defaults.json create mode 100644 novnc/tokenfile create mode 100644 novnc/webaudio.js create mode 100644 pulse/client.conf create mode 100644 pulse/default.pa diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8df4971 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +FROM ubuntu + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends\ + bash \ + sudo \ + git \ + procps + +RUN mkdir -p /opt/noVNC + +RUN adduser --home /home/novnc --shell /bin/bash --system --disabled-password novnc \ + && echo "novnc ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# X11 and xfce +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends\ + xvfb xauth dbus-x11 xfce4 xfce4-terminal \ + x11-xserver-utils + +# VNC +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends\ + python3 python3-pip \ + tigervnc-standalone-server tigervnc-common \ + openssl \ + && pip3 install numpy + +# NoVNC +RUN git clone --single-branch https://github.com/novnc/noVNC.git /opt/noVNC \ + && git clone --single-branch https://github.com/novnc/websockify.git /opt/noVNC/utils/websockify \ + && ln -s /opt/noVNC/vnc.html /opt/noVNC/index.html + +RUN openssl req -batch -new -x509 -days 365 -nodes -out self.pem -keyout /opt/noVNC/utils/websockify/self.pem + + +# Audio +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends\ + pulseaudio \ + pavucontrol \ + ucspi-tcp \ + gstreamer1.0-plugins-good \ + gstreamer1.0-pulseaudio \ + gstreamer1.0-tools + +COPY pulse/ /etc/pulse +COPY novnc /opt/noVNC/ +RUN sed -i "/import RFB/a \ + import '../webaudio.js'" \ + /opt/noVNC/app/ui.js + +# Extra applications +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install -y --no-install-recommends\ + firefox neovim + +COPY entrypoint.sh /opt/noVNC/entrypoint.sh + +ENTRYPOINT ["/opt/noVNC/entrypoint.sh"] +EXPOSE 8080 + +USER novnc +WORKDIR /home/novnc diff --git a/README.md b/README.md new file mode 100644 index 0000000..27adf42 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# NOVNC-BASE + +A desktop environment with sound in docker + +Can be used as a base file for application specific containers. + + +E.g: +```dockerfile +FROM thomasloven/novnc-base + +RUN sudo apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + sudo apt-get install -y blender + +CMD ["blender"] +``` + +### Bonus functionality - dotfiles installation. +If the environment variable `DOTFILES_REPO` is set, the container will `git +clone` that into `~/dotfiles` and then run `~/dotfiles/install.sh` if it +exists. + + diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..8e9a84d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,57 @@ +#! /bin/bash + +shopt -s nullglob + +kill_pid() { + if [ -f $1 ]; then + cat $1 | xargs kill > /dev/null 2>&1 + rm -f $1 + fi +} + +# Perform cleanup and remove X1 lock files if the container was restarted +rm -f /tmp/.X1-lock /tmp/.X11-unix/X1 +kill_pid ~/.vnc-pid +kill_pid ~/.pa-pid +kill_pid ~/.tcp-pid +kill_pid ~/.ws-pid + +# Clone and install dotfiles if DOTFILES_REPO is defined +if [ -n "$DOTFILES_REPO" ]; then + if [ ! -d ~/dotfiles ]; then + git clone $DOTFILES_REPO ~/dotfiles + if [ -f ~/dotfiles/install.sh ]; then + /bin/bash ~/dotfiles/install.sh + fi + fi +fi + +# Launch VNC server - view :1 defaults to port 5901 +vncserver :1 -SecurityTypes None -localhost no --I-KNOW-THIS-IS-INSECURE & +echo "$!" > ~/.vnc-pid + +# Launch pulseaudio server +# /etc/pulse/client.conf and /etc/pulse/default.pa are setup to make a default +# audio sink which outputs to a socket at /tmp/pulseaudio.socket +DISPLAY=:0.0 pulseaudio --disallow-module-loading --disallow-exit --exit-idle-time=-1& +echo "$!" > ~/.pa-pid + +# Use gstreamer to stream the pulseaudio source /tmp/pulseaudio.socket to stdout (fd=1) +# the tcpserver from ucspi-tcp pipes this to tcp port 6901 +tcpserver localhost 6901 gst-launch-1.0 -q pulsesrc server=/tmp/pulseaudio.socket ! audio/x-raw, channels=2, rate=12000 ! cutter ! opusenc ! webmmux ! fdsink fd=1 & +echo "$!" > ~/.tcp-pid + +# Websockify does three things: +# - publishes /opt/noVNC to http port 8080 +# - proxies vnc port 5901 to 8080/websockify?token=vnc +# - proxies pulseaudio port 6901 to 8080/websockify?token=pulse +# The latter two are defined through the tokenfile +/opt/noVNC/utils/websockify/websockify.py --web /opt/noVNC 8080 --token-plugin=TokenFile --token-source=/opt/noVNC/tokenfile & +echo "$!" > ~/.ws-pid + +if [ -n "$@" ]; then + DISPLAY=:1.0 exec "$@" & +fi + +wait + diff --git a/novnc/defaults.json b/novnc/defaults.json new file mode 100644 index 0000000..c533413 --- /dev/null +++ b/novnc/defaults.json @@ -0,0 +1,6 @@ +{ + "autoconnect": true, + "reconnect": true, + "path": "websockify?token=vnc", + "resize": "remote" +} diff --git a/novnc/tokenfile b/novnc/tokenfile new file mode 100644 index 0000000..0e1a81e --- /dev/null +++ b/novnc/tokenfile @@ -0,0 +1,2 @@ +vnc: localhost:5901 +pulse: localhost:6901 diff --git a/novnc/webaudio.js b/novnc/webaudio.js new file mode 100644 index 0000000..a67703d --- /dev/null +++ b/novnc/webaudio.js @@ -0,0 +1,142 @@ +class WebAudio { + constructor(url=null) { + if (url !== null) + this.url = url + else { + if (window.location.protocol === "https:") + this.url = "wss" + else + this.url = "ws" + this.url += "://" + window.location.host + "/websockify?token=pulse" + } + + this.connected = false; + + //constants for audio behavoir + this.maximumAudioLag = 1.5; //amount of seconds we can potentially be behind the server audio stream + this.syncLagInterval = 5000; //check every x milliseconds if we are behind the server audio stream + this.updateBufferEvery = 20; //add recieved data to the player buffer every x milliseconds + this.reduceBufferInterval = 500; //trim the output audio stream buffer every x milliseconds so we don't overflow + this.maximumSecondsOfBuffering = 1; //maximum amount of data to store in the play buffer + this.connectionCheckInterval = 500; //check the connection every x milliseconds + + //register all our background timers. these need to be created only once - and will run independent of the object's streams/properties + setInterval(() => this.updateQueue(), this.updateBufferEvery); + setInterval(() => this.syncInterval(), this.syncLagInterval); + setInterval(() => this.reduceBuffer(), this.reduceBufferInterval); + setInterval(() => this.tryLastPacket(), this.connectionCheckInterval); + + } + + //registers all the event handlers for when this stream is closed - or when data arrives. + registerHandlers() { + this.mediaSource.addEventListener('sourceended', e => this.socketDisconnected(e)) + this.mediaSource.addEventListener('sourceclose', e => this.socketDisconnected(e)) + this.mediaSource.addEventListener('error', e => this.socketDisconnected(e)) + this.buffer.addEventListener('error', e => this.socketDisconnected(e)) + this.buffer.addEventListener('abort', e => this.socketDisconnected(e)) + } + + //starts the web audio stream. only call this method on button click. + start() { + if (!!this.connected) return; + if (!!this.audio) this.audio.remove(); + this.queue = null; + + this.mediaSource = new MediaSource() + this.mediaSource.addEventListener('sourceopen', e => this.onSourceOpen()) + //first we need a media source - and an audio object that contains it. + this.audio = document.createElement('audio'); + this.audio.src = window.URL.createObjectURL(this.mediaSource); + + //start our stream - we can only do this on user input + this.audio.play(); + } + + wsConnect() { + if (!!this.socket) this.socket.close(); + + this.socket = new WebSocket(this.url, ['binary', 'base64']) + this.socket.binaryType = 'arraybuffer' + this.socket.addEventListener('message', e => this.websocketDataArrived(e), false); + } + + //this is called when the media source contains data + onSourceOpen(e) { + this.buffer = this.mediaSource.addSourceBuffer('audio/webm; codecs="opus"') + this.registerHandlers(); + this.wsConnect(); + } + + //whenever data arrives in our websocket this is called. + websocketDataArrived(e) { + this.lastPacket = Date.now(); + this.connected = true; + this.queue = this.queue == null ? e.data : this.concat(this.queue, e.data); + } + + //whenever a disconnect happens this is called. + socketDisconnected(e) { + console.log(e); + this.connected = false; + } + + tryLastPacket() { + if (this.lastPacket == null) return; + if ((Date.now() - this.lastPacket) > 1000) { + this.socketDisconnected('timeout'); + } + } + + //this updates the buffer with the data from our queue + updateQueue() { + if (!(!!this.queue && !!this.buffer && !this.buffer.updating)) { + return; + } + + this.buffer.appendBuffer(this.queue); + this.queue = null; + } + + //reduces the stream buffer to the minimal size that we need for streaming + reduceBuffer() { + if (!(this.buffer && !this.buffer.updating && !!this.audio && !!this.audio.currentTime && this.audio.currentTime > 1)) { + return; + } + + this.buffer.remove(0, this.audio.currentTime - 1); + } + + //synchronizes the current time of the stream with the server + syncInterval() { + if (!(this.audio && this.audio.currentTime && this.audio.currentTime > 1 && this.buffer && this.buffer.buffered && this.buffer.buffered.length > 1)) { + return; + } + + var currentTime = this.audio.currentTime; + var targetTime = this.buffer.buffered.end(this.buffer.buffered.length - 1); + + if (targetTime > (currentTime + this.maximumAudioLag)) this.audio.fastSeek(targetTime); + } + + //joins two data arrays - helper function + concat(buffer1, buffer2) { + var tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); + tmp.set(new Uint8Array(buffer1), 0); + tmp.set(new Uint8Array(buffer2), buffer1.byteLength); + return tmp.buffer; + }; +} + + +const wa = new WebAudio(null); +// window.wa = wa; // For debugging accesibility + +// Audio playback requires user interaction before being allowed by browsers +const connect = () => { + if (!wa.connected) + wa.start(); +}; +addEventListener("keydown", connect); +addEventListener("pointerdown", connect); +addEventListener("touchstart", connect); diff --git a/pulse/client.conf b/pulse/client.conf new file mode 100644 index 0000000..c758e4a --- /dev/null +++ b/pulse/client.conf @@ -0,0 +1 @@ +default-server=unix:/tmp/pulseaudio.socket \ No newline at end of file diff --git a/pulse/default.pa b/pulse/default.pa new file mode 100644 index 0000000..8cc9cbd --- /dev/null +++ b/pulse/default.pa @@ -0,0 +1,3 @@ +#!/usr/bin/pulseaudio -nF +load-module module-native-protocol-unix socket=/tmp/pulseaudio.socket auth-anonymous=1 +load-module module-always-sink \ No newline at end of file