From a8d406fa19a860d6521aa38abe7227df74e63297 Mon Sep 17 00:00:00 2001 From: Alexander Maassen Date: Mon, 15 Jan 2024 17:59:36 +0100 Subject: [PATCH 1/7] add proxy-authenticate to SIP.py --- pyVoIP/SIP.py | 71 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/pyVoIP/SIP.py b/pyVoIP/SIP.py index 0337e3e..4db8bb6 100644 --- a/pyVoIP/SIP.py +++ b/pyVoIP/SIP.py @@ -347,6 +347,7 @@ def __init__(self, data: bytes): self.headers: Dict[str, Any] = {"Via": []} self.body: Dict[str, Any] = {} self.authentication: Dict[str, str] = {} + self.proxy_authentication: Dict[str, str] = {} self.raw = data self.auth_match = re.compile(r'(\w+)=("[^",]+"|[^ \t,]+)') self.parse(data) @@ -460,14 +461,17 @@ def parse_header(self, header: str, data: str) -> None: self.headers[header] = data.split(", ") elif header == "Content-Length": self.headers[header] = int(data) - elif header == "WWW-Authenticate" or header == "Authorization": + elif header == "WWW-Authenticate" or header == "Authorization" or header == "Proxy-Authenticate": data = data.replace("Digest ", "") row_data = self.auth_match.findall(data) header_data = {} for var, data in row_data: header_data[var] = data.strip('"') self.headers[header] = header_data - self.authentication = header_data + if header == "Proxy-Authenticate": + self.proxy_authentication = header_data + else: + self.authentication = header_data else: self.headers[header] = data @@ -813,12 +817,14 @@ def __init__( myPort=5060, callCallback: Optional[Callable[[SIPMessage], None]] = None, fatalCallback: Optional[Callable[..., None]] = None, + auth_username: Optional[str] = None, ): self.NSD = False self.server = server self.port = port self.myIP = myIP self.username = username + self.auth_username = auth_username self.password = password self.phone = phone @@ -1074,8 +1080,16 @@ def genAuthorization(self, request: SIPMessage) -> bytes: return self.gen_authorization(request) def gen_authorization(self, request: SIPMessage) -> bytes: - realm = request.authentication["realm"] - HA1 = self.username + ":" + realm + ":" + self.password + if request.status == SIPStatus(407): + nonce = request.proxy_authentication["nonce"] + realm = request.proxy_authentication["realm"] + user = self.auth_username + else: + nonce = request.authentication["nonce"] + realm = request.authentication["realm"] + user = self.username + + HA1 = user + ":" + realm + ":" + self.password HA1 = hashlib.md5(HA1.encode("utf8")).hexdigest() HA2 = ( "" @@ -1085,7 +1099,6 @@ def gen_authorization(self, request: SIPMessage) -> bytes: + ";transport=UDP" ) HA2 = hashlib.md5(HA2.encode("utf8")).hexdigest() - nonce = request.authentication["nonce"] response = (HA1 + ":" + nonce + ":" + HA2).encode("utf8") response = hashlib.md5(response).hexdigest().encode("utf8") @@ -1216,8 +1229,14 @@ def genRegister(self, request: SIPMessage, deregister=False) -> str: def gen_register(self, request: SIPMessage, deregister=False) -> str: response = str(self.genAuthorization(request), "utf8") - nonce = request.authentication["nonce"] - realm = request.authentication["realm"] + if request.status == SIPStatus(407): + nonce = request.proxy_authentication["nonce"] + realm = request.proxy_authentication["realm"] + user = self.auth_username + else: + nonce = request.authentication["nonce"] + realm = request.authentication["realm"] + user = self.username regRequest = f"REGISTER sip:{self.server} SIP/2.0\r\n" regRequest += ( @@ -1250,8 +1269,10 @@ def gen_register(self, request: SIPMessage, deregister=False) -> str: "Expires: " + f"{self.default_expires if not deregister else 0}\r\n" ) + if request.status == SIPStatus(407): + regRequest += "Proxy-" regRequest += ( - f'Authorization: Digest username="{self.username}",' + f'Authorization: Digest username="{user}",' + f'realm="{realm}",nonce="{nonce}",' + f'uri="sip:{self.server};transport=UDP",' + f'response="{response}",algorithm=MD5\r\n' @@ -1609,6 +1630,7 @@ def invite( while ( response.status != SIPStatus(401) + and response.status != SIPStatus(407) and response.status != SIPStatus(100) and response.status != SIPStatus(180) ) or response.headers["Call-ID"] != call_id: @@ -1626,10 +1648,19 @@ def invite( self.out.sendto(ack.encode("utf8"), (self.server, self.port)) debug("Acknowledged") authhash = self.genAuthorization(response) - nonce = response.authentication["nonce"] - realm = response.authentication["realm"] - auth = ( - f'Authorization: Digest username="{self.username}",realm=' + + auth = "" + if response.status == SIPStatus(407): + nonce = response.proxy_authentication["nonce"] + realm = response.proxy_authentication["realm"] + user = self.auth_username + auth += "Proxy-" + else: + nonce = response.authentication["nonce"] + realm = response.authentication["realm"] + user = self.username + auth += ( + f'Authorization: Digest username="{user}",realm=' + f'"{realm}",nonce="{nonce}",uri="sip:{self.server};' + f'transport=UDP",response="{str(authhash, "utf8")}",' + "algorithm=MD5\r\n" @@ -1689,8 +1720,9 @@ def __deregister(self) -> bool: response = SIPMessage(resp) response = self.trying_timeout_check(response) - if response.status == SIPStatus(401): - # Unauthorized, likely due to being password protected. + if response.status == SIPStatus(401) or response.status == SIPStatus(407): + # 401 Unauthorized, likely due to being password protected. + # 407 Proxy Authentication Required regRequest = self.genRegister(response, deregister=True) self.out.sendto( regRequest.encode("utf8"), (self.server, self.port) @@ -1699,7 +1731,7 @@ def __deregister(self) -> bool: if ready[0]: resp = self.s.recv(8192) response = SIPMessage(resp) - if response.status == SIPStatus(401): + if response.status == SIPStatus(401) or response.status == SIPStatus(407): # At this point, it's reasonable to assume that # this is caused by invalid credentials. debug("Unauthorized") @@ -1793,7 +1825,7 @@ def __register(self) -> bool: # with new urn:uuid or reply with expire 0 self._handle_bad_request() - if response.status == SIPStatus(401): + if response.status == SIPStatus(401) or response.status == SIPStatus(407): # Unauthorized, likely due to being password protected. regRequest = self.genRegister(response) self.out.sendto( @@ -1804,7 +1836,7 @@ def __register(self) -> bool: resp = self.s.recv(8192) response = SIPMessage(resp) response = self.trying_timeout_check(response) - if response.status == SIPStatus(401): + if response.status == SIPStatus(401) or response.status == SIPStatus(407): # At this point, it's reasonable to assume that # this is caused by invalid credentials. debug("=" * 50) @@ -1833,11 +1865,6 @@ def __register(self) -> bool: else: raise TimeoutError("Registering on SIP Server timed out") - if response.status == SIPStatus(407): - # Proxy Authentication Required - # TODO: implement - debug("Proxy auth required") - # TODO: This must be done more reliable if response.status not in [ SIPStatus(400), From 5721f9a2c25dfc7219926c1a5be19b0ccc4658a1 Mon Sep 17 00:00:00 2001 From: Alexander Maassen Date: Mon, 15 Jan 2024 18:03:13 +0100 Subject: [PATCH 2/7] add auth_username parm to VoIP.py --- pyVoIP/VoIP/VoIP.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyVoIP/VoIP/VoIP.py b/pyVoIP/VoIP/VoIP.py index f6132ad..907df86 100644 --- a/pyVoIP/VoIP/VoIP.py +++ b/pyVoIP/VoIP/VoIP.py @@ -479,6 +479,7 @@ def __init__( sipPort=5060, rtpPortLow=10000, rtpPortHigh=20000, + auth_username: str = None, ): if rtpPortLow > rtpPortHigh: raise InvalidRangeError("'rtpPortHigh' must be >= 'rtpPortLow'") @@ -495,6 +496,7 @@ def __init__( self.port = port self.myIP = myIP self.username = username + self.auth_username = auth_username self.password = password self.callCallback = callCallback self._status = PhoneStatus.INACTIVE @@ -517,6 +519,7 @@ def __init__( myPort=sipPort, callCallback=self.callback, fatalCallback=self.fatal, + auth_username=self.auth_username, ) def callback(self, request: SIP.SIPMessage) -> None: From 0a3140e9abf6e6a7501b13e3879771f3116c931b Mon Sep 17 00:00:00 2001 From: Alexander Maassen Date: Mon, 15 Jan 2024 18:07:11 +0100 Subject: [PATCH 3/7] Update documentation for proxy-authentication --- docs/VoIP.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/VoIP.rst b/docs/VoIP.rst index f8cf0ce..1d91bad 100644 --- a/docs/VoIP.rst +++ b/docs/VoIP.rst @@ -146,7 +146,7 @@ VoIPPhone The VoIPPhone class is used to manage the :ref:`SIPClient` class and create :ref:`VoIPCall`'s when there is an incoming call. It then passes the VoIPCall as the argument in the callback. -*class* VoIP.\ **VoIPPhone**\ (server: str, port: int, username: str, password: str, callCallback: Optional[Callable] = None, myIP: Optional[str] = None, sipPort=5060, rtpPortLow=10000, rtpPortHigh=20000) +*class* VoIP.\ **VoIPPhone**\ (server: str, port: int, username: str, password: str, callCallback: Optional[Callable] = None, myIP: Optional[str] = None, sipPort=5060, rtpPortLow=10000, rtpPortHigh=20000, auth_username: str) The *server* argument is your PBX/VoIP server's IP, represented as a string. The *port* argument is your PBX/VoIP server's port, represented as an integer. @@ -162,6 +162,8 @@ The VoIPPhone class is used to manage the :ref:`SIPClient` class and create :ref The *sipPort* argument is the port SIP will bind to to receive SIP requests. The default for this protocol is port 5060, but any port can be used. The *rtpPortLow* and *rtpPortHigh* arguments are used to generate random ports to use for audio transfer. Per RFC 4566 Sections `5.7 `_ and `5.14 `_, it can take multiple ports to fully communicate with other :term:`clients`, as such a large range is recommended. If an invalid range is given, a :ref:`InvalidStateError` will be thrown. + + The *auth_username* argument is the optional username for proxy-authentication, represented as a string. **callback**\ (request: :ref:`SIPMessage`) -> None This method is called by the :ref:`SIPClient` when an INVITE or BYE request is received. This function then creates a :ref:`VoIPCall` or terminates it respectively. When a VoIPCall is created, it will then pass it to the *callCallback* function as an argument. If *callCallback* is set to None, this function replies as BUSY. **This function should not be called by the** :term:`user`. From df5237a83f658cb5d73991dce888a6948857f9ee Mon Sep 17 00:00:00 2001 From: Alexander Maassen Date: Mon, 15 Jan 2024 18:13:06 +0100 Subject: [PATCH 4/7] add proxy-authentication updates --- docs/SIP.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/SIP.rst b/docs/SIP.rst index b0fb78f..270bd8d 100644 --- a/docs/SIP.rst +++ b/docs/SIP.rst @@ -68,7 +68,7 @@ SIPClient The SIPClient class is used to communicate with the PBX/VoIP server. It is responsible for registering with the server, and receiving phone calls. -*class* SIP.\ **SIPClient**\ (server: str, port: int, username: str, password: str, myIP="0.0.0.0", myPort=5060, callCallback: Optional[Callable[[SIPMessage], None]] = None) +*class* SIP.\ **SIPClient**\ (server: str, port: int, username: str, password: str, myIP="0.0.0.0", myPort=5060, callCallback: Optional[Callable[[SIPMessage], None]] = None, auth_username: str) The *server* argument is your PBX/VoIP server's IP. The *port* argument is your PBX/VoIP server's port. @@ -83,6 +83,8 @@ The SIPClient class is used to communicate with the PBX/VoIP server. It is resp The *callCallback* argument is the callback function for :ref:`VoIPPhone`. VoIPPhone will process the SIP request, and perform the appropriate actions. + The *auth_username* argument is the optional username for proxy-authentication, represented as a string. + **recv**\ () -> None This method is called by SIPClient.start() and is responsible for receiving and parsing through SIP requests. **This should not be called by the** :term:`user`. From c7b22a37c5e9580e0a6242de016d345ebe1b3401 Mon Sep 17 00:00:00 2001 From: Alexander Maassen Date: Mon, 15 Jan 2024 18:38:28 +0100 Subject: [PATCH 5/7] make black happy --- pyVoIP/SIP.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pyVoIP/SIP.py b/pyVoIP/SIP.py index 4db8bb6..cfd3633 100644 --- a/pyVoIP/SIP.py +++ b/pyVoIP/SIP.py @@ -1720,7 +1720,9 @@ def __deregister(self) -> bool: response = SIPMessage(resp) response = self.trying_timeout_check(response) - if response.status == SIPStatus(401) or response.status == SIPStatus(407): + if response.status == SIPStatus(401) or response.status == SIPStatus( + 407 + ): # 401 Unauthorized, likely due to being password protected. # 407 Proxy Authentication Required regRequest = self.genRegister(response, deregister=True) @@ -1731,8 +1733,10 @@ def __deregister(self) -> bool: if ready[0]: resp = self.s.recv(8192) response = SIPMessage(resp) - if response.status == SIPStatus(401) or response.status == SIPStatus(407): - # At this point, it's reasonable to assume that + if response.status == SIPStatus(401) or response.status == SIPStatus( + 407 + ): + # At this point, it's reasonable to assume that # this is caused by invalid credentials. debug("Unauthorized") raise InvalidAccountInfoError( @@ -1825,7 +1829,9 @@ def __register(self) -> bool: # with new urn:uuid or reply with expire 0 self._handle_bad_request() - if response.status == SIPStatus(401) or response.status == SIPStatus(407): + if response.status == SIPStatus(401) or response.status == SIPStatus( + 407 + ): # Unauthorized, likely due to being password protected. regRequest = self.genRegister(response) self.out.sendto( @@ -1836,7 +1842,9 @@ def __register(self) -> bool: resp = self.s.recv(8192) response = SIPMessage(resp) response = self.trying_timeout_check(response) - if response.status == SIPStatus(401) or response.status == SIPStatus(407): + if response.status == SIPStatus(401) or response.status == SIPStatus( + 407 + ): # At this point, it's reasonable to assume that # this is caused by invalid credentials. debug("=" * 50) From 10d7617ae74764b4d306fa0b3d9e50f7a87f9b4e Mon Sep 17 00:00:00 2001 From: Alexander Maassen Date: Mon, 15 Jan 2024 18:44:05 +0100 Subject: [PATCH 6/7] happy now? --- pyVoIP/SIP.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pyVoIP/SIP.py b/pyVoIP/SIP.py index cfd3633..bc0e93b 100644 --- a/pyVoIP/SIP.py +++ b/pyVoIP/SIP.py @@ -461,7 +461,11 @@ def parse_header(self, header: str, data: str) -> None: self.headers[header] = data.split(", ") elif header == "Content-Length": self.headers[header] = int(data) - elif header == "WWW-Authenticate" or header == "Authorization" or header == "Proxy-Authenticate": + elif ( + header == "WWW-Authenticate" + or header == "Authorization" + or header == "Proxy-Authenticate" + ): data = data.replace("Digest ", "") row_data = self.auth_match.findall(data) header_data = {} @@ -1733,10 +1737,10 @@ def __deregister(self) -> bool: if ready[0]: resp = self.s.recv(8192) response = SIPMessage(resp) - if response.status == SIPStatus(401) or response.status == SIPStatus( - 407 - ): - # At this point, it's reasonable to assume that + if response.status == SIPStatus( + 401 + ) or response.status == SIPStatus(407): + # At this point, it's reasonable to assume that # this is caused by invalid credentials. debug("Unauthorized") raise InvalidAccountInfoError( @@ -1842,9 +1846,9 @@ def __register(self) -> bool: resp = self.s.recv(8192) response = SIPMessage(resp) response = self.trying_timeout_check(response) - if response.status == SIPStatus(401) or response.status == SIPStatus( - 407 - ): + if response.status == SIPStatus( + 401 + ) or response.status == SIPStatus(407): # At this point, it's reasonable to assume that # this is caused by invalid credentials. debug("=" * 50) From 2a041dc360e3ed4f87eedebebb1b822d9c55dde8 Mon Sep 17 00:00:00 2001 From: "HANSALIE-SIMSYN\\Hansalie" Date: Mon, 23 Jun 2025 15:13:57 +0530 Subject: [PATCH 7/7] Add bot integration to pyvoip --- audio.wav | Bin 0 -> 34644 bytes pyVoIP/audio.wav | Bin 0 -> 34644 bytes pyVoIP/audio_handler.py | 216 ++++++++++++++++++++++++++++++++++++ pyVoIP/openai_utils.py | 113 +++++++++++++++++++ pyVoIP/speech_processing.py | 73 ++++++++++++ test-bot-audio.py | 95 ++++++++++++++++ test-bot.py | 66 +++++++++++ 7 files changed, 563 insertions(+) create mode 100644 audio.wav create mode 100644 pyVoIP/audio.wav create mode 100644 pyVoIP/audio_handler.py create mode 100644 pyVoIP/openai_utils.py create mode 100644 pyVoIP/speech_processing.py create mode 100644 test-bot-audio.py create mode 100644 test-bot.py diff --git a/audio.wav b/audio.wav new file mode 100644 index 0000000000000000000000000000000000000000..58bf3be4c46245f16f604ef08c371c663c3f3735 GIT binary patch literal 34644 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 literal 0 HcmV?d00001 diff --git a/pyVoIP/audio.wav b/pyVoIP/audio.wav new file mode 100644 index 0000000000000000000000000000000000000000..58bf3be4c46245f16f604ef08c371c663c3f3735 GIT binary patch literal 34644 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 literal 0 HcmV?d00001 diff --git a/pyVoIP/audio_handler.py b/pyVoIP/audio_handler.py new file mode 100644 index 0000000..5989ab4 --- /dev/null +++ b/pyVoIP/audio_handler.py @@ -0,0 +1,216 @@ +from asyncio import InvalidStateError +import logging +import time +import wave +import os +import io +import numpy as np +from collections import deque +from pyVoIP.VoIP import CallState +from pyVoIP.speech_processing import text_to_speech +from pyVoIP.openai_utils import transcribe_with_whisper, get_openai_gpt_response, extract_lead_info +from pydub import AudioSegment +import threading +# import webrtcvad + +# vad = webrtcvad.Vad() +# vad.set_mode(2) # Try 0–3 (0 = lenient, 3 = strict). Start with 2 as a balanced mode. + +# Setup logging format +logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(message)s', datefmt='%H:%M:%S') + +conversation_history = "" +conversation_context = [] + +is_playing = False +silence_threshold = 1.0 +max_silence_threshold = 10.0 +last_speech_time = time.time() +buffer_audio = b"" + +energy_levels = deque(maxlen=20) +DYNAMIC_NOISE_MARGIN = 0.9 + +def update_playing_status(duration): + global is_playing + time.sleep(duration) + is_playing = False + +# def save_audio_to_wav(audio_data, filename): +# try: +# logging.info(f"Start saving audio to {filename}") +# with wave.open(filename, 'wb') as wf: +# wf.setnchannels(1) +# wf.setsampwidth(1) +# wf.setframerate(8000) +# wf.writeframes(audio_data) + +# padding_duration = 2000 # 2s +# original = AudioSegment.from_wav(filename) +# padded = original + AudioSegment.silent(duration=padding_duration) +# padded.export(filename, format="wav") +# logging.info(f"Audio saved successfully to {filename}") +# except Exception as e: +# logging.error(f"Error saving audio: {e}") + + +def get_wav_bytes(audio_data, sample_width=2, frame_rate=8000, channels=1): + wav_io = io.BytesIO() + with wave.open(wav_io, 'wb') as wf: + wf.setnchannels(channels) + wf.setsampwidth(sample_width) + wf.setframerate(frame_rate) + wf.writeframes(audio_data) + wav_io.seek(0) + return wav_io + + +def threaded_play_audio(call, audio_data): + global is_playing + is_playing = True + try: + logging.info("Playback started (threaded)") + playback_index = 0 + while call.state == CallState.ANSWERED and playback_index < len(audio_data): + chunk = audio_data[playback_index:playback_index+160] + call.write_audio(chunk) + playback_index += 160 + time.sleep(0.02) + logging.info("Playback finished") + except Exception as e: + logging.error(f"Playback error: {e}") + finally: + is_playing = False + +def is_speech(audio_bytes): + audio_array = np.frombuffer(audio_bytes, dtype=np.int16) + energy = np.mean(audio_array ** 2) if len(audio_array) > 0 else 0 + energy_levels.append(energy) + if len(energy_levels) == energy_levels.maxlen: + baseline = np.median(energy_levels) + threshold = baseline * DYNAMIC_NOISE_MARGIN + return energy > threshold + return False + +# def vad_is_speech(audio_bytes, sample_rate=8000): +# # WebRTC VAD requires mono, 16-bit PCM, and 10/20/30ms frames +# # You must make sure `audio_bytes` is 16-bit PCM +# try: +# return vad.is_speech(audio_bytes, sample_rate) +# except Exception as e: +# logging.warning(f"VAD error: {e}") +# return False + + +def log_interaction(session_id, user_text, bot_response): + log_dir = f"./output/{session_id}" + os.makedirs(log_dir, exist_ok=True) + with open(f"{log_dir}/conversation.txt", "a") as f: + f.write(f"User: {user_text}\n") + f.write(f"Bot: {bot_response}\n\n") + +def answer(call): + global is_playing, buffer_audio, last_speech_time, conversation_history, conversation_context + + try: + logging.info("Call answered") + call.answer() + + session_id = time.strftime("%Y%m%d_%H%M%S") + chunk_index = 1 + idle_count = 0 + voice_detected = False + # silence_start_time = None + conversation_context.clear() + conversation_history = "" + + greeting = "Hi! This is UniBot from UniConnect. I'm here to tell you about a special loan offer. Would you like to hear more?" + # play_audio(call, text_to_speech(greeting)) + threading.Thread(target=threaded_play_audio, args=(call, text_to_speech(greeting))).start() + last_speech_time = time.time() + + while call.state == CallState.ANSWERED: + try: + incoming_audio = call.read_audio(length=160, blocking=True) + buffer_audio += incoming_audio + + if is_speech(incoming_audio): + #if vad_is_speech(incoming_audio): + + # logging.info("Voice detected") + if not voice_detected: + logging.info("Speech started") + # silence_start_time = None + voice_detected = True + last_speech_time = time.time() + # else: + # logging.info("Silence detected") + + if time.time() - last_speech_time > silence_threshold and voice_detected and not is_playing: + silence_duration = time.time() - last_speech_time + logging.info(f"Silence detected for {silence_duration:.2f}s. Beginning processing") + + # output_dir = "./output" + # if not os.path.exists(output_dir): + # os.makedirs(output_dir) + + #chunk_filename = f"./output/speech_chunk_{session_id}_{chunk_index}.wav" + #save_audio_to_wav(buffer_audio, chunk_filename) + audio_wav=get_wav_bytes(buffer_audio) + + logging.info("Transcription started") + text = transcribe_with_whisper(audio_wav) + logging.info("Transcription completed") + + if text: + logging.info(f"User said: {text}") + conversation_history += f"User: {text}\n" + conversation_context.append({"role": "user", "content": text}) + + logging.info("Generating bot response") + response = get_openai_gpt_response(text, conversation_context) + logging.info(f"Bot: {response}") + conversation_context.append({"role": "assistant", "content": response}) + conversation_history += f"Bot: {response}\n" + + log_interaction(session_id, text, response) + + # play_audio(call, text_to_speech(response)) + threading.Thread(target=threaded_play_audio, args=(call, text_to_speech(response))).start() + last_speech_time = time.time() + buffer_audio = b"" + voice_detected = False + chunk_index += 1 + + if any(word in text.lower() for word in ["loan", "apply", "yes", "need", "interested", "buy", "want", "help"]): + lead_json = extract_lead_info(conversation_context) + logging.info(f"Lead info: {lead_json}") + else: + idle_count += 1 + logging.info(f"Could not understand. Idle count: {idle_count}") + if idle_count > 3: + # play_audio(call, text_to_speech("Sorry, I didn't get that. Could you say that again?")) + threading.Thread(target=threaded_play_audio, args=(call, text_to_speech("Sorry, I didn't get that. Could you say that again?"))).start() + last_speech_time = time.time() + idle_count = 0 + + elif time.time() - last_speech_time > max_silence_threshold and idle_count > 0: + logging.info("Extended silence detected. Sending wakeup message") + last_speech_time = time.time() + idle_count = 0 + buffer_audio = b"" + voice_detected = False + # play_audio(call, text_to_speech("Still there? If you're interested in a loan or need help, just say so!")) + threading.Thread(target=threaded_play_audio, args=(call, text_to_speech("Still there? If you're interested in a loan or need help, just say so!"))).start() + last_speech_time = time.time() + + except Exception as e: + logging.error(f"Call stream error: {e}") + + call.hangup() + logging.info("Call ended") + + except Exception as e: + logging.error(f"Fatal error: {e}") + call.hangup() + diff --git a/pyVoIP/openai_utils.py b/pyVoIP/openai_utils.py new file mode 100644 index 0000000..f3911bb --- /dev/null +++ b/pyVoIP/openai_utils.py @@ -0,0 +1,113 @@ +import openai +import logging +from pyVoIP.config import OPENAI_KEY +openai.api_key = OPENAI_KEY # or use os.getenv("OPENAI_API_KEY") + +# Loan-focused system prompt +SYSTEM_PROMPT = """ +You are UniBot, a helpful and friendly call center agent for a company offering a loan product. + +Your goals are: +1. Correct any misheard or mis-transcribed words in the user's message. For example: + - "load", "lone", "loaf", or "loam" → "loan" + - "apple" → "apply" + - "interested in a lone" → "interested in a loan" + Silently fix these before responding. +2. Only talk about the loan product. If the user says something completely unrelated or confusing, politely steer the conversation back to the loan offer. +3. Engage the user in a friendly, conversational tone. +4. If the user says "yes" or shows interest, clearly explain the loan offer. +5. Gather their name and phone number politely if they are interested. +6. If the user is hesitant, gently encourage them by explaining a benefit (e.g., low interest rate). +7. Keep responses short and to the point. + +IMPORTANT: +- Do not answer questions or respond to topics outside the loan offer. +- Do not generate random or irrelevant replies. +- Stay focused on promoting the loan and helping the customer with it. +""" + + +# Common mis-transcriptions that we want to fix +COMMON_CORRECTIONS = { + "load": "loan", + "lone": "loan", + "loam": "loan", + "loaf": "loan", +} + +def transcribe_with_whisper(audio_file_path): + with open(audio_file_path, "rb") as audio_file: + transcript = openai.audio.transcriptions.create( + model="whisper-1", + file=audio_file, + response_format="text", + language="en" + ) + return transcript.strip() + +def correct_transcription(text: str) -> str: + words = text.split() + corrected_words = [COMMON_CORRECTIONS.get(word.lower(), word) for word in words] + return " ".join(corrected_words) + +def get_openai_gpt_response(user_text: str, history: list) -> str: + corrected_text = correct_transcription(user_text) + + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages.extend(history) + messages.append({"role": "user", "content": corrected_text}) + + # logging.info("Conversation so far: %s", messages) + + try: + response = openai.chat.completions.create( + model="gpt-4", + messages=messages, + temperature=0.7, + max_tokens=200, + ) + bot_message = response.choices[0].message.content + # logging.info("Bot response: %s", bot_message) + return bot_message + except Exception as e: + logging.error("OpenAI API error: %s", str(e)) + return "I'm sorry, something went wrong." + + +def extract_lead_info(conversation: list) -> dict: + """ + After a few turns, extract the lead details from the full conversation. + Returns a dict with name, phone, and interest level. + """ + try: + prompt = """ +You are an AI assistant helping to capture lead details from a customer interaction. +From the following conversation, extract: +- Name (if given) +- Phone number (if given) +- Whether the user is interested in the loan + +Respond with a JSON object like: +{ "name": ..., "phone": ..., "interest": ... } + +Conversation: +""" + "\n".join( + [f"{msg['role'].capitalize()}: {msg['content']}" for msg in conversation] + ) + + response = openai.chat.completions.create( + model="gpt-4", + messages=[ + {"role": "system", "content": "You are an assistant that extracts structured lead data from call transcripts."}, + {"role": "user", "content": prompt}, + ], + temperature=0, + max_tokens=150, + ) + + extracted = response.choices[0].message.content + # logging.info("Extracted lead: %s", extracted) + return eval(extracted) # If you're concerned about `eval`, use `json.loads` after validating format + except Exception as e: + logging.error("Failed to extract lead info: %s", str(e)) + return {"name": None, "phone": None, "interest": None} diff --git a/pyVoIP/speech_processing.py b/pyVoIP/speech_processing.py new file mode 100644 index 0000000..572cecd --- /dev/null +++ b/pyVoIP/speech_processing.py @@ -0,0 +1,73 @@ +import logging +import speech_recognition as sr +from gtts import gTTS +import io +import os +import wave +from pydub import AudioSegment + +os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "./voic-bot-demo-080f5ddb64e6.json" + +def speech_to_text(wav_file_path): + """Converts incoming raw audio to text.""" + recognizer = sr.Recognizer() + # Load the WAV file + with sr.AudioFile(wav_file_path) as source: + audio = recognizer.record(source) # Record the audio from the file + + try: + # Try to recognize speech in the audio using Google's speech recognition API + text = recognizer.recognize_google(audio) + logging.info(f"Recognized Speech: {text}") + return text + except sr.UnknownValueError: + logging.warning("Speech Recognition could not understand the audio.") + return "" + except sr.RequestError as e: + logging.error(f"Speech Recognition API error: {e}") + return "" + +def text_to_speech(text): + """Converts text response to speech audio.""" + tts = gTTS(text=text, lang="en") + audio_stream = io.BytesIO() + tts.write_to_fp(audio_stream) + audio_stream.seek(0) + + return convert_mp3_to_wav(audio_stream) + +def convert_mp3_to_wav(mp3_stream): + """Converts MP3 audio to WAV format.""" + import pydub + audio = pydub.AudioSegment.from_file(mp3_stream, format="mp3") + audio = audio.set_channels(1).set_frame_rate(8000).set_sample_width(1) + wav_stream = io.BytesIO() + audio.export(wav_stream, format="wav") + wav_stream.seek(0) + + return wav_stream.read() + + +# def trim_silence(raw_audio_bytes, silence_thresh=-40, min_silence_len=200): +# from pydub import AudioSegment +# from pydub.silence import detect_nonsilent + +# audio_segment = AudioSegment( +# data=raw_audio_bytes, +# sample_width=2, +# frame_rate=8000, +# channels=1 +# ) + +# nonsilent_ranges = detect_nonsilent(audio_segment, +# min_silence_len=min_silence_len, +# silence_thresh=silence_thresh) + +# if not nonsilent_ranges: +# return raw_audio_bytes + +# start, end = nonsilent_ranges[0][0], nonsilent_ranges[-1][1] +# trimmed = audio_segment[start:end] +# return trimmed.raw_data + + diff --git a/test-bot-audio.py b/test-bot-audio.py new file mode 100644 index 0000000..396ce60 --- /dev/null +++ b/test-bot-audio.py @@ -0,0 +1,95 @@ +import logging +from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState +import time +import wave +import socket +import pyVoIP + +# Configure logging +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') + +pyVoIP.DEBUG = True + +# Your SIP credentials +SIP_EXTENSION = '2082' +SIP_USERNAME = 'EpheF2GMjn' +SIP_PASSWORD = 'eFQvv0LnZl' +SIP_SERVER = '15.207.103.137' +SIP_PORT = 5060 + +# Your local and public IPs +def get_local_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + return "127.0.0.1" + finally: + s.close() + +def get_free_port(start_port, end_port): + for port in range(start_port, end_port + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + # Set socket options to avoid address already in use error + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(('0.0.0.0', port)) + # If binding succeeds, return the available port + return port + except socket.error: + # If the port is already in use, try the next one + continue + raise Exception("No free port found in the given range.") + +def answer(call): + try: + f = wave.open('audio.wav', 'rb') + frames = f.getnframes() + data = f.readframes(frames) + f.close() + + call.answer() + call.write_audio(data) # This writes the audio data to the transmit buffer, this must be bytes. + + stop = time.time() + (frames / 8000) # frames/8000 is the length of the audio in seconds. 8000 is the hertz of PCMU. + + while time.time() <= stop and call.state == CallState.ANSWERED: + time.sleep(0.1) + call.hangup() + + except InvalidStateError as e: + logging.warning(f"Invalid state error encountered: {e}") + except Exception as e: + logging.error(f"Unexpected error in answer function: {e}") + finally: + if call.state != CallState.ENDED: + logging.info("Hanging up the call explicitly.") + call.hangup() + +def start_sip_phone(): + try: + logging.info("Initializing VoIPPhone...") + phone = VoIPPhone( + SIP_SERVER, SIP_PORT, SIP_EXTENSION, SIP_PASSWORD, + myIP=get_local_ip(), sipPort=get_free_port(5061,5070), auth_username=SIP_USERNAME, callCallback=answer + ) + phone.start() + logging.info("Phone started successfully.") + return phone + except Exception as e: + logging.critical(f"Fatal error: {e}") + return None + + +# Instantiate and start the VoIP phone +if __name__ == "__main__": + phone = start_sip_phone() + + if phone: + input("Press enter to disable the phone...") + phone.stop() + time.sleep(3) + logging.info("Phone stopped successfully.") + + diff --git a/test-bot.py b/test-bot.py new file mode 100644 index 0000000..2dd1680 --- /dev/null +++ b/test-bot.py @@ -0,0 +1,66 @@ +import logging +from pyVoIP.VoIP import VoIPPhone, InvalidStateError, CallState +import time +import wave +import socket +import pyVoIP +from pyVoIP.audio_handler import answer +from pyVoIP.config import SIP_SERVER, SIP_PORT, SIP_EXTENSION, SIP_PASSWORD, SIP_USERNAME + +# Configure logging +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') + +pyVoIP.DEBUG = True + +# Your local and public IPs +def get_local_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + return "127.0.0.1" + finally: + s.close() + +def get_free_port(start_port, end_port): + for port in range(start_port, end_port + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + # Set socket options to avoid address already in use error + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(('0.0.0.0', port)) + # If binding succeeds, return the available port + return port + except socket.error: + # If the port is already in use, try the next one + continue + raise Exception("No free port found in the given range.") + + +def start_sip_phone(): + try: + logging.info("Initializing VoIPPhone...") + phone = VoIPPhone( + SIP_SERVER, SIP_PORT, SIP_EXTENSION, SIP_PASSWORD, + myIP=get_local_ip(), sipPort=get_free_port(5061,5070), auth_username=SIP_USERNAME, callCallback=answer + ) + phone.start() + logging.info("Phone started successfully.") + return phone + except Exception as e: + logging.critical(f"Fatal error: {e}") + return None + + +# Instantiate and start the VoIP phone +if __name__ == "__main__": + phone = start_sip_phone() + + if phone: + input("Press enter to disable the phone...") + phone.stop() + time.sleep(3) + logging.info("Phone stopped successfully.") + +