diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 8c39a80a..38a70961 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -733,14 +733,11 @@ def stream_full(id): with open(file_path, "rb") as file: file_content = file.read() return Response(file_content, mimetype="video/mp4") - # Extract range start and end from Range header (e.g., "bytes=0-499") range_match = re.search(r"bytes=(\d+)-(\d*)", range_header) start, end = range_match.groups() - start = int(start) end = int(end) if end else file_size - 1 - # Generate response with part of file with open(file_path, "rb") as file: file.seek(start) @@ -752,7 +749,6 @@ def stream_full(id): "Content-Range": f"bytes {start}-{end}/{file_size}", "Content-Length": str(len(data)), } - return Response(data, status=status_code, headers=headers) except IOError: flash("File not found.", "is-danger") @@ -896,6 +892,13 @@ def main(): help="Download higher quality video. Note: requires ffmpeg and may cause CPU, download speed, and other performance issues", required=False, ) + parser.add_argument( + "-b", + "--buffer-fully-before-playback", + action="store_true", + help="Allow ffmpeg transcoding buffer to fully complete before playback begins. This can help with streaming on slower devices and improve browser compatibility (Safari), but will increase the delay before playback begins.", + required=False, + ) parser.add_argument( "--logo-path", nargs="+", @@ -958,6 +961,7 @@ def main(): log_level=args.log_level, volume=parsed_volume, normalize_audio=args.normalize_audio, + buffer_fully_before_playback=args.buffer_fully_before_playback, hide_url=args.hide_url, hide_raspiwifi_instructions=args.hide_raspiwifi_instructions, hide_splash_screen=args.hide_splash_screen, diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index ce7449d2..dd49c190 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -85,6 +85,7 @@ def __init__( high_quality=False, volume=0.85, normalize_audio=False, + buffer_fully_before_playback=False, log_level=logging.DEBUG, splash_delay=2, youtubedl_path="/usr/local/bin/yt-dlp", @@ -104,6 +105,7 @@ def __init__( self.splash_delay = int(splash_delay) self.volume = volume self.normalize_audio = normalize_audio + self.buffer_fully_before_playback = buffer_fully_before_playback self.youtubedl_path = youtubedl_path self.logo_path = self.default_logo_path if logo_path == None else logo_path self.hide_overlay = hide_overlay @@ -135,6 +137,7 @@ def __init__( download path: {self.download_path} default volume: {self.volume} normalize audio: {self.normalize_audio} + buffer audio fully before playback: {self.buffer_fully_before_playback} youtube-dl path: {self.youtubedl_path} logo path: {self.logo_path} log_level: {log_level} @@ -410,7 +413,7 @@ def play_file(self, file_path, semitones=0): logging.info(f"Playing file: {file_path} transposed {semitones} semitones") try: - fr = FileResolver(file_path) + fr = FileResolver(file_path, self.buffer_fully_before_playback) except Exception as e: logging.error("Error resolving file: " + str(e)) self.queue.pop(0) @@ -418,7 +421,9 @@ def play_file(self, file_path, semitones=0): self.kill_ffmpeg() - ffmpeg_cmd = build_ffmpeg_cmd(fr, semitones, self.normalize_audio) + ffmpeg_cmd = build_ffmpeg_cmd( + fr, semitones, self.normalize_audio, self.buffer_fully_before_playback + ) self.ffmpeg_process = ffmpeg_cmd.run_async(pipe_stderr=True, pipe_stdin=True) # ffmpeg outputs everything useful to stderr for some insane reason! @@ -445,11 +450,13 @@ def play_file(self, file_path, semitones=0): except: try: output_file_size = os.path.getsize(fr.output_file) - is_buffering_complete = output_file_size > buffering_threshold - if is_buffering_complete: - logging.debug(f"Buffering complete. File size: {output_file_size}") + if not self.buffer_fully_before_playback: + is_buffering_complete = output_file_size > buffering_threshold + if is_buffering_complete: + logging.debug(f"Buffering complete. File size: {output_file_size}") except: pass + # Check if the stream is ready to play. Determined by: # - completed transcoding # - buffered file size being greater than a threshold diff --git a/pikaraoke/lib/ffmpeg.py b/pikaraoke/lib/ffmpeg.py index e7d68572..e5f74b1e 100644 --- a/pikaraoke/lib/ffmpeg.py +++ b/pikaraoke/lib/ffmpeg.py @@ -6,7 +6,7 @@ from pikaraoke.lib.get_platform import supports_hardware_h264_encoding -def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True): +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" # just copy the video stream if it's an mp4 or webm file, since they are supported natively in html5 @@ -28,6 +28,10 @@ def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True): # normalize the audio audio = audio.filter("loudnorm", i=-16, tp=-1.5, lra=11) if normalize_audio else audio + # frag_keyframe+default_base_moof is used to set the correct headers for streaming incomplete files, + # without it, there's better compatibility for streaming on certain browsers like Firefox + movflags = "+faststart" if buffer_fully_before_playback else "frag_keyframe+default_base_moof" + if fr.cdg_file_path != None: # handle CDG files logging.info("Playing CDG/MP3 file: " + fr.file_path) # copyts helps with sync issues, fps=25 prevents ffmpeg from needlessly encoding cdg at 300fps @@ -35,7 +39,7 @@ def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True): video = cdg_input.video.filter("fps", fps=25) # cdg is very fussy about these flags. # pi ffmpeg needs to encode to aac and cant just copy the mp3 stream - # It alse appears to have memory issues with hardware acceleration h264_v4l2m2m + # It also appears to have memory issues with hardware acceleration h264_v4l2m2m output = ffmpeg.output( audio, video, @@ -47,7 +51,7 @@ def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True): listen=1, f="mp4", video_bitrate="500k", - movflags="frag_keyframe+default_base_moof", + movflags=movflags, ) else: video = input.video @@ -61,7 +65,7 @@ def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True): listen=1, f="mp4", video_bitrate=vbitrate, - movflags="frag_keyframe+default_base_moof", + movflags=movflags, ) args = output.get_args() diff --git a/pikaraoke/lib/file_resolver.py b/pikaraoke/lib/file_resolver.py index 69e844ea..2c3b214e 100644 --- a/pikaraoke/lib/file_resolver.py +++ b/pikaraoke/lib/file_resolver.py @@ -40,13 +40,19 @@ class FileResolver: cdg_file_path = None file_extension = None - def __init__(self, file_path): + def __init__(self, file_path, buffer_fully_before_playback=False): 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" - self.stream_url_path = f"/stream/{self.stream_uid}" + 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):