Skip to content

Commit

Permalink
perf: only transcode files when necessary
Browse files Browse the repository at this point in the history
  • Loading branch information
vicwomg committed Jan 3, 2025
1 parent 823d83d commit 936c3cd
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 87 deletions.
10 changes: 5 additions & 5 deletions pikaraoke/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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,
Expand Down
138 changes: 81 additions & 57 deletions pikaraoke/karaoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import os
import random
import shutil
import socket
import subprocess
import threading
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions pikaraoke/lib/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 13 additions & 11 deletions pikaraoke/lib/file_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand All @@ -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":
Expand All @@ -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
Expand All @@ -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)
18 changes: 10 additions & 8 deletions pikaraoke/templates/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
var nowPlayingHtml = `<p style="margin-bottom: 5px">${obj.now_playing}</p>
<p class="has-text-success" style="margin-bottom: 5px"><i class="icon icon-mic-1" title="Current singer"></i>${obj.now_playing_user}</p>`;

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. #}
"<span class='is-size-6 has-text-success'><b>Key</b>: " +
getSemitonesLabel(obj.transpose_value) +
getSemitonesLabel(obj.now_playing_transpose) +
"<span>";
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 936c3cd

Please sign in to comment.