Merge remote-tracking branch 'origin/dev' into mstratford/norm-switch

This commit is contained in:
Matthew Stratford 2021-11-03 00:02:11 +00:00
commit 04cf5a6d03
17 changed files with 292 additions and 102 deletions

View file

@ -22,10 +22,14 @@ jobs:
npm run presenter-make
build/build-macos.sh
zip -r build/output/BAPSicle.zip build/output/BAPSicle.app
- name: Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})"
id: extract_branch
- name: Archive Build
uses: actions/upload-artifact@v2
with:
name: Package - MacOS
name: BAPSicle-${{ steps.extract_branch.outputs.branch }}-${{github.sha}}-MacOS
path: |
build/output/BAPSicle.zip
@ -48,10 +52,14 @@ jobs:
run: |
npm run presenter-make
build/build-linux.sh
- name: Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})"
id: extract_branch
- name: Archive Build
uses: actions/upload-artifact@v2
with:
name: Package - Ubuntu
name: BAPSicle-${{ steps.extract_branch.outputs.branch }}-${{github.sha}}-Ubuntu
path: |
build/output/BAPSicle
@ -74,10 +82,14 @@ jobs:
run: |
npm run presenter-make
build/build-windows.bat no-venv
- name: Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})"
id: extract_branch
- name: Archive Build
uses: actions/upload-artifact@v2
with:
name: Package - Windows
name: BAPSicle-${{ steps.extract_branch.outputs.branch }}-${{github.sha}}-Windows
path: |
build/output/BAPSicle.exe
install/

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ music-tmp/
presenter-build
node_modules/

View file

@ -7,7 +7,7 @@ build_branch="$(git branch --show-current)"
echo "BUILD: str = \"$build_commit\"" > ../build.py
echo "BRANCH: str = \"$build_branch\"" >> ../build.py
apt install libportaudio2
sudo apt install libportaudio2
python3 -m venv ../venv
source ../venv/bin/activate
@ -19,6 +19,8 @@ pip3 install -e ../
python3 ./generate-build-exe-config.py
chmod +x output/BAPSicle
python3 ./build-exe.py
bash ./build-exe-pyinstaller-command.sh

View file

@ -1,5 +1,5 @@
wheel
pygame==2.0.1
pygame==2.0.2
sanic==21.3.4
sanic-Cors==1.0.0
syncer==1.3.0

View file

@ -0,0 +1,24 @@
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
import pygame._sdl2 as sdl2
import pygame
from pygame import mixer
pygame.init()
import time
mixer.init(44100, -16, 2, 1024)
is_capture = 0 # zero to request playback devices, non-zero to request recording devices
num = sdl2.get_num_audio_devices(is_capture)
names = [str(sdl2.get_audio_device_name(i, is_capture), encoding="utf-8") for i in range(num)]
mixer.quit()
for i in names:
print(i)
mixer.init(44100, -16, 2, 1024, devicename=i)
print(mixer.get_init())
mixer.music.load("/home/mstratford/Downloads/managed_play.mp3")
mixer.music.play()
# my_song = mixer.Sound("/home/mstratford/Downloads/managed_play.mp3")
# my_song.play()
time.sleep(5)
pygame.quit()

View file

