ellagranger commited on
Commit
b6f3797
·
1 Parent(s): b46e23a

Change to subprocess

Browse files
Files changed (5) hide show
  1. Dockerfile +2 -1
  2. README.md +15 -16
  3. backend_worker.py +29 -54
  4. frontend_app.py +21 -25
  5. start.sh +0 -10
Dockerfile CHANGED
@@ -12,7 +12,8 @@ ENV DEBIAN_FRONTEND=noninteractive \
12
  PIP_NO_BUILD_ISOLATION=1 \
13
  FRONTEND_VENV=/opt/frontend-py310 \
14
  BACKEND_VENV=/opt/backend-py39 \
15
- BEATNET_SOCKET=/tmp/beatnet_backend.sock \
 
16
  PORT=7860
17
 
18
  RUN apt-get update && apt-get install -y --no-install-recommends \
 
12
  PIP_NO_BUILD_ISOLATION=1 \
13
  FRONTEND_VENV=/opt/frontend-py310 \
14
  BACKEND_VENV=/opt/backend-py39 \
15
+ BACKEND_PYTHON=/opt/backend-py39/bin/python \
16
+ BACKEND_SCRIPT=/app/backend_worker.py \
17
  PORT=7860
18
 
19
  RUN apt-get update && apt-get install -y --no-install-recommends \
README.md CHANGED
@@ -16,9 +16,9 @@ needs two Python interpreters in one container:
16
 
17
  - **Frontend:** Python 3.10 virtual environment for Gradio / pyharp.
18
  - **Backend:** Python 3.9 virtual environment for BeatNet and madmom.
19
- - **Transport:** JSON over a Unix-domain socket, with uploaded audio passed as a
20
- file path. This keeps large audio payloads out of IPC and avoids importing
21
- BeatNet/madmom in the frontend interpreter.
22
 
23
  The public `teamup-tech/BeatNet` Space currently uses a single
24
  `python:3.10-slim` Docker image and starts `app.py` directly. Replace that setup
@@ -31,23 +31,23 @@ with these files, or adapt the same pattern in-place.
31
  | `Dockerfile` | Builds separate Python 3.9 and 3.10 virtual environments. |
32
  | `requirements-backend-py39.txt` | BeatNet/madmom dependencies only. |
33
  | `requirements-frontend-py310.txt` | Gradio/pyharp dependencies only. |
34
- | `backend_worker.py` | Long-lived BeatNet worker running under Python 3.9. |
35
  | `frontend_app.py` | Gradio app running under Python 3.10. |
36
- | `start.sh` | Starts the backend worker, then execs the frontend. |
37
 
38
- ## Why sockets instead of importing both stacks?
39
 
40
  `madmom` and BeatNet can be sensitive to Python, NumPy, Cython, and build
41
  isolation versions. Running BeatNet/madmom in a backend-only Python 3.9
42
  environment avoids the madmom compatibility patching needed by newer Python
43
  versions, while the public web layer runs independently on Python 3.10. The
44
- frontend and backend exchange only stable JSON data:
45
 
46
- ```json
47
- {"audio_path": "/tmp/gradio/upload.wav"}
48
  ```
49
 
50
- The backend responds with:
51
 
52
  ```json
53
  {"ok": true, "beats": [{"time": 0.42, "beat": 1.0}]}
@@ -64,10 +64,9 @@ The backend responds with:
64
  4. Push to the Space. Hugging Face will build the Docker image and route traffic
65
  to `PORT=7860`.
66
 
67
- ## Alternative: file-queue transport
68
 
69
- If Unix sockets are not desired, keep the same two virtual environments and have
70
- the frontend write request JSON to `/tmp/beatnet_queue/inbox/<uuid>.json`; the
71
- backend atomically moves the request into a processing directory and writes the
72
- response to `/tmp/beatnet_queue/outbox/<uuid>.json`. Sockets are preferred here
73
- because there is no polling delay and no stale-file cleanup path.
 
16
 
17
  - **Frontend:** Python 3.10 virtual environment for Gradio / pyharp.
18
  - **Backend:** Python 3.9 virtual environment for BeatNet and madmom.
19
+ - **Transport:** on-demand Python 3.9 subprocess calls, with uploaded audio
20
+ passed as a file path. This avoids importing BeatNet/madmom in the frontend
21
+ interpreter without keeping a backend process alive.
22
 
23
  The public `teamup-tech/BeatNet` Space currently uses a single
24
  `python:3.10-slim` Docker image and starts `app.py` directly. Replace that setup
 
31
  | `Dockerfile` | Builds separate Python 3.9 and 3.10 virtual environments. |
32
  | `requirements-backend-py39.txt` | BeatNet/madmom dependencies only. |
33
  | `requirements-frontend-py310.txt` | Gradio/pyharp dependencies only. |
34
+ | `backend_worker.py` | One-shot BeatNet subprocess runner under Python 3.9. |
35
  | `frontend_app.py` | Gradio app running under Python 3.10. |
36
+ | `start.sh` | Starts the frontend. |
37
 
38
+ ## Why a subprocess instead of importing both stacks?
39
 
40
  `madmom` and BeatNet can be sensitive to Python, NumPy, Cython, and build
41
  isolation versions. Running BeatNet/madmom in a backend-only Python 3.9
42
  environment avoids the madmom compatibility patching needed by newer Python
43
  versions, while the public web layer runs independently on Python 3.10. The
44
+ frontend invokes the backend script only when processing audio:
45
 
46
+ ```sh
47
+ /opt/backend-py39/bin/python /app/backend_worker.py /tmp/gradio/upload.wav
48
  ```
49
 
50
+ The backend prints stable JSON to stdout:
51
 
52
  ```json
