mirror of
https://github.com/RVC-Project/Retrieval-based-Voice-Conversion-WebUI.git
synced 2026-09-02 12:08:56 +02:00
1019 lines
35 KiB
Python
1019 lines
35 KiB
Python
import asyncio
|
|
import base64
|
|
import binascii
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from ..model_download import DownloadError, download_model
|
|
from ..model_registry import model_root
|
|
from .audio import (
|
|
decode_pcm,
|
|
json_response,
|
|
normalize_stems,
|
|
parse_int,
|
|
validate_common_options,
|
|
zip_response,
|
|
)
|
|
from .config import ServerConfig
|
|
from .errors import APIError
|
|
from .models import (
|
|
catalog_model_card,
|
|
catalog_model_detail,
|
|
filter_catalog_models,
|
|
parse_include_files,
|
|
parse_local_filter,
|
|
parse_supported_filter,
|
|
)
|
|
from .state import DEFAULT_ENDPOINT, InferenceParameterError, close_loaded_model, load_model, load_state, model_card
|
|
from .webui import register_webui_routes
|
|
|
|
|
|
try:
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse, Response
|
|
except ImportError as exc: # pragma: no cover - exercised only without optional deps.
|
|
raise RuntimeError("Install server dependencies with `pip install pymss[server]` or `uv sync --extra server`.") from exc
|
|
|
|
|
|
def _error_response(exc):
|
|
"""Implement the error response helper.
|
|
|
|
Args:
|
|
exc (Any): Exc value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={
|
|
"error": {
|
|
"message": exc.message,
|
|
"type": exc.error_type,
|
|
"param": exc.param,
|
|
"code": exc.code,
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
def _check_auth(request, state):
|
|
"""Check auth.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
None: This callable completes for its side effects."""
|
|
if not state.config.api_key:
|
|
return
|
|
expected = f"Bearer {state.config.api_key}"
|
|
if request.headers.get("authorization") != expected:
|
|
raise APIError(401, "invalid_api_key", "Invalid or missing API key.")
|
|
|
|
|
|
def _content_type(request):
|
|
"""Implement the content type helper.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
return request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
|
|
|
|
async def _read_body(request, state):
|
|
"""Read body.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
max_request_bytes = state.config.max_request_bytes
|
|
content_length = request.headers.get("content-length")
|
|
if content_length is not None:
|
|
try:
|
|
declared_length = int(content_length)
|
|
except ValueError:
|
|
raise APIError(400, "invalid_request", "Invalid Content-Length header.")
|
|
if declared_length < 0:
|
|
raise APIError(400, "invalid_request", "Invalid Content-Length header.")
|
|
if declared_length > max_request_bytes:
|
|
raise APIError(413, "request_too_large", "Request body is too large.")
|
|
|
|
chunks = []
|
|
total = 0
|
|
async for chunk in request.stream():
|
|
total += len(chunk)
|
|
if total > max_request_bytes:
|
|
raise APIError(413, "request_too_large", "Request body is too large.")
|
|
chunks.append(chunk)
|
|
return b"".join(chunks)
|
|
|
|
|
|
def _require_request_model(model):
|
|
"""Implement the require request model helper.
|
|
|
|
Args:
|
|
model (str): Model value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
if not model:
|
|
raise APIError(400, "invalid_model", "The 'model' field is required.", param="model")
|
|
return str(model)
|
|
|
|
|
|
def _require_loaded_for_inference(state):
|
|
"""Implement the require loaded for inference helper.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
if state.model_loading:
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
loaded = state.loaded
|
|
if loaded is None:
|
|
raise APIError(503, "model_not_loaded", "No model is currently loaded.", param="model")
|
|
return loaded
|
|
|
|
|
|
def _require_model_id(loaded, model):
|
|
"""Implement the require model id helper.
|
|
|
|
Args:
|
|
loaded (LoadedModel): Loaded value.
|
|
model (str): Model value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
model = _require_request_model(model)
|
|
if not loaded.is_model_id(model):
|
|
raise APIError(404, "model_not_found", f"Model {model!r} is not loaded by this process.", param="model")
|
|
return model
|
|
|
|
|
|
def _validate_download_source(source, endpoint, *, source_required=False):
|
|
"""Validate download source.
|
|
|
|
Args:
|
|
source (str): Download source name.
|
|
endpoint (str | None): Optional custom download endpoint.
|
|
source_required (Any, optional): Source required value. Defaults to False.
|
|
|
|
Returns:
|
|
None: This callable completes for its side effects."""
|
|
if source_required and not source:
|
|
raise APIError(400, "invalid_download_source", "The 'source' field is required.", param="source")
|
|
if source is not None and source not in {"modelscope", "huggingface", "hf-mirror"}:
|
|
raise APIError(
|
|
400,
|
|
"invalid_download_source",
|
|
"source must be one of: modelscope, huggingface, hf-mirror.",
|
|
param="source",
|
|
)
|
|
if endpoint is not DEFAULT_ENDPOINT and endpoint is not None and not isinstance(endpoint, str):
|
|
raise APIError(400, "invalid_download_source", "endpoint must be a string or null.", param="endpoint")
|
|
|
|
|
|
def _effective_download_source(state, source, endpoint):
|
|
"""Implement the effective download source helper.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
source (str): Download source name.
|
|
endpoint (str | None): Optional custom download endpoint.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
effective_source = source or state.config.source
|
|
effective_endpoint = state.config.endpoint if endpoint is DEFAULT_ENDPOINT else endpoint
|
|
return effective_source, effective_endpoint
|
|
|
|
|
|
def _query_endpoint(query_params):
|
|
"""Implement the query endpoint helper.
|
|
|
|
Args:
|
|
query_params (Any): Query params value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
return query_params["endpoint"] if "endpoint" in query_params else DEFAULT_ENDPOINT
|
|
|
|
|
|
def _parse_bool_field(value, default, param):
|
|
"""Parse bool field.
|
|
|
|
Args:
|
|
value (Any): Value value.
|
|
default (Any): Default value.
|
|
param (str | None): Param value.
|
|
|
|
Returns:
|
|
Any: Parsed value."""
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if lowered in {"0", "false", "no", "off"}:
|
|
return False
|
|
raise APIError(400, "invalid_request", f"{param} must be a boolean.", param=param)
|
|
|
|
|
|
def _parse_json_request_body(body, loaded, state):
|
|
"""Parse json request body.
|
|
|
|
Args:
|
|
body (Any): Body value.
|
|
loaded (LoadedModel): Loaded value.
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
Any: Parsed value."""
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise APIError(400, "invalid_request", "Request body must be valid JSON.")
|
|
if not isinstance(payload, dict):
|
|
raise APIError(400, "invalid_request", "JSON request body must be an object.")
|
|
|
|
model = _require_model_id(loaded, payload.get("model"))
|
|
input_data = payload.get("input")
|
|
if not isinstance(input_data, dict):
|
|
raise APIError(400, "invalid_request", "The 'input' object is required.", param="input")
|
|
|
|
audio_format = str(input_data.get("format", "")).lower()
|
|
sample_rate = parse_int(input_data.get("sample_rate"), "input.sample_rate")
|
|
channels = parse_int(input_data.get("channels"), "input.channels")
|
|
encoded = input_data.get("data")
|
|
if not isinstance(encoded, str):
|
|
raise APIError(400, "invalid_request", "input.data must be a base64 string.", param="input.data")
|
|
try:
|
|
raw = base64.b64decode(encoded, validate=True)
|
|
except binascii.Error:
|
|
raise APIError(400, "invalid_base64", "input.data must be valid base64.", param="input.data")
|
|
|
|
stems = normalize_stems(payload.get("stems"), loaded.instruments)
|
|
response_format = str(payload.get("response_format", "json")).lower()
|
|
output_audio_format = str(payload.get("output_audio_format", "pcm_f32le")).lower()
|
|
validate_common_options(response_format, output_audio_format)
|
|
mix, seconds = decode_pcm(
|
|
raw,
|
|
audio_format,
|
|
sample_rate,
|
|
channels,
|
|
loaded.sample_rate,
|
|
state.config.max_audio_seconds,
|
|
)
|
|
return model, mix, stems, response_format, output_audio_format, seconds
|
|
|
|
|
|
def _parse_binary_request_body(request, body, loaded, state):
|
|
"""Parse binary request body.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
body (Any): Body value.
|
|
loaded (LoadedModel): Loaded value.
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
Any: Parsed value."""
|
|
params = request.query_params
|
|
model = _require_model_id(loaded, params.get("model"))
|
|
audio_format = str(params.get("format", "")).lower()
|
|
if not audio_format or params.get("sample_rate") is None or params.get("channels") is None:
|
|
raise APIError(400, "missing_audio_metadata", "format, sample_rate, and channels are required.")
|
|
sample_rate = parse_int(params.get("sample_rate"), "sample_rate", code="invalid_query_parameter")
|
|
channels = parse_int(params.get("channels"), "channels", code="invalid_query_parameter")
|
|
stems = normalize_stems(params.get("stems"), loaded.instruments)
|
|
response_format = str(params.get("response_format", "json")).lower()
|
|
output_audio_format = str(params.get("output_audio_format", "pcm_f32le")).lower()
|
|
validate_common_options(response_format, output_audio_format)
|
|
mix, seconds = decode_pcm(
|
|
body,
|
|
audio_format,
|
|
sample_rate,
|
|
channels,
|
|
loaded.sample_rate,
|
|
state.config.max_audio_seconds,
|
|
)
|
|
return model, mix, stems, response_format, output_audio_format, seconds
|
|
|
|
|
|
async def _parse_request(request, state, loaded, body=None):
|
|
"""Parse request.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
state (Any): State value.
|
|
loaded (LoadedModel): Loaded value.
|
|
body (Any, optional): Body value. Defaults to None.
|
|
|
|
Returns:
|
|
Any: Parsed value."""
|
|
if body is None:
|
|
body = await _read_body(request, state)
|
|
content_type = _content_type(request)
|
|
if content_type == "application/json":
|
|
return _parse_json_request_body(body, loaded, state)
|
|
if content_type == "application/octet-stream":
|
|
return _parse_binary_request_body(request, body, loaded, state)
|
|
raise APIError(415, "unsupported_content_type", "Content-Type must be application/json or application/octet-stream.")
|
|
|
|
|
|
def _run_separation_sync(loaded, mix, stems):
|
|
"""Run separation sync.
|
|
|
|
Args:
|
|
loaded (LoadedModel): Loaded value.
|
|
mix (np.ndarray): Mix value.
|
|
stems (Sequence[str] | None): Requested output stem names.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
if loaded.separator.model_type == "vr":
|
|
return loaded.separator.separate(mix, pbar=False)
|
|
return loaded.separator.separate(mix, pbar=False, stems=stems)
|
|
|
|
|
|
async def _run_separation(state, loaded, model, mix, stems):
|
|
"""Run separation.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
loaded (LoadedModel): Loaded value.
|
|
model (str): Model value.
|
|
mix (np.ndarray): Mix value.
|
|
stems (Sequence[str] | None): Requested output stem names.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
if state.model_loading:
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
acquired = await state.limiter.acquire()
|
|
if not acquired:
|
|
raise APIError(429, "server_overloaded", "Inference queue is full.")
|
|
try:
|
|
if state.model_loading:
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
async with state.inference_lock:
|
|
if state.model_loading:
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
if state.loaded is not loaded:
|
|
raise APIError(404, "model_not_found", f"Model {model!r} is not loaded by this process.", param="model")
|
|
task = asyncio.to_thread(_run_separation_sync, loaded, mix, stems)
|
|
if state.config.request_timeout_seconds:
|
|
try:
|
|
return await asyncio.wait_for(task, timeout=state.config.request_timeout_seconds)
|
|
except asyncio.TimeoutError:
|
|
raise APIError(504, "separation_timeout", "Separation request timed out.")
|
|
return await task
|
|
finally:
|
|
await state.limiter.release()
|
|
|
|
|
|
def _parse_load_payload(payload):
|
|
"""Parse load payload.
|
|
|
|
Args:
|
|
payload (Any): Payload value.
|
|
|
|
Returns:
|
|
Any: Parsed value."""
|
|
if not isinstance(payload, dict):
|
|
raise APIError(400, "invalid_request", "JSON request body must be an object.")
|
|
model = payload.get("model")
|
|
if not model:
|
|
raise APIError(400, "invalid_model", "The 'model' field is required.", param="model")
|
|
inference_params = payload.get("inference_params")
|
|
if inference_params is None:
|
|
inference_params = None
|
|
elif not isinstance(inference_params, dict):
|
|
raise APIError(400, "invalid_inference_parameter", "inference_params must be an object.", param="inference_params")
|
|
source = payload.get("source")
|
|
endpoint = payload["endpoint"] if "endpoint" in payload else DEFAULT_ENDPOINT
|
|
_validate_download_source(source, endpoint)
|
|
return str(model), source, endpoint, inference_params
|
|
|
|
|
|
async def _load_or_switch_model(state, model, source, endpoint, inference_params):
|
|
"""Load or switch model.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
model (str): Model value.
|
|
source (str): Download source name.
|
|
endpoint (str | None): Optional custom download endpoint.
|
|
inference_params (dict | None): Inference params value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
async with state.operation_lock:
|
|
if state.model_lock.locked():
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
if state.download_lock.locked():
|
|
raise APIError(409, "model_download_in_progress", "A model download operation is in progress.")
|
|
await state.model_lock.acquire()
|
|
try:
|
|
previous_loaded = state.loaded is not None
|
|
old_loaded = state.loaded
|
|
state.loaded = None
|
|
state.model_loading = True
|
|
state.model_loading_target = model
|
|
effective_source, effective_endpoint = _effective_download_source(state, source, endpoint)
|
|
if old_loaded is not None:
|
|
async with state.inference_lock:
|
|
try:
|
|
await asyncio.to_thread(close_loaded_model, old_loaded)
|
|
except Exception as exc:
|
|
state.logger.exception("Model unload failed")
|
|
raise APIError(500, "model_unload_failed", str(exc), error_type="server_error")
|
|
try:
|
|
loaded = await asyncio.to_thread(
|
|
load_model,
|
|
state.config,
|
|
model,
|
|
effective_source,
|
|
effective_endpoint,
|
|
inference_params,
|
|
)
|
|
except InferenceParameterError as exc:
|
|
raise APIError(400, "invalid_inference_parameter", str(exc), param="inference_params")
|
|
except ValueError as exc:
|
|
raise APIError(400, "invalid_model", str(exc), param="model")
|
|
except KeyError as exc:
|
|
raise APIError(404, "model_not_found", str(exc), param="model")
|
|
except Exception as exc:
|
|
state.logger.exception("Model load failed")
|
|
raise APIError(500, "model_load_failed", str(exc), error_type="server_error")
|
|
state.loaded = loaded
|
|
return previous_loaded, loaded
|
|
finally:
|
|
state.model_loading = False
|
|
state.model_loading_target = None
|
|
state.model_lock.release()
|
|
|
|
|
|
def _parse_download_payload(payload):
|
|
"""Parse download payload.
|
|
|
|
Args:
|
|
payload (Any): Payload value.
|
|
|
|
Returns:
|
|
Any: Parsed value."""
|
|
if not isinstance(payload, dict):
|
|
raise APIError(400, "invalid_request", "JSON request body must be an object.")
|
|
model = payload.get("model")
|
|
if not model:
|
|
raise APIError(400, "invalid_model", "The 'model' field is required.", param="model")
|
|
source = payload.get("source")
|
|
endpoint = payload["endpoint"] if "endpoint" in payload else DEFAULT_ENDPOINT
|
|
_validate_download_source(source, endpoint)
|
|
force = _parse_bool_field(payload.get("force"), False, "force")
|
|
verify = _parse_bool_field(payload.get("verify"), True, "verify")
|
|
timeout = payload.get("timeout_seconds", 30)
|
|
try:
|
|
timeout = float(timeout)
|
|
except (TypeError, ValueError):
|
|
raise APIError(400, "invalid_request", "timeout_seconds must be a number.", param="timeout_seconds")
|
|
if timeout <= 0:
|
|
raise APIError(400, "invalid_request", "timeout_seconds must be greater than 0.", param="timeout_seconds")
|
|
return str(model), source, endpoint, force, verify, timeout
|
|
|
|
|
|
def _download_result_relpaths(paths, model_dir):
|
|
"""Download result relpaths.
|
|
|
|
Args:
|
|
paths (Any): Paths value.
|
|
model_dir (str | os.PathLike | None): Local model cache directory. Uses the package default when None.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
root = model_root(model_dir).resolve()
|
|
relpaths = []
|
|
for path in paths:
|
|
try:
|
|
relpaths.append(Path(path).resolve().relative_to(root).as_posix())
|
|
except (OSError, ValueError):
|
|
relpaths.append("<path_resolve_error>")
|
|
return relpaths
|
|
|
|
|
|
async def _download_model_to_local(state, model, source, endpoint, force, verify, timeout):
|
|
"""Download model to local.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
model (str): Model value.
|
|
source (str): Download source name.
|
|
endpoint (str | None): Optional custom download endpoint.
|
|
force (bool): Whether to overwrite or redownload existing files.
|
|
verify (bool): Whether to validate downloads with available metadata.
|
|
timeout (int): Network timeout in seconds.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
async with state.operation_lock:
|
|
if state.model_lock.locked():
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
if state.download_lock.locked():
|
|
raise APIError(409, "model_download_in_progress", "A model download operation is in progress.")
|
|
await state.download_lock.acquire()
|
|
try:
|
|
state.model_downloading = True
|
|
state.model_downloading_target = model
|
|
effective_source, effective_endpoint = _effective_download_source(state, source, endpoint)
|
|
try:
|
|
result = await asyncio.to_thread(
|
|
download_model,
|
|
model,
|
|
model_dir=state.config.model_dir,
|
|
source=effective_source,
|
|
endpoint=effective_endpoint,
|
|
verify=verify,
|
|
force=force,
|
|
timeout=timeout,
|
|
)
|
|
except KeyError as exc:
|
|
raise APIError(404, "model_not_found", str(exc), param="model")
|
|
except (DownloadError, OSError) as exc:
|
|
state.logger.exception("Model download failed")
|
|
raise APIError(500, "model_download_failed", str(exc), error_type="server_error")
|
|
except Exception as exc:
|
|
state.logger.exception("Model download failed")
|
|
raise APIError(500, "model_download_failed", str(exc), error_type="server_error")
|
|
entry = result["entry"]
|
|
return {
|
|
"object": "model.download",
|
|
"model": catalog_model_card(
|
|
entry,
|
|
model_dir=state.config.model_dir,
|
|
source=effective_source,
|
|
endpoint=effective_endpoint,
|
|
include_files=False,
|
|
),
|
|
"source": effective_source,
|
|
"endpoint": effective_endpoint,
|
|
"downloaded": _download_result_relpaths(result.get("downloaded", []), state.config.model_dir),
|
|
"skipped": _download_result_relpaths(result.get("skipped", []), state.config.model_dir),
|
|
}
|
|
finally:
|
|
state.model_downloading = False
|
|
state.model_downloading_target = None
|
|
state.download_lock.release()
|
|
|
|
|
|
def _download_source_response(state):
|
|
"""Download source response.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
return {
|
|
"object": "download.source",
|
|
"source": state.config.source,
|
|
"endpoint": state.config.endpoint,
|
|
}
|
|
|
|
|
|
def _server_info_response(state):
|
|
"""Implement the server info response helper.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
return {
|
|
"object": "server.info",
|
|
"webui": {
|
|
"enabled": bool(state.config.webui),
|
|
"path": "/ui/" if state.config.webui else None,
|
|
},
|
|
"auth": {
|
|
"api_key_required": bool(state.config.api_key),
|
|
},
|
|
"limits": {
|
|
"max_audio_seconds": state.config.max_audio_seconds,
|
|
"max_request_bytes": state.config.max_request_bytes,
|
|
"max_queue_size": state.config.max_queue_size,
|
|
"request_timeout_seconds": state.config.request_timeout_seconds,
|
|
},
|
|
"download_source": {
|
|
"source": state.config.source,
|
|
"endpoint": state.config.endpoint,
|
|
},
|
|
}
|
|
|
|
|
|
async def _update_download_source(state, source, endpoint):
|
|
"""Update download source.
|
|
|
|
Args:
|
|
state (Any): State value.
|
|
source (str): Download source name.
|
|
endpoint (str | None): Optional custom download endpoint.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_validate_download_source(source, endpoint, source_required=True)
|
|
async with state.operation_lock:
|
|
if state.model_lock.locked():
|
|
raise APIError(409, "model_operation_in_progress", "A model load or switch operation is in progress.")
|
|
if state.download_lock.locked():
|
|
raise APIError(409, "model_download_in_progress", "A model download operation is in progress.")
|
|
await state.download_lock.acquire()
|
|
try:
|
|
state.config.source = source
|
|
if endpoint is not DEFAULT_ENDPOINT:
|
|
state.config.endpoint = endpoint
|
|
return _download_source_response(state)
|
|
finally:
|
|
state.download_lock.release()
|
|
|
|
|
|
def create_app(config):
|
|
"""Create the FastAPI application.
|
|
|
|
Args:
|
|
config (AttrDict | dict): Loaded pymss configuration.
|
|
|
|
Returns:
|
|
FastAPI: Configured application instance.
|
|
|
|
Example:
|
|
>>> app = create_app()"""
|
|
state = load_state(config)
|
|
app = FastAPI(title="pymss server", version="1")
|
|
app.state.pymss_state = state
|
|
if config.webui:
|
|
register_webui_routes(app)
|
|
|
|
@app.exception_handler(APIError)
|
|
async def handle_api_error(_request, exc):
|
|
"""Implement the handle api error helper.
|
|
|
|
Args:
|
|
_request (Any): request value.
|
|
exc (Any): Exc value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
return _error_response(exc)
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
"""Implement the health helper.
|
|
|
|
Args:
|
|
None: This callable does not accept user-provided arguments.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
loaded = state.loaded
|
|
return {
|
|
"status": "ok",
|
|
"model_loaded": loaded is not None,
|
|
"model_loading": state.model_loading,
|
|
"model": loaded.model_id if loaded is not None else None,
|
|
"device": loaded.device if loaded is not None else None,
|
|
}
|
|
|
|
@app.get("/v1/models")
|
|
async def list_models(request: Request):
|
|
"""List model catalog entries.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
list[ModelEntry]: Matching catalog entries.
|
|
|
|
Example:
|
|
>>> models = list_models(supported=True)
|
|
>>> models[0].name"""
|
|
_check_auth(request, state)
|
|
loaded = state.loaded
|
|
return {
|
|
"object": "list",
|
|
"data": [] if loaded is None else [model_card(loaded)],
|
|
}
|
|
|
|
@app.get("/v1/models/{model}")
|
|
async def get_model(model: str, request: Request):
|
|
"""Return model.
|
|
|
|
Args:
|
|
model (str): Model value.
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
loaded = state.loaded
|
|
if loaded is None or not loaded.is_model_id(model):
|
|
raise APIError(404, "model_not_found", f"Model {model!r} is not loaded by this process.", param="model")
|
|
return model_card(loaded)
|
|
|
|
@app.get("/v1/catalog/models")
|
|
async def list_catalog_models(request: Request):
|
|
"""Implement the list catalog models helper.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
try:
|
|
supported = parse_supported_filter(request.query_params.get("supported"))
|
|
local = parse_local_filter(request.query_params.get("local"))
|
|
include_files = parse_include_files(request.query_params.get("include_files"))
|
|
except ValueError as exc:
|
|
raise APIError(400, "invalid_request", str(exc))
|
|
source, endpoint = _effective_download_source(
|
|
state,
|
|
request.query_params.get("source"),
|
|
_query_endpoint(request.query_params),
|
|
)
|
|
_validate_download_source(source, endpoint)
|
|
entries = filter_catalog_models(
|
|
category=request.query_params.get("category"),
|
|
supported=supported,
|
|
local=local,
|
|
q=request.query_params.get("q"),
|
|
model_dir=state.config.model_dir,
|
|
)
|
|
return {
|
|
"object": "list",
|
|
"data": [
|
|
catalog_model_card(
|
|
entry,
|
|
model_dir=state.config.model_dir,
|
|
source=source,
|
|
endpoint=endpoint,
|
|
include_files=include_files,
|
|
)
|
|
for entry in entries
|
|
],
|
|
"pymss": {
|
|
"source": source,
|
|
"endpoint": endpoint,
|
|
"total": len(entries),
|
|
},
|
|
}
|
|
|
|
@app.get("/v1/catalog/models/{model}")
|
|
async def get_catalog_model(model: str, request: Request):
|
|
"""Return catalog model.
|
|
|
|
Args:
|
|
model (str): Model value.
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
source, endpoint = _effective_download_source(
|
|
state,
|
|
request.query_params.get("source"),
|
|
_query_endpoint(request.query_params),
|
|
)
|
|
_validate_download_source(source, endpoint)
|
|
try:
|
|
return catalog_model_detail(model, model_dir=state.config.model_dir, source=source, endpoint=endpoint)
|
|
except KeyError as exc:
|
|
raise APIError(404, "model_not_found", str(exc), param="model")
|
|
|
|
@app.post("/v1/models/load")
|
|
async def load_model_endpoint(request: Request):
|
|
"""Load model endpoint.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
body = await _read_body(request, state)
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise APIError(400, "invalid_request", "Request body must be valid JSON.")
|
|
model, source, endpoint, inference_params = _parse_load_payload(payload)
|
|
previous_loaded, loaded = await _load_or_switch_model(state, model, source, endpoint, inference_params)
|
|
return {
|
|
"object": "model.load",
|
|
"previous_model_loaded": previous_loaded,
|
|
"model_loaded": True,
|
|
"model": model_card(loaded),
|
|
}
|
|
|
|
@app.post("/v1/models/download")
|
|
async def download_model_endpoint(request: Request):
|
|
"""Download model endpoint.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
body = await _read_body(request, state)
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise APIError(400, "invalid_request", "Request body must be valid JSON.")
|
|
model, source, endpoint, force, verify, timeout = _parse_download_payload(payload)
|
|
return await _download_model_to_local(state, model, source, endpoint, force, verify, timeout)
|
|
|
|
@app.get("/v1/download-source")
|
|
async def get_download_source(request: Request):
|
|
"""Return download source.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
return _download_source_response(state)
|
|
|
|
@app.post("/v1/download-source")
|
|
async def update_download_source(request: Request):
|
|
"""Update download source.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
body = await _read_body(request, state)
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise APIError(400, "invalid_request", "Request body must be valid JSON.")
|
|
if not isinstance(payload, dict):
|
|
raise APIError(400, "invalid_request", "JSON request body must be an object.")
|
|
endpoint = payload["endpoint"] if "endpoint" in payload else DEFAULT_ENDPOINT
|
|
return await _update_download_source(state, payload.get("source"), endpoint)
|
|
|
|
@app.get("/v1/server/info")
|
|
async def get_server_info(request: Request):
|
|
"""Return server info.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
return _server_info_response(state)
|
|
|
|
@app.post("/v1/audio/separations")
|
|
async def separate_audio(request: Request):
|
|
"""Separate audio.
|
|
|
|
Args:
|
|
request (Request): Incoming FastAPI request.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
_check_auth(request, state)
|
|
body = await _read_body(request, state)
|
|
loaded = _require_loaded_for_inference(state)
|
|
model, mix, stems, response_format, output_audio_format, input_seconds = await _parse_request(
|
|
request,
|
|
state,
|
|
loaded,
|
|
body,
|
|
)
|
|
try:
|
|
results = await _run_separation(state, loaded, model, mix, stems)
|
|
except APIError:
|
|
raise
|
|
except Exception as exc:
|
|
state.logger.exception("Separation failed")
|
|
raise APIError(500, "separation_failed", str(exc), error_type="server_error")
|
|
|
|
try:
|
|
if response_format == "json":
|
|
return json_response(loaded, model, results, stems, input_seconds)
|
|
|
|
content = zip_response(loaded, model, results, stems, input_seconds, output_audio_format)
|
|
return Response(content=content, media_type="application/zip")
|
|
except APIError:
|
|
raise
|
|
except Exception as exc:
|
|
state.logger.exception("Encoding separation response failed")
|
|
raise APIError(500, "separation_failed", str(exc), error_type="server_error")
|
|
|
|
return app
|
|
|
|
|
|
def _server_display_host(host):
|
|
"""Implement the server display host helper.
|
|
|
|
Args:
|
|
host (str): Host value.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
if host in {"0.0.0.0", "::"}:
|
|
return "127.0.0.1"
|
|
if ":" in host and not host.startswith("["):
|
|
return f"[{host}]"
|
|
return host
|
|
|
|
|
|
def _server_url(config, path="/"):
|
|
"""Implement the server url helper.
|
|
|
|
Args:
|
|
config (AttrDict | dict): Loaded pymss configuration.
|
|
path (str | os.PathLike, optional): File system path. Defaults to '/'.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
return f"http://{_server_display_host(config.host)}:{config.port}{normalized_path}"
|
|
|
|
|
|
def _log_webui_url(config):
|
|
"""Implement the log webui url helper.
|
|
|
|
Args:
|
|
config (AttrDict | dict): Loaded pymss configuration.
|
|
|
|
Returns:
|
|
None: This callable completes for its side effects."""
|
|
if not config.webui:
|
|
return
|
|
logging.getLogger("uvicorn.error").info("WebUI available at %s", _server_url(config, "/ui/"))
|
|
|
|
|
|
def _create_uvicorn_server(uvicorn, app, config):
|
|
"""Create uvicorn server.
|
|
|
|
Args:
|
|
uvicorn (Any): Uvicorn value.
|
|
app (FastAPI): App value.
|
|
config (AttrDict | dict): Loaded pymss configuration.
|
|
|
|
Returns:
|
|
Any: Computed result."""
|
|
|
|
class PymssUvicornServer(uvicorn.Server):
|
|
"""Represent PymssUvicornServer."""
|
|
|
|
def _log_started_message(self, listeners):
|
|
"""Implement the log started message helper.
|
|
|
|
Args:
|
|
listeners (Any): Listeners value.
|
|
|
|
Returns:
|
|
None: This callable completes for its side effects."""
|
|
super()._log_started_message(listeners)
|
|
_log_webui_url(config)
|
|
|
|
uvicorn_config = uvicorn.Config(app, host=config.host, port=config.port)
|
|
return PymssUvicornServer(uvicorn_config)
|
|
|
|
|
|
def run_server(config):
|
|
"""Run the pymss HTTP server.
|
|
|
|
Args:
|
|
config (AttrDict | dict): Loaded pymss configuration.
|
|
|
|
Returns:
|
|
None: Runs until the server stops.
|
|
|
|
Example:
|
|
>>> run_server()"""
|
|
try:
|
|
import uvicorn
|
|
except ImportError as exc: # pragma: no cover - exercised only without optional deps.
|
|
raise RuntimeError("Install server dependencies with `pip install pymss[server]` or `uv sync --extra server`.") from exc
|
|
|
|
app = create_app(config)
|
|
_create_uvicorn_server(uvicorn, app, config).run()
|