Add talkbot, qwen-tts, sdcpp containers and CPU whisper build
Этот коммит содержится в:
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Qwen3-TTS entrypoint script
|
||||
# Starts the FastAPI server; model weights are downloaded automatically
|
||||
# on first request via from_pretrained() and cached under /app/models
|
||||
set -e
|
||||
|
||||
LOAD="${QWEN_TTS_LOAD:-customvoice,voiceclone}"
|
||||
|
||||
echo "=== Qwen3-TTS Server (CPU) ==="
|
||||
echo "Loading: $LOAD"
|
||||
echo "Device: cpu"
|
||||
echo
|
||||
|
||||
exec uvicorn server:app --host 0.0.0.0 --port 8000 --app-dir /app "$@"
|
||||
@@ -0,0 +1,39 @@
|
||||
### Qwen3-TTS Container - CPU only
|
||||
### Text-to-Speech using Qwen3-TTS: https://github.com/QwenLM/Qwen3-TTS
|
||||
###
|
||||
### BUILD: podman build -t qwen-tts:cpu -f qwen-tts-cpu.Containerfile .
|
||||
### RUN: podman run --rm -p 8000:8000 -v /path/to/models:/app/models qwen-tts:cpu
|
||||
FROM python:3.12-slim
|
||||
|
||||
USER root
|
||||
EXPOSE 8000
|
||||
|
||||
# sox (system tool, required at runtime by qwen-tts for audio processing)
|
||||
# ffmpeg (optional but commonly needed for reference-audio format conversion)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends sox ffmpeg curl \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install CPU-only PyTorch first (avoids pulling CUDA wheels as a transitive dependency)
|
||||
RUN pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Install qwen-tts and a minimal API server
|
||||
RUN pip install --no-cache-dir qwen-tts fastapi uvicorn python-multipart
|
||||
|
||||
# Models are downloaded at runtime into this directory (mount as a volume
|
||||
# to persist them across container restarts and avoid re-downloading).
|
||||
RUN mkdir -p /app/models
|
||||
ENV HF_HOME=/app/models
|
||||
ENV QWEN_TTS_DEVICE=cpu
|
||||
|
||||
# Copy entrypoint / server script
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
COPY server.py /app/server.py
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD []
|
||||
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
Description=Qwen3-TTS Server (CPU)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/qwen-tts:cpu
|
||||
ContainerName=qwen-tts
|
||||
|
||||
# HTTP API
|
||||
PublishPort=8000:8000
|
||||
|
||||
# Persist downloaded model weights across restarts
|
||||
# Adjust the host path to wherever you want the models cached
|
||||
Volume=/srv/containers/qwen-tts/models:/app/models:Z
|
||||
|
||||
# Which model(s) to load at startup: "customvoice", "voiceclone", or both
|
||||
# (default, comma-separated). Loading both lets a single instance serve
|
||||
# /speech (preset voices) and /speech/clone (voice cloning) at once.
|
||||
#Environment=QWEN_TTS_LOAD=customvoice,voiceclone
|
||||
# Optional: override the default checkpoints
|
||||
#Environment=QWEN_TTS_CUSTOMVOICE_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
||||
#Environment=QWEN_TTS_VOICECLONE_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
||||
|
||||
|
||||
# CPU-only container: no GPU devices needed
|
||||
PodmanArgs=--pids-limit=-1
|
||||
|
||||
# Resource limits (tune to your host; a 1.7B model on CPU needs headroom)
|
||||
#Memory=8g
|
||||
#CPUs=4
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Minimal FastAPI server exposing Qwen3-TTS generation over HTTP.
|
||||
CPU-only: models are loaded once at startup with device_map="cpu".
|
||||
|
||||
Loads BOTH Qwen3-TTS model families at once, so a single container/process
|
||||
can serve:
|
||||
- /speech -> CustomVoice model, 9 built-in preset speakers
|
||||
(generate_custom_voice)
|
||||
- /speech/clone -> Base model, clone a voice from a short reference audio
|
||||
file (generate_voice_clone), 10-20s recommended,
|
||||
3s min, 60s max
|
||||
|
||||
Set QWEN_TTS_LOAD=customvoice,voiceclone (default: both) to control which
|
||||
model(s) are loaded, useful if you want a lighter single-purpose instance
|
||||
for memory-constrained setups. Requesting an endpoint whose model wasn't
|
||||
loaded returns a clear 400 error instead of a generic failure.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
_DEFAULT_MODELS = {
|
||||
"customvoice": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
"voiceclone": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||
}
|
||||
|
||||
_load_env = os.environ.get("QWEN_TTS_LOAD", "customvoice,voiceclone")
|
||||
LOAD_MODES = [m.strip() for m in _load_env.split(",") if m.strip()]
|
||||
for m in LOAD_MODES:
|
||||
if m not in _DEFAULT_MODELS:
|
||||
raise ValueError(
|
||||
f"QWEN_TTS_LOAD entries must be 'customvoice' and/or 'voiceclone', got: {m}"
|
||||
)
|
||||
|
||||
CUSTOMVOICE_MODEL_NAME = os.environ.get(
|
||||
"QWEN_TTS_CUSTOMVOICE_MODEL", _DEFAULT_MODELS["customvoice"]
|
||||
)
|
||||
VOICECLONE_MODEL_NAME = os.environ.get(
|
||||
"QWEN_TTS_VOICECLONE_MODEL", _DEFAULT_MODELS["voiceclone"]
|
||||
)
|
||||
|
||||
app = FastAPI(title="Qwen3-TTS CPU server")
|
||||
|
||||
# Populated at startup, keyed by mode ("customvoice" / "voiceclone")
|
||||
models: dict[str, Qwen3TTSModel] = {}
|
||||
|
||||
|
||||
class SpeechRequest(BaseModel):
|
||||
text: str
|
||||
language: str = "Auto"
|
||||
speaker: str = "Vivian"
|
||||
instruct: str = ""
|
||||
|
||||
|
||||
def _require_model(mode: str) -> Qwen3TTSModel:
|
||||
model = models.get(mode)
|
||||
if model is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"The '{mode}' model is not loaded on this instance "
|
||||
f"(QWEN_TTS_LOAD={','.join(LOAD_MODES)}). "
|
||||
f"Restart the container with QWEN_TTS_LOAD including "
|
||||
f"'{mode}' to use this endpoint.",
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def load_models():
|
||||
if "customvoice" in LOAD_MODES:
|
||||
print(f"Loading CustomVoice model {CUSTOMVOICE_MODEL_NAME} on CPU...")
|
||||
models["customvoice"] = Qwen3TTSModel.from_pretrained(
|
||||
CUSTOMVOICE_MODEL_NAME,
|
||||
device_map="cpu",
|
||||
dtype=torch.float32, # bfloat16/float16 are not well supported on CPU
|
||||
)
|
||||
print("CustomVoice model loaded.")
|
||||
|
||||
if "voiceclone" in LOAD_MODES:
|
||||
print(f"Loading Base (voice-clone) model {VOICECLONE_MODEL_NAME} on CPU...")
|
||||
models["voiceclone"] = Qwen3TTSModel.from_pretrained(
|
||||
VOICECLONE_MODEL_NAME,
|
||||
device_map="cpu",
|
||||
dtype=torch.float32,
|
||||
)
|
||||
print("Base (voice-clone) model loaded.")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"loaded_models": {
|
||||
mode: (CUSTOMVOICE_MODEL_NAME if mode == "customvoice" else VOICECLONE_MODEL_NAME)
|
||||
for mode in models
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/speakers")
|
||||
def speakers():
|
||||
model = _require_model("customvoice")
|
||||
return {"speakers": model.get_supported_speakers()}
|
||||
|
||||
|
||||
@app.post("/speech")
|
||||
def speech(req: SpeechRequest):
|
||||
model = _require_model("customvoice")
|
||||
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=req.text,
|
||||
language=req.language,
|
||||
speaker=req.speaker,
|
||||
instruct=req.instruct or None,
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, wavs[0], sr, format="WAV")
|
||||
buf.seek(0)
|
||||
return StreamingResponse(buf, media_type="audio/wav")
|
||||
|
||||
|
||||
@app.post("/speech/clone")
|
||||
def speech_clone(
|
||||
text: str = Form(...),
|
||||
language: str = Form("Auto"),
|
||||
ref_text: str = Form(""),
|
||||
x_vector_only_mode: bool = Form(False),
|
||||
ref_audio: UploadFile = File(...),
|
||||
):
|
||||
"""
|
||||
Clone a voice from a short reference audio file and synthesize `text`
|
||||
with it.
|
||||
|
||||
ref_audio: 10-20s of clear, continuous speech recommended (min 3s, max 60s).
|
||||
ref_text: transcript of what is said in ref_audio. Required unless
|
||||
x_vector_only_mode=true.
|
||||
x_vector_only_mode: if true, clones only from the speaker embedding
|
||||
(x-vector) without needing ref_text. Faster, but generally
|
||||
lower fidelity than the default in-context-learning mode.
|
||||
"""
|
||||
model = _require_model("voiceclone")
|
||||
|
||||
if not x_vector_only_mode and not ref_text:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="ref_text is required unless x_vector_only_mode=true.",
|
||||
)
|
||||
|
||||
ref_bytes = ref_audio.file.read()
|
||||
|
||||
# generate_voice_clone does not accept an in-memory BytesIO object,
|
||||
# it expects a file path. Write the upload to a temp file instead.
|
||||
suffix = os.path.splitext(ref_audio.filename or "")[1] or ".wav"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(ref_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
wavs, sr = model.generate_voice_clone(
|
||||
text=text,
|
||||
language=language,
|
||||
ref_audio=tmp_path,
|
||||
ref_text=ref_text or None,
|
||||
x_vector_only_mode=x_vector_only_mode,
|
||||
)
|
||||
finally:
|
||||
os.remove(tmp_path)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, wavs[0], sr, format="WAV")
|
||||
buf.seek(0)
|
||||
return StreamingResponse(buf, media_type="audio/wav")
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/bin/bash
|
||||
# Build stable-diffusion.cpp with Vulkan support and create container image
|
||||
# Usage: ./build-container.sh [--no-cache]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP_BUILD_DIR="${SCRIPT_DIR}/.tmp"
|
||||
BUILD_DIR="${TMP_BUILD_DIR}/sdcpp"
|
||||
BIN_DIR="${TMP_BUILD_DIR}/bin-vulkan"
|
||||
HOME_DIR="${HOME}"
|
||||
NO_CACHE=""
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo
|
||||
echo "⚠ Build failed (exit code: $exit_code). Cleaning up temporary files..."
|
||||
fi
|
||||
rm -rf "$TMP_BUILD_DIR" "${SCRIPT_DIR}/bin-vulkan" 2>/dev/null || true
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "✓ Cleanup complete"
|
||||
fi
|
||||
return $exit_code
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$1" == "--no-cache" ]]; then
|
||||
NO_CACHE="--no-cache"
|
||||
echo "Build mode: NO CACHE (clean rebuild)"
|
||||
fi
|
||||
|
||||
echo "=== stable-diffusion.cpp Vulkan Build ==="
|
||||
echo "Temporary build directory: $TMP_BUILD_DIR"
|
||||
echo "Output directory: $BIN_DIR"
|
||||
echo "Home directory: $HOME_DIR"
|
||||
if [ -n "$NO_CACHE" ]; then
|
||||
echo "Cache mode: DISABLED"
|
||||
fi
|
||||
echo
|
||||
|
||||
mkdir -p "$TMP_BUILD_DIR"
|
||||
echo
|
||||
|
||||
# Install dependencies
|
||||
echo "[1/5] Installing dependencies..."
|
||||
REQUIRED_PACKAGES="build-essential cmake git libvulkan-dev glslc spirv-headers"
|
||||
MISSING_PACKAGES=""
|
||||
|
||||
for pkg in $REQUIRED_PACKAGES; do
|
||||
if ! dpkg -l | grep -q "^ii $pkg"; then
|
||||
MISSING_PACKAGES="$MISSING_PACKAGES $pkg"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$MISSING_PACKAGES" ]; then
|
||||
echo " Installing missing packages:$MISSING_PACKAGES"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y $MISSING_PACKAGES
|
||||
echo "✓ Dependencies installed"
|
||||
else
|
||||
echo "✓ All dependencies already installed"
|
||||
fi
|
||||
|
||||
echo " Verifying dependencies..."
|
||||
for cmd in git cmake make gcc g++ glslc; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo "ERROR: $cmd is still not available after installation."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! pkg-config --exists vulkan; then
|
||||
echo "ERROR: Vulkan development files not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ All dependencies verified"
|
||||
echo
|
||||
|
||||
# Clone or update stable-diffusion.cpp (with submodules: ggml is vendored as a submodule)
|
||||
echo "[2/5] Cloning/updating stable-diffusion.cpp repository..."
|
||||
if [ -d "$BUILD_DIR" ]; then
|
||||
echo " Updating existing repository..."
|
||||
cd "$BUILD_DIR"
|
||||
git fetch origin
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git submodule update --init --recursive
|
||||
else
|
||||
echo " Cloning stable-diffusion.cpp (with submodules)..."
|
||||
git clone --recursive https://github.com/leejet/stable-diffusion.cpp.git "$BUILD_DIR"
|
||||
cd "$BUILD_DIR"
|
||||
fi
|
||||
echo "✓ Repository ready"
|
||||
echo
|
||||
|
||||
# Build with Vulkan support
|
||||
echo "[3/5] Building with Vulkan support (this may take a while)..."
|
||||
mkdir -p build
|
||||
cd build
|
||||
|
||||
if [ -n "$NO_CACHE" ]; then
|
||||
echo " Cleaning previous build..."
|
||||
rm -rf * .cmake
|
||||
fi
|
||||
|
||||
cmake .. -DSD_VULKAN=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build . --config Release -j "$(nproc)"
|
||||
echo "✓ Build complete"
|
||||
echo
|
||||
|
||||
# Prepare binary directory
|
||||
echo "[4/5] Preparing binary directory..."
|
||||
mkdir -p "$BIN_DIR"
|
||||
cd "$BUILD_DIR"
|
||||
|
||||
echo " Copying binaries..."
|
||||
# sd-cli is the current binary name (renamed from the old 'sd' in upstream #1037)
|
||||
cp build/bin/sd-cli "$BIN_DIR/"
|
||||
chmod +x "$BIN_DIR"/*
|
||||
echo "✓ Binaries ready"
|
||||
echo
|
||||
|
||||
# Build Podman image
|
||||
echo "[5/5] Building Podman image..."
|
||||
|
||||
echo " Preparing build context..."
|
||||
cp "${SCRIPT_DIR}/sdcpp-vulkan.Containerfile" "${TMP_BUILD_DIR}/"
|
||||
cp "${SCRIPT_DIR}/sdcpp-entrypoint.sh" "${TMP_BUILD_DIR}/entrypoint.sh"
|
||||
cp "${SCRIPT_DIR}/watch-gpu.sh" "${TMP_BUILD_DIR}/"
|
||||
|
||||
cd "$TMP_BUILD_DIR"
|
||||
podman build $NO_CACHE -t sdcpp:vulkan-amd64 -f sdcpp-vulkan.Containerfile .
|
||||
echo "✓ Podman image built"
|
||||
echo
|
||||
|
||||
echo "=== BUILD COMPLETE ==="
|
||||
echo
|
||||
echo "✓ Container image created: sdcpp:vulkan-amd64"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. Create a models directory with the GGUF files, e.g.:"
|
||||
echo " mkdir -p ~/sdcpp-models"
|
||||
echo " # place minimax_h3_fl2va_pruned-*.gguf, qwen3vl_32b_minimax_h3-*.gguf,"
|
||||
echo " # minimax_h3_video_vae_fp16.safetensors, minimax_h3_audio_vae_fp32.safetensors there"
|
||||
echo
|
||||
echo " 2. Run directly:"
|
||||
echo " podman run --rm -it \\"
|
||||
echo " --device /dev/dri/renderD128 \\"
|
||||
echo " --group-add keep-groups \\"
|
||||
echo " -v ~/sdcpp-models:/app/models \\"
|
||||
echo " -v ~/sdcpp-output:/app/output \\"
|
||||
echo " sdcpp:vulkan-amd64 \\"
|
||||
echo " --mode vid_gen \\"
|
||||
echo " --diffusion-model /app/models/minimax_h3_fl2va_pruned-Q4_K.gguf \\"
|
||||
echo " --llm /app/models/qwen3vl_32b_minimax_h3-Q4_K_M.gguf \\"
|
||||
echo " --vae /app/models/minimax_h3_video_vae_fp16.safetensors \\"
|
||||
echo " --audio-vae /app/models/minimax_h3_audio_vae_fp32.safetensors \\"
|
||||
echo " --prompt \"a red fox trotting through falling snow, cinematic\" \\"
|
||||
echo " --width 640 --height 384 --video-frames 25 --steps 4 --cfg-scale 1.0 \\"
|
||||
echo " --backend te=cpu --diffusion-fa \\"
|
||||
echo " --output /app/output/out.webm"
|
||||
echo
|
||||
echo " 3. Or install as a Quadlet service (see sdcpp.container)"
|
||||
echo
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# stable-diffusion.cpp entrypoint
|
||||
# Starts the optional GPU watchdog in background (best-effort, never fatal),
|
||||
# then runs sd-cli with whatever arguments were passed to the container.
|
||||
set -e
|
||||
|
||||
echo "=== stable-diffusion.cpp (Vulkan) ==="
|
||||
echo
|
||||
|
||||
# Start the watchdog in the background if /dev/kmsg is accessible.
|
||||
# It only prints warnings to stderr, it never interferes with sd-cli.
|
||||
/app/watch-gpu.sh /dev/kmsg &
|
||||
WATCH_PID=$!
|
||||
disown "$WATCH_PID" 2>/dev/null || true
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "No arguments passed. Example usage:"
|
||||
echo
|
||||
echo " --mode vid_gen \\"
|
||||
echo " --diffusion-model /app/models/minimax_h3_fl2va_pruned-Q4_K.gguf \\"
|
||||
echo " --llm /app/models/qwen3vl_32b_minimax_h3-Q4_K_M.gguf \\"
|
||||
echo " --vae /app/models/minimax_h3_video_vae_fp16.safetensors \\"
|
||||
echo " --audio-vae /app/models/minimax_h3_audio_vae_fp32.safetensors \\"
|
||||
echo " --prompt \"...\" --width 640 --height 384 --video-frames 25 \\"
|
||||
echo " --steps 4 --cfg-scale 1.0 --backend te=cpu --diffusion-fa \\"
|
||||
echo " --output /app/output/out.webm"
|
||||
exec /app/sd-cli --help
|
||||
fi
|
||||
|
||||
exec /app/sd-cli "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
### stable-diffusion.cpp Container with Vulkan GPU support
|
||||
### Diffusion model (image/video/audio) inference: https://github.com/leejet/stable-diffusion.cpp
|
||||
###
|
||||
### BUILD: ./build-sdcpp-vulkan.sh (compiles locally with Vulkan)
|
||||
### THEN: podman build -t sdcpp:vulkan-amd64 -f sdcpp-vulkan.Containerfile .
|
||||
FROM debian:13-slim
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libvulkan1 vulkan-tools mesa-vulkan-drivers libdrm-amdgpu1 \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /tmp/* /var/tmp/* \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete \
|
||||
&& find /var/cache -type f -delete
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-compiled binary with Vulkan support
|
||||
COPY bin-vulkan/ /app/
|
||||
RUN chmod +x /app/sd-cli
|
||||
|
||||
# Copy entrypoint and GPU-crash watchdog helper
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
COPY watch-gpu.sh /app/watch-gpu.sh
|
||||
RUN chmod +x /app/entrypoint.sh /app/watch-gpu.sh
|
||||
|
||||
# Models and output are mounted as volumes at runtime
|
||||
RUN mkdir -p /app/models /app/output
|
||||
|
||||
ENV PATH=/app:$PATH
|
||||
ENV LD_LIBRARY_PATH=/app:/usr/local/lib:/usr/lib:/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
|
||||
# Vulkan environment (adjust if your ICD lives elsewhere)
|
||||
ENV VK_DRIVER_FILES=/usr/share/vulkan/icd.d/radeon_icd.json
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD []
|
||||
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=stable-diffusion.cpp (Vulkan, video generation)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/sdcpp:vulkan-amd64
|
||||
ContainerName=sdcpp-vulkan
|
||||
|
||||
# GPU access (Vulkan render node)
|
||||
AddDevice=/dev/dri/renderD128
|
||||
PodmanArgs=--group-add=keep-groups
|
||||
|
||||
# Let the in-container watchdog read the kernel ring buffer to spot
|
||||
# amdgpu MES hangs early. Optional: comment out if you'd rather not
|
||||
# expose /dev/kmsg to the container.
|
||||
AddDevice=/dev/kmsg
|
||||
|
||||
# Models (GGUF denoiser + text encoder + VAEs) and generated output.
|
||||
# Populate %h/sdcpp-models with the files from the model card before
|
||||
# starting a generation run.
|
||||
Volume=%h/sdcpp-models:/app/models:Z
|
||||
Volume=%h/sdcpp-output:/app/output:Z
|
||||
|
||||
# This is meant to be run on-demand with specific --prompt / --output
|
||||
# arguments rather than as a long-running daemon, so Exec is left to
|
||||
# `podman start --attach` invocations with Exec= overrides, e.g.:
|
||||
# systemctl --user start sdcpp-vulkan
|
||||
# then attach and pass args via `podman exec` is not applicable for
|
||||
# one-shot CLI tools; prefer running with `podman run` directly for
|
||||
# ad-hoc generations, or set Exec below for a fixed recurring job.
|
||||
#Exec=--mode vid_gen --diffusion-model /app/models/minimax_h3_fl2va_pruned-Q4_K.gguf --llm /app/models/qwen3vl_32b_minimax_h3-Q4_K_M.gguf --vae /app/models/minimax_h3_video_vae_fp16.safetensors --audio-vae /app/models/minimax_h3_audio_vae_fp32.safetensors --prompt "a red fox trotting through falling snow, cinematic" --width 640 --height 384 --video-frames 25 --steps 4 --cfg-scale 1.0 --backend te=cpu --diffusion-fa --output /app/output/out.webm
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=no
|
||||
TimeoutStartSec=1800
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Lightweight GPU watchdog: tails the host kernel ring buffer (if mounted)
|
||||
# and warns loudly if the known amdgpu MES-hang pattern shows up while
|
||||
# sd-cli is generating. Purely informational: it never kills the process,
|
||||
# it just makes the problem visible immediately instead of after the fact.
|
||||
#
|
||||
# Requires /dev/kmsg (or a mounted /host-dmesg log) to be readable inside
|
||||
# the container. If it isn't available, this script exits quietly.
|
||||
|
||||
set -u
|
||||
|
||||
LOGFILE="${1:-/dev/kmsg}"
|
||||
|
||||
if [ ! -r "$LOGFILE" ]; then
|
||||
echo "[watch-gpu] $LOGFILE not readable from inside the container, skipping GPU watchdog." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[watch-gpu] Monitoring $LOGFILE for amdgpu MES/reset issues..."
|
||||
|
||||
tail -F -n0 "$LOGFILE" 2>/dev/null | while read -r line; do
|
||||
case "$line" in
|
||||
*"MES failed to respond"*|*"GPU reset"*|*"ring"*"timeout"*|*"device wedged"*|*"soft lockup"*)
|
||||
echo "[watch-gpu] !!! GPU issue detected: $line" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -0,0 +1,208 @@
|
||||
# Nextcloud Talk voice-translate bot
|
||||
|
||||
Bot per Nextcloud Talk: quando arriva un messaggio vocale in una conversazione
|
||||
dove il bot è presente, lo trascrive, lo traduce nella lingua preferita del
|
||||
destinatario e risponde con il testo tradotto (l'audio sintetizzato verrà
|
||||
allegato una volta completata l'integrazione con l'upload file, vedi TODO).
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
Nextcloud Talk (webhook bot, bots-v1)
|
||||
→ talkbot/server.py (questo container)
|
||||
→ whisper.cpp : trascrizione audio → testo + lingua rilevata
|
||||
→ llama.cpp : traduzione testo → lingua del destinatario
|
||||
→ qwen-tts : sintesi vocale del testo tradotto
|
||||
- se esiste voice-samples/<talk-username>.wav → voce clonata
|
||||
(istanza qwen-tts in modalità voiceclone)
|
||||
- altrimenti → voce preset di default (istanza qwen-tts in
|
||||
modalità customvoice)
|
||||
→ talkbot/server.py posta la risposta in chat (bot message API)
|
||||
```
|
||||
|
||||
Il container è **generico e riusabile**: lo stesso bot (stesso secret) può
|
||||
essere aggiunto a più conversazioni Talk. Il token della conversazione
|
||||
arriva in ogni richiesta webhook (`target.id`), quindi non serve
|
||||
un'istanza per chat.
|
||||
|
||||
## Voce personalizzata per utente
|
||||
|
||||
Se in `VOICE_SAMPLES_DIR` (di default `/app/voice-samples`, montato da
|
||||
`~/talkbot-voice-samples` via Quadlet) esiste un file chiamato
|
||||
`<talk-username>.wav`, il bot userà quella voce (voice cloning) invece
|
||||
della voce preimpostata di default.
|
||||
|
||||
`<talk-username>` è lo user ID Nextcloud del **mittente** del messaggio
|
||||
vocale (lo stesso usato per determinare la lingua di destinazione).
|
||||
|
||||
**Qualità del cloning**: puoi opzionalmente affiancare al campione un
|
||||
file `<talk-username>.txt` con la trascrizione esatta di cosa dice il
|
||||
`.wav` (es. `sara.wav` + `sara.txt`). Se presente, il bot userà la
|
||||
modalità ICL (in-context learning) di qwen-tts, che dà una clonazione più
|
||||
fedele. Se manca il `.txt`, si usa automaticamente `x_vector_only_mode`
|
||||
(più veloce, qualità leggermente inferiore, non richiede trascrizione).
|
||||
|
||||
Il testo di riferimento puoi ottenerlo facilmente trascrivendo tu stesso
|
||||
il campione con whisper:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/inference \
|
||||
-F "file=@sara.wav" -F "response_format=json"
|
||||
```
|
||||
e salvando il campo `text` risultante in `sara.txt`.
|
||||
|
||||
Il container qwen-tts carica **entrambi i modelli** (CustomVoice e Base)
|
||||
nello stesso processo (`QWEN_TTS_LOAD=customvoice,voiceclone`, default),
|
||||
così un'unica istanza espone sia `/speech` (voce preset) che
|
||||
`/speech/clone` (voce clonata): il bot punta a un solo `QWEN_TTS_URL` e
|
||||
sceglie l'endpoint giusto in base alla presenza del campione utente.
|
||||
Questo costa più RAM/tempo di avvio rispetto a un solo modello, ma evita
|
||||
di dover gestire due container qwen-tts separati.
|
||||
|
||||
## File
|
||||
|
||||
| File | Scopo |
|
||||
| ------------------------ | ------------------------------------------------------------------ |
|
||||
| `server.py` | Logica del bot: webhook, pipeline whisper→llama.cpp→qwen-tts, risposta |
|
||||
| `entrypoint.sh` | Avvia uvicorn, valida le variabili d'ambiente richieste |
|
||||
| `talkbot.Containerfile` | Immagine del container |
|
||||
| `talkbot.container` | Quadlet Podman/systemd per l'esecuzione come servizio |
|
||||
| `README.md` | Questo file |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Registrare il bot su Nextcloud
|
||||
|
||||
Serve accesso CLI (`occ`) al server Nextcloud:
|
||||
|
||||
```bash
|
||||
occ talk:bot:install "TranslateBot" <SECRET> https://talkbot.example.tld/webhook \
|
||||
--feature=webhook
|
||||
```
|
||||
|
||||
`<SECRET>` deve corrispondere a `NC_BOT_SECRET` nel Quadlet.
|
||||
L'URL deve essere raggiungibile dal server Nextcloud (attenzione a
|
||||
firewall/reti interne se Nextcloud e il bot non sono sulla stessa rete).
|
||||
|
||||
Per aggiungere il bot a una conversazione specifica, va abilitato dalle
|
||||
impostazioni della conversazione stessa (icona bot) o via API conversazioni.
|
||||
|
||||
### 2. App password Nextcloud
|
||||
|
||||
`NC_ADMIN_USER` / `NC_ADMIN_PASSWORD` sono usate per:
|
||||
- leggere la lingua preferita del mittente (`/ocs/v1.php/cloud/users/{id}`)
|
||||
- scaricare l'allegato audio del messaggio vocale
|
||||
- caricare l'audio tradotto generato (upload lato Files)
|
||||
|
||||
Usa una **app password** dedicata (Impostazioni personali → Sicurezza →
|
||||
Password per app), non la password reale dell'account.
|
||||
|
||||
### 3. Build e avvio
|
||||
|
||||
```bash
|
||||
podman build -t talkbot:latest -f talkbot.Containerfile .
|
||||
cp talkbot.container ~/.config/containers/systemd/
|
||||
# modifica i valori NC_URL / NC_BOT_SECRET / NC_ADMIN_USER / NC_ADMIN_PASSWORD
|
||||
# e gli URL di whisper/llama.cpp/qwen-tts nel file .container
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start talkbot
|
||||
journalctl --user -u talkbot -f
|
||||
```
|
||||
|
||||
### 4. Test rapido
|
||||
|
||||
```bash
|
||||
curl http://localhost:8100/health
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
Il vero test end-to-end richiede un messaggio vocale reale inviato in una
|
||||
conversazione Talk dove il bot è abilitato: vedi sezione "Stato dei test".
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
| Variabile | Obbligatoria | Default | Note |
|
||||
| -------------------------- | ------------ | ------------------- | ------------------------------------------------- |
|
||||
| `NC_URL` | sì | — | URL base Nextcloud, senza slash finale |
|
||||
| `NC_BOT_SECRET` | sì | — | Secret condiviso usato in `occ talk:bot:install` |
|
||||
| `NC_ADMIN_USER` | sì | — | Utente Nextcloud per API/download/upload |
|
||||
| `NC_ADMIN_PASSWORD` | sì | — | App password di `NC_ADMIN_USER` |
|
||||
| `WHISPER_URL` | no | `http://whisper:8080` | Endpoint whisper-server |
|
||||
| `LLAMACPP_URL` | no | `http://llamacpp:7000` | Endpoint llama.cpp (OpenAI-compatible chat) |
|
||||
| `LLAMACPP_MODEL` | no | (vuoto) | Nome modello, se il tuo router llama.cpp lo richiede |
|
||||
| `LLAMACPP_TIMEOUT` | no | `600` | Timeout (secondi) per la chiamata a llama.cpp |
|
||||
| `QWEN_TTS_URL` | no | `http://qwen-tts:8000` | Endpoint qwen-tts (deve girare con `QWEN_TTS_LOAD` includendo sia `customvoice` che `voiceclone`, default) |
|
||||
| `QWEN_TTS_TIMEOUT` | no | `1800` | Timeout (secondi) per la chiamata a qwen-tts |
|
||||
| `VOICE_SAMPLES_DIR` | no | `/app/voice-samples` | Cartella con i campioni `<talk-username>.wav` per il cloning |
|
||||
| `DEFAULT_TARGET_LANGUAGE` | no | `en` | Lingua di fallback se non si riesce a leggere quella dell'utente |
|
||||
| `QWEN_TTS_SPEAKER` | no | `Ryan` | Voce preset usata quando non c'è un campione utente |
|
||||
|
||||
## Stato dei test / cosa manca ancora
|
||||
|
||||
Questo file va aggiornato man mano che verifichiamo il comportamento reale
|
||||
contro un'istanza Nextcloud Talk vera. Alla creazione (2 settembre 2026):
|
||||
|
||||
- [ ] **Non ancora testato contro un webhook reale.** La struttura del
|
||||
payload per i messaggi vocali (dove si trova esattamente l'URL/ID del
|
||||
file allegato dentro `object.content.parameters`) è assunta dalla
|
||||
documentazione generica dei bot, ma va confermata con un vero
|
||||
messaggio vocale: il parametro potrebbe chiamarsi diversamente o
|
||||
avere una struttura leggermente diversa da quella ipotizzata in
|
||||
`file_param` dentro `server.py`.
|
||||
- [ ] **Download dell'allegato audio**: `download_attachment()` assume che
|
||||
`file_param["link"]` sia un URL scaricabile direttamente con
|
||||
Basic Auth. Da verificare se serve invece passare dal WebDAV
|
||||
(`/remote.php/dav/files/...`) o da un endpoint di preview/download
|
||||
specifico dei messaggi di chat.
|
||||
- [ ] **Upload e allegazione dell'audio tradotto**: `upload_and_share_audio()`
|
||||
è un placeholder. Attualmente il bot risponde solo con il **testo**
|
||||
tradotto (via `send_bot_message`), non ancora con il file audio
|
||||
allegato in chat. Serve capire l'API esatta di Talk per condividere
|
||||
un file già presente nei Files dell'utente dentro una conversazione
|
||||
(probabilmente `POST /ocs/v2.php/apps/spreed/api/v1/chat/{token}/share`
|
||||
o simile — da verificare nella doc "Chat management").
|
||||
- [ ] **Lingua in chat di gruppo**: `get_user_language(actor_id)` prende la
|
||||
lingua di chi ha *inviato* il messaggio, non del destinatario. Per
|
||||
chat 1:1 o per un bot dedicato a un singolo utente va bene così; per
|
||||
gruppi con più utenti/lingue diverse la logica "traduci nella lingua
|
||||
del destinatario" va ripensata (quale destinatario? tutti? uno alla
|
||||
volta?).
|
||||
- [ ] **Rilevamento lingua sorgente**: whisper-server deve essere lanciato
|
||||
con `WHISPER_LANGUAGE=auto` (o comunque senza lingua fissa) perché
|
||||
`detected_lang` in `transcribe()` sia affidabile.
|
||||
- [ ] **Gestione errori/timeout**: i tre servizi (whisper, llama.cpp,
|
||||
qwen-tts) sono chiamati in sequenza sincrona nello stesso worker
|
||||
HTTP; su CPU la sintesi qwen-tts può richiedere decine di secondi.
|
||||
Da valutare se serve un timeout più alto lato Nextcloud o passare a
|
||||
elaborazione asincrona con risposta differita.
|
||||
- [x] ~~Doppia istanza qwen-tts~~: risolto, qwen-tts ora carica entrambi i
|
||||
modelli (CustomVoice + Base) nello stesso processo via
|
||||
`QWEN_TTS_LOAD=customvoice,voiceclone` (default). Un solo container,
|
||||
un solo `QWEN_TTS_URL`, entrambi gli endpoint disponibili. **Nota**:
|
||||
questo raddoppia la RAM e il tempo di avvio rispetto a un solo
|
||||
modello (due checkpoint da 1.7B caricati in memoria su CPU), tienine
|
||||
conto se il sistema ha risorse limitate — in tal caso si può
|
||||
restringere a un solo modello con `QWEN_TTS_LOAD=customvoice` (o
|
||||
`voiceclone`), sapendo che l'endpoint mancante risponderà 400.
|
||||
- [ ] **Provenienza dei campioni voce**: al momento i file
|
||||
`voice-samples/<talk-username>.wav` vanno creati manualmente
|
||||
sull'host (es. copiandoli dal container qwen-tts o da altre
|
||||
registrazioni). Non c'è ancora un modo per l'utente di caricare il
|
||||
proprio campione dalla chat stessa (es. "invia un vocale e digli
|
||||
/set-voice per usarlo come tuo campione").
|
||||
- [ ] **Sicurezza rete**: il Quadlet pubblica la porta 8100 su tutte le
|
||||
interfacce; se Nextcloud non è sulla stessa rete Podman/host, va
|
||||
esposto solo dove necessario (reverse proxy, firewall).
|
||||
|
||||
## Note di debug utili
|
||||
|
||||
```bash
|
||||
# Log del bot
|
||||
journalctl --user -u talkbot -f
|
||||
|
||||
# Verifica manuale della pipeline, bypassando Nextcloud:
|
||||
curl -X POST http://localhost:8080/inference -F "file=@test.wav" -F "response_format=json"
|
||||
curl -X POST http://localhost:7000/v1/chat/completions -H "Content-Type: application/json" \
|
||||
-d '{"messages":[{"role":"user","content":"Translate into Italian: Hello"}]}'
|
||||
curl -X POST http://localhost:8000/speech -H "Content-Type: application/json" \
|
||||
-d '{"text":"Ciao","language":"Italian","speaker":"Ryan"}' --output test-tts.wav
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Nextcloud Talk voice-translate bot entrypoint
|
||||
set -e
|
||||
|
||||
echo "=== Nextcloud Talk voice-translate bot ==="
|
||||
echo "NC_URL: ${NC_URL:-<not set>}"
|
||||
echo "WHISPER_URL: ${WHISPER_URL:-http://whisper:8080}"
|
||||
echo "LLAMACPP_URL: ${LLAMACPP_URL:-http://llamacpp:7000}"
|
||||
echo "QWEN_TTS_URL: ${QWEN_TTS_URL:-http://qwen-tts:8000}"
|
||||
echo "Default lang: ${DEFAULT_TARGET_LANGUAGE:-en}"
|
||||
echo
|
||||
|
||||
for required in NC_URL NC_BOT_SECRET NC_USER NC_PASSWORD; do
|
||||
if [ -z "${!required}" ]; then
|
||||
echo "ERROR: required environment variable $required is not set."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
exec uvicorn server:app --host 0.0.0.0 --port 8100 --app-dir /app "$@"
|
||||
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
@@ -0,0 +1,27 @@
|
||||
### Nextcloud Talk voice-translate bot
|
||||
### Receives voice messages via the Talk bot webhook, transcribes them
|
||||
### with whisper.cpp, translates with llama.cpp, and synthesizes the
|
||||
### translation with qwen-tts, replying into the same conversation.
|
||||
###
|
||||
### BUILD: podman build -t talkbot:latest -f talkbot.Containerfile .
|
||||
FROM python:3.12-slim
|
||||
|
||||
USER root
|
||||
EXPOSE 8100
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir fastapi uvicorn httpx
|
||||
|
||||
COPY server.py /app/server.py
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD []
|
||||
@@ -0,0 +1,45 @@
|
||||
[Unit]
|
||||
Description=Nextcloud Talk voice-translate bot
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/talkbot:latest
|
||||
ContainerName=talkbot
|
||||
|
||||
Network=internal.network
|
||||
PublishPort=8100:8100
|
||||
|
||||
PodmanArgs=--group-add=keep-groups
|
||||
SecurityLabelType=container_runtime_t
|
||||
|
||||
Volume=/srv/containers/talkbot/voices:/app/voice-samples:Z,ro
|
||||
Environment=VOICE_SAMPLES_DIR=/app/voice-samples
|
||||
|
||||
# --- Nextcloud connection ---
|
||||
Environment=NC_URL=https://your-nextcloud.example.tld
|
||||
# Shared secret used when registering the bot via:
|
||||
# occ talk:bot:install "TranslateBot" <secret> https://talkbot.example/webhook
|
||||
Environment=NC_BOT_SECRET=changeme
|
||||
# Nextcloud user + app password (Settings > Security > App passwords) used
|
||||
# only to read user languages and download/upload chat attachments.
|
||||
Environment=NC_USER=changeme
|
||||
Environment=NC_PASSWORD=changeme
|
||||
|
||||
# --- Backend services (adjust host:port to match your setup/network) ---
|
||||
Environment=WHISPER_URL=http://whisper:8080
|
||||
Environment=LLAMACPP_URL=http://llm:7000
|
||||
#Environment=LLAMACPP_MODEL=
|
||||
Environment=LLAMACPP_ENABLE_THINKING=false
|
||||
Environment=QWEN_TTS_URL=http://qwen-tts:8000
|
||||
|
||||
# --- Behaviour ---
|
||||
Environment=DEFAULT_TARGET_LANGUAGE=it
|
||||
Environment=QWEN_TTS_SPEAKER=Ryan
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,40 @@
|
||||
### Whisper.cpp Container - CPU only
|
||||
### High-performance Speech-to-Text using OpenAI's Whisper model
|
||||
### Based on whisper.cpp: https://github.com/ggml-org/whisper.cpp
|
||||
###
|
||||
### BUILD: ./build-container-cpu.sh (compiles locally, CPU-only)
|
||||
### THEN: podman build -t whisper:cpu-amd64 -f whisper-cpu.Containerfile .
|
||||
### With custom model: podman build --build-arg MODELS="small" -t whisper:cpu-amd64 -f whisper-cpu.Containerfile .
|
||||
FROM ubuntu:26.04
|
||||
ARG MODELS=small
|
||||
USER root
|
||||
EXPOSE 8080
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y curl ffmpeg nano \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /tmp/* /var/tmp/* \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete \
|
||||
&& find /var/cache -type f -delete
|
||||
WORKDIR /app
|
||||
# Copy pre-compiled CPU-only binaries
|
||||
COPY bin-cpu/ /app/
|
||||
RUN chmod +x /app/whisper-*
|
||||
# Copy models downloader and entrypoint
|
||||
COPY models/download-ggml-model.sh /app/
|
||||
COPY entrypoint.sh /app/
|
||||
RUN chmod +x /app/download-ggml-model.sh /app/entrypoint.sh
|
||||
# Create models directory (will be mounted as volume at runtime)
|
||||
RUN mkdir -p /app/models
|
||||
# Set environment variables
|
||||
ENV PATH=/app:$PATH
|
||||
ENV LD_LIBRARY_PATH=/app:/usr/local/lib:/usr/lib:/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
|
||||
ENV HF_HUB_ENABLE_HF_TRANSFER=1
|
||||
# Whisper model configuration
|
||||
# MODELS arg is passed but not used during build (downloaded at runtime)
|
||||
ENV WHISPER_MODEL_NAME=${MODELS}
|
||||
ENV WHISPER_MODEL_FILE=ggml-${MODELS}.bin
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD []
|
||||
Ссылка в новой задаче
Block a user