diff --git a/.gitignore b/.gitignore index 6bb7afc..4d03b77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ venv key_env.bat -.vscode \ No newline at end of file +.vscode + +voice_announce_tmp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d2c81b0..7c2a949 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,11 @@ ENV UWSGI_INI /srv/www/yogsite/uwsgi.ini COPY . /srv/www/yogsite COPY nginx.conf /etc/nginx/sites-available/ +RUN echo "deb http://deb.debian.org/debian experimental main" >> /etc/apt/sources.list +RUN echo "deb http://deb.debian.org/debian sid main" >> /etc/apt/sources.list +RUN apt-get update +RUN apt-get install -y -t experimental ffmpeg + RUN pip install -r /srv/www/yogsite/requirements.txt WORKDIR /srv/www/yogsite \ No newline at end of file diff --git a/config.yml b/config.yml index 24dff08..1c02bdc 100644 --- a/config.yml +++ b/config.yml @@ -15,10 +15,12 @@ items_per_page: 20 servers: main: id: "main" + sqlname: "yogstation" host: "game.yogstation.net" port: 4133 name: "YogStation Main" primary: true + comms_key: $COMMS_KEY logs: directory: $GAME_LOGS_DIR @@ -103,7 +105,11 @@ roles: [ "gangster", "darkspawn", "Holoparasite", - "Zombie" + "Zombie", + "Appearance", + "Emote", + "OOC", + "Voice Announcements" ] library: @@ -133,6 +139,9 @@ paypal: return_url: "/donate?confirm=1" notify_url: "/api/paypal_donate" +voice_announce: + directory: $GAME_VOICE_ANNOUNCE_DIR + donation: tiers: - amount: 7.00 diff --git a/run_with_env_vars.bat b/run_with_env_vars.bat index 4124f97..acfe90e 100644 --- a/run_with_env_vars.bat +++ b/run_with_env_vars.bat @@ -5,5 +5,8 @@ set DB_GAME_PORT=3306 set DB_GAME_NAME="yogstation" set FLASK_SECRET_KEY="geiogjiovheiofhweiofh" +set COMMS_KEY="" + +set GAME_VOICE_ANNOUNCE_DIR=".\\voice_announce_tmp" python wsgi.py \ No newline at end of file diff --git a/yogsite/__init__.py b/yogsite/__init__.py index 440d960..da46afa 100644 --- a/yogsite/__init__.py +++ b/yogsite/__init__.py @@ -85,6 +85,7 @@ def modify_query(**new_values): from yogsite.modules.login import blueprint as bp_login from yogsite.modules.home import blueprint as bp_home from yogsite.modules.rounds import blueprint as bp_rounds +from yogsite.modules.voice_announce import blueprint as bp_voice_announce app.register_blueprint(bp_admin) app.register_blueprint(bp_api) @@ -94,4 +95,5 @@ def modify_query(**new_values): app.register_blueprint(bp_home) app.register_blueprint(bp_library) app.register_blueprint(bp_login) -app.register_blueprint(bp_rounds) \ No newline at end of file +app.register_blueprint(bp_rounds) +app.register_blueprint(bp_voice_announce) \ No newline at end of file diff --git a/yogsite/config.py b/yogsite/config.py index 8329a00..142c417 100644 --- a/yogsite/config.py +++ b/yogsite/config.py @@ -2,4 +2,4 @@ cfg = EnvYAML("config.yml") -XENFORO_HEADERS = {"XF-Api-Key": cfg.get("xenforo_key")} \ No newline at end of file +XENFORO_HEADERS = {"XF-Api-Key": cfg.get("xenforo_key")} diff --git a/yogsite/modules/voice_announce/__init__.py b/yogsite/modules/voice_announce/__init__.py new file mode 100644 index 0000000..05c7d51 --- /dev/null +++ b/yogsite/modules/voice_announce/__init__.py @@ -0,0 +1 @@ +from .routes import blueprint \ No newline at end of file diff --git a/yogsite/modules/voice_announce/routes.py b/yogsite/modules/voice_announce/routes.py new file mode 100644 index 0000000..eb2dcd8 --- /dev/null +++ b/yogsite/modules/voice_announce/routes.py @@ -0,0 +1,102 @@ +from flask import Blueprint, Response, request, render_template +from werkzeug.utils import secure_filename +from io import open +from os import path, remove +from yogsite.extensions import flask_csrf_ext +from yogsite.config import cfg +from yogsite.util import topic_query +from subprocess import run,DEVNULL + +blueprint = Blueprint("voice_announce", __name__) + +type_extensions = { + "audio/aac": ".aac", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/opus": ".opus", + "audio/wav": ".wav", + "audio/webm": ".weba" +} + +@blueprint.route("/voice_announce//") +def page_voice_announce(serversqlname, id): + for s in cfg.get("servers").values(): + if s["sqlname"] == serversqlname: + server = s + res = topic_query(server, "", args = { + "voice_announce_query": id, + "key": server["comms_key"] + }) + if (not res) or (not int(res["exists"])): + return Response("Invalid voice announcement URL", status=404) + + return render_template("voice_announce/voice_announce.html", enable_robot_voice = int(res["is_ai"])) + +@blueprint.route("/voice_announce///upload", methods=["POST"]) +@flask_csrf_ext.exempt +def voice_announce_upload(serversqlname, id): + for s in cfg.get("servers").values(): + if s["sqlname"] == serversqlname: + server = s + id = secure_filename(id) + dir = cfg.get('voice_announce.directory') + res = topic_query(server, "", args = { + "voice_announce_query": id, + "key": server["comms_key"] + }) + if (not res) or (not int(res["exists"])): + return Response("Invalid voice announcement URL", status=404) + + if request.content_length > 120000: + return Response("File too large", status=400) + ct = request.content_type + semicolon_loc = ct.find(";") + if semicolon_loc != -1: + ct = ct[:semicolon_loc] + if type_extensions[ct] == None: + return Response("Invalid file type", status=400) + filename_base = id + filename = filename_base + "_base" + type_extensions[ct] + ogg_filename = filename_base + "_converted.ogg" + with open(path.join(dir,filename), "wb") as file: + file.write(request.get_data()) + + result = run(["ffmpeg", "-i", path.join(dir,filename), "-c:a", "libvorbis", "-y", path.join(dir,ogg_filename)], stdin=DEVNULL,stdout=DEVNULL,stderr=DEVNULL) + if result.returncode != 0: + remove(path.join(dir, filename)) + return Response("Conversion failed", status=500) + probe_result = run(["ffprobe", "-i", path.join(dir, ogg_filename), "-show_entries", "format=duration", "-v", "quiet", "-of", "csv=p=0"], capture_output=True) + if probe_result.returncode != 0: + remove(path.join(dir, filename)) + remove(path.join(dir, ogg_filename)) + return Response("ffprobe failed: " + probe_result.stderr.decode("utf-8"), status=500) + duration = float(probe_result.stdout) + if duration > 35: + remove(path.join(dir, filename)) + remove(path.join(dir, ogg_filename)) + return Response("Duration too long!", status=400) + + topic_query(server, "", args = { + "voice_announce": id, + "ogg_file": ogg_filename, + "uploaded_file": filename, + "ip": request.remote_addr, + "duration": duration, + "key": server["comms_key"] + }) + + return Response(None, status=204) + +@blueprint.route("/voice_announce///cancel", methods=["GET","POST"]) +@flask_csrf_ext.exempt +def voice_announce_cancel(serversqlname, id): + for s in cfg.get("servers").values(): + if s["sqlname"] == serversqlname: + server = s + + topic_query(server, "", args = { + "voice_announce_cancel": id, + "key": server["comms_key"] + }) + + return Response(None, status=204) diff --git a/yogsite/static/js/voice_announce.js b/yogsite/static/js/voice_announce.js new file mode 100644 index 0000000..11649c8 --- /dev/null +++ b/yogsite/static/js/voice_announce.js @@ -0,0 +1,245 @@ +const RECORDING_STATE_IDLE = 0; +const RECORDING_STATE_WAITING = 1; +const RECORDING_STATE_RECORDING = 2; +const RECORDING_STATE_UPLOADING = 3; +const RECORDING_STATE_DONE = 4; + +let recording_state = RECORDING_STATE_IDLE; +/** @type {MediaStream} */ +let mic_media_stream; +/** @type {MediaStream} */ +let dest_media_stream; +/** @type {AnalyserNode} */ +let analyser; +/** @type {Float32Array} */ +let analyser_data; +let recorder; +let recording_start_time; + +/** @type {Blob} */ +let recorded_blob; +/** @type {string} */ +let recorded_blob_url; + +const audio_ctx = new AudioContext(); + +const robot_freq = 59.94; // Our nanotrasen-brand AIs operate on NTSC, okay? +const robot_cycles = 300; + +function set_recording_state(state) { + let prev = recording_state; + if(recording_state == state) return; + recording_state = state; + let elem = document.getElementById("va-button-record"); + elem.classList.remove("is-warning", "is-success"); + if(state == RECORDING_STATE_IDLE) { + if(prev == RECORDING_STATE_RECORDING || prev == RECORDING_STATE_UPLOADING) elem.textContent = "Re-record"; + else elem.textContent = "Record"; + } else if(state == RECORDING_STATE_WAITING) { + elem.textContent = "Waiting for permission..."; + elem.classList.add("is-warning"); + } else if(state == RECORDING_STATE_RECORDING) { + elem.textContent = "Stop Recording"; + elem.classList.add("is-success"); + } + for(let item of document.getElementsByClassName("va-record-show")) { + if(state == RECORDING_STATE_RECORDING) { + item.classList.remove("is-hidden"); + } else { + item.classList.add("is-hidden"); + } + } + let upload_button = document.getElementById("va-button-announce"); + upload_button.classList.remove("is-warning"); + upload_button.classList.remove("is-success"); + if(state == RECORDING_STATE_UPLOADING) { + elem.classList.add("is-hidden"); + upload_button.classList.add("is-warning"); + upload_button.textContent = "Uploading..."; + } else if(state == RECORDING_STATE_DONE) { + elem.classList.add("is-hidden"); + upload_button.classList.add("is-success"); + upload_button.textContent = "Upload successful!"; + } else { + elem.classList.remove("is-hidden"); + upload_button.textContent = "Confirm Announcement" + } +} + +let speaker_setups = [ + [0, 0, 0.7], // delay, pan, gain + [0.1, -0.6, 0.25], + [0.2, 0.9, 0.18], + [0.4, 0.3, 0.03] +]; + +/** + * + * @param {AudioParam} param + */ +function apply_robot_wave(param, time = audio_ctx.currentTime) { + console.log("Applying " + time + ", now: " + audio_ctx.currentTime); + let values = new Float32Array(robot_cycles * 2); + for(let i = 0; i < robot_cycles; i++) { + values[i*2] = 0; + values[i*2+1] = 1; + } + let duration = robot_cycles / robot_freq; + param.setValueCurveAtTime(values, time, duration); + setTimeout(() => {apply_robot_wave(param, time + duration + 0.001);}, ((time + duration - (duration/3)) - audio_ctx.currentTime) * 1000); +} + +/** + * + * @param {AudioNode} output + * @param {AudioNode} input + */ +function apply_effects(output, input) { + if(enable_robot_voice) { + // Apply a really primitive "robot voice" filter + let final_gain = audio_ctx.createGain(); + input.connect(final_gain); + input = final_gain; + apply_robot_wave(final_gain.gain); + } + analyser = audio_ctx.createAnalyser(); + analyser.connect(output); + analyser_data = new Float32Array(analyser.frequencyBinCount); + output = analyser; + //input.connect(analyser); + + for(let [delay, pan, gain] of speaker_setups) { + let curr = input; + + if(delay) { + let delay_node = audio_ctx.createDelay(delay); + delay_node.delayTime.value = delay; + curr.connect(delay_node); + curr = delay_node; + } + + if(pan) { + let pan_node = audio_ctx.createStereoPanner(); + pan_node.pan.value = pan; + curr.connect(pan_node); + curr = pan_node; + } + + if(gain) { + let gain_node = audio_ctx.createGain(); + gain_node.gain.value = gain; + curr.connect(gain_node); + curr = gain_node; + } + curr.connect(output); + } +} + +function update_level() { + analyser.getFloatTimeDomainData(analyser_data); + /** @type {HTMLProgressElement} */ + let bar = document.getElementById("va-level"); + let max_abs = 0; + for(let i = 0; i < analyser_data.length; i++) { + max_abs = Math.max(max_abs, Math.abs(analyser_data[i])); + } + bar.value = 100 * max_abs; + if(recording_state == RECORDING_STATE_RECORDING) { + document.getElementById("va-time").textContent = (Math.floor(audio_ctx.currentTime - recording_start_time)) + " / 30"; + if((audio_ctx.currentTime - recording_start_time) >= 30) { + stop_recording(); + } + } + requestAnimationFrame(update_level); +} + +async function setup_media_stream() { + if(dest_media_stream) return; + + await audio_ctx.resume(); + mic_media_stream = await navigator.mediaDevices.getUserMedia({audio: true}); + let stream_node = audio_ctx.createMediaStreamSource(mic_media_stream); + let dest_node = audio_ctx.createMediaStreamDestination(); + dest_media_stream = dest_node.stream; + apply_effects(dest_node, stream_node); + update_level(); +} + +async function stop_recording() { + set_recording_state(RECORDING_STATE_IDLE); + recorded_blob = await new Promise((resolve, reject) => { + recorder.ondataavailable = (e) => {resolve(e.data);}; + recorder.onerror = reject; + recorder.stop(); + }); + for(let track of mic_media_stream.getAudioTracks()) track.enabled = false; + recorded_blob_url = URL.createObjectURL(recorded_blob); + document.getElementById("va-button-announce").classList.remove("is-hidden"); + document.getElementById("va-preview").classList.remove("is-hidden"); + document.getElementById("va-preview").src = recorded_blob_url; +} + +async function record_button() { + if(recording_state == RECORDING_STATE_IDLE) { + if(recorded_blob) { + recorded_blob = null; + URL.revokeObjectURL(recorded_blob_url); + recorded_blob_url = null; + document.getElementById("va-button-announce").classList.add("is-hidden"); + document.getElementById("va-preview").classList.add("is-hidden"); + document.getElementById("va-preview").pause(); + } + set_recording_state(RECORDING_STATE_WAITING); + try { + await setup_media_stream(); + for(let track of mic_media_stream.getAudioTracks()) track.enabled = true; + set_recording_state(RECORDING_STATE_RECORDING); + recorder = new MediaRecorder(dest_media_stream, {audioBitsPerSecond: 20000}); + recorder.start(); + recording_start_time = audio_ctx.currentTime; + } catch(e) { + console.error(e); + set_recording_state(RECORDING_STATE_IDLE); + } + } else if(recording_state == RECORDING_STATE_RECORDING) { + await stop_recording(); + } +} + +async function upload_button() { + if(recording_state == RECORDING_STATE_IDLE && recorded_blob) { + set_recording_state(RECORDING_STATE_UPLOADING); + let url = location.origin + location.pathname + "/upload"; + try { + let res = await fetch(url, { + method: 'POST', + cache: 'no-cache', + body: recorded_blob, + headers: { + 'Content-Type': recorded_blob.type + }, + mode: 'same-origin' + }); + if(res.status == 204) { + set_recording_state(RECORDING_STATE_DONE); + for(let track of mic_media_stream.getAudioTracks()) track.stop(); + } else { + set_recording_state(RECORDING_STATE_IDLE); + } + } catch(e) { + set_recording_state(RECORDING_STATE_IDLE); + } + } +} + +window.addEventListener("DOMContentLoaded", () => { + document.getElementById("va-button-record").addEventListener("click", record_button); + document.getElementById("va-button-announce").addEventListener("click", upload_button); +}); + +document.addEventListener("visibilitychange", () => { + let url = location.origin + location.pathname + "/cancel"; + if(recording_state != RECORDING_STATE_DONE && document.visibilityState == 'hidden') { + navigator.sendBeacon(url) + } +}) diff --git a/yogsite/templates/voice_announce/voice_announce.html b/yogsite/templates/voice_announce/voice_announce.html new file mode 100644 index 0000000..6793f59 --- /dev/null +++ b/yogsite/templates/voice_announce/voice_announce.html @@ -0,0 +1,49 @@ +{% extends "layout.html" %} + +{% block title %}Voice Announcements{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +

Voice Announcement System

+ +
+
+
+

WARNING: Misuse of this system will lead to a ban. You may only use this system to upload your own voice.

+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file