Merge pull request #24 from UniversityRadioYork/mstratford/normalisation

Add Normalisation support.
This commit is contained in:
Matthew Stratford 2021-08-17 22:33:50 +01:00 committed by GitHub
commit 48b5ff7aba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 202 additions and 51 deletions

View file

@ -11,6 +11,7 @@ from syncer import sync
from helpers.logging_manager import LoggingManager
from helpers.the_terminator import Terminator
from helpers.myradio_api import MyRadioAPI
from helpers.normalisation import generate_normalised_file
from baps_types.plan import PlanItem
@ -28,19 +29,21 @@ class FileManager:
current_process().name = process_title
terminator = Terminator()
channel_count = len(channel_from_q)
channel_received = None
last_known_show_plan = [[]]*channel_count
next_channel_preload = 0
last_known_item_ids = [[]]*channel_count
self.channel_count = len(channel_from_q)
self.channel_received = None
self.last_known_show_plan = [[]]*self.channel_count
self.next_channel_preload = 0
self.known_channels_preloaded = [False]*self.channel_count
self.known_channels_normalised = [False]*self.channel_count
self.last_known_item_ids = [[]]*self.channel_count
try:
while not terminator.terminate:
# If all channels have received the delete command, reset for the next one.
if (channel_received == None or channel_received == [True]*channel_count):
channel_received = [False]*channel_count
if (self.channel_received == None or self.channel_received == [True]*self.channel_count):
self.channel_received = [False]*self.channel_count
for channel in range(channel_count):
for channel in range(self.channel_count):
try:
message = channel_from_q[channel].get_nowait()
except Exception:
@ -53,11 +56,11 @@ class FileManager:
# If we have requested a new show plan, empty the music-tmp directory for the previous show.
if command == "GET_PLAN":
if channel_received != [False]*channel_count and channel_received[channel] != True:
if self.channel_received != [False]*self.channel_count and self.channel_received[channel] != True:
# We've already received a delete trigger on a channel, let's not delete the folder more than once.
# If the channel was already in the process of being deleted, the user has requested it again, so allow it.
channel_received[channel] = True
self.channel_received[channel] = True
continue
# Delete the previous show files!
@ -80,7 +83,9 @@ class FileManager:
except Exception:
self.logger.log.warning("Failed to remove, skipping. Likely file is still in use.")
continue
channel_received[channel] = True
self.channel_received[channel] = True
self.known_channels_preloaded = [False]*self.channel_count
self.known_channels_normalised = [False]*self.channel_count
# If we receive a new status message, let's check for files which have not been pre-loaded.
if command == "STATUS":
@ -96,44 +101,123 @@ class FileManager:
# If the new status update has a different order / list of items, let's update the show plan we know about
# This will trigger the chunk below to do the rounds again and preload any new files.
if item_ids != last_known_item_ids[channel]:
last_known_item_ids[channel] = item_ids
last_known_show_plan[channel] = show_plan
if item_ids != self.last_known_item_ids[channel]:
self.last_known_item_ids[channel] = item_ids
self.last_known_show_plan[channel] = show_plan
self.known_channels_preloaded[channel] = False
except Exception:
self.logger.log.exception("Failed to handle message {} on channel {}.".format(message, channel))
# Let's try preload / normalise some files now we're free of messages.
preloaded = self.do_preload()
normalised = self.do_normalise()
# Right, let's have a quick check in the status for shows without filenames, to preload them.
delay = True
for i in range(len(last_known_show_plan[next_channel_preload])):
if (not preloaded and not normalised):
# We didn't do any hard work, let's sleep.
sleep(0.2)
item_obj = PlanItem(last_known_show_plan[next_channel_preload][i])
if not item_obj.filename:
self.logger.log.info("Checking pre-load on channel {}, weight {}: {}".format(next_channel_preload, item_obj.weight, item_obj.name))
# Getting the file name will only pull the new file if the file doesn't already exist, so this is not too inefficient.
item_obj.filename,did_download = sync(self.api.get_filename(item_obj, True))
# Alright, we've done one, now let's give back control to process new statuses etc.
# Save back the resulting item back in regular dict form
last_known_show_plan[next_channel_preload][i] = item_obj.__dict__
if did_download:
# Given we probably took some time to download, let's not sleep in the loop.
delay = False
self.logger.log.info("File successfully preloaded: {}".format(item_obj.filename))
break
else:
# We didn't download anything this time, file was already loaded.
# Let's try the next one.
continue
next_channel_preload += 1
if next_channel_preload >= channel_count:
next_channel_preload = 0
if delay:
sleep(0.1)
except Exception as e:
self.logger.log.exception(
"Received unexpected exception: {}".format(e))
del self.logger
# Attempt to preload a file onto disk.
def do_preload(self):
channel = self.next_channel_preload
# All channels have preloaded all files, do nothing.
if (self.known_channels_preloaded == [True]*self.channel_count):
return False # Didn't preload anything
# Right, let's have a quick check in the status for shows without filenames, to preload them.
# Keep an eye on if we downloaded anything.
# If we didn't, we know that all items in this channel have been downloaded.
downloaded_something = False
for i in range(len(self.last_known_show_plan[channel])):
item_obj = PlanItem(self.last_known_show_plan[channel][i])
# We've not downloaded this file yet, let's do that.
if not item_obj.filename:
self.logger.log.info("Checking pre-load on channel {}, weight {}: {}".format(channel, item_obj.weight, item_obj.name))
# Getting the file name will only pull the new file if the file doesn't already exist, so this is not too inefficient.
item_obj.filename,did_download = sync(self.api.get_filename(item_obj, True))
# Alright, we've done one, now let's give back control to process new statuses etc.
# Save back the resulting item back in regular dict form
self.last_known_show_plan[channel][i] = item_obj.__dict__
if did_download:
downloaded_something = True
self.logger.log.info("File successfully preloaded: {}".format(item_obj.filename))
break
else:
# We didn't download anything this time, file was already loaded.
# Let's try the next one.
continue
# Tell the file manager that this channel is fully downloaded, this is so it can consider normalising once all channels have files.
self.known_channels_preloaded[channel] = not downloaded_something
self.next_channel_preload += 1
if self.next_channel_preload >= self.channel_count:
self.next_channel_preload = 0
return downloaded_something
# If we've preloaded everything, get to work normalising tracks before playback.
def do_normalise(self):
# Some channels still have files to preload, do nothing.
if (self.known_channels_preloaded != [True]*self.channel_count):
return False # Didn't normalise
# Quit early if all channels are normalised already.
if (self.known_channels_normalised == [True]*self.channel_count):
return False
channel = self.next_channel_preload
normalised_something = False
# Look through all the show plan files
for i in range(len(self.last_known_show_plan[channel])):
item_obj = PlanItem(self.last_known_show_plan[channel][i])
filename = item_obj.filename
if not filename:
self.logger.log.exception("Somehow got empty filename when all channels are preloaded.")
continue # Try next song.
elif (not os.path.isfile(filename)):
self.logger.log.exception("Filename for normalisation does not exist. This is bad.")
continue
elif "normalised" in filename:
continue
# Sweet, we now need to try generating a normalised version.
try:
self.logger.log.info("Normalising on channel {}: {}".format(channel,filename))
# This will return immediately if we already have a normalised file.
item_obj.filename = generate_normalised_file(filename)
# TODO Hacky
self.last_known_show_plan[channel][i] = item_obj.__dict__
normalised_something = True
break # Now go let another channel have a go.
except Exception as e:
self.logger.log.exception("Failed to generate normalised file.", str(e))
continue
self.known_channels_normalised[channel] = not normalised_something
self.next_channel_preload += 1
if self.next_channel_preload >= self.channel_count:
self.next_channel_preload = 0
return normalised_something

View file

@ -156,23 +156,22 @@ class MyRadioAPI:
payload = json.loads(await request)["payload"]
shows = []
if not payload["current"]:
self._logException("API did not return a current show.")
else:
shows.append(payload["current"])
if not payload["next"]:
self._logException("API did not return a list of next shows.")
else:
shows.extend(payload["next"])
shows = []
shows.append(payload["current"])
shows.extend(payload["next"])
timeslots = []
# Remove jukebox etc
for show in shows:
if not "timeslot_id" in show:
shows.remove(show)
# TODO filter out jukebox
return shows
async def get_showplan(self, timeslotid: int):

49
helpers/normalisation.py Normal file
View file

@ -0,0 +1,49 @@
import os
from helpers.os_environment import resolve_external_file_path
from pydub import AudioSegment, effects # Audio leveling!
# Stuff to help make BAPSicle play out leveled audio.
def match_target_amplitude(sound, target_dBFS):
change_in_dBFS = target_dBFS - sound.dBFS
return sound.apply_gain(change_in_dBFS)
# Takes filename in, normalialises it and returns a normalised file path.
def generate_normalised_file(filename: str):
if (not (isinstance(filename, str) and filename.endswith(".mp3"))):
raise ValueError("Invalid filename given.")
# Already normalised.
if filename.endswith("-normalised.mp3"):
return filename
normalised_filename = "{}-normalised.mp3".format(filename.rsplit(".",1)[0])
# The file already exists, short circuit.
if (os.path.exists(normalised_filename)):
return normalised_filename
sound = AudioSegment.from_file(filename, "mp3")
normalised_sound = effects.normalize(sound) #match_target_amplitude(sound, -10)
normalised_sound.export(normalised_filename, bitrate="320k", format="mp3")
return normalised_filename
# Returns either a normalised file path (based on filename), or the original if not available.
def get_normalised_filename_if_available(filename:str):
if (not (isinstance(filename, str) and filename.endswith(".mp3"))):
raise ValueError("Invalid filename given.")
# Already normalised.
if filename.endswith("-normalised.mp3"):
return filename
normalised_filename = "{}-normalised.mp3".format(filename.rstrip(".mp3"))
# normalised version exists
if (os.path.exists(normalised_filename)):
return normalised_filename
# Else we've not got a normalised verison, just take original.
return filename

