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