53
  {"ok": true, "beats": [{"time": 0.42, "beat": 1.0}]}
 
64
  4. Push to the Space. Hugging Face will build the Docker image and route traffic
65
  to `PORT=7860`.
66
 
67
+ ## Alternative: persistent worker transport
68
 
69
+ If startup latency becomes a problem, keep the same two virtual environments and
70
+ run `backend_worker.py` as a long-lived process behind a Unix socket or file
71
+ queue. The subprocess approach uses less idle memory, while a persistent worker
72
+ can reuse the loaded BeatNet model across requests.
 
backend_worker.py CHANGED
@@ -1,73 +1,48 @@
1
  #!/usr/bin/env python3
2
- """BeatNet backend worker for the Python 3.9 environment.
3
 
4
- The worker owns the BeatNet import and model initialization. The frontend sends
5
- small JSON messages over a Unix-domain socket and passes uploaded audio by file
6
- path, avoiding cross-interpreter object sharing.
7
  """
8
 
9
  from __future__ import annotations
10
 
 
11
  import json
12
- import os
13
- import socket
14
- from pathlib import Path
15
  from typing import Any
16
 
17
- from BeatNet.BeatNet import BeatNet
18
 
19
- SOCKET_PATH = Path(os.environ.get("BEATNET_SOCKET", "/tmp/beatnet_backend.sock"))
 
 
20
 
 
 
21
 
22
- def _read_message(conn: socket.socket) -> dict[str, Any]:
23
- chunks: list[bytes] = []
24
- while True:
25
- chunk = conn.recv(65536)
26
- if not chunk:
27
- break
28
- chunks.append(chunk)
29
- if not chunks:
30
- return {}
31
- return json.loads(b"".join(chunks).decode("utf-8"))
32
 
33
 
34
- def _send_message(conn: socket.socket, payload: dict[str, Any]) -> None:
35
- conn.sendall(json.dumps(payload).encode("utf-8"))
 
 
 
 
 
36
 
 
 
 
 
37
 
38
- def serve() -> None:
39
- SOCKET_PATH.parent.mkdir(parents=True, exist_ok=True)
40
- if SOCKET_PATH.exists():
41
- SOCKET_PATH.unlink()
42
-
43
- estimator = BeatNet(1, mode="offline", inference_model="DBN", plot=[], thread=False)
44
-
45
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
46
- server.bind(str(SOCKET_PATH))
47
- server.listen(16)
48
- os.chmod(SOCKET_PATH, 0o666)
49
- print(f"BeatNet backend listening on {SOCKET_PATH}", flush=True)
50
-
51
- while True:
52
- conn, _ = server.accept()
53
- with conn:
54
- try:
55
- request = _read_message(conn)
56
- audio_path = request["audio_path"]
57
- beats = estimator.process(audio_path)
58
- _send_message(
59
- conn,
60
- {
61
- "ok": True,
62
- "beats": [
63
- {"time": float(timestamp), "beat": float(beat)}
64
- for timestamp, beat in beats
65
- ],
66
- },
67
- )
68
- except Exception as exc: # Keep worker alive across bad requests.
69
- _send_message(conn, {"ok": False, "error": repr(exc)})
70
 
71
 
72
  if __name__ == "__main__":
73
- serve()
 
1
  #!/usr/bin/env python3
2
+ """One-shot BeatNet backend runner for the Python 3.9 environment.
3
 
4
+ The frontend invokes this script as a subprocess and passes uploaded audio by
5
+ file path, avoiding cross-interpreter object sharing without keeping a backend
6
+ worker process alive.
7
  """
8
 
9
  from __future__ import annotations
10
 
11
+ import contextlib
12
  import json
13
+ import sys
 
 
14
  from typing import Any
15
 
 
16
 
17
+ def run_beatnet(audio_path: str) -> list[dict[str, float]]:
18
+ with contextlib.redirect_stdout(sys.stderr):
19
+ from BeatNet.BeatNet import BeatNet
20
 
21
+ estimator = BeatNet(1, mode="offline", inference_model="DBN", plot=[], thread=False)
22
+ beats = estimator.process(audio_path)
23
 