View file

@ -35,6 +35,7 @@ from mutagen.mp3 import MP3
from syncer import sync
from threading import Timer
from helpers.normalisation import get_normalised_filename_if_available
from helpers.myradio_api import MyRadioAPI
from helpers.state_manager import StateManager
from helpers.logging_manager import LoggingManager
@ -427,6 +428,9 @@ class Player:
if not loaded_item.filename:
return False
# Swap with a normalised version if it's ready, else returns original.
loaded_item.filename = get_normalised_filename_if_available(loaded_item.filename)
self.state.update("loaded_item", loaded_item)
for i in range(len(showplan)):
@ -436,6 +440,10 @@ class Player:
# TODO: Update the show plan filenames???
load_attempt = 0
if not isinstance(loaded_item.filename, str):
return False
while load_attempt < 5:
load_attempt += 1
try:
@ -456,10 +464,11 @@ class Player:
continue # Try loading again.
try:
if ".mp3" in loaded_item.filename:
if loaded_item.filename.endswith(".mp3"):
song = MP3(loaded_item.filename)
self.state.update("length", song.info.length)
else:
# WARNING! Pygame / SDL can't seek .wav files :/
self.state.update(
"length", mixer.Sound(
loaded_item.filename).get_length() / 1000

View file

@ -1,3 +1,4 @@
from helpers.normalisation import get_normalised_filename_if_available
from helpers.myradio_api import MyRadioAPI
from sanic import Sanic
from sanic.exceptions import NotFound, abort
@ -313,15 +314,24 @@ def json_status(request):
async def audio_file(request, type: str, id: int):
if type not in ["managed", "track"]:
abort(404)
return await file("music-tmp/" + type + "-" + str(id) + ".mp3")
filename = resolve_external_file_path("music-tmp/{}-{}.mp3".format(type,id))
# Swap with a normalised version if it's ready, else returns original.
filename = get_normalised_filename_if_available(filename)
# Send file or 404
return await file(filename)
# Static Files
app.static("/favicon.ico", resolve_local_file_path("ui-static/favicon.ico"), name="ui-favicon")
app.static("/static", resolve_local_file_path("ui-static"), name="ui-static")
dist_directory = resolve_local_file_path("presenter-build")
app.static('/presenter', dist_directory)
app.static("/presenter/", resolve_local_file_path("presenter-build/index.html"),
strict_slashes=True, name="presenter-index")
app.static("/presenter/", resolve_local_file_path("presenter-build"))
# Helper Functions