diff --git a/pikaraoke/app.py b/pikaraoke/app.py index cc6ff463..aefdb315 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -162,11 +162,12 @@ def nowplaying(): "now_playing": k.now_playing, "now_playing_user": k.now_playing_user, "now_playing_command": k.now_playing_command, + "now_playing_duration": k.now_playing_duration, + "now_playing_transpose": k.now_playing_transpose, + "now_playing_url": k.now_playing_url, "up_next": next_song, "next_user": next_user, - "now_playing_url": k.now_playing_url, "is_paused": k.is_paused, - "transpose_value": k.now_playing_transpose, "volume": k.volume, # "is_transpose_enabled": k.is_transpose_enabled, } @@ -550,7 +551,6 @@ def splash(): hide_url=k.hide_url, hide_overlay=k.hide_overlay, screensaver_timeout=k.screensaver_timeout, - show_end_time=k.complete_transcode_before_play, ) @@ -915,13 +915,13 @@ def main(): "-c", "--complete-transcode-before-play", action="store_true", - help="Wait for ffmpeg transcoding to fully complete before playback begins. Also adds end time to splash screen display. This can help with streaming on slower devices and improve browser compatibility (Safari, Firefox), but will significantly increase the delay before playback begins. On modern hardware, the delay is likely negligible.", + help="Wait for ffmpeg video transcoding to fully complete before playback begins. Transcoding occurs when you have normalization on, play a cdg file, or change key. May improve performance and browser compatibility (Safari, Firefox), but will significantly increase the delay before playback begins. On modern hardware, the delay is likely negligible.", required=False, ) parser.add_argument( "-b", "--buffer-size", - help=f"Buffer size for streaming video (in bytes). Increase if you experience songs cutting off early. Higher buffer size will increase the delay before playback begins. This value is ignored if --complete-transcode-before-play was specified. Default is: {default_buffer_size}", + help=f"Buffer size for transcoded video (in bytes). Increase if you experience songs cutting off early. Higher size will transcode more of the file before streaming it to the client. This will increase the delay before playback begins. This value is ignored if --complete-transcode-before-play was specified. Default is: {default_buffer_size}", default=default_buffer_size, type=int, required=False, diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index 3e6a5dae..4decf64a 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -3,6 +3,7 @@ import logging import os import random +import shutil import socket import subprocess import threading @@ -21,7 +22,11 @@ get_ffmpeg_version, is_transpose_enabled, ) -from pikaraoke.lib.file_resolver import FileResolver, delete_tmp_dir +from pikaraoke.lib.file_resolver import ( + FileResolver, + delete_tmp_dir, + is_transcoding_required, +) from pikaraoke.lib.get_platform import ( get_os_version, get_platform, @@ -54,6 +59,7 @@ class Karaoke: now_playing_filename = None now_playing_user = None now_playing_transpose = 0 + now_playing_duration = None now_playing_url = None now_playing_command = None @@ -441,67 +447,83 @@ def log_ffmpeg_output(self): def play_file(self, file_path, semitones=0): logging.info(f"Playing file: {file_path} transposed {semitones} semitones") + requires_transcoding = ( + semitones != 0 or self.normalize_audio or is_transcoding_required(file_path) + ) + try: - fr = FileResolver(file_path, self.complete_transcode_before_play) + fr = FileResolver(file_path) except Exception as e: logging.error("Error resolving file: " + str(e)) self.queue.pop(0) return False - self.kill_ffmpeg() + if self.complete_transcode_before_play or not requires_transcoding: + # This route is used for streaming the full video file, and includes more + # accurate headers for safari and other browsers + stream_url_path = f"/stream/full/{fr.stream_uid}" + else: + # This route is used for streaming the video file in chunks, only works on chrome + stream_url_path = f"/stream/{fr.stream_uid}" - ffmpeg_cmd = build_ffmpeg_cmd( - fr, semitones, self.normalize_audio, self.complete_transcode_before_play - ) - self.ffmpeg_process = ffmpeg_cmd.run_async(pipe_stderr=True, pipe_stdin=True) - - # ffmpeg outputs everything useful to stderr for some insane reason! - # prevent reading stderr from being a blocking action - self.ffmpeg_log = Queue() - t = Thread(target=enqueue_output, args=(self.ffmpeg_process.stderr, self.ffmpeg_log)) - t.daemon = True - t.start() - - output_file_size = 0 - max_playback_retries = 2500 # approx 2 minutes - - is_transcoding_complete = False - is_buffering_complete = False - - # Playback start retry loop - while True: - self.log_ffmpeg_output() - # Check if the ffmpeg process has exited - if self.ffmpeg_process.poll() is not None: - exitcode = self.ffmpeg_process.poll() - if exitcode != 0: - logging.error( - f"FFMPEG transcode exited with nonzero exit code ending: {exitcode}. Skipping track" - ) - self.end_song() - break - else: - is_transcoding_complete = True + if not requires_transcoding: + # simply copy file path to the tmp directory and the stream is ready + shutil.copy(file_path, fr.output_file) + is_transcoding_complete = True + else: + self.kill_ffmpeg() + ffmpeg_cmd = build_ffmpeg_cmd( + fr, semitones, self.normalize_audio, self.complete_transcode_before_play + ) + self.ffmpeg_process = ffmpeg_cmd.run_async(pipe_stderr=True, pipe_stdin=True) + + # ffmpeg outputs everything useful to stderr for some insane reason! + # prevent reading stderr from being a blocking action + self.ffmpeg_log = Queue() + t = Thread(target=enqueue_output, args=(self.ffmpeg_process.stderr, self.ffmpeg_log)) + t.daemon = True + t.start() + + output_file_size = 0 + transcode_max_retries = 2500 # Transcode completion max: approx 2 minutes + + is_transcoding_complete = False + is_buffering_complete = False + + # Transcoding readiness polling loop + while True: + self.log_ffmpeg_output() + # Check if the ffmpeg process has exited + if self.ffmpeg_process.poll() is not None: + exitcode = self.ffmpeg_process.poll() + if exitcode != 0: + logging.error( + f"FFMPEG transcode exited with nonzero exit code ending: {exitcode}. Skipping track" + ) + self.end_song() + break + else: + is_transcoding_complete = True + output_file_size = os.path.getsize(fr.output_file) + logging.debug(f"Transcoding complete. File size: {output_file_size}") + break + # Check if the file has buffered enough to start playback + try: output_file_size = os.path.getsize(fr.output_file) - logging.debug(f"Transcoding complete. File size: {output_file_size}") + if not self.complete_transcode_before_play: + is_buffering_complete = output_file_size > self.buffer_size + if is_buffering_complete: + logging.debug(f"Buffering complete. File size: {output_file_size}") + break + except: + pass + # Prevent infinite loop if playback never starts + if transcode_max_retries <= 0: + logging.error("Max retries reached trying to play song. Skipping track") + self.end_song() break - # Check if the file has buffered enough to start playback - try: - output_file_size = os.path.getsize(fr.output_file) - if not self.complete_transcode_before_play: - is_buffering_complete = output_file_size > self.buffer_size - if is_buffering_complete: - logging.debug(f"Buffering complete. File size: {output_file_size}") - break - except: - pass - # Prevent infinite loop if playback never starts - if max_playback_retries <= 0: - logging.error("Max retries reached trying to play song. Skipping track") - self.end_song() - break - max_playback_retries -= 1 - time.sleep(0.05) + transcode_max_retries -= 1 + time.sleep(0.05) # Check if the stream is ready to play. Determined by: # - completed transcoding @@ -511,15 +533,16 @@ def play_file(self, file_path, semitones=0): self.now_playing = self.filename_from_path(file_path) self.now_playing_filename = file_path self.now_playing_transpose = semitones - self.now_playing_url = fr.stream_url_path + self.now_playing_duration = fr.duration + self.now_playing_url = stream_url_path self.now_playing_user = self.queue[0]["user"] self.is_paused = False self.queue.pop(0) # Pause until the stream is playing - max_retries = 100 - while self.is_playing == False and max_retries > 0: + transcode_max_retries = 100 + while self.is_playing == False and transcode_max_retries > 0: time.sleep(0.1) # prevents loop from trying to replay track - max_retries -= 1 + transcode_max_retries -= 1 if self.is_playing: logging.debug("Stream is playing") else: @@ -723,6 +746,7 @@ def reset_now_playing(self): self.is_paused = True self.is_playing = False self.now_playing_transpose = 0 + self.now_playing_duration = None self.ffmpeg_log = None def run(self): diff --git a/pikaraoke/lib/ffmpeg.py b/pikaraoke/lib/ffmpeg.py index e5f74b1e..b8f7a46b 100644 --- a/pikaraoke/lib/ffmpeg.py +++ b/pikaraoke/lib/ffmpeg.py @@ -6,6 +6,14 @@ from pikaraoke.lib.get_platform import supports_hardware_h264_encoding +def get_media_duration(file_path): + try: + duration = ffmpeg.probe(file_path)["format"]["duration"] + return round(float(duration)) + except: + return None + + def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True, buffer_fully_before_playback=False): # use h/w acceleration on pi default_vcodec = "h264_v4l2m2m" if supports_hardware_h264_encoding() else "libx264" diff --git a/pikaraoke/lib/file_resolver.py b/pikaraoke/lib/file_resolver.py index 2c3b214e..fe8e79e7 100644 --- a/pikaraoke/lib/file_resolver.py +++ b/pikaraoke/lib/file_resolver.py @@ -4,6 +4,7 @@ import zipfile from sys import maxsize +from pikaraoke.lib.ffmpeg import get_media_duration from pikaraoke.lib.get_platform import get_platform @@ -34,25 +35,28 @@ def string_to_hash(s): return hash(s) % ((maxsize + 1) * 2) +def is_cdg_file(file_path): + file_extension = os.path.splitext(file_path)[1].casefold() + return file_extension == ".zip" or file_extension == ".mp3" + + +def is_transcoding_required(file_path): + file_extension = os.path.splitext(file_path)[1].casefold() + return file_extension != ".mp4" and file_extension != ".webm" + + # Processes a given file path and determines the file format and file path, extracting zips into cdg + mp3 if necessary. class FileResolver: file_path = None cdg_file_path = None file_extension = None - def __init__(self, file_path, buffer_fully_before_playback=False): + def __init__(self, file_path): create_tmp_dir() self.tmp_dir = get_tmp_dir() self.resolved_file_path = self.process_file(file_path) self.stream_uid = string_to_hash(file_path) self.output_file = f"{self.tmp_dir}/{self.stream_uid}.mp4" - if buffer_fully_before_playback: - # This route is used for streaming the full video file, and includes more - # accurate headers for safari and other browsers - self.stream_url_path = f"/stream/full/{self.stream_uid}" - else: - # This route is used for streaming the video file in chunks, only works on chrome - self.stream_url_path = f"/stream/{self.stream_uid}" # Extract zipped cdg + mp3 files into a temporary directory, and set the paths to both files. def handle_zipped_cdg(self, file_path): @@ -65,7 +69,6 @@ def handle_zipped_cdg(self, file_path): mp3_file = None cdg_file = None files = os.listdir(extracted_dir) - print(files) for file in files: ext = os.path.splitext(file)[1] if ext.casefold() == ".mp3": @@ -86,8 +89,6 @@ def handle_mp3_cdg(self, file_path): pattern = f + ".cdg" rule = re.compile(re.escape(pattern), re.IGNORECASE) p = os.path.dirname(file_path) # get the path, not the filename - print(p) - print(pattern) for n in os.listdir(p): if rule.match(n): self.file_path = file_path @@ -105,3 +106,4 @@ def process_file(self, file_path): self.handle_mp3_cdg(file_path) else: self.file_path = file_path + self.duration = get_media_duration(self.file_path) diff --git a/pikaraoke/templates/home.html b/pikaraoke/templates/home.html index fb6a5856..f9f9778a 100644 --- a/pikaraoke/templates/home.html +++ b/pikaraoke/templates/home.html @@ -13,11 +13,11 @@ var nowPlayingHtml = `
${obj.now_playing}
${obj.now_playing_user}
`; - if (obj.transpose_value != 0) { + if (obj.now_playing_transpose != 0) { nowPlayingHtml += // {# MSG: Label for display of how many semitones the song has been shifted. #} "Key: " + - getSemitonesLabel(obj.transpose_value) + + getSemitonesLabel(obj.now_playing_transpose) + ""; } @@ -39,12 +39,16 @@ $("#up-next").html("{{ _('No song is queued.') }}"); } - if (obj.transpose_value != 0) { - $("#transpose").val(obj.transpose_value); - $("#semitones-label").html(getSemitonesLabel(obj.transpose_value)); + if (obj.now_playing_transpose != 0) { + $("#transpose").val(obj.now_playing_transpose); + $("#semitones-label").html( + getSemitonesLabel(obj.now_playing_transpose) + ); } else { $("#transpose").val(0); - $("#semitones-label").html(getSemitonesLabel(obj.transpose_value)); + $("#semitones-label").html( + getSemitonesLabel(obj.now_playing_transpose) + ); } // set the volume slider to the current volume @@ -82,9 +86,7 @@ $(".control-box").hide(); var slider = document.getElementById("transpose"); - console.log("slider returned: ", slider) var output = document.getElementById("semitones-label"); - console.log("output returned: ", output) if (slider && output) { output.innerHTML = getSemitonesLabel(slider.value); // Update the current slider value (each time you drag the slider handle) diff --git a/pikaraoke/templates/splash.html b/pikaraoke/templates/splash.html index 6bb3a837..9058646b 100644 --- a/pikaraoke/templates/splash.html +++ b/pikaraoke/templates/splash.html @@ -105,10 +105,10 @@ if (obj.now_playing) { var nowPlayingHtml = `${obj.now_playing} `; - if (obj.transpose_value != 0) { + if (obj.now_playing_transpose != 0) { nowPlayingHtml += `Key: ` + - getSemitonesLabel(obj.transpose_value) + + getSemitonesLabel(obj.now_playing_transpose) + ""; } @@ -152,6 +152,16 @@ volume = obj.volume; video.volume = volume; } + + // show end time if available + const duration = $("#duration"); + if (obj.now_playing_duration) { + duration.text(`/${formatTime(obj.now_playing_duration)}`); + duration.show(); + } else { + duration.hide(); + } + video.play(); // handle timeout if video fails to play @@ -271,7 +281,6 @@ }); video.addEventListener("timeupdate", (e) => { $("#current").text(formatTime(video.currentTime)); - $("#duration").text(formatTime(video.duration)); }); $("#video source")[0].addEventListener("error", (e) => { @@ -338,9 +347,8 @@ >