@ -1,6 +1,13 @@
from typing import Any, Dict, List, Optional, Tuple
import sounddevice as sd
from helpers.os_environment import isLinux, isMacOS, isWindows
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
if isLinux():
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
import pygame._sdl2 as sdl2
from pygame import mixer
import glob
if isWindows():
@ -20,10 +27,10 @@ class DeviceManager:
return host_api
@classmethod
def _getAudioDevices(cls) -> sd.DeviceList:
def _getSDAudioDevices(cls):
# To update the list of devices
# Sadly this doesn't work on MacOS.
if not isMacOS():
# Sadly this only works on Windows. Linux hangs, MacOS crashes.
if isWindows():
sd._terminate()
sd._initialize()
devices: sd.DeviceList = sd.query_devices()
@ -31,11 +38,13 @@ class DeviceManager:
@classmethod
def getAudioOutputs(cls) -> Tuple[List[Dict]]:
host_apis = sd.query_hostapis()
devices: sd.DeviceList = cls._getAudioDevices()
host_apis = list(sd.query_hostapis())
devices: sd.DeviceList = cls._getSDAudioDevices()
for host_api_id in range(len(host_apis)):
if isWindows() and host_apis[host_api_id]["name"] not in WINDOWS_APIS:
# Linux SDL uses PortAudio, which SoundDevice doesn't find. So mark all as unsable.
if (isWindows() and host_apis[host_api_id]["name"] not in WINDOWS_APIS) or (isLinux()):
host_apis[host_api_id]["usable"] = False
else:
host_apis[host_api_id]["usable"] = True
@ -51,6 +60,15 @@ class DeviceManager:
return host_apis
@classmethod
def getAudioDevices(cls) -> List[str]:
mixer.init(44100, -16, 2, 1024)
is_capture = 0 # zero to request playback devices, non-zero to request recording devices
num = sdl2.get_num_audio_devices(is_capture)
names = [str(sdl2.get_audio_device_name(i, is_capture), encoding="utf-8") for i in range(num)]
mixer.quit()
return names
@classmethod
def getSerialPorts(cls) -> List[Optional[str]]:
"""Lists serial port names

View file

@ -60,6 +60,7 @@ class MyRadioAPI:
+ str(response.status)
)
self._logException(str(await response.text()))
return None # Given the output was bad, don't forward it.
return await response.read()
def call(self, url, method="GET", data=None, timeout=10, json_payload=True):
@ -101,16 +102,21 @@ class MyRadioAPI:
self._log("Requesting API V2 URL with method {}: {}".format(method, url))
request = None
try:
if method == "GET":
request = self.async_call(url, method="GET", timeout=timeout)
request = await self.async_call(url, method="GET", timeout=timeout)
elif method == "POST":
self._log("POST data: {}".format(data))
request = self.async_call(url, data=data, method="POST", timeout=timeout)
request = await self.async_call(url, data=data, method="POST", timeout=timeout)
elif method == "PUT":
request = self.async_call(url, method="PUT", timeout=timeout)
request = await self.async_call(url, method="PUT", timeout=timeout)
else:
self._logException("Invalid API method. Request not sent.")
return None
except aiohttp.ClientError:
self._logException("Failed async API request.")
return None
self._log("Finished request.")
return request
@ -157,7 +163,7 @@ class MyRadioAPI:
self._logException("Failed to get list of show plans.")
return None
payload = json.loads(await request)["payload"]
payload = json.loads(request)["payload"]
shows = []
if not payload["current"]:
@ -186,7 +192,7 @@ class MyRadioAPI:
self._logException("Failed to get show plan.")
return None
payload = json.loads(await request)["payload"]
payload = json.loads(request)["payload"]
plan = {}
@ -203,7 +209,7 @@ class MyRadioAPI:
# Audio Library
async def get_filename(self, item: PlanItem, did_download: bool = False):
async def get_filename(self, item: PlanItem, did_download: bool = False, redownload=False):
format = "mp3" # TODO: Maybe we want this customisable?
if item.trackid:
itemType = "track"
@ -234,7 +240,9 @@ class MyRadioAPI:
filename: str = resolve_external_file_path(
"/music-tmp/{}-{}.{}".format(itemType, id, format)
)
# Check if we already downloaded the file. If we did, give that.
if not redownload:
# Check if we already downloaded the file. If we did, give that, unless we're forcing a redownload.
if os.path.isfile(filename):
self._log("Already got file: " + filename, DEBUG)
return (filename, False) if did_download else filename
@ -267,17 +275,20 @@ class MyRadioAPI:
request = await self.async_api_call(url, api_version="non")
if not request:
if not request or not isinstance(request, (bytes, bytearray)):
# Remove the .downloading temp file given we gave up trying to download.
os.remove(filename + dl_suffix)
return (None, False) if did_download else None
try:
with open(filename + dl_suffix, "wb") as file:
file.write(await request)
file.write(request)
os.rename(filename + dl_suffix, filename)
except Exception as e:
self._logException("Failed to write music file: {}".format(e))
return (None, False) if did_download else None
self._log("Successfully re/downloaded file.", DEBUG)
return (filename, True) if did_download else filename
# Gets the list of managed music playlists.
@ -285,22 +296,22 @@ class MyRadioAPI:
url = "/playlist/allitonesplaylists"
request = await self.async_api_call(url)
if not request:
if not request or not isinstance(request, bytes):
self._logException("Failed to retrieve music playlists.")
return None
return []
return json.loads(await request)["payload"]
return json.loads(request)["payload"]
# Gets the list of managed aux playlists (sfx, beds etc.)
async def get_playlist_aux(self):
url = "/nipswebPlaylist/allmanagedplaylists"
request = await self.async_api_call(url)
if not request:
if not request or not isinstance(request, bytes):
self._logException("Failed to retrieve music playlists.")
return None
return []
return json.loads(await request)["payload"]
return json.loads(request)["payload"]
# Loads the playlist items for a certain managed aux playlist
async def get_playlist_aux_items(self, library_id: str):
@ -311,13 +322,13 @@ class MyRadioAPI:
url = "/nipswebPlaylist/{}/items".format(library_id)
request = await self.async_api_call(url)
if not request:
if not request or not isinstance(request, bytes):
self._logException(
"Failed to retrieve items for aux playlist {}.".format(library_id)
)
return None
return []
return json.loads(await request)["payload"]
return json.loads(request)["payload"]
# Loads the playlist items for a certain managed playlist
@ -325,13 +336,13 @@ class MyRadioAPI:
url = "/playlist/{}/tracks".format(library_id)
request = await self.async_api_call(url)
if not request:
if not request or not isinstance(request, bytes):
self._logException(
"Failed to retrieve items for music playlist {}.".format(library_id)
)
return None
return []
return json.loads(await request)["payload"]
return json.loads(request)["payload"]
async def get_track_search(
self, title: Optional[str], artist: Optional[str], limit: int = 100
@ -341,11 +352,11 @@ class MyRadioAPI:
)
request = await self.async_api_call(url)
if not request:
if not request or not isinstance(request, bytes):
self._logException("Failed to search for track.")
return None
return []
return json.loads(await request)["payload"]
return json.loads(request)["payload"]
def post_tracklist_start(self, item: PlanItem):
if item.type != "central":

View file

@ -44,3 +44,24 @@ def get_normalised_filename_if_available(filename: str):
# Else we've not got a normalised verison, just take original.
return filename
# Returns the original file from the normalised one, useful if the normalised one is borked.
def get_original_filename_from_normalised(filename: str):
if not (isinstance(filename, str) and filename.endswith(".mp3")):
raise ValueError("Invalid filename given.")
# Already not normalised.
if not filename.endswith("-normalised.mp3"):
if os.path.exists(filename):
return filename
return None
# Take the filename, remove "-normalised" from it.
original_filename = "{}.mp3".format(filename.rsplit("-", 1)[0])
# original version exists
if os.path.exists(original_filename):
return original_filename
return None

12
package-lock.json generated
View file

@ -1,5 +1,13 @@
{
"name": "bapsicle",
"version": "3.0.0",
"lockfileVersion": 1
"version": "3.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"yarn": {
"version": "1.22.15",
"resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.15.tgz",
"integrity": "sha512-AzoEDxj256BOS/jqDXA3pjyhmi4FRBBUMgYoTHI4EIt2EhREkvH0soPVEtnD+DQIJfU5R9bKhcZ1H9l8zPWeoA=="
}
}
}

View file

@ -24,5 +24,8 @@
"bugs": {
"url": "https://github.com/universityradioyork/bapsicle/issues"
},
"homepage": "https://github.com/universityradioyork/bapsicle#readme"
"homepage": "https://github.com/universityradioyork/bapsicle#readme",
"dependencies": {
"yarn": "^1.22.15"
}
}

View file

@ -21,8 +21,11 @@
# Stop the Pygame Hello message.
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
from helpers.os_environment import isLinux
# It's the only one we could get to work.
if isLinux():
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
from queue import Empty
import multiprocessing
@ -37,7 +40,7 @@ from syncer import sync
from threading import Timer
from datetime import datetime
from helpers.normalisation import get_normalised_filename_if_available
from helpers.normalisation import get_normalised_filename_if_available, get_original_filename_from_normalised
from helpers.myradio_api import MyRadioAPI
from helpers.state_manager import StateManager
from helpers.logging_manager import LoggingManager
@ -171,9 +174,10 @@ class Player:
# Audio Playout Related Methods
def play(self, pos: float = 0):
if not self.isLoaded:
return
self.logger.log.info("Playing from pos: " + str(pos))
if not self.isLoaded:
self.logger.log.warning("Player is not loaded.")
return False
try:
mixer.music.play(0, pos)
self.state.update("pos_offset", pos)
@ -200,9 +204,7 @@ class Player:
if not self.isPlaying:
state = self.state.get()
position: float = state["pos_true"]
try:
self.play(position)
except Exception:
if not self.play(position):
self.logger.log.exception(
"Failed to unpause from pos: " + str(position)
)
@ -332,7 +334,7 @@ class Player:
# Right. So this may be confusing.
# So... If the user has just moved the loaded item in the channel (by removing above and readding)
# Then we want to re-associate the loaded_item object reference with the new one.
# The loaded item object before this change is now an ophan, which was
# The loaded item object before this change is now an orphan, which was
# kept around while the loaded item was potentially moved to another
# channel.
if loaded_item.timeslotitemid == new_item_obj.timeslotitemid:
@ -409,9 +411,13 @@ class Player:
def load(self, weight: int):
if not self.isPlaying:
loaded_state = self.state.get()
# If we have something loaded already, unload it first.
self.unload()
loaded_state = self.state.get()
# Sometimes (at least on windows), the pygame player will lose output to the sound output after a while.
# It's odd, but essentially, to stop / recover from this, we de-init the pygame mixer and init it again.
self.logger.log.info(
"Resetting output (in case of sound output gone silent somehow) to "
+ str(loaded_state["output"])
@ -422,16 +428,22 @@ class Player:
loaded_item: Optional[PlanItem] = None
# Go find the show plan item of the weight we've been asked to load.
for i in range(len(showplan)):
if showplan[i].weight == weight:
loaded_item = showplan[i]
break
# If we didn't find it, exit.
if loaded_item is None:
self.logger.log.error(
"Failed to find weight: {}".format(weight))
return False
# This item exists, so we're comitting to load this item.
self.state.update("loaded_item", loaded_item)
# The file_manager helper may have pre-downloaded the file already, or we've played it before.
reload = False
if loaded_item.filename == "" or loaded_item.filename is None:
self.logger.log.info(
@ -443,10 +455,12 @@ class Player:
)
reload = True
# Ask the API for the file if we need it.
if reload:
loaded_item.filename = sync(
self.api.get_filename(item=loaded_item))
file = sync(self.api.get_filename(item=loaded_item))
loaded_item.filename = str(file) if file else None
# If the API still couldn't get the file, RIP.
if not loaded_item.filename:
return False
@ -455,38 +469,55 @@ class Player:
loaded_item.filename
)
# Given we've just messed around with filenames etc, update the item again.
self.state.update("loaded_item", loaded_item)
for i in range(len(showplan)):
if showplan[i].weight == weight:
self.state.update("show_plan", index=i, value=loaded_item)
break
# TODO: Update the show plan filenames???
load_attempt = 0
if not isinstance(loaded_item.filename, str):
return False
# Let's have 5 attempts at loading the item audio
while load_attempt < 5:
load_attempt += 1
original_file = None
if load_attempt == 3:
# Ok, we tried twice already to load the file.
# Let's see if we can recover from this.
# Try swapping the normalised version out for the original.
original_file = get_original_filename_from_normalised(
loaded_item.filename
)
self.logger.log.warning("3rd attempt. Trying the non-normalised file: {}".format(original_file))
if load_attempt == 4:
# well, we've got so far that the normalised and original files didn't load.
# Take a last ditch effort to download the original file again.
file = sync(self.api.get_filename(item=loaded_item, redownload=True))
if file:
original_file = str(file)
self.logger.log.warning("4rd attempt. Trying to redownload the file, got: {}".format(original_file))
if original_file:
loaded_item.filename = original_file
try:
self.logger.log.info(
"Loading file: " + str(loaded_item.filename))
"Attempt {} Loading file: {}".format(load_attempt, loaded_item.filename))
mixer.music.load(loaded_item.filename)
except Exception:
# We couldn't load that file.
self.logger.log.exception(
"Couldn't load file: " + str(loaded_item.filename)
)
time.sleep(1)
continue # Try loading again.
if not self.isLoaded:
self.logger.log.error(
"Pygame loaded file without error, but never actually loaded."
)
time.sleep(1)
continue # Try loading again.
try:
@ -503,23 +534,25 @@ class Player:
except Exception:
self.logger.log.exception(
"Failed to update the length of item.")
time.sleep(1)
continue # Try loading again.
# Everything worked, we made it!
# Write the loaded item again once more, to confirm the filename if we've reattempted.
self.state.update("loaded_item", loaded_item)
if loaded_item.cue > 0:
self.seek(loaded_item.cue)
else:
self.seek(0)
if self.state.get()["play_on_load"]:
if loaded_state["play_on_load"]:
self.unpause()
return True
self.logger.log.error(
"Failed to load track after numerous retries.")
return False
# Even though we failed, make sure state is up to date with latest failure.
# We're comitting to load this item.
self.state.update("loaded_item", loaded_item)
return False
@ -549,8 +582,10 @@ class Player:
self.logger.log.exception("Failed to quit mixer.")
def output(self, name: Optional[str] = None):
wasPlaying = self.state.get()["playing"]
oldPos = self.state.get()["pos_true"]
wasPlaying = self.isPlaying
state = self.state.get()
oldPos = state["pos_true"]
name = None if (not name or name.lower() == "none") else name
@ -567,7 +602,7 @@ class Player:
)
return False
loadedItem = self.state.get()["loaded_item"]
loadedItem = state["loaded_item"]
if loadedItem:
self.logger.log.info("Reloading after output change.")
self.load(loadedItem.weight)
@ -802,6 +837,8 @@ class Player:
loaded_item.name, loaded_item.weight
)
)
# Just make sure that if we stop and do nothing, we end up at 0.
self.state.update("pos", 0)
# Repeat 1
# TODO ENUM

View file

@ -30,7 +30,7 @@ class PlayerHandler:
command = message.split(":")[1]
# Let the file manager manage the files based on status and loading new show plan triggers.
if command == "GET_PLAN" or command == "STATUS":
if command == "GETPLAN" or command == "STATUS":
file_to_q[channel].put(message)
# TODO ENUM

View file

@ -23,7 +23,7 @@ import json
from setproctitle import setproctitle
import psutil
from helpers.os_environment import isMacOS
from helpers.os_environment import isLinux, isMacOS
if not isMacOS():
# Rip, this doesn't like threading on MacOS.
@ -207,7 +207,9 @@ class BAPSicleServer:
time.sleep(1)
def startServer(self):
if isMacOS():
# On MacOS, the default causes something to keep creating new processes.
# On Linux, this is needed to make pulseaudio initiate properly.
if isMacOS() or isLinux():
multiprocessing.set_start_method("spawn", True)
process_title = "startServer"

View file

@ -33,6 +33,26 @@ Set for:
Default Audio Output
</code>
</p>
{% if data.sdl_direct %}
Linux (Pulse Audio)
<br>
<code>
{% for output in data.outputs %}
Set for:
{% for channel in data.channels %}
{% if not channel %}
Player {{loop.index0}}
{% elif channel.output == output %}
<strong>Player {{channel.channel}}</strong>
{% else %}
<a href="/player/{{channel.channel}}/output/{{output}}">Player {{channel.channel}}</a>
{% endif %}
/
{% endfor %}
{% if output %}{{output}}{% else %}System Default Output{% endif %}<br>
{% endfor %}
</code>
{% else %}
{% for host_api in data.outputs %}
{{host_api.name}}
<br>
@ -54,4 +74,5 @@ Default Audio Output
{% endfor %}
</code>
{% endfor %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block content_inner %}
<div class="text-center">
<p class="lead text-gray-800 mb-2">Hang on a second!</p>
<p class="text-gray-900 mb-3">Something is currently playing. Restarting will interrupt it! Are you sure?</p>
<a href="/status" class="btn btn-info">Cancel</a>
<a href="/restart?confirm=true" class="btn btn-danger">Confirm</a>
</div>
{% endblock %}

View file

@ -12,6 +12,7 @@
<h3 class="h5">Player {{player.channel}}</h3>
<p>
Initialised: {{player.initialised}}<br/>
Successful Load: {{player.loaded}}<br/>
Fader Live: {{player.live}}<br/>
Current Tracklist: {{player.tracklist_id}}
</p>

View file

@ -17,6 +17,7 @@ import json
import os
from helpers.os_environment import (
isLinux,
resolve_external_file_path,
resolve_local_file_path,
)
@ -171,11 +172,16 @@ def ui_config_player(request):
for i in range(server_state.get()["num_channels"]):
channel_states.append(status(i))
outputs = None
if isLinux():
outputs = DeviceManager.getAudioDevices()
else:
outputs = DeviceManager.getAudioOutputs()
data = {
"channels": channel_states,
"outputs": outputs,
"sdl_direct": isLinux(),
"ui_page": "config",
"ui_title": "Player Config",
}
@ -428,7 +434,12 @@ async def audio_file(request, type: str, id: int):
filename = get_normalised_filename_if_available(filename)
# Send file or 404
return await file(filename)
try:
response = await file(filename)
except FileNotFoundError:
abort(404)
return
return response
# Static Files
@ -498,6 +509,11 @@ def quit(request):
@app.route("/restart")
def restart(request):
if request.args.get("confirm", '') != "true":
for i in range(server_state.get()["num_channels"]):
state = status(i)
if state["playing"]:
return render_template("restart-confirm.html", data=None)
server_state.update("running_state", "restarting")
data = {
@ -542,9 +558,12 @@ def WebServer(player_to: List[Queue], player_from: List[Queue], state: StateMana
)
except Exception:
break
try:
loop = asyncio.get_event_loop()
if loop:
loop.close()
if app:
app.stop()
del app
except Exception:
pass