24
+ return [
25
+ {"time": float(timestamp), "beat": float(beat)}
26
+ for timestamp, beat in beats
27
+ ]
 
 
 
 
 
 
28
 
29
 
30
+ def main(argv: list[str]) -> int:
31
+ if len(argv) != 2:
32
+ print(
33
+ json.dumps({"ok": False, "error": "Usage: backend_worker.py <audio_path>"}),
34
+ flush=True,
35
+ )
36
+ return 2
37
 
38
+ try:
39
+ payload: dict[str, Any] = {"ok": True, "beats": run_beatnet(argv[1])}
40
+ except Exception as exc:
41
+ payload = {"ok": False, "error": repr(exc)}
42
 
43
+ print(json.dumps(payload), flush=True)
44
+ return 0 if payload["ok"] else 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  if __name__ == "__main__":
48
+ raise SystemExit(main(sys.argv))
frontend_app.py CHANGED
@@ -5,41 +5,37 @@ from __future__ import annotations
5
 
6
  import json
7
  import os
8
- import socket
9
- import time
10
- from pathlib import Path
11
  from typing import Any
12
 
13
  import gradio as gr
14
  from pyharp import AudioLabel, LabelList, OutputLabel
15
  from pyharp.core import ModelCard, build_endpoint
16
 
17
- SOCKET_PATH = Path(os.environ.get("BEATNET_SOCKET", "/tmp/beatnet_backend.sock"))
 
18
 
19
 
20
  def call_backend(audio_path: str, timeout_s: float = 120.0) -> list[dict[str, float]]:
21
- deadline = time.time() + timeout_s
22
- while not SOCKET_PATH.exists():
23
- if time.time() > deadline:
24
- raise TimeoutError(f"Timed out waiting for BeatNet backend at {SOCKET_PATH}")
25
- time.sleep(0.1)
26
-
27
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
28
- client.settimeout(timeout_s)
29
- client.connect(str(SOCKET_PATH))
30
- client.sendall(json.dumps({"audio_path": audio_path}).encode("utf-8"))
31
- client.shutdown(socket.SHUT_WR)
32
-
33
- chunks: list[bytes] = []
34
- while True:
35
- chunk = client.recv(65536)
36
- if not chunk:
37
- break
38
- chunks.append(chunk)
39
-
40
- response: dict[str, Any] = json.loads(b"".join(chunks).decode("utf-8"))
41
  if not response.get("ok"):
42
- raise RuntimeError(response.get("error", "BeatNet backend failed"))
 
43
  return response["beats"]
44
 
45
 
 
5
 
6
  import json
7
  import os
8
+ import subprocess
 
 
9
  from typing import Any
10
 
11
  import gradio as gr
12
  from pyharp import AudioLabel, LabelList, OutputLabel
13
  from pyharp.core import ModelCard, build_endpoint
14
 
15
+ BACKEND_PYTHON = os.environ.get("BACKEND_PYTHON", "/opt/backend-py39/bin/python")
16
+ BACKEND_SCRIPT = os.environ.get("BACKEND_SCRIPT", "/app/backend_worker.py")
17
 
18
 
19
  def call_backend(audio_path: str, timeout_s: float = 120.0) -> list[dict[str, float]]:
20
+ completed = subprocess.run(
21
+ [BACKEND_PYTHON, BACKEND_SCRIPT, audio_path],
22
+ capture_output=True,
23
+ check=False,
24
+ text=True,
25
+ timeout=timeout_s,
26
+ )
27
+
28
+ try:
29
+ response: dict[str, Any] = json.loads(completed.stdout)
30
+ except json.JSONDecodeError as exc:
31
+ raise RuntimeError(
32
+ "BeatNet backend returned invalid JSON. "
33
+ f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
34
+ ) from exc
35
+
 
 
 
 
36
  if not response.get("ok"):
37
+ error = response.get("error") or completed.stderr or "BeatNet backend failed"
38
+ raise RuntimeError(error)
39
  return response["beats"]
40
 
41
 
start.sh CHANGED
@@ -1,14 +1,4 @@
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
 
4
- rm -f "${BEATNET_SOCKET:-/tmp/beatnet_backend.sock}"
5
-
6
- "${BACKEND_VENV:-/opt/backend-py39}/bin/python" -u /app/backend_worker.py &
7
- backend_pid="$!"
8
-
9
- cleanup() {
10
- kill "$backend_pid" 2>/dev/null || true
11
- }
12
- trap cleanup EXIT INT TERM
13
-
14
  exec "${FRONTEND_VENV:-/opt/frontend-py310}/bin/python" -u /app/frontend_app.py
 
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
 
 
 
 
 
 
 
 
 
 
 
4
  exec "${FRONTEND_VENV:-/opt/frontend-py310}/bin/python" -u /app/frontend_app.py