diff --git a/examples/midi_pitch_shifter/app.py b/examples/midi_pitch_shifter/app.py index 44d9087..ece30ee 100644 --- a/examples/midi_pitch_shifter/app.py +++ b/examples/midi_pitch_shifter/app.py @@ -28,37 +28,38 @@ def process_fn( return output_midi_path # Build Gradio endpoint -with gr.Blocks() as demo: - # Define input Gradio Components - input_components = [ - gr.File(type="filepath", - label="Input Midi", - file_types=[".mid", ".midi"]) - .harp_required(True), - gr.Slider( - minimum=-24, - maximum=24, - step=1, - value=7, - label="Pitch Shift (semitones)", - info="Controls the amount of pitch shift in semitones" - ), - ] +if __name__ == "__main__": + with gr.Blocks() as demo: + # Define input Gradio Components + input_components = [ + gr.File(type="filepath", + label="Input Midi", + file_types=[".mid", ".midi"]) + .harp_required(True), + gr.Slider( + minimum=-24, + maximum=24, + step=1, + value=7, + label="Pitch Shift (semitones)", + info="Controls the amount of pitch shift in semitones" + ), + ] - # Define output Gradio Components - output_components = [ - gr.File(type="filepath", - label="Output Midi", - file_types=[".mid", ".midi"]) - .set_info("The pitch-shifted MIDI."), - ] + # Define output Gradio Components + output_components = [ + gr.File(type="filepath", + label="Output Midi", + file_types=[".mid", ".midi"]) + .set_info("The pitch-shifted MIDI."), + ] - # Build a HARP-compatible endpoint - app = build_endpoint( - model_card=model_card, - input_components=input_components, - output_components=output_components, - process_fn=process_fn, - ) + # Build a HARP-compatible endpoint + app = build_endpoint( + model_card=model_card, + input_components=input_components, + output_components=output_components, + process_fn=process_fn, + ) -demo.queue().launch(share=True, show_error=False, pwa=True) + demo.queue().launch(share=True, show_error=False, pwa=True) diff --git a/examples/midi_synthesizer/app.py b/examples/midi_synthesizer/app.py index b998827..1ca992a 100644 --- a/examples/midi_synthesizer/app.py +++ b/examples/midi_synthesizer/app.py @@ -33,28 +33,29 @@ def process_fn(input_midi_path: str) -> str: return output_audio_path # Build Gradio endpoint -with gr.Blocks() as demo: - # Define input Gradio Components - input_components = [ - gr.File(type="filepath", - label="Input Midi", - file_types=[".mid", ".midi"]) - .harp_required(True), - ] - - # Define output Gradio Components - output_components = [ - gr.Audio(type="filepath", - label="Output Audio") - .set_info("The synthesized audio."), - ] - - # Build a HARP-compatible endpoint - app = build_endpoint( - model_card=model_card, - input_components=input_components, - output_components=output_components, - process_fn=process_fn, - ) - -demo.queue().launch(share=True, show_error=False, pwa=True) +if __name__ == "__main__": + with gr.Blocks() as demo: + # Define input Gradio Components + input_components = [ + gr.File(type="filepath", + label="Input Midi", + file_types=[".mid", ".midi"]) + .harp_required(True), + ] + + # Define output Gradio Components + output_components = [ + gr.Audio(type="filepath", + label="Output Audio") + .set_info("The synthesized audio."), + ] + + # Build a HARP-compatible endpoint + app = build_endpoint( + model_card=model_card, + input_components=input_components, + output_components=output_components, + process_fn=process_fn, + ) + + demo.queue().launch(share=True, show_error=False, pwa=True) diff --git a/examples/pitch_shifter/app.py b/examples/pitch_shifter/app.py index 2a61421..16815e5 100644 --- a/examples/pitch_shifter/app.py +++ b/examples/pitch_shifter/app.py @@ -38,35 +38,36 @@ def process_fn( # Build Gradio endpoint -with gr.Blocks() as demo: - # Define input Gradio Components - input_components = [ - gr.Audio(type="filepath", - label="Input Audio A") - .harp_required(True), - gr.Slider( - minimum=-24, - maximum=24, - step=1, - value=7, - label="Pitch Shift (semitones)", - info="Controls the amount of pitch shift in semitones" - ), - ] +if __name__ == "__main__": + with gr.Blocks() as demo: + # Define input Gradio Components + input_components = [ + gr.Audio(type="filepath", + label="Input Audio A") + .harp_required(True), + gr.Slider( + minimum=-24, + maximum=24, + step=1, + value=7, + label="Pitch Shift (semitones)", + info="Controls the amount of pitch shift in semitones" + ), + ] - # Define output Gradio Components - output_components = [ - gr.Audio(type="filepath", - label="Output Audio") - .set_info("The pitch-shifted audio."), - ] + # Define output Gradio Components + output_components = [ + gr.Audio(type="filepath", + label="Output Audio") + .set_info("The pitch-shifted audio."), + ] - # Build a HARP-compatible endpoint - app = build_endpoint( - model_card=model_card, - input_components=input_components, - output_components=output_components, - process_fn=process_fn, - ) + # Build a HARP-compatible endpoint + app = build_endpoint( + model_card=model_card, + input_components=input_components, + output_components=output_components, + process_fn=process_fn, + ) -demo.queue().launch(share=True, show_error=False, pwa=True) + demo.queue().launch(share=True, show_error=False, pwa=True) diff --git a/pyharp/core.py b/pyharp/core.py index e5950c9..58eebb7 100644 --- a/pyharp/core.py +++ b/pyharp/core.py @@ -2,9 +2,15 @@ from dataclasses import dataclass, asdict from typing import List +import multiprocessing as mp +import threading import gradio as gr +# "spawn" avoids deadlocks with CUDA/PyTorch libraries that "fork" can cause on Linux (HuggingFace Spaces) +mp.set_start_method("spawn", force=True) + + __all__ = [ 'ModelCard', 'build_endpoint' @@ -174,8 +180,88 @@ def get_harp_component(gr_cmp: Component) -> HarpComponent: return harp_cmp +def _worker_entry(fn, args, result_q): + import traceback + try: + result = fn(*args) + result_q.put(("ok", result)) + except gr.Error as e: + traceback.print_exc() + result_q.put(( + "gr_error", + (e.message, e.duration, e.visible, e.title), + )) + except Exception as e: + tb = traceback.format_exc() + result_q.put(("err", (str(e), tb))) + +class JobSupervisor: + """ + Runs a callable in a subprocess and allows it to be cancelled or + timed out via SIGTERM, rather than running to completion server-side. + """ + + def __init__(self, timeout_s=900): + self.timeout_s = timeout_s + self._process = None + self._result_q = None + self._lock = threading.Lock() + + def run(self, fn, *args): + self.cancel() # single-flight: kill any previous job first + + with self._lock: + self._result_q = mp.Queue() + self._process = mp.Process( + target=_worker_entry, + args=(fn, args, self._result_q), + daemon=True, + ) + self._process.start() + process, result_q = self._process, self._result_q + + process.join(self.timeout_s) + + with self._lock: + if self._process is process and process.is_alive(): + try: + self._terminate("timeout") + except RuntimeError: + pass # sentinel pushed to result_q, handled below + + status, payload = result_q.get() + + with self._lock: + if self._process is process: + self._cleanup() + + if status == "gr_error": + message, duration, visible, title = payload + raise gr.Error(message, duration=duration, visible=visible, title=title) + if status == "err": + short_msg, tb = payload + raise RuntimeError(f"{short_msg}\n\n{tb}") + return payload + + def cancel(self): + with self._lock: + if self._process and self._process.is_alive(): + self._terminate("cancelled") + + def _terminate(self, reason): + self._process.terminate() + self._process.join() + if self._result_q is not None: + self._result_q.put(("gr_error", (f"Job {reason}.", 10, True, reason.capitalize()))) + self._cleanup() + raise RuntimeError(f"Job {reason}") + + def _cleanup(self): + self._process = None + self._result_q = None + def build_endpoint(model_card: ModelCard, input_components: list, output_components: list, - process_fn: callable) -> tuple: + process_fn: callable, timeout_s: int = 900) -> tuple: """ Builds a Gradio endpoint compatible with HARP. @@ -197,6 +283,16 @@ def build_endpoint(model_card: ModelCard, input_components: list, output_compone - The function must accept the inputs in the same order as the inputs list. - The function must return the outputs in the same order as the outputs list, with a filepath string pointing to each output file. + - process_fn runs in a separate subprocess, so its arguments and + return values must be picklable (e.g. filepath strings, numbers, + booleans, JSON-serializable data). It cannot rely on gr.Progress + or other objects tied to the Gradio request context. + - If the Cancel button is pressed, or if process_fn runs longer + than timeout_s, the subprocess is killed (SIGTERM) and the job + is aborted. + timeout_s (int): Maximum time in seconds to let process_fn run before + it is forcibly killed. Defaults to 900 (15 minutes). Increase this + for models that need more time to process their inputs. Returns: app (dict): A dictionary containing: @@ -231,10 +327,22 @@ def fetch_model_info(): api_name="controls" ) + # Supervise process_fn in a subprocess so it can be killed on cancel/timeout + supervisor = JobSupervisor(timeout_s=timeout_s) + + def supervised_process(*args): + return supervisor.run(process_fn, *args) + + def cancel_handler(): + try: + supervisor.cancel() + except RuntimeError: + pass # expected -- job was cancelled successfully + # Create a button to begin processing process_button = gr.Button("Process") process_event = process_button.click( - fn=process_fn, + fn=supervised_process, inputs=input_components, outputs=output_components, api_name="process" @@ -243,7 +351,7 @@ def fetch_model_info(): # Create a button to cancel processing cancel_button = gr.Button("Cancel") cancel_button.click( - fn=lambda: None, + fn=cancel_handler, inputs=[], outputs=[], api_name="cancel", diff --git a/setup.py b/setup.py index 0970f92..4eb70d4 100644 --- a/setup.py +++ b/setup.py @@ -4,12 +4,12 @@ name='pyharp', version='0.3.0', url='https://github.com/TEAMuP-dev/pyharp', - author='Frank Cwitkowitz, Christodoulos Benetatos, Hugo Flores García, Patrick O\'Reilly, Nathan Pruyne, and Aldo Aguilar', + author='Frank Cwitkowitz, Christodoulos Benetatos, Hugo Flores García, Patrick O\'Reilly, Nathan Pruyne, Aldo Aguilar and Saumya Pailwan', author_email='fcwitkow@ur.rochester.edu', description='', packages=find_packages(), install_requires=[ - 'gradio==5.28.0', + 'gradio>=6.17.3,<7', 'descript-audiotools', 'symusic' ]