first commit
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal file
@@ -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
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
models/
|
||||
asterisk/tmp/
|
||||
445
agi-scripts/voicebot.py
Executable file
445
agi-scripts/voicebot.py
Executable file
@@ -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()
|
||||
31
asterisk/Dockerfile
Normal file
31
asterisk/Dockerfile
Normal file
@@ -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"]
|
||||
0
asterisk/config/.gitkeep
Normal file
0
asterisk/config/.gitkeep
Normal file
3
asterisk/config/agi.conf
Normal file
3
asterisk/config/agi.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
[general]
|
||||
enabled = yes
|
||||
agidir => /var/lib/asterisk/agi-bin
|
||||
41
asterisk/config/extensions.conf
Normal file
41
asterisk/config/extensions.conf
Normal file
@@ -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()
|
||||
5
asterisk/config/modules.conf
Normal file
5
asterisk/config/modules.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
[modules]
|
||||
autoload=yes
|
||||
|
||||
; در صورتی که نمیخوای chan_sip قدیمی لود بشه (توصیه میشه فقط pjsip استفاده کنی)
|
||||
noload => chan_sip.so
|
||||
64
asterisk/config/pjsip.conf
Normal file
64
asterisk/config/pjsip.conf
Normal file
@@ -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
|
||||
41
asterisk/entrypoint.sh
Normal file
41
asterisk/entrypoint.sh
Normal file
@@ -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
|
||||
3
asterisk/templates/agi.conf
Normal file
3
asterisk/templates/agi.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
[general]
|
||||
enabled = yes
|
||||
agidir => /var/lib/asterisk/agi-bin
|
||||
41
asterisk/templates/extensions.conf.template
Normal file
41
asterisk/templates/extensions.conf.template
Normal file
@@ -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()
|
||||
5
asterisk/templates/modules.conf
Normal file
5
asterisk/templates/modules.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
[modules]
|
||||
autoload=yes
|
||||
|
||||
; در صورتی که نمیخوای chan_sip قدیمی لود بشه (توصیه میشه فقط pjsip استفاده کنی)
|
||||
noload => chan_sip.so
|
||||
30
asterisk/templates/pjsip-base.conf.template
Normal file
30
asterisk/templates/pjsip-base.conf.template
Normal file
@@ -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
|
||||
34
asterisk/templates/pjsip-trunk.conf.template
Normal file
34
asterisk/templates/pjsip-trunk.conf.template
Normal file
@@ -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}
|
||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@@ -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: # تعریف حجم مشترک
|
||||
21
stt/Dockerfile
Normal file
21
stt/Dockerfile
Normal file
@@ -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"]
|
||||
78
stt/app.py
Normal file
78
stt/app.py
Normal file
@@ -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)
|
||||
5
stt/requirements.txt
Normal file
5
stt/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
flask==3.0.3
|
||||
faster-whisper==1.0.3
|
||||
ctranslate2
|
||||
requests
|
||||
numpy
|
||||
14
tts/Dockerfile
Normal file
14
tts/Dockerfile
Normal file
@@ -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"]
|
||||
18
tts/app.py
Normal file
18
tts/app.py
Normal file
@@ -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)
|
||||
2
tts/requirements.txt
Normal file
2
tts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.0.3
|
||||
piper-tts==1.2.0
|
||||
Reference in New Issue
Block a user