From c18a1c229869db455e7ce3c2b805b4a283b3ab2e Mon Sep 17 00:00:00 2001 From: Mehdi Aslani Date: Sat, 18 Jul 2026 01:25:01 +0330 Subject: [PATCH] first commit --- .env.example | 32 ++ .gitignore | 3 + agi-scripts/voicebot.py | 445 +++++++++++++++++++ asterisk/Dockerfile | 31 ++ asterisk/config/.gitkeep | 0 asterisk/config/agi.conf | 3 + asterisk/config/extensions.conf | 41 ++ asterisk/config/modules.conf | 5 + asterisk/config/pjsip.conf | 64 +++ asterisk/entrypoint.sh | 41 ++ asterisk/templates/agi.conf | 3 + asterisk/templates/extensions.conf.template | 41 ++ asterisk/templates/modules.conf | 5 + asterisk/templates/pjsip-base.conf.template | 30 ++ asterisk/templates/pjsip-trunk.conf.template | 34 ++ docker-compose.yml | 65 +++ stt/Dockerfile | 21 + stt/app.py | 78 ++++ stt/requirements.txt | 5 + tts/Dockerfile | 14 + tts/app.py | 18 + tts/requirements.txt | 2 + 22 files changed, 981 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100755 agi-scripts/voicebot.py create mode 100644 asterisk/Dockerfile create mode 100644 asterisk/config/.gitkeep create mode 100644 asterisk/config/agi.conf create mode 100644 asterisk/config/extensions.conf create mode 100644 asterisk/config/modules.conf create mode 100644 asterisk/config/pjsip.conf create mode 100644 asterisk/entrypoint.sh create mode 100644 asterisk/templates/agi.conf create mode 100644 asterisk/templates/extensions.conf.template create mode 100644 asterisk/templates/modules.conf create mode 100644 asterisk/templates/pjsip-base.conf.template create mode 100644 asterisk/templates/pjsip-trunk.conf.template create mode 100644 docker-compose.yml create mode 100644 stt/Dockerfile create mode 100644 stt/app.py create mode 100644 stt/requirements.txt create mode 100644 tts/Dockerfile create mode 100644 tts/app.py create mode 100644 tts/requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f08a995 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# ========================================================== +# فایل تنظیمات مرکزی پروژه چت‌بات تلفنی +# این تنها فایلی است که باید برای راه‌اندازی ادیت کنی. +# بعد از تغییر، کافیه: docker compose up -d --build +# ========================================================== + +# ---------- Ollama ---------- +OLLAMA_URL=http://OLLAMA_HOST:11434/api/generate +OLLAMA_MODEL=llama3 + +# ---------- STT (faster-whisper) ---------- +WHISPER_MODEL=medium +WHISPER_DEVICE=cpu + +# ---------- TTS (Piper) ---------- +PIPER_VOICE=/models/piper/fa_IR-model.onnx + +# ---------- ترانک مرکز تماس شماره ۱ (SIP Trunk) ---------- +TRUNK1_NAME=callcenter1 +TRUNK1_HOST=CALLCENTER1_IP +TRUNK1_USERNAME=USERNAME +TRUNK1_PASSWORD=CALLCENTER1_PASS + +# ---------- ترانک مرکز تماس شماره ۲ (اختیاری - اگر نداری خالی بذار) ---------- +TRUNK2_NAME= +TRUNK2_HOST= +TRUNK2_USERNAME= +TRUNK2_PASSWORD= + +# ---------- SIP User داخلی (برای تست با سافت‌فون) ---------- +TEST_USER_EXTEN=1000 +TEST_USER_PASSWORD=CHANGE_ME_STRONG_PASSWORD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..546e5ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +models/ +asterisk/tmp/ diff --git a/agi-scripts/voicebot.py b/agi-scripts/voicebot.py new file mode 100755 index 0000000..7b297c0 --- /dev/null +++ b/agi-scripts/voicebot.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import uuid +import requests +from asterisk.agi import AGI + + +TMP_DIR = "/tmp/voicebot" + +STT_URL = "http://127.0.0.1:5001/transcribe" +TTS_URL = "http://127.0.0.1:5002/synthesize" + +OLLAMA_URL = "http://127.0.0.1:11434/api/generate" +OLLAMA_MODEL = "qwen3:14b" + + + +def log(agi, msg): + agi.verbose( + "VOICEBOT: " + str(msg), + 1 + ) + + + +def tts_generate(agi, text, name): + + try: + + log( + agi, + "TTS request: " + text + ) + + + r = requests.post( + TTS_URL, + json={ + "text": text + }, + timeout=60 + ) + + r.raise_for_status() + + + path = TMP_DIR + "/" + name + ".wav" + + + with open(path, "wb") as f: + f.write(r.content) + + + log( + agi, + "TTS created " + path + ) + + + return path + + + except Exception as e: + + log( + agi, + "TTS ERROR " + str(e) + ) + + return None + + + +def play_file(agi, path): + + if not path: + return + + + try: + + file = path.replace( + ".wav", + "" + ) + + + agi.appexec( + "Playback", + file + ) + + + except Exception as e: + + log( + agi, + "PLAY ERROR " + str(e) + ) + + + + +def record_audio(agi, callid): + + + filename = TMP_DIR + "/" + callid + "_record" + + + log( + agi, + "Recording user voice" + ) + + + try: + + + result = agi.send_command( + + f'RECORD FILE "{filename}" wav "#" 10000 0 s=5' + + ) + + + log( + agi, + "Record result: " + str(result) + ) + + + except Exception as e: + + + log( + agi, + "Record ERROR " + str(e) + ) + + return None + + + + wav = filename + ".wav" + + + if os.path.exists(wav): + + + size=os.path.getsize(wav) + + + log( + agi, + f"Recording saved {wav} size={size}" + ) + + + return wav + + + + log( + agi, + "Recording file missing" + ) + + + return None + + + + + +def stt_process(agi,wav): + + + try: + + + log( + agi, + "Sending audio to STT" + ) + + + with open(wav,"rb") as f: + + + r=requests.post( + + STT_URL, + + files={ + "audio":f + }, + + timeout=120 + + ) + + + data=r.json() + + + text=data.get( + "text", + "" + ) + + + log( + agi, + "STT text: " + text + ) + + + return text + + + + except Exception as e: + + + log( + agi, + "STT ERROR " + str(e) + ) + + + return "" + + + + + +def ask_ai(agi,text): + + + try: + + + log( + agi, + "AI request: " + text + ) + + + prompt = """ +تو دستیار تلفنی هستی. +کوتاه و طبیعی جواب بده. +حداکثر دو جمله. + +کاربر: +""" + text + + + + r=requests.post( + + OLLAMA_URL, + + json={ + + "model":OLLAMA_MODEL, + + "prompt":prompt, + + "stream":False, + + "options":{ + "num_predict":80, + "temperature":0.3 + } + + }, + + timeout=180 + + ) + + + data=r.json() + + + answer=data.get( + "response", + "" + ) + + + log( + agi, + "AI response: " + answer + ) + + + return answer + + + + except Exception as e: + + + log( + agi, + "AI ERROR " + str(e) + ) + + + return "ارتباط با هوش مصنوعی برقرار نشد." + + + + + +def main(): + + + os.makedirs( + TMP_DIR, + exist_ok=True + ) + + + agi=AGI() + + + callid=str(uuid.uuid4())[:8] + + + log( + agi, + "Call started " + callid + ) + + + try: + + + agi.answer() + + + + welcome=tts_generate( + + agi, + + "سلام، من دستیار صوتی هوشمند هستم. بفرمایید.", + + callid+"_welcome" + + ) + + + play_file( + agi, + welcome + ) + + + + wav=record_audio( + agi, + callid + ) + + + if not wav: + + log( + agi, + "No recording" + ) + + return + + + + text=stt_process( + agi, + wav + ) + + + + if text: + + answer=ask_ai( + agi, + text + ) + + else: + + answer="صدای شما را دریافت نکردم." + + + + reply=tts_generate( + + agi, + + answer, + + callid+"_reply" + + ) + + + play_file( + agi, + reply + ) + + + log( + agi, + "Call completed" + ) + + + + except Exception as e: + + + log( + agi, + "MAIN ERROR " + str(e) + ) + + + finally: + + agi.hangup() + + + + + +if __name__ == "__main__": + + main() diff --git a/asterisk/Dockerfile b/asterisk/Dockerfile new file mode 100644 index 0000000..bcd2487 --- /dev/null +++ b/asterisk/Dockerfile @@ -0,0 +1,31 @@ +FROM andrius/asterisk:latest + +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + gettext-base \ + curl \ + file \ + sox \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +RUN pip3 install --break-system-packages \ + requests \ + pyst2 + +RUN mkdir -p \ + /tmp/voicebot \ + /var/lib/asterisk/sounds/custom \ + /etc/asterisk-templates + +COPY templates/ /etc/asterisk-templates/ +COPY entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +EXPOSE 5060/udp +EXPOSE 5060/tcp +EXPOSE 10000-20000/udp + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/asterisk/config/.gitkeep b/asterisk/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/asterisk/config/agi.conf b/asterisk/config/agi.conf new file mode 100644 index 0000000..74ae7eb --- /dev/null +++ b/asterisk/config/agi.conf @@ -0,0 +1,3 @@ +[general] +enabled = yes +agidir => /var/lib/asterisk/agi-bin diff --git a/asterisk/config/extensions.conf b/asterisk/config/extensions.conf new file mode 100644 index 0000000..ddefe06 --- /dev/null +++ b/asterisk/config/extensions.conf @@ -0,0 +1,41 @@ +[general] +static=yes +writeprotect=no + + +; ========================================================== +; Incoming calls from SIP Trunk +; ========================================================== + +[from-trunk] + +; Incoming call without DID +exten => s,1,NoOp(Incoming call from trunk - CallerID: ${CALLERID(num)}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(voicebot.py) + same => n,Hangup() + + +; Incoming call with DID +exten => _X.,1,NoOp(Incoming DID call: ${EXTEN} - CallerID: ${CALLERID(num)}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(voicebot.py) + same => n,Hangup() + + + +; ========================================================== +; Internal test extension +; Used with Zoiper / Linphone for testing +; ========================================================== + +[from-internal] + +; Match any dialed extension +exten => _X.,1,NoOp(Test call from internal extension ${EXTEN}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(voicebot.py) + same => n,Hangup() diff --git a/asterisk/config/modules.conf b/asterisk/config/modules.conf new file mode 100644 index 0000000..ec6e50d --- /dev/null +++ b/asterisk/config/modules.conf @@ -0,0 +1,5 @@ +[modules] +autoload=yes + +; در صورتی که نمی‌خوای chan_sip قدیمی لود بشه (توصیه می‌شه فقط pjsip استفاده کنی) +noload => chan_sip.so diff --git a/asterisk/config/pjsip.conf b/asterisk/config/pjsip.conf new file mode 100644 index 0000000..5d5cc75 --- /dev/null +++ b/asterisk/config/pjsip.conf @@ -0,0 +1,64 @@ +[global] +type=global +user_agent=Pline-AI + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 + +; ====================== +; Test Extension +; ====================== + +[1000] +type=endpoint +context=from-internal +disallow=all +allow=ulaw,alaw +auth=1000-auth +aors=1000 + +[1000-auth] +type=auth +auth_type=userpass +username=1000 +password=1000 + +[1000] +type=aor +max_contacts=5 +[121-reg] +type=registration +transport=transport-udp +outbound_auth=121-auth +server_uri=sip:192.168.102.253 +client_uri=sip:121@192.168.102.253 +retry_interval=60 +expiration=3600 + +[121-auth] +type=auth +auth_type=userpass +username=121 +password=121 + +[121] +type=aor +contact=sip:192.168.102.253 +qualify_frequency=60 + +[121] +type=endpoint +context=from-trunk +transport=transport-udp +disallow=all +allow=ulaw,alaw +outbound_auth=121-auth +aors=121 + +; --- اضافه کردن این بخش --- +[121-identify] +type=identify +endpoint=121 +match=192.168.102.253 diff --git a/asterisk/entrypoint.sh b/asterisk/entrypoint.sh new file mode 100644 index 0000000..7d5f9a6 --- /dev/null +++ b/asterisk/entrypoint.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -e + +TEMPLATES_DIR="/etc/asterisk-templates" +CONFIG_DIR="/etc/asterisk" + +echo "[entrypoint] generating asterisk configuration..." + +# کپی فایل‌های ثابت +cp "$TEMPLATES_DIR/agi.conf" "$CONFIG_DIR/agi.conf" +cp "$TEMPLATES_DIR/modules.conf" "$CONFIG_DIR/modules.conf" + +# تولید extensions.conf +envsubst '${TEST_USER_EXTEN}' \ + < "$TEMPLATES_DIR/extensions.conf.template" \ + > "$CONFIG_DIR/extensions.conf" + +# تولید pjsip.conf پایه (بدون ترانک) +envsubst '${TEST_USER_EXTEN} ${TEST_USER_PASSWORD}' \ + < "$TEMPLATES_DIR/pjsip-base.conf.template" \ + > "$CONFIG_DIR/pjsip.conf" + +# تابع افزودن ترانک +add_trunk() { + TRUNK_NAME=$1 TRUNK_HOST=$2 TRUNK_USERNAME=$3 TRUNK_PASSWORD=$4 \ + envsubst '${TRUNK_NAME} ${TRUNK_HOST} ${TRUNK_USERNAME} ${TRUNK_PASSWORD}' \ + < "$TEMPLATES_DIR/pjsip-trunk.conf.template" \ + >> "$CONFIG_DIR/pjsip.conf" +} + +# اضافه کردن ترانک ۱ +add_trunk "$TRUNK1_NAME" "$TRUNK1_HOST" "$TRUNK1_USERNAME" "$TRUNK1_PASSWORD" + +# اضافه کردن ترانک ۲ (اگر وجود داشت) +if [ -n "$TRUNK2_HOST" ]; then + echo "[entrypoint] adding second trunk..." + add_trunk "$TRUNK2_NAME" "$TRUNK2_HOST" "$TRUNK2_USERNAME" "$TRUNK2_PASSWORD" +fi + +echo "[entrypoint] configuration generated" +exec asterisk -f -vvv diff --git a/asterisk/templates/agi.conf b/asterisk/templates/agi.conf new file mode 100644 index 0000000..74ae7eb --- /dev/null +++ b/asterisk/templates/agi.conf @@ -0,0 +1,3 @@ +[general] +enabled = yes +agidir => /var/lib/asterisk/agi-bin diff --git a/asterisk/templates/extensions.conf.template b/asterisk/templates/extensions.conf.template new file mode 100644 index 0000000..ddefe06 --- /dev/null +++ b/asterisk/templates/extensions.conf.template @@ -0,0 +1,41 @@ +[general] +static=yes +writeprotect=no + + +; ========================================================== +; Incoming calls from SIP Trunk +; ========================================================== + +[from-trunk] + +; Incoming call without DID +exten => s,1,NoOp(Incoming call from trunk - CallerID: ${CALLERID(num)}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(voicebot.py) + same => n,Hangup() + + +; Incoming call with DID +exten => _X.,1,NoOp(Incoming DID call: ${EXTEN} - CallerID: ${CALLERID(num)}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(voicebot.py) + same => n,Hangup() + + + +; ========================================================== +; Internal test extension +; Used with Zoiper / Linphone for testing +; ========================================================== + +[from-internal] + +; Match any dialed extension +exten => _X.,1,NoOp(Test call from internal extension ${EXTEN}) + same => n,Answer() + same => n,Wait(1) + same => n,AGI(voicebot.py) + same => n,Hangup() diff --git a/asterisk/templates/modules.conf b/asterisk/templates/modules.conf new file mode 100644 index 0000000..ec6e50d --- /dev/null +++ b/asterisk/templates/modules.conf @@ -0,0 +1,5 @@ +[modules] +autoload=yes + +; در صورتی که نمی‌خوای chan_sip قدیمی لود بشه (توصیه می‌شه فقط pjsip استفاده کنی) +noload => chan_sip.so diff --git a/asterisk/templates/pjsip-base.conf.template b/asterisk/templates/pjsip-base.conf.template new file mode 100644 index 0000000..7fa9642 --- /dev/null +++ b/asterisk/templates/pjsip-base.conf.template @@ -0,0 +1,30 @@ +[global] +type=global +user_agent=Pline-AI + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 + +; ====================== +; Test Extension +; ====================== + +[${TEST_USER_EXTEN}] +type=endpoint +context=from-internal +disallow=all +allow=ulaw,alaw +auth=${TEST_USER_EXTEN}-auth +aors=${TEST_USER_EXTEN} + +[${TEST_USER_EXTEN}-auth] +type=auth +auth_type=userpass +username=${TEST_USER_EXTEN} +password=${TEST_USER_PASSWORD} + +[${TEST_USER_EXTEN}] +type=aor +max_contacts=5 diff --git a/asterisk/templates/pjsip-trunk.conf.template b/asterisk/templates/pjsip-trunk.conf.template new file mode 100644 index 0000000..a75fb64 --- /dev/null +++ b/asterisk/templates/pjsip-trunk.conf.template @@ -0,0 +1,34 @@ +[${TRUNK_NAME}-reg] +type=registration +transport=transport-udp +outbound_auth=${TRUNK_NAME}-auth +server_uri=sip:${TRUNK_HOST} +client_uri=sip:${TRUNK_USERNAME}@${TRUNK_HOST} +retry_interval=60 +expiration=3600 + +[${TRUNK_NAME}-auth] +type=auth +auth_type=userpass +username=${TRUNK_USERNAME} +password=${TRUNK_PASSWORD} + +[${TRUNK_NAME}] +type=aor +contact=sip:${TRUNK_HOST} +qualify_frequency=60 + +[${TRUNK_NAME}] +type=endpoint +context=from-trunk +transport=transport-udp +disallow=all +allow=ulaw,alaw +outbound_auth=${TRUNK_NAME}-auth +aors=${TRUNK_NAME} + +; --- اضافه کردن این بخش --- +[${TRUNK_NAME}-identify] +type=identify +endpoint=${TRUNK_NAME} +match=${TRUNK_HOST} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7632590 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +services: + asterisk: + image: pline-ai/asterisk:latest + build: + context: ./asterisk + container_name: pline-ai-asterisk + network_mode: host + env_file: + - .env + environment: + STT_URL: http://127.0.0.1:5001/transcribe + TTS_URL: http://127.0.0.1:5002/synthesize + volumes: + - ./asterisk/config:/etc/asterisk + - ./asterisk/templates:/etc/asterisk-templates + - ./agi-scripts:/var/lib/asterisk/agi-bin + - ./asterisk/sounds:/var/lib/asterisk/sounds/custom + - voice_storage:/tmp/voicebot # استفاده از volume مشترک + restart: unless-stopped + depends_on: + - stt-service + - tts-service + + stt-service: + image: pline-ai/stt:latest + build: + context: ./stt + container_name: pline-ai-stt + network_mode: host + env_file: + - .env + environment: + PORT: 5001 + WHISPER_MODEL: /models/whisper + WHISPER_DEVICE: cuda + WHISPER_COMPUTE_TYPE: float16 + volumes: + - ./models/whisper:/models/whisper + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: + - gpu + restart: unless-stopped + + tts-service: + image: pline-ai/tts:latest + build: + context: ./tts + container_name: pline-ai-tts + network_mode: host + env_file: + - .env + environment: + PORT: 5002 + volumes: + - ./models/piper:/models/piper + - voice_storage:/tmp/voicebot # اضافه شده برای دسترسی به مسیر مشترک + restart: unless-stopped + +volumes: + voice_storage: # تعریف حجم مشترک diff --git a/stt/Dockerfile b/stt/Dockerfile new file mode 100644 index 0000000..76c5f48 --- /dev/null +++ b/stt/Dockerfile @@ -0,0 +1,21 @@ +FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . + +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5001 + +CMD ["python3", "app.py"] diff --git a/stt/app.py b/stt/app.py new file mode 100644 index 0000000..c6a7214 --- /dev/null +++ b/stt/app.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +import os +import tempfile +from flask import Flask, request, jsonify +from faster_whisper import WhisperModel + +# ========================================================== +# Configuration +# ========================================================== +MODEL_PATH = os.environ.get("WHISPER_MODEL", "/models/whisper") +DEVICE = os.environ.get("WHISPER_DEVICE", "cuda") +COMPUTE_TYPE = "float16" if DEVICE == "cuda" else "int8" +PORT = int(os.environ.get("PORT", 5001)) + +app = Flask(__name__) + +# ========================================================== +# Load Whisper Model +# ========================================================== +print(f"[stt] Loading model from {MODEL_PATH} on {DEVICE} ({COMPUTE_TYPE})") +try: + model = WhisperModel(MODEL_PATH, device=DEVICE, compute_type=COMPUTE_TYPE) + print("[stt] Whisper model loaded successfully") +except Exception as e: + print(f"[stt] Fatal error loading model: {e}") + exit(1) + +# ========================================================== +# Speech To Text Endpoint +# ========================================================== +@app.route("/transcribe", methods=["POST"]) +def transcribe(): + if "audio" not in request.files: + return jsonify({"error": "audio file missing"}), 400 + + audio_file = request.files["audio"] + + # استفاده از NamedTemporaryFile با مدیریت بهتر برای محیط داکر + tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + audio_path = tmp_file.name + tmp_file.close() + + try: + audio_file.save(audio_path) + + # اجرای اینفرنس با تنظیمات بهینه شده + segments, info = model.transcribe( + audio_path, + language="fa", + beam_size=10, + vad_filter=True, + vad_parameters=dict(min_silence_duration_ms=500) # بهبود تشخیص سکوت + ) + + text = " ".join([segment.text.strip() for segment in segments]) + + return jsonify({ + "text": text.strip(), + "language": info.language + }) + + except Exception as e: + print(f"[stt] Error during transcription: {e}") + return jsonify({"error": str(e)}), 500 + + finally: + if os.path.exists(audio_path): + os.remove(audio_path) + +# ========================================================== +# Health Check +# ========================================================== +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok"}) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=PORT) diff --git a/stt/requirements.txt b/stt/requirements.txt new file mode 100644 index 0000000..b0448fb --- /dev/null +++ b/stt/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.3 +faster-whisper==1.0.3 +ctranslate2 +requests +numpy diff --git a/tts/Dockerfile b/tts/Dockerfile new file mode 100644 index 0000000..5f84019 --- /dev/null +++ b/tts/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y \ + wget ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5002 +CMD ["python3", "app.py"] diff --git a/tts/app.py b/tts/app.py new file mode 100644 index 0000000..285ad72 --- /dev/null +++ b/tts/app.py @@ -0,0 +1,18 @@ +import os, subprocess, tempfile +from flask import Flask, request, send_file + +app = Flask(__name__) +PIPER_VOICE = os.environ.get("PIPER_VOICE", "/models/piper/fa_IR-model.onnx") + +@app.route("/synthesize", methods=["POST"]) +def synthesize(): + text = request.get_json().get("text", "") + p_out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + a_out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + + subprocess.run(["piper", "--model", PIPER_VOICE, "--output_file", p_out], input=text.encode("utf-8"), capture_output=True) + subprocess.run(["ffmpeg", "-y", "-i", p_out, "-ar", "8000", "-ac", "1", "-acodec", "pcm_s16le", "-f", "wav", a_out], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return send_file(a_out, mimetype="audio/wav") + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5002) diff --git a/tts/requirements.txt b/tts/requirements.txt new file mode 100644 index 0000000..dd32a0e --- /dev/null +++ b/tts/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.3 +piper-tts==1.2.0