From 8d98410a6ab9ffe574b97a8bf01f09249fcdc964 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Fri, 16 Jul 2021 23:56:58 +0100 Subject: [PATCH 1/6] demo pydub normalisation --- player.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/player.py b/player.py index 3f76b9d..bb42f86 100644 --- a/player.py +++ b/player.py @@ -34,6 +34,7 @@ from pygame import mixer from mutagen.mp3 import MP3 from syncer import sync from threading import Timer +from pydub import AudioSegment, effects # Audio leveling! from helpers.myradio_api import MyRadioAPI from helpers.state_manager import StateManager @@ -436,12 +437,30 @@ 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: self.logger.log.info("Loading file: " + str(loaded_item.filename)) - mixer.music.load(loaded_item.filename) + + + + + + def match_target_amplitude(sound, target_dBFS): + change_in_dBFS = target_dBFS - sound.dBFS + return sound.apply_gain(change_in_dBFS) + + sound = AudioSegment.from_file(loaded_item.filename, "mp3") + normalized_sound = effects.normalize(sound) #match_target_amplitude(sound, -10) + normalized_sound.export("{}-normalised.mp3".format(loaded_item.filename), bitrate="320k", format="mp3") + + + mixer.music.load("{}-normalised.mp3".format(loaded_item.filename)) except Exception: # We couldn't load that file. self.logger.log.exception( @@ -456,10 +475,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 From b90330ff578b4c540ebd0cb66d51bf1605897796 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Mon, 16 Aug 2021 23:29:58 +0100 Subject: [PATCH 2/6] Fix failing to return when no shows. --- helpers/myradio_api.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py index 8e8c7a4..6621327 100644 --- a/helpers/myradio_api.py +++ b/helpers/myradio_api.py @@ -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): From e696e2237aeff3595c799ad8a051b43eb79fdf22 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Mon, 16 Aug 2021 23:43:09 +0100 Subject: [PATCH 3/6] WIP Normalisation in filemanager. Needs race conditions fixing. --- file_manager.py | 156 ++++++++++++++++++++++++++++----------- helpers/normalisation.py | 49 ++++++++++++ player.py | 21 ++---- web_server.py | 14 +++- 4 files changed, 180 insertions(+), 60 deletions(-) create mode 100644 helpers/normalisation.py diff --git a/file_manager.py b/file_manager.py index 34b818d..c853f06 100644 --- a/file_manager.py +++ b/file_manager.py @@ -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,20 @@ 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.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 +55,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 +82,7 @@ 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 # If we receive a new status message, let's check for files which have not been pre-loaded. if command == "STATUS": @@ -96,44 +98,114 @@ 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.5) - 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 + # 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 + # Given we probably took some time to download, let's not sleep in the loop. + if not downloaded_something: + # 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] = True + + 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 + # TODO: quit early if all channels are normalised already. + + 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. + + if "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.next_channel_preload += 1 + if self.next_channel_preload >= self.channel_count: + self.next_channel_preload = 0 + + return normalised_something + + + + + diff --git a/helpers/normalisation.py b/helpers/normalisation.py new file mode 100644 index 0000000..9622c3a --- /dev/null +++ b/helpers/normalisation.py @@ -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 +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.rstrip(".mp3")) + + # 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 + diff --git a/player.py b/player.py index bb42f86..5808c23 100644 --- a/player.py +++ b/player.py @@ -34,8 +34,8 @@ from pygame import mixer from mutagen.mp3 import MP3 from syncer import sync from threading import Timer -from pydub import AudioSegment, effects # Audio leveling! +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 @@ -428,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)): @@ -446,21 +449,7 @@ class Player: try: self.logger.log.info("Loading file: " + str(loaded_item.filename)) - - - - - - def match_target_amplitude(sound, target_dBFS): - change_in_dBFS = target_dBFS - sound.dBFS - return sound.apply_gain(change_in_dBFS) - - sound = AudioSegment.from_file(loaded_item.filename, "mp3") - normalized_sound = effects.normalize(sound) #match_target_amplitude(sound, -10) - normalized_sound.export("{}-normalised.mp3".format(loaded_item.filename), bitrate="320k", format="mp3") - - - mixer.music.load("{}-normalised.mp3".format(loaded_item.filename)) + mixer.music.load(loaded_item.filename) except Exception: # We couldn't load that file. self.logger.log.exception( diff --git a/web_server.py b/web_server.py index 521f6f0..60ab571 100644 --- a/web_server.py +++ b/web_server.py @@ -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 From b1d9ad8c93b55202a63a108ecb8a2456974720da Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 17 Aug 2021 21:59:48 +0100 Subject: [PATCH 4/6] Fix normalised files being incorrectly named. --- file_manager.py | 9 ++++++--- helpers/normalisation.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/file_manager.py b/file_manager.py index c853f06..1e5e54f 100644 --- a/file_manager.py +++ b/file_manager.py @@ -83,6 +83,7 @@ class FileManager: self.logger.log.warning("Failed to remove, skipping. Likely file is still in use.") continue self.channel_received[channel] = True + self.known_channels_preloaded = [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": @@ -112,7 +113,7 @@ class FileManager: if (not preloaded and not normalised): # We didn't do any hard work, let's sleep. - sleep(0.5) + sleep(0.2) except Exception as e: self.logger.log.exception( @@ -181,8 +182,10 @@ class FileManager: if not filename: self.logger.log.exception("Somehow got empty filename when all channels are preloaded.") continue # Try next song. - - if "normalised" in filename: + 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: diff --git a/helpers/normalisation.py b/helpers/normalisation.py index 9622c3a..b8eec49 100644 --- a/helpers/normalisation.py +++ b/helpers/normalisation.py @@ -16,7 +16,7 @@ def generate_normalised_file(filename: str): if filename.endswith("-normalised.mp3"): return filename - normalised_filename = "{}-normalised.mp3".format(filename.rstrip(".mp3")) + normalised_filename = "{}-normalised.mp3".format(filename.rsplit(".",1)[0]) # The file already exists, short circuit. if (os.path.exists(normalised_filename)): From b3ac0a83720c42feb88eb364b41ba4a14a0f9de9 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 17 Aug 2021 22:04:20 +0100 Subject: [PATCH 5/6] Fix comment. --- helpers/normalisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/normalisation.py b/helpers/normalisation.py index b8eec49..900ffee 100644 --- a/helpers/normalisation.py +++ b/helpers/normalisation.py @@ -7,7 +7,7 @@ def match_target_amplitude(sound, target_dBFS): change_in_dBFS = target_dBFS - sound.dBFS return sound.apply_gain(change_in_dBFS) -# Takes +# 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.") From 27974ab3928b0b5dce27bfbe7220fc03d2318f4b Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 17 Aug 2021 22:24:28 +0100 Subject: [PATCH 6/6] Optimise preload/normalisation idle states. --- file_manager.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/file_manager.py b/file_manager.py index 1e5e54f..ed6d482 100644 --- a/file_manager.py +++ b/file_manager.py @@ -34,6 +34,7 @@ class FileManager: 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: @@ -84,6 +85,7 @@ class FileManager: continue 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": @@ -124,6 +126,11 @@ class FileManager: # 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. @@ -151,10 +158,9 @@ class FileManager: # We didn't download anything this time, file was already loaded. # Let's try the next one. continue - # Given we probably took some time to download, let's not sleep in the loop. - if not downloaded_something: - # 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] = True + + # 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: @@ -168,7 +174,10 @@ class FileManager: # Some channels still have files to preload, do nothing. if (self.known_channels_preloaded != [True]*self.channel_count): return False # Didn't normalise - # TODO: quit early if all channels are normalised already. + + # Quit early if all channels are normalised already. + if (self.known_channels_normalised == [True]*self.channel_count): + return False channel = self.next_channel_preload @@ -200,7 +209,7 @@ class FileManager: 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: