Repository: moha-abdi/PySIP (language: python)

Issue:
Add Call Transfer Capability



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 804f3fa..afb8a73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,6 @@ secrets.ini
 *.flac
 *.txt
 *.log
-premaster.txt
 
 # Byte-compiled / optimized / DLL files
 __pycache__/
diff --git a/PySIP/__init__.py b/PySIP/__init__.py
index 09c4e63..a49c8ff 100644
--- a/PySIP/__init__.py
+++ b/PySIP/__init__.py
@@ -16,20 +16,12 @@
 #
 # For more information and usage examples, visit the
 # PySIP GitHub repository: https://github.com/moha-abdi/pysip
+import logging
+from .utils.logger import logger, console_handler
 
-__all__ = [
-    'client',
-    'filters',
-    'call',
-    'rtp'
-]
-
-__version__ = "1.5.0"
+__version__ = "1.6.0"
 __license__ = "MIT License"
 __copyright__ = "Copyright (C) 2023-present Moha"
 
-DEBUG = True
 
-def _print_debug_info(*args, **kwargs):
-    if DEBUG:
-        print(args, kwargs)
+console_handler.setLevel(logging.DEBUG) 
diff --git a/PySIP/amd/amd.py b/PySIP/amd/amd.py
index bf4312e..18a9f93 100644
--- a/PySIP/amd/amd.py
+++ b/PySIP/amd/amd.py
@@ -2,12 +2,13 @@
 import audioop
 from dataclasses import dataclass
 from enum import Enum, auto
+import logging
 import time
-import janus
 import numpy as np
 
-from ..filters import PayloadType
+from ..codecs.codec_info import CodecInfo
 from .silence_detection import SilenceDetection
+from ..utils.logger import logger
 
 
 @dataclass
@@ -51,7 +52,6 @@ def __init__(self) -> None:
         self.amd_started = asyncio.Event()
         self.DEFAULT_SAMPLES_PER_MS = 8000 / 1000
         self.amd_start_time = time.monotonic_ns() / 1e6
-        self.buffer = np.array([], np.uint8)
         self.amd_status = AmdStatus.NOTSURE
         self.amd_state = AmdState.WORD
         self.audio_frame_count = 0
@@ -61,27 +61,31 @@ def __init__(self) -> None:
         self.consecutive_voice_duration = 0
         self.voice_duration = 0
         self.words_count = 0
-        self.frame_queue = janus.Queue()
         self.total_time_ms = 0
         self.silence_detector = SilenceDetection(self.settings.silence_threshold)
         self.in_initial_silence = True
         self.in_greeting = False
 
-    async def run_detector(self):
-        # print("Waiting for app to start")
-        # await self.amd_started.wait()
-        print("App started")
+    async def run_detector(self, input_q: asyncio.Queue, _callbacks):
+        await self.amd_started.wait()
+        self.amd_start_time = time.monotonic_ns() / 1e6
+        logger.log(logging.DEBUG, "AMD app started")
         while True:
             try:
                 start_time = time.monotonic_ns() / 1e6
-                f = await asyncio.wait_for(
-                    self.frame_queue.async_q.get(), self.max_wait_time_for_frame * 2
+                data = await asyncio.wait_for(
+                    input_q.get(), self.max_wait_time_for_frame * 2
                 )
                 end_time = time.monotonic_ns() / 1e6
 
                 # figure out how much we waited
                 res = end_time - start_time
-                ms = (2 * self.max_wait_time_for_frame) - res
+                ms = int((2 * self.max_wait_time_for_frame) - res)
+                # if we fail to read in a frame it means call hanged up
+                if data is None:
+                    logger.log(logging.INFO, "Amd Stopped. REASON: Call Hanged Up")
+                    self.amd_status = AmdStatus.HANGUP
+                    break
 
                 current_time = time.monotonic_ns() / 1e6
                 if (
@@ -89,187 +93,171 @@ async def run_detector(self):
                 ) > self.settings.total_analysis_time:
                     self.amd_status = AmdStatus.NOTSURE
                     if self.audio_frame_count == 0:
-                        print("STOPPED DUE TO NOAUDIO DATA")
+                        logger.log(logging.DEBUG, "Amd Stopped. REASON: No audio data")
                         break
                     else:
-                        print("NOT SURE DOUT LONGTIME OR NOAUDIO DATA")
-                        print("time was: ", current_time - self.amd_start_time)
+                        logger.log(logging.DEBUG, "Amd Stopped. REASON: NO audio data or long time")
                         break
 
-                if f.payload_type in [
-                    PayloadType.PCMA,
-                    PayloadType.PCMU,
-                    PayloadType.CN,
-                ]:
-                    self.audio_frame_count += 1
-                    # convert to PCM for consistency
-
-                    data_array = np.array([], np.int16)
-                    if f.payload_type == PayloadType.PCMU:
-                        data = audioop.ulaw2lin(f.payload, 1)
-                        data = audioop.bias(data, 2, 0)
-                        data_array = np.frombuffer(data, np.int16)
-                        self.buffer = np.concatenate((self.buffer, data_array))
-                        self.frame_length = data_array.size / self.DEFAULT_SAMPLES_PER_MS
-
-                    elif f.payload_type == PayloadType.PCMA:
-                        data = audioop.alaw2lin(f.payload, 1)
-                        data = audioop.bias(data, 2, 0)
-                        data_array = np.frombuffer(data, np.int16)
-                        self.buffer = np.concatenate((self.buffer, data_array))
-                        self.frame_length = data_array.size / self.DEFAULT_SAMPLES_PER_MS
+                
+                self.audio_frame_count += 1
+                # convert to PCM for consistency
+                
+                data = audioop.bias(data, 2, 0)
+                data_array = np.array([], np.int16)
+                data_array = np.frombuffer(data, np.int16)
+                self.frame_length = data_array.size / self.DEFAULT_SAMPLES_PER_MS 
+                self.total_time_ms += self.frame_length
+
+                # if total time exceeds the total analysis time then give up and stop
+                if self.total_time_ms >= self.settings.total_analysis_time:
+                    self.amd_status = AmdStatus.NOTSURE
+                    logger.log(logging.DEBUG, "Amd Stopped. REASON: Too Long time")
+                    break
+
+                # feed the frames to the silence detector
+                loop = asyncio.get_event_loop()
+                self.dspsilence = await loop.run_in_executor(
+                      None,
+                      self.silence_detector.detect_silence,
+                      data_array
+                )
 
-                    else:
-                        self.frame_length = ms
+                if self.dspsilence > 0:
+                    self.silence_duration = self.dspsilence
 
-                    self.total_time_ms += self.frame_length
+                    # first check
+                    if self.silence_duration >= self.settings.between_words_silence:
+                        if self.amd_state != AmdState.SILENCE:
+                            logger.log(logging.DEBUG, "AmdState Changed the state to SILENCE")
 
-                    # if total time exceeds the total analysis time then give up and stop
-                    if self.total_time_ms >= self.settings.total_analysis_time:
-                        print("THe time is: ", self.total_time_ms)
-                        self.amd_status = AmdStatus.NOTSURE
-                        print("STOPPED DUE TO TOOLONG TIME")
+                        # find words less than minimum_word_length
+                        if (
+                            self.consecutive_voice_duration
+                            < self.settings.minimum_word_length
+                        ) and (self.consecutive_voice_duration) > 0:
+                            logger.log(
+                                logging.DEBUG,
+                                "Amd Got Short voice duration: {}".format(
+                                    self.consecutive_voice_duration
+                                )
+                            )
+                        self.amd_state = AmdState.SILENCE
+                        self.consecutive_voice_duration = 0
+
+                    # second check
+                    if (
+                        self.in_initial_silence
+                        and self.silence_duration >= self.settings.initial_silence
+                    ):
+                        logger.log(
+                            logging.INFO,
+                            f"Ansering Machine Detected, silence_duration{self.silence_duration} -- initial_silence {self.in_initial_silence}"
+                        )
+                        self.amd_status = AmdStatus.MACHINE
                         break
 
-                    # feed the frames to the silence detector
-                    if f.payload_type not in [PayloadType.PCMA, PayloadType.PCMU]:
-                        self.dspsilence += ms
-
-                    else:
-                        self.dspsilence = self.silence_detector.detect_silence(
-                            data_array
+                    # third check
+                    if (
+                        self.silence_duration
+                        >= self.settings.after_greeting_silence
+                        and self.in_greeting
+                    ):
+                        logger.log(
+                            logging.INFO,
+                            f"Human Detected, after_greeting_silence {self.settings.after_greeting_silence} -- silence_duration {self.silence_duration}"
                         )
+                        self.amd_status = AmdStatus.HUMAN
+                        break
 
-                    if self.dspsilence > 0:
-                        self.silence_duration = self.dspsilence
-                        # print("The silence duration is: ", self.silence_duration)
-
-                        # first check
-                        if self.silence_duration >= self.settings.between_words_silence:
-                            if self.amd_state != AmdState.SILENCE:
-                                print("Changed the state to SILENCE")
-
-                            # find words less than minimum_word_length
-                            if (
+                else:
+                    self.consecutive_voice_duration += self.frame_length
+                    self.voice_duration += self.frame_length
+
+                    # If I have enough consecutive voice to say that I am in a Word,
+                    # I can only increment the number of words if my previous
+                    # state was Silence, which means that I moved into a word.
+                    if (
+                        self.consecutive_voice_duration
+                        >= self.settings.minimum_word_length
+                    ) and self.amd_state == AmdState.SILENCE:
+                        self.words_count += 1
+                        logger.log(logging.DEBUG, f"Amd Detected a Word -- words_count: {self.words_count}")
+                        self.amd_state = AmdState.WORD
+
+                    if (
+                        self.consecutive_voice_duration
+                        >= self.settings.maximum_word_length
+                    ):
+                        logger.log(
+                            logging.DEBUG,
+                            "Amd Detected Maximum word length: {}".format(
                                 self.consecutive_voice_duration
-                                < self.settings.minimum_word_length
-                            ) and (self.consecutive_voice_duration) > 0:
-                                print(
-                                    "Short voice duration: ",
-                                    self.consecutive_voice_duration,
-                                )
-                            self.amd_state = AmdState.SILENCE
-                            self.consecutive_voice_duration = 0
-
-                        # second check
-                        if (
-                            self.in_initial_silence
-                            and self.silence_duration >= self.settings.initial_silence
-                        ):
-                            print(
-                                f"Ansering MACHINE, silence_duration{self.silence_duration} ; initial_silence {self.in_initial_silence}"
                             )
-                            self.amd_status = AmdStatus.MACHINE
-                            break
-
-                        # third check
-                        if (
-                            self.silence_duration
-                            >= self.settings.after_greeting_silence
-                            and self.in_greeting
-                        ):
-                            print(
-                                f"Human, after_greeting_silence {self.settings.after_greeting_silence} ; silence_duration {self.silence_duration}"
-                            )
-                            self.amd_status = AmdStatus.HUMAN
-                            break
-
-                    else:
-                        self.consecutive_voice_duration += self.frame_length
-                        self.voice_duration += self.frame_length
-
-                        # If I have enough consecutive voice to say that I am in a Word,
-                        # I can only increment the number of words if my previous
-                        # state was Silence, which means that I moved into a word.
-                        if (
-                            self.consecutive_voice_duration
-                            >= self.settings.minimum_word_length
-                        ) and self.amd_state == AmdState.SILENCE:
-                            self.words_count += 1
-                            print("Word detected: words_count: ", self.words_count)
-                            self.amd_state = AmdState.WORD
+                        )
+                        self.amd_status = AmdStatus.MACHINE
+                        break
 
-                        if (
-                            self.consecutive_voice_duration
-                            >= self.settings.maximum_word_length
-                        ):
-                            print(
-                                "Maximum word length detected: ",
-                                self.consecutive_voice_duration,
-                            )
-                            self.amd_status = AmdStatus.MACHINE
-                            break
+                    if self.words_count > self.settings.maximum_number_of_words:
+                        logger.log(
+                            logging.INFO,
+                            f"Answring Machine Detected -- Max num of maximum_number_of_words: {self.words_count}"
+                        )
+                        self.amd_status = AmdStatus.MACHINE
+                        break
 
-                        if self.words_count > self.settings.maximum_number_of_words:
-                            print(
-                                "Answring machine ; Max num of maximum_number_of_words: ",
-                                self.words_count,
+                    if (
+                        self.in_greeting
+                        and self.voice_duration >= self.settings.greeting
+                    ):
+                        logger.log(
+                            logging.INFO,
+                            "Answring Machine Detected -- voice_duration {} -- greeting: {}".format(
+                                self.voice_duration, self.settings.greeting
                             )
-                            self.amd_status = AmdStatus.MACHINE
-                            break
+                        )
+                        self.amd_status = AmdStatus.MACHINE
+                        break
 
-                        if (
-                            self.in_greeting
-                            and self.voice_duration >= self.settings.greeting
-                        ):
-                            print(
-                                "Answring machine; voice_duration {} ;greeting: {}".format(
-                                    self.voice_duration, self.settings.greeting
+                    if self.voice_duration >= self.settings.minimum_word_length:
+                        if self.silence_duration > 0:
+                            logger.log(
+                                logging.DEBUG,
+                                "Amd Detected Talk -- Previous silence_duration: {}".format(
+                                    self.silence_duration
                                 )
                             )
-                            self.amd_status = AmdStatus.MACHINE
-                            break
-
-                        if self.voice_duration >= self.settings.minimum_word_length:
-                            if self.silence_duration > 0:
-                                print(
-                                    "Detected talk; Previous silence_duration",
-                                    self.silence_duration,
-                                )
-                            self.silence_duration = 0
-
-                        if (
-                            self.consecutive_voice_duration
-                            >= self.settings.minimum_word_length
-                            and not self.in_greeting
-                        ):
-                            # Only go in here once to change the greeting flag
-                            # when we detect the 1st word
-                            if self.silence_duration > 0:
-                                print(
-                                    "Before greeting time, silence duration {} ; voice_duration: {}".format(
-                                        self.silence_duration, self.voice_duration
-                                    )
+                        self.silence_duration = 0
+
+                    if (
+                        self.consecutive_voice_duration
+                        >= self.settings.minimum_word_length
+                        and not self.in_greeting
+                    ):
+                        # Only go in here once to change the greeting flag
+                        # when we detect the 1st word
+                        if self.silence_duration > 0:
+                            logger.log(
+                                logging.DEBUG,
+                                "Amd Before greeting time, silence duration {} -- voice_duration: {}".format(
+                                    self.silence_duration, self.voice_duration
                                 )
+                            )
 
-                            self.in_initial_silence = False
-                            self.in_greeting = True
-
-                else:
-                    self.total_time_ms += ms
-
-                    if self.total_time_ms >= self.settings.total_analysis_time:
-                        self.amd_status = AmdStatus.NOTSURE
-                        print("Stopped due to NOTSURE")
-                        break
+                        self.in_initial_silence = False
+                        self.in_greeting = True 
 
             except asyncio.TimeoutError:
                 # if It took too long to get a frame back. Giving up.
-                print("Couldnt read the frame")
+                logger.log(logging.WARNING, "Couldnt read the frame from the input queue")
                 self.amd_status = AmdStatus.NOTSURE
                 break
 
-        print("Reason for stopping is: ", self.amd_status)
+            finally:
+                await asyncio.sleep(0)
+                pass
 
-    def update_packet(self, packet):
-        self.frame_queue.sync_q.put(packet)
+        logger.log(logging.INFO, f"Amd Stopped. REASON: {self.amd_status}")
+        # Notify the callbacks
+        await asyncio.gather(*[_cb(self.amd_status) for _cb in _callbacks])
+ 
diff --git a/PySIP/audio_stream.py b/PySIP/audio_stream.py
new file mode 100644
index 0000000..bc04805
--- /dev/null
+++ b/PySIP/audio_stream.py
@@ -0,0 +1,47 @@
+import logging
+from .utils.logger import logger
+from wave import Wave_read
+import asyncio
+import uuid
+
+
+class AudioStream(Wave_read):
+    def __init__(self, f) -> None:
+        self.stream_done_future: asyncio.Future = asyncio.Future()
+        self.stream_id = str(uuid.uuid4())
+        super().__init__(f)
+
+        self.audio_length = self.getnframes() / float(self.getframerate())
+        self.input_q: asyncio.Queue = asyncio.Queue()
+
+    async def recv(self):
+        logger.log(logging.DEBUG, f"Started stream now - id ({self.stream_id})")
+        while True:
+            await asyncio.sleep(0)
+
+            frame = self.readframes(160) # 80 not 160 so that it can fit 160 samples when encoded
+            if not frame:
+                logger.log(logging.DEBUG, "Done preparing all frames in AudioStream")
+                # put None to the q to indicate end of stream
+                await self.input_q.put(None)
+                break
+            await self.input_q.put(frame)
+
+    @property
+    def audio_length(self):
+        """The audio_length property."""
+        return self._audio_length
+
+    @audio_length.setter
+    def audio_length(self, value):
+        self._audio_length = value 
+
+    def stream_done(self):
+        if not self.stream_done_future.done():
+            self.stream_done_future.set_result("Sream Sent")
+
+    async def wait_finished(self):
+        """Wait for the current stream to be fully sent"""
+        await self.stream_done_future
+
+
diff --git a/PySIP/call_handler.py b/PySIP/call_handler.py
index b18b150..f738a61 100644
--- a/PySIP/call_handler.py
+++ b/PySIP/call_handler.py
@@ -1,65 +1,92 @@
 import asyncio
-import traceback
-from typing import List
-from wave import Wave_read
+import logging
+from typing import List, Optional
 
 from pydub import AudioSegment
 from scipy.io.wavfile import io
 
-from PySIP import _print_debug_info
 from .filters import CallState
-from .CustomCommuicate import CommWithPauses
+from .audio_stream import AudioStream
 from .utils.async_utils import wait_for
+from .utils.logger import logger
+from .utils.edge_tts_utils import CommWithPauses
 from .exceptions import SIPTransferException
 
 
 class CallHandler:
     def __init__(self, call) -> None:
         self.call = call
-        self.audio_queue = asyncio.Queue()
-        self.previous_stream: AudioStream = None
+        self.audio_queue: asyncio.Queue = asyncio.Queue()
+        self.previous_stream: Optional[AudioStream] = None
+        self._voice = "en-GB-SoniaNeural"
 
     async def say(self, text: str):
-        if not asyncio.current_task() in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-
         try:
-            self.audio_bytes = await CommWithPauses(text=text).generate_audio(text=text)
-            self.audio_stream = AudioStream(self.audio_bytes, self)
-            await self.audio_queue.put(("audio", self.audio_stream))
-            return self.audio_stream
+            _audio_task = asyncio.create_task(
+                CommWithPauses(text, self._voice).generate_audio(text=text)
+            )
+            app_stopped_task = asyncio.create_task(self.call._wait_stopped())
+
+            # Wait for either audio processing task to complete or the application to stop
+            done, pending = await asyncio.wait(
+                [_audio_task, app_stopped_task], return_when=asyncio.FIRST_COMPLETED
+            )
+
+            # If the audio processing task is completed, retrieve the audio bytes
+            if _audio_task in done:
+                for _t in pending:
+                    _t.cancel()
+                self.audio_bytes = await _audio_task
+                self.audio_stream = AudioStream(self.audio_bytes)
+                await self.audio_stream.recv()
+                await self.audio_queue.put(("audio", self.audio_stream))
+                return self.audio_stream
+
+            # If the application stops before audio processing completes
+            elif app_stopped_task in done:
+                for _t in pending:
+                    _t.cancel()
+                logger.log(
+                    logging.WARNING,
+                    "Application stopped before audio processing completed.",
+                )
+                raise RuntimeError("App is no longer running")
+
+            else:
+                raise RuntimeError("Unable to generate audio due to unknow error")
 
         except Exception as e:
-            _print_debug_info("Error in say: ", e)
+            logger.log(logging.ERROR, "Error in say: %s", e)
+            raise e
 
-    async def play(self, file_name: str, format: str = 'wav'):
+    async def play(self, file_name: str, format: str = "wav"):
         """Simple method to play an audio in call"""
-        if not asyncio.current_task() in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-
         temp_file = io.BytesIO()
         try:
-            decoded_chunk: AudioSegment = AudioSegment.from_file(file_name, format=format)
-        except Exception as e: 
+            decoded_chunk: AudioSegment = AudioSegment.from_file(
+                file_name, format=format
+            )
+        except Exception as e:
             raise e
         decoded_chunk = decoded_chunk.set_channels(1)
-        decoded_chunk = decoded_chunk.set_frame_rate(8000)        
-        decoded_chunk.export(temp_file, format='wav')
+        decoded_chunk = decoded_chunk.set_frame_rate(8000)
+        decoded_chunk.export(temp_file, format="wav")
 
-        self.audio_stream = AudioStream(temp_file, self)
+        self.audio_stream = AudioStream(temp_file)
+        await self.audio_stream.recv()
         await self.audio_queue.put(("audio", self.audio_stream))
 
         return self.audio_stream
 
     async def gather(
         self, length: int = 1, timeout: float = 7.0, finish_on_key=None, stream=None
-    ) -> int:
+    ):
         """This method gathers a dtmf tone with the specified
-        length and then returns when done"""
-        if not asyncio.current_task() in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
+        length and then returns when done""" 
+        if not self.call.sip_core.is_running.is_set():
+            raise RuntimeError("App is no longer running")
 
-        dtmf_future = asyncio.Future()
+        dtmf_future: asyncio.Future = asyncio.Future()
         dtmf_future.__setattr__("length", length)
         dtmf_future.__setattr__("timeout", timeout)
         dtmf_future.__setattr__("finish_on_key", finish_on_key)
@@ -76,8 +103,9 @@ async def gather(
                     await dtmf_future
                 except asyncio.CancelledError:
                     pass
-                
-            if asyncio.current_task().cancelling() > 0:
+
+            _task = asyncio.current_task()
+            if (_task is not None) and _task.cancelling() > 0:
                 raise
 
     async def gather_and_say(
@@ -91,9 +119,6 @@ async def gather_and_say(
     ):
         """This method waits for dtmf keys and then if received
         it instantly send it"""
-        if not asyncio.current_task() in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-
         dtmf_result = None
         for _ in range(loop):
             try:
@@ -114,7 +139,51 @@ async def gather_and_say(
             or f"You failed to enter the key in {loop} tries. Hanging up the call"
         )
         stream = await self.say(text)
-        await stream.flush()
+        await stream.wait_finished()
+
+        return dtmf_result
+
+    async def gather_and_play(
+        self,
+        file_name: str,
+        format: str = "wav",
+        length: int = 1,
+        delay: int = 7,
+        loop: int = 3,
+        finish_on_key=None,
+        loop_audio_file: str = "",
+        delay_audio_file: str = "",
+    ):
+        """This method waits for dtmf keys and then if received
+        it instantly send it"""
+        dtmf_result = None
+        for _ in range(loop):
+            try:
+                stream = await self.play(file_name, format)
+                dtmf_result = await self.gather(
+                    length=length,
+                    timeout=delay,
+                    finish_on_key=finish_on_key,
+                    stream=stream,
+                )
+                if dtmf_result:
+                    dtmf_result = dtmf_result
+                    return dtmf_result
+
+            except asyncio.TimeoutError:
+                if delay_audio_file:
+                    await self.play(delay_audio_file, format=format)
+                else:
+                    await self.say("You did not any keys please try again")
+                continue
+
+        if loop_audio_file:
+            stream = await self.play(loop_audio_file, format=format)
+        else:
+            stream = await self.say(
+                f"You failed to enter the key in {loop} tries. Hanging up the call"
+            )
+        await stream.wait_finished()
 
         return dtmf_result
 
@@ -167,25 +236,15 @@ async def transfer_to(self, to: str | int):
         Args:
             to (str|int): The target phone number to transfer the call to.
         """
-        if asyncio.current_task() not in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-
-        if self.call.call_state != CallState.ANSWERED:
-            return(None, "Call can only be transferred after it has been established")
- 
-        try:
-            self.call.refer_future = asyncio.Future()
-            self.refer_future: asyncio.Future = self.call.refer_future
-            self.refer_message = self.call.client.refer_generator(to)
-            await self.call.client.send(self.refer_message)
-        except Exception as e:
-            return (None, e)
+        self.call._refer_future = asyncio.Future()
+        self.refer_message = self.call.refer_generator(to)
+        await self.call.sip_core.send(self.refer_message)
+        logger.log(logging.DEBUG, f"Trnasfering the call to -> {to}")
 
         try:
-            print("Event loop in call handler is: ", asyncio.get_event_loop())
-            result = await asyncio.wait_for(self.refer_future, 5)
+            result = await asyncio.wait_for(self.call._refer_future, 5)
             return (result, None)
-        
+
         except asyncio.TimeoutError:
             return (None, "Timed out")
 
@@ -194,54 +253,52 @@ async def transfer_to(self, to: str | int):
 
         except Exception:
             return (None, "Unknown error")
-        
 
     async def sleep(self, delay: float):
-        if not asyncio.current_task() in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-
         await self.audio_queue.put(("sleep", delay))
 
     async def hangup(self):
-        if not asyncio.current_task() in self.call.client.pysip_tasks:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-
-        if self.call.rtp_session:
-            await self.call.client.hangup(self.call.rtp_session)
-
-        else:
-            raise ValueError("No rtp_session, couldn't hangup")
+        await self.call.stop("Caller hanged up")
 
     @property
     def dtmf_codes(self) -> List[str]:
         """Contains all the dtmf codes from the start of the call."""
-        if self.call.dtmf_handler is not None:
-            return self.call.dtmf_handler.dtmf_codes
-        
+        if self.call._dtmf_handler is not None:
+            return self.call._dtmf_handler.dtmf_codes
+
         else:
             return []
 
     @property
     def call_id(self):
         """Retturns the call id of the current call"""
-        return self.call.client.call_id
-        
+        return self.call.call_id
+
+    @property
+    def voice(self):
+        return self._voice
+
+    @voice.setter
+    def voice(self, value):
+        self._voice = value
+
     async def send_handler(self):
         try:
-            self.call.client.pysip_tasks.append(asyncio.current_task())
-            _print_debug_info("CallHandler has been initialized..")
+            logger.log(logging.INFO, "CallHandler has been initialized..")
             empty_queue_count = 0  # Counter for consecutive empty queue checks
 
             while True:
                 await asyncio.sleep(0.1)
+                if not self.call.sip_core.is_running.is_set():
+                    break  # Exit the loop if the call is not running
+
+                if self.call.call_state in [CallState.ENDED, CallState.FAILED, CallState.BUSY]:
+                    break
 
                 if self.call.call_state is not CallState.ANSWERED:
                     continue
 
-                if not self.call.client.is_running:
-                    break  # Exit the loop if the client is not running
-
-                if not self.call.rtp_session:
+                if not self.call._rtp_session:
                     continue
 
                 try:
@@ -253,97 +310,70 @@ async def send_handler(self):
 
                     if event_type == "audio":
                         if self.previous_stream:
-                            await self.previous_stream.flush()
-
-                        asyncio.get_event_loop().run_in_executor(
-                            None, self.call.rtp_session.send_now, result
-                        )
+                            await self.previous_stream.wait_finished()
 
+                        self.call._rtp_session.set_audio_stream(result)
                         self.previous_stream = result
 
                     elif event_type == "sleep":
                         await asyncio.sleep(result)
 
                     elif event_type == "drain":
-                        result.should_stop_streaming.set()
+                        self.call._rtp_session.set_audio_stream(None)
+                        result.is_running.clear()
 
                     elif event_type == "dtmf":
+                        stream = None
                         try:
                             length = result.length
                             timeout = result.timeout
                             finish_on_key = result.finish_on_key
                             stream = result.stream
-                            _print_debug_info("Started to wait for DTMF")
+                            logger.log(logging.INFO, "Started to wait for DTMF")
                             awaitable = None
 
                             if self.previous_stream:
                                 asyncio.create_task(
-                                    self.call.dtmf_handler.started_typing(
-                                        self.previous_stream.drain
+                                    self.call._dtmf_handler.started_typing(
+                                        self.call._rtp_session.set_audio_stream, None
                                     )
                                 )
-                                awaitable = self.previous_stream.audio_sent_future
+                                awaitable = self.previous_stream.stream_done_future
 
                             dtmf_result = await wait_for(
-                                self.call.dtmf_handler.get_dtmf(length, finish_on_key),
+                                self.call._dtmf_handler.get_dtmf(length, finish_on_key),
                                 timeout,
-                                awaitable
+                                awaitable,
                             )
                             self.previous_stream = stream
                             if stream:
-                                await stream.drain()
+                                await self.call._rtp_session.set_audio_stream(None)
                             result.set_result(dtmf_result)
 
                         except asyncio.TimeoutError:
                             self.previous_stream = stream
                             if stream:
-                                await stream.drain()
+                                await self.call._rtp_session.set_audio_stream(None)
                             result.set_exception(asyncio.TimeoutError)
 
                 except asyncio.TimeoutError:
                     empty_queue_count += 1
                     if empty_queue_count >= 10:
-                        _print_debug_info(
-                            "Queue has been empty for a while. Exiting the loop."
+                        logger.log(
+                            logging.WARNING,
+                            "Queue has been empty for a while. Exiting the loop.",
                         )
                         break
 
                 except asyncio.CancelledError:
-                    # _print_debug_info("Subtask has been cancelled")
                     break
 
+            while not self.audio_queue.empty():
+                event_type, result = await self.audio_queue.get()
+                if event_type == "audio":
+                    result.stream_done()
+                
+            logger.log(logging.INFO, "The call handler has been stopped")
         except asyncio.CancelledError:
-            _print_debug_info("The send handler task has been cancelled")
+            logger.log(logging.DEBUG, "The send handler task has been cancelled")
             pass
-
-
-class AudioStream(Wave_read):
-    def __init__(self, f, instance: CallHandler = None) -> None:
-        self.audio_sent_future = asyncio.Future()
-        self.should_stop_streaming = asyncio.Event()
-        self.instance = instance
-        super().__init__(f)
-
-        self.audio_length = self.getnframes() / float(self.getframerate())
-
-    @property
-    def audio_length(self):
-        """The audio_length property."""
-        return self._audio_length
-
-    @audio_length.setter
-    def audio_length(self, value):
-        self._audio_length = value
-
-    async def drain(self):
-        """This ensures that any remains of the current stream is dropped"""
-        if self.instance:
-            self.should_stop_streaming.set()
-            return
-        self.should_stop_streaming.set()
-
-    async def flush(self):
-        await self.audio_sent_future
-
-    def __repr__(self) -> str:
-        return str(id(self))
diff --git a/PySIP/codecs/__init__.py b/PySIP/codecs/__init__.py
new file mode 100644
index 0000000..c3959be
--- /dev/null
+++ b/PySIP/codecs/__init__.py
@@ -0,0 +1,27 @@
+
+from .g711 import PcmaDecoder, PcmaEncoder, PcmuDecoder, PcmuEncoder
+from .codec_info import CodecInfo
+
+
+CODECS = [
+    CodecInfo.PCMA,
+    CodecInfo.PCMU,
+    CodecInfo.EVENT
+]
+
+
+def get_encoder(codec: CodecInfo):
+    if codec == CodecInfo.PCMA:
+        return PcmaEncoder()
+    elif codec == CodecInfo.PCMU:
+        return PcmuEncoder()
+    else:
+        raise ValueError(f"No encoder found for: {codec}")
+
+def get_decoder(codec: CodecInfo):
+    if codec == CodecInfo.PCMA:
+        return PcmaDecoder()
+    elif codec == CodecInfo.PCMU:
+        return PcmuDecoder()
+    else:
+        raise ValueError(f"No decoder foun for: {codec}")
diff --git a/PySIP/codecs/base.py b/PySIP/codecs/base.py
new file mode 100644
index 0000000..dd6bbe2
--- /dev/null
+++ b/PySIP/codecs/base.py
@@ -0,0 +1,13 @@
+from abc import ABCMeta, abstractmethod
+
+
+class Encoder(metaclass=ABCMeta):
+    @abstractmethod 
+    def encode(self, frame) -> bytes:
+        pass
+
+
+class Decoder(metaclass=ABCMeta):
+    @abstractmethod 
+    def decode(self, frame) -> bytes:
+        pass
diff --git a/PySIP/codecs/codec_info.py b/PySIP/codecs/codec_info.py
new file mode 100644
index 0000000..6a133b2
--- /dev/null
+++ b/PySIP/codecs/codec_info.py
@@ -0,0 +1,90 @@
+from enum import Enum
+from typing import Union
+
+
+class CodecInfo(Enum):
+    def __new__(
+        cls,
+        value: Union[int, str],
+        clock: int = 0,
+        channel: int = 0,
+        description: str = "",
+    ):
+        obj = object.__new__(cls)
+        obj._value_ = value
+        obj.rate = clock
+        obj.channel = channel
+        obj.description = description
+        return obj
+
+    @property
+    def rate(self) -> int:
+        return self._rate
+
+    @rate.setter
+    def rate(self, value: int) -> None:
+        self._rate = value
+
+    @property
+    def channel(self) -> int:
+        return self._channel
+
+    @channel.setter
+    def channel(self, value: int) -> None:
+        self._channel = value
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @description.setter
+    def description(self, value: str) -> None:
+        self._description = value
+
+    def __int__(self) -> int:
+        try:
+            return int(self.value)
+        except ValueError:
+            pass
+        raise Exception(
+            self.description + " is a dynamically assigned payload"
+        )
+
+    def __str__(self) -> str:
+        if isinstance(self.value, int):
+            return self.description
+        return str(self.value)
+
+    # Audio
+    PCMU = 0, 8000, 1, "PCMU"
+    GSM = 3, 8000, 1, "GSM"
+    G723 = 4, 8000, 1, "G723"
+    DVI4_8000 = 5, 8000, 1, "DVI4"
+    DVI4_16000 = 6, 16000, 1, "DVI4"
+    LPC = 7, 8000, 1, "LPC"
+    PCMA = 8, 8000, 1, "PCMA"
+    G722 = 9, 8000, 1, "G722"
+    L16_2 = 10, 44100, 2, "L16"
+    L16 = 11, 44100, 1, "L16"
+    QCELP = 12, 8000, 1, "QCELP"
+    CN = 13, 8000, 1, "CN"
+    # MPA channel varries, should be defined in the RTP packet.
+    MPA = 14, 90000, 0, "MPA"
+    G728 = 15, 8000, 1, "G728"
+    DVI4_11025 = 16, 11025, 1, "DVI4"
+    DVI4_22050 = 17, 22050, 1, "DVI4"
+    G729 = 18, 8000, 1, "G729"
+
+    # Video
+    CELB = 25, 90000, 0, "CelB"
+    JPEG = 26, 90000, 0, "JPEG"
+    NV = 28, 90000, 0, "nv"
+    H261 = 31, 90000, 0, "H261"
+    MPV = 32, 90000, 0, "MPV"
+    # MP2T is both audio and video per RFC 3551 July 2003 5.7
+    MP2T = 33, 90000, 1, "MP2T"
+    H263 = 34, 90000, 0, "H263"
+
+    # Non-codec
+    EVENT = 121, 8000, 0, "telephone-event"
+    UNKNOWN = "UNKNOWN", 0, 0, "UNKNOWN CODEC"
diff --git a/PySIP/codecs/g711.py b/PySIP/codecs/g711.py
new file mode 100644
index 0000000..b43b8db
--- /dev/null
+++ b/PySIP/codecs/g711.py
@@ -0,0 +1,52 @@
+from abc import ABC, abstractmethod
+import audioop
+from .base import Decoder, Encoder
+
+
+SAMPLE_RATE = 8000
+SAMPLE_WIDTH = 2 # 16 bits
+SAMPLES_PER_FRAME = 160
+
+
+class PcmEncoder(ABC, Encoder):
+    @staticmethod
+    @abstractmethod 
+    def _convert(data: bytes, width: int) -> bytes:
+        pass
+
+    def encode(self, frame) -> bytes:
+        return self._convert(frame, SAMPLE_WIDTH)
+
+
+class PcmDecoder(ABC, Decoder):
+    @staticmethod
+    @abstractmethod 
+    def _convert(data: bytes, width: int) -> bytes:
+        pass
+
+    def decode(self, frame) -> bytes:
+        return self._convert(frame, SAMPLE_WIDTH)
+
+
+class PcmaEncoder(PcmEncoder):
+    @staticmethod
+    def _convert(data: bytes, width: int) -> bytes:
+        return audioop.lin2alaw(data, width)
+
+
+class PcmaDecoder(PcmDecoder):
+    @staticmethod
+    def _convert(data: bytes, width: int) -> bytes:
+        return audioop.alaw2lin(data, width)
+
+
+class PcmuEncoder(PcmaEncoder):
+    @staticmethod
+    def _convert(data: bytes, width: int) -> bytes:
+        return audioop.lin2ulaw(data, width)
+
+
+class PcmuDecoder(PcmDecoder):
+    @staticmethod
+    def _convert(data: bytes, width: int) -> bytes:
+        return audioop.ulaw2lin(data, width)
diff --git a/PySIP/exceptions.py b/PySIP/exceptions.py
index cb81c6f..18a118c 100644
--- a/PySIP/exceptions.py
+++ b/PySIP/exceptions.py
@@ -6,4 +6,16 @@ def __init__(self, code, description):
         self.description = description
 
 
+class NoPasswordFound(Exception):
+    def __init__(self, *args: object) -> None:
+        super().__init__(*args)
 
+
+class NoSupportedCodecsFound(Exception):
+    def __init__(self, *args: object) -> None:
+        super().__init__(*args)
+
+
+class AudioStreamError(Exception):
+    def __init__(self, *args: object) -> None:
+        super().__init__(*args)
diff --git a/PySIP/filters.py b/PySIP/filters.py
index 07bc0d9..8ba7081 100644
--- a/PySIP/filters.py
+++ b/PySIP/filters.py
@@ -1,7 +1,5 @@
 from enum import IntEnum, Enum
-import random
-import re
-from typing import Any, Literal, Union
+from typing import Any, Union
 
 __all__ = [
     'SIPCompatibleMethods',
@@ -11,13 +9,10 @@
     'MethodFilter',
     'TypeFilter',
     'SipFilter',
-    'SIPStatus',
-    'SipMessage',
-    'SDPParser',
-    'PayloadType'
+    'SIPStatus', 
 ]
 
-SIPCompatibleMethods = ["PRACK", "INVITE", "ACK", "BYE", "CANCEL", "UPDATE",
+SIPCompatibleMethods = ["INVITE", "ACK", "BYE", "CANCEL", "UPDATE",
                         "INFO", "SUBSCRIBE", "NOTIFY", "REFER", "MESSAGE", "OPTIONS"]
 SIPCompatibleVersions = ["SIP/2.0"]
 
@@ -32,26 +27,20 @@ def __new__(cls, value: int):
 
 
 class ConnectionType(Enum):
-    TCP = 1, 'TCP'
-    UDP = 2, 'UDP'
-    TLS = 3, 'TLS'
-    TLSv1 = 4, 'TLSv1'
-
-    def __new__(cls, value: object, conn_type: Literal['TCP', 'UDP', 'TLS', 'TLSv1']):
-        obj = object.__new__(cls)
-        obj._value_ = value
-        obj.conn_type = conn_type
-        cls._value2member_map_[conn_type] = obj
-
-        return obj
+    TCP = 'TCP'
+    UDP = 'UDP'
+    TLS = 'TLS'
+    TLSv1 = 'TLSv1'
 
 
 class CallState(Enum):
+    INITIALIZING = "INITIALIZING"
     DAILING = "DIALING"
     RINGING = "RINGING"
     ANSWERED = "ANSWERED"
     ENDED = "ENDED"
     FAILED = "FAILED"
+    BUSY = "BUSY"
 
 
 class Filter:
@@ -84,6 +73,14 @@ def __init__(self, type_):
     def __call__(self, msg) -> Any:
         return msg.type == self.type_
 
+class CallIdFilter(Filter):
+    def __init__(self, call_id):
+        super().__init__()
+        self.call_id = call_id
+
+    def __call__(self, msg):
+        return msg.call_id == self.call_id
+
 class SipFilter:
     """Filter received :obj:`SipMessage``s"""
 
@@ -126,6 +123,7 @@ class SipFilter:
     REQUEST: 'SipFilter' = TypeFilter(SIPMessageType.MESSAGE)
     """This filters out only the messages that are sent out to
     the server through :meth:`Client.send`"""
+    CALL_ID: 'SipFilter' = lambda callid: CallIdFilter(callid)
 
 
 class SIPStatus(Enum):
@@ -398,440 +396,3 @@ def phrase(self, value):
     UNWANTED = 607, "Unwanted"
     REJECTED = 608, "Rejected"
 
-
-class SipMessage:
-    def __init__(self, message: str = None) -> None:
-        self.headers = {}
-        self.body = None
-        self.body_data = None
-        self.nonce = None
-        self.realm = None
-        self.data = message
-
-        # Initialize properties with default values
-        self._type = None
-        self._cseq = None
-        self._rseq = None
-        self._method = None
-        self._from_tag = None
-        self._to_tag = None
-        self._call_id = None
-        self._status = None
-        self._public_ip = None
-        self._rport = None
-        self._branch = None
-        self._did = None
-
-    @property
-    def type(self):
-        return self._type
-
-    @type.setter
-    def type(self, value):
-        self._type = value
-
-    @property
-    def cseq(self):
-        return self._cseq
-
-    @cseq.setter
-    def cseq(self, value):
-        self._cseq = value
-
-    @property
-    def rseq(self):
-        return self._rseq
-
-    @rseq.setter
-    def rseq(self, value):
-        self._rseq = value
-
-    @property
-    def method(self):
-        return self._method
-
-    @method.setter
-    def method(self, value):
-        self._method = value
-
-    @property
-    def from_tag(self):
-        return self._from_tag
-
-    @from_tag.setter
-    def from_tag(self, value):
-        self._from_tag = value
-
-    @property
-    def to_tag(self):
-        return self._to_tag
-
-    @to_tag.setter
-    def to_tag(self, value):
-        self._to_tag = value
-
-    @property
-    def call_id(self):
-        return self._call_id
-
-    @call_id.setter
-    def call_id(self, value):
-        self._call_id = value
-
-    @property
-    def status(self):
-        return self._status
-
-    @status.setter
-    def status(self, value):
-        self._status = value
-
-    @property
-    def branch(self):
-        return self._branch
-
-    @branch.setter
-    def branch(self, value):
-        self._branch = value
-
-    @property
-    def did(self):
-        return self._did
-
-    @did.setter
-    def did(self, value):
-        self._did = value
-
-    @property
-    def public_ip(self):
-        return self._public_ip
-
-    @public_ip.setter
-    def public_ip(self, value):
-        self._public_ip = value
-
-    @property
-    def rport(self):
-        return self._rport
-
-    @rport.setter
-    def rport(self, value):
-        self._rport = value
-
-    @property
-    def nonce(self):
-        return self._nonce
-
-    @nonce.setter
-    def nonce(self, value):
-        self._nonce = value
-
-    @property
-    def realm(self):
-        return self._realm
-
-    @realm.setter
-    def realm(self, value):
-        self._realm = value
-
-
-    def parse(self):
-        data = self.data.split('\r\n\r\n')
-        self.headers_data = data[0]
-        try:
-            self.body_data = data[1]
-        except IndexError:
-            self.body_data = ''
-
-        headers_lines = self.headers_data.split("\r\n")
-        for index, line in enumerate(headers_lines):
-            if index == 0:  # First line
-                self.headers['type'] = line  # Set 'type' in headers
-
-            else:
-                key, value = line.split(":", 1)  # Split at first colon
-                self.headers[key.strip()] = value.strip()
-
-        if self.body_data != '':
-            body_lines = self.body_data.split("\r\n")
-            self.body = {}
-            for line in body_lines:
-                if "=" in line:
-                    key, value = line.split("=", 1)
-                    if key.strip() in self.body:
-                        # This expression checks if the ey value already exists in the
-                        # dictionary and if it does then it makes a list of the old value
-                        # and the new value, also it checks if it were already a list and just
-                        # appends the value t it if it was a list.
-                        if not isinstance(self.body[key.strip()], list):
-                            self.body[key.strip()] = [self.body[key], value]
-                        else:
-                            self.body[key.strip()].append(value)
-                    else:
-                        self.body[key.strip()] = value.strip()
-
-        self.set_properties()
-
-    def set_properties(self):
-        """type property, should be LITERAL[Message, Response]"""
-        self.type_header = self.get_header('type').split(" ")
-
-        if self.type_header[0] in SIPCompatibleMethods:
-            self.type = SIPMessageType.MESSAGE
-        elif self.type_header[0] in SIPCompatibleVersions:
-            self.type = SIPMessageType.RESPONSE
-
-        """shared properties for both request/response"""
-        # CSeq
-        cseq = self.get_header('CSeq')
-        self.cseq = int(cseq.split(' ')[0])
-        self.method = cseq.split(' ')[1]
-
-        # From tag
-        from_header = self.get_header('From')
-        from_tag_match = re.search(r';tag=([^;]+)', from_header)
-        self.from_tag = from_tag_match.group(1) if from_tag_match else None
-
-        # To tag
-        to_header = self.get_header('To')
-        to_tag_match = re.search(r';tag=([^;]+)', to_header)
-        self.to_tag = to_tag_match.group(1) if to_tag_match else None
-
-        self.call_id = self.get_header('Call-ID')
-
-        branch_header = self.get_header('Via')
-        self.branch = branch_header.split('branch=')[1].split(";")[0]
-
-        if self.type == SIPMessageType.RESPONSE:
-            try:
-                self.status = SIPStatus(int(self.type_header[1]))
-                via_header = self.get_header('Via')
-                self.public_ip = via_header.split('received=')[1].split(";")[0]
-
-                # RPort
-                self.rport = via_header.split('rport=')[1].split(';')[0]
-                auth_header = self.get_header('WWW-Authenticate')
-                if auth_header:
-                    self.nonce = auth_header.split('nonce="')[1].split('"')[0]
-                    self.realm = auth_header.split('realm="')[1].split('"')[0]
-                # dialog_id
-                contact_header = self.get_header("Contact")
-                if contact_header:
-                    try:
-                        self.did = contact_header.split("did=")[1].split(">")[0]
-                    except IndexError:
-                        pass
-                #RSeq
-                rseq_header = self.get_header("RSeq")
-                if rseq_header:
-                    self.rseq = rseq_header
-            except IndexError:
-                pass
-
-            except ValueError:
-                pass
-
-    def get_header(self, key) -> str:
-        return self.headers.get(key)
-
-    def get_headers(self):
-        return self.headers
-
-    def get_body(self, key):
-        return self.body.get(key)
-
-    @classmethod
-    def generate_sdp(cls, ip, port):
-        ssrc = random.getrandbits(32)
-        cname = f"host_{random.randint(100, 999)}"
-
-        sdp = f"v=0\r\n"
-        sdp += f"o=- {random.randint(100000000, 999999999)} {random.randint(100000000, 999999999)} IN IP4 {ip}\r\n"
-        sdp += f"s=pjmedia\r\n"
-        sdp += f"b=AS:84\r\n"
-        sdp += f"t=0 0\r\n"
-        sdp += f"a=X-nat:1\r\n"
-        sdp += f"m=audio {port} RTP/AVP 96 97 98 99 3 0 8 9 120 121 122\r\n"
-        sdp += f"c=IN IP4 {ip}\r\n"
-        sdp += f"b=TIAS:64000\r\n"
-        sdp += f"a=rtcp:{port + 1} IN IP4 {ip}\r\n"
-        sdp += f"a=sendrecv\r\n"
-        sdp += cls.get_rtpmap_lines()
-        sdp += cls.get_telephone_event_lines()
-        sdp += f"a=ssrc:{ssrc} cname:{cname}\r\n"
-
-        return sdp
-
-    @staticmethod
-    def get_rtpmap_lines():
-        lines = []
-        lines.append(f"a=rtpmap:96 speex/16000\r\n")
-        lines.append(f"a=rtpmap:97 speex/8000\r\n")
-        lines.append(f"a=rtpmap:98 speex/32000\r\n")
-        lines.append(f"a=rtpmap:99 iLBC/8000\r\n")
-        lines.append(f"a=fmtp:99 mode=30\r\n")
-        lines.append(f"a=rtpmap:3 GSM/8000\r\n")
-        lines.append(f"a=rtpmap:0 PCMU/8000\r\n")
-        lines.append(f"a=rtpmap:8 PCMA/8000\r\n")
-        lines.append(f"a=rtpmap:9 G722/8000\r\n")
-        return "".join(lines)
-
-    @staticmethod
-    def get_telephone_event_lines():
-        lines = []
-        lines.append(f"a=rtpmap:120 telephone-event/16000\r\n")
-        lines.append(f"a=fmtp:120 0-16\r\n")
-        lines.append(f"a=rtpmap:121 telephone-event/8000\r\n")
-        lines.append(f"a=fmtp:121 0-16\r\n")
-        lines.append(f"a=rtpmap:122 telephone-event/32000\r\n")
-        lines.append(f"a=fmtp:122 0-16\r\n")
-        return "".join(lines)
-
-    @classmethod
-    def parse_sdp(cls, sdp):
-        parser = SDPParser(sdp)
-
-        return parser
-
-class SDPParser:
-    def __init__(self, sdp):
-        self.ip_address = None
-        self.media_type = None
-        self.transport = None
-        self.port = None
-        self.rtcp_port = None
-        self.ptime = None
-        self.rtpmap = {}
-        self.direction = None
-
-        self.parse_sdp(sdp)
-
-    def parse_sdp(self, sdp):
-        self.ip_address = sdp['c'].split(' ')[2]
-        m = sdp['m'].split(' ')
-        self.media_type = m[0]
-        self.transport = m[2]
-        self.port = int(m[1])
-
-        for attr in sdp['a']:
-            if 'rtcp' in attr:
-                self.rtcp_port = int(attr.split(':')[1])
-            elif 'ptime' in attr:
-                self.ptime = int(attr.split(':')[1])
-            elif 'rtpmap' in attr:
-                try:
-                    rtpmap_val = attr.split(' ')
-                    payload_type = int(rtpmap_val[0].split(':')[1])
-                    codec = PayloadType(payload_type)
-                    self.rtpmap[payload_type] = codec
-
-                except ValueError:
-                    continue
-
-            elif 'sendrecv' in attr:
-                self.direction = attr
-
-    def __str__(self):
-        rtpmap_str = ', '.join([f'{payload_type}: {codec}' for payload_type, codec in self.rtpmap.items()])
-        return (
-            f"SDP Information:\n"
-            f"IP Address: {self.ip_address}\n"
-            f"Media Type: {self.media_type}\n"
-            f"Transport: {self.transport}\n"
-            f"Port: {self.port}\n"
-            f"RTCP Port: {self.rtcp_port}\n"
-            f"Ptime: {self.ptime}\n"
-            f"RTP Map: {rtpmap_str}\n"
-            f"Direction: {self.direction}"
-        )
-
-    def __repr__(self):
-        return str(self)
-
-
-class PayloadType(Enum):
-    def __new__(
-        cls,
-        value: Union[int, str],
-        clock: int = 0,
-        channel: int = 0,
-        description: str = "",
-    ):
-        obj = object.__new__(cls)
-        obj._value_ = value
-        obj.rate = clock
-        obj.channel = channel
-        obj.description = description
-        return obj
-
-    @property
-    def rate(self) -> int:
-        return self._rate
-
-    @rate.setter
-    def rate(self, value: int) -> None:
-        self._rate = value
-
-    @property
-    def channel(self) -> int:
-        return self._channel
-
-    @channel.setter
-    def channel(self, value: int) -> None:
-        self._channel = value
-
-    @property
-    def description(self) -> str:
-        return self._description
-
-    @description.setter
-    def description(self, value: str) -> None:
-        self._description = value
-
-    def __int__(self) -> int:
-        try:
-            return int(self.value)
-        except ValueError:
-            pass
-        raise Exception(
-            self.description + " is a dynamically assigned payload"
-        )
-
-    def __str__(self) -> str:
-        if isinstance(self.value, int):
-            return self.description
-        return str(self.value)
-
-    # Audio
-    PCMU = 0, 8000, 1, "PCMU"
-    GSM = 3, 8000, 1, "GSM"
-    G723 = 4, 8000, 1, "G723"
-    DVI4_8000 = 5, 8000, 1, "DVI4"
-    DVI4_16000 = 6, 16000, 1, "DVI4"
-    LPC = 7, 8000, 1, "LPC"
-    PCMA = 8, 8000, 1, "PCMA"
-    G722 = 9, 8000, 1, "G722"
-    L16_2 = 10, 44100, 2, "L16"
-    L16 = 11, 44100, 1, "L16"
-    QCELP = 12, 8000, 1, "QCELP"
-    CN = 13, 8000, 1, "CN"
-    # MPA channel varries, should be defined in the RTP packet.
-    MPA = 14, 90000, 0, "MPA"
-    G728 = 15, 8000, 1, "G728"
-    DVI4_11025 = 16, 11025, 1, "DVI4"
-    DVI4_22050 = 17, 22050, 1, "DVI4"
-    G729 = 18, 8000, 1, "G729"
-
-    # Video
-    CELB = 25, 90000, 0, "CelB"
-    JPEG = 26, 90000, 0, "JPEG"
-    NV = 28, 90000, 0, "nv"
-    H261 = 31, 90000, 0, "H261"
-    MPV = 32, 90000, 0, "MPV"
-    # MP2T is both audio and video per RFC 3551 July 2003 5.7
-    MP2T = 33, 90000, 1, "MP2T"
-    H263 = 34, 90000, 0, "H263"
-
-    # Non-codec
-    EVENT = 121, 8000, 0, "telephone-event"
-    UNKNOWN = "UNKNOWN", 0, 0, "UNKNOWN CODEC"
diff --git a/PySIP/jitter_buffer.py b/PySIP/jitter_buffer.py
new file mode 100644
index 0000000..5c45781
--- /dev/null
+++ b/PySIP/jitter_buffer.py
@@ -0,0 +1,117 @@
+from typing import Any, List, Optional
+
+MAX_MISORDER = 100
+
+
+class JitterFrame:
+    def __init__(self, data: bytes, timestamp: int) -> None:
+        self.data = data
+        self.timestamp = timestamp
+
+
+class JitterBuffer:
+    def __init__(
+        self, capacity: int, prefetch: int = 0
+    ) -> None:
+        assert capacity & (capacity - 1) == 0, "capacity must be a power of 2"
+        self._capacity = capacity
+        self._origin: Optional[int] = None
+        self._packets: List[Any] = [None for _ in range(capacity)]
+        self._prefetch = prefetch
+
+    @property
+    def capacity(self) -> int:
+        return self._capacity
+
+    def add(self, packet) -> Optional[JitterFrame]:
+        if self._origin is None:
+            self._origin = packet.sequence_number
+            delta = 0
+            misorder = 0
+        else:
+            delta = (packet.sequence_number + -self._origin) & 0xFFFF
+            misorder = (self._origin + -packet.sequence_number) & 0xFFFF
+
+        if misorder < delta:
+            if misorder >= MAX_MISORDER:
+                self.remove(self.capacity)
+                self._origin = packet.sequence_number
+                delta = misorder = 0
+            else:
+                return None
+
+        if delta >= self.capacity:
+            # remove just enough frames to fit the received packets
+            excess = delta - self.capacity + 1
+            if self.smart_remove(excess):
+                self._origin = packet.sequence_number
+
+        pos = packet.sequence_number % self._capacity
+        self._packets[pos] = packet
+
+        return self._remove_frame(packet.sequence_number)
+
+    def _remove_frame(self, sequence_number: int) -> Optional[JitterFrame]:
+        frame = None
+        frames = 0
+        packets = []
+        remove = 0
+        timestamp = None
+        assert self._origin is not None
+
+        for count in range(self.capacity):
+            pos = (self._origin + count) % self._capacity
+            packet = self._packets[pos]
+            if packet is None:
+                break
+            if timestamp is None:
+                timestamp = packet.timestamp
+            elif packet.timestamp != timestamp:
+                # we now have a complete frame, only store the first one
+                if frame is None:
+                    frame = JitterFrame(
+                        data=b"".join([x.payload for x in packets]), timestamp=timestamp
+                    )
+                    remove = count
+
+                # check we have prefetched enough
+                frames += 1
+                if frames >= self._prefetch:
+                    self.remove(remove)
+                    return frame
+
+                # start a new frame
+                packets = []
+                timestamp = packet.timestamp
+
+            packets.append(packet)
+
+        return None
+
+    def remove(self, count: int) -> None:
+        assert count <= self._capacity
+        assert self._origin is not None
+        for i in range(count):
+            pos = self._origin % self._capacity
+            self._packets[pos] = None
+            self._origin = (self._origin + 1) & 0xFFFF
+
+    def smart_remove(self, count: int) -> bool:
+        """
+        Makes sure that all packages belonging to the same frame are removed
+        to prevent sending corrupted frames to the decoder.
+        """
+        timestamp = None
+        assert self._origin is not None
+        for i in range(self._capacity):
+            pos = self._origin % self._capacity
+            packet = self._packets[pos]
+            if packet is not None:
+                if i >= count and timestamp != packet.timestamp:
+                    break
+                timestamp = packet.timestamp
+            self._packets[pos] = None
+            self._origin = (self._origin + 1) & 0xFFFF
+            if i == self._capacity - 1:
+                return True
+        return False
diff --git a/PySIP/rtp_handler.py b/PySIP/rtp_handler.py
new file mode 100644
index 0000000..3904bb1
--- /dev/null
+++ b/PySIP/rtp_handler.py
@@ -0,0 +1,495 @@
+import asyncio
+from dataclasses import dataclass
+from enum import Enum
+import logging
+import random
+from struct import unpack, unpack_from
+import time
+import numpy as np
+from typing import Callable, Dict, List, Optional, Union
+from PySIP.amd.amd import AnswringMachineDetector
+
+from PySIP.exceptions import AudioStreamError, NoSupportedCodecsFound
+from PySIP.jitter_buffer import JitterBuffer
+from PySIP.sip_core import DTMFMode
+from .audio_stream import AudioStream
+from .udp_handler import open_udp_connection
+from .utils.logger import logger
+from .utils.inband_dtmf import dtmf_decode
+from .codecs import get_encoder, get_decoder, CODECS
+from .codecs.codec_info import CodecInfo
+
+
+MAX_WAIT_FOR_STREAM = 40  # seconds
+RTP_HEADER_LENGTH = 12
+RTP_PORT_RANGE = range(10_000, 20_000)
+SEND_SILENCE = True # send silence frames when no stream
+USE_AMD_APP = True
+DTMF_MODE = DTMFMode.RFC_2833
+
+
+def decoder_worker(input_data, output_qs, loop):
+    codec, encoded_frame = input_data
+    if not encoded_frame:
+        for output_q in output_qs.values():
+            asyncio.run_coroutine_threadsafe(output_q.put(None), loop)
+        return
+
+    decoder = get_decoder(codec)
+    if not decoder:
+        logger.log(logging.WARNING, f"No decoder found for codec: {codec}")
+        return
+
+    decoded_frame = decoder.decode(encoded_frame.data)
+    for output_q in output_qs.values():
+        asyncio.run_coroutine_threadsafe(output_q.put(decoded_frame), loop)
+
+
+@dataclass 
+class DTMFBuffer:
+    """for accumulating data for INBAND dtmf detection"""
+    duration: int = 1
+    buffer = np.array([], np.int16)
+    size: int = 0
+    rate: int = 8000
+
+    def __post_init__(self):
+        self.size = int(self.duration * self.rate)
+
+
+def dtmf_detector_worker(input_buffer, _callbacks, loop):
+    dtmf_codes = dtmf_decode(input_buffer.buffer, input_buffer.rate)
+
+    for cb in _callbacks:
+        for code in dtmf_codes:
+            asyncio.run_coroutine_threadsafe(cb(code), loop)
+    # finally reset buffer
+    for code in dtmf_codes:
+        logger.log(logging.DEBUG, "Detected INBAND DTMF key: %s", code)
+    input_buffer.buffer = np.array([], np.int16)
+
+
+class RTPProtocol(Enum):
+    UDP = "udp"
+    AVP = "RTP/AVP"
+    SAVP = "RTP/SAVP"
+
+
+class TransmitType(Enum):
+    RECVONLY = "recvonly"
+    SENDRECV = "sendrecv"
+    SENDONLY = "sendonly"
+    INACTIVE = "inactive"
+
+    def __str__(self):
+        return self.value
+
+
+class RTPClient:
+    def __init__(
+        self, offered_codecs, src_ip, src_port, dst_ip, dst_port, transmit_type, ssrc,
+        callbacks: Optional[Dict[str, List[Callable]]] = None
+    ):
+        self.offered_codecs = offered_codecs
+        self.src_ip = src_ip
+        self.src_port = src_port
+        self.dst_ip = dst_ip
+        self.dst_port = dst_port
+        self.transmit_type = transmit_type
+        self.selected_codec = self.select_audio_codecs(offered_codecs)
+        self.udp_reader, self.udp_writer = None, None
+        self.send_lock = asyncio.Lock()
+        self.is_running = asyncio.Event()
+        self._input_queue: asyncio.Queue = asyncio.Queue()
+        self._output_queues: Dict[str, asyncio.Queue] = {'audio_record': asyncio.Queue()}
+        self._audio_stream: Optional[AudioStream] = None
+        self.__encoder = get_encoder(self.selected_codec)
+        self.__decoder = get_decoder(self.selected_codec)
+        self.ssrc = ssrc
+        self.__timestamp = random.randint(2000, 8000)
+        self.__sequence_number = random.randint(200, 800)
+        self.__jitter_buffer = JitterBuffer(16, 4)
+        self.__callbacks = callbacks
+
+    async def _start(self):
+        self.is_running.set()
+        logger.log(
+            logging.DEBUG,
+            f"Establishing RTP Connection: "
+            f"LOCAL: {self.src_ip}:{self.src_port} -- "
+            f"SERVER: {self.dst_ip}:{self.dst_port}"
+        )
+        self.udp_reader, self.udp_writer = await open_udp_connection(
+            (self.dst_ip, self.dst_port), (self.src_ip, self.src_port)
+        )
+
+        send_task = asyncio.create_task(self.send(), name="Rtp Send Task")
+        receive_task = asyncio.create_task(self.receive(), name="Rtp receive Task")
+        frame_monitor_task = asyncio.create_task(self.frame_monitor(), name="Frame Monitor Task")
+        _tasks = [send_task, receive_task, frame_monitor_task]
+
+        amd_task = None
+        if USE_AMD_APP: 
+            self.__amd_detector = AnswringMachineDetector()
+            # create an input for the amd
+            self._output_queues["amd_app"] = amd_input = asyncio.Queue()
+            amd_cb = [self.__callbacks.get("amd_app") if self.__callbacks else []][0]
+            amd_task = asyncio.create_task(self.__amd_detector.run_detector(
+                amd_input,
+                amd_cb
+            ), name="AMD App Task")
+            _tasks.append(amd_task)
+
+        dtmf_task = None
+        if DTMF_MODE is DTMFMode.INBAND:
+            dtmf_task = asyncio.create_task(self._handle_inband(), name="Inband Dtmf Task")
+            _tasks.append(dtmf_task)
+
+        try: 
+            await asyncio.gather(*_tasks)
+        except asyncio.CancelledError: 
+            _task = asyncio.current_task()
+            if _task and _task.cancelling() > 0:
+                raise
+
+        except Exception as e:
+            logger.log(logging.ERROR, e, exc_info=True)
+
+        finally:
+            for _task in _tasks:
+                if _task.done():
+                    continue
+                _task.cancel()
+                try:
+                    await _task
+                except asyncio.CancelledError:
+                    pass
+
+    async def _stop(self):
+        # close the connections
+        if not self.udp_writer or not self.udp_reader:
+            raise ValueError("No UdpWriter or UdpReader")
+
+        if not self.udp_writer.protocol.transport:
+            logger.log(logging.WARNING, "Couldnt stop RTP due to Udp transport")
+            return
+        if self.udp_writer.protocol.transport.is_closing():
+            logger.log(
+                logging.WARNING, "Couldnt stop RTP, transport is already closing."
+            )
+            return
+        self.udp_writer.protocol.transport.close()
+        self.is_running.clear()
+        if s := self.get_audio_stream():
+            s.stream_done()
+        # put None to receiver to make sure it stopped
+        await self.udp_reader.protocol.data_q.put(None)
+        logger.log(logging.DEBUG, "Rtp Handler Succesfully stopped.")  
+
+    async def _wait_stopped(self):
+        while True:
+            if not self.is_running.is_set():
+                break
+
+            await asyncio.sleep(0.1)
+
+    def select_audio_codecs(self, offered_codecs) -> CodecInfo:
+        for codec in offered_codecs.values():
+            if codec in CODECS:
+                return codec
+
+        raise NoSupportedCodecsFound
+
+    def generate_silence_frames(self, sample_width = 2, nframes = 160):
+        # Generate silence sound data or mimic sound data
+        return b"\x00" * (sample_width * nframes)
+
+    def is_rfc_2833_supported(self, offered_codecs):
+        for codec in offered_codecs.values():
+            if codec == CodecInfo.EVENT:
+                return True
+
+        return False
+
+    async def _handle_rfc_2833(self, packet):
+        dtmf_mapping = [
+        "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#", "A", "B", "C", "D"
+        ]
+        payload = packet.payload
+        event = dtmf_mapping[payload[0]]
+
+        if not packet.marker:
+            return
+
+        if not DTMF_MODE == DTMFMode.RFC_2833:
+            logger.log(logging.DEBUG, "RFC_2833 DRMF key received but ignored")
+            return
+
+        # check for registered callbacks
+        if not self.__callbacks:
+            logger.log(logging.DEBUG, "No callbacks passed to RtpHandler.")
+            return
+        if not (callbacks := self.__callbacks.get('dtmf_callback')):
+            return
+        # notify the callbacks
+        for cb in callbacks:
+            await cb(event)
+
+    async def _handle_inband(self):
+        self._output_queues['inband_dtmf'] = dtmf_q = asyncio.Queue()
+        _buffer = DTMFBuffer()
+        while True:
+            if not self.is_running.is_set():
+                break
+
+            data = await dtmf_q.get() 
+            if data is None:
+                break
+
+            data_array = np.frombuffer(data, np.int16)
+            _buffer.buffer = np.concatenate((_buffer.buffer, data_array))
+
+            if not (len(_buffer.buffer) >= _buffer.size):
+                await asyncio.sleep(0.01)
+                continue 
+
+            # check for registered callbacks
+            if not self.__callbacks:
+                logger.log(logging.DEBUG, "No callbacks passed to RtpHandler.")
+                return
+            if not (callbacks := self.__callbacks.get('dtmf_callback')):
+                return
+
+            loop = asyncio.get_event_loop()
+            loop.run_in_executor(None, dtmf_detector_worker, _buffer, callbacks, loop)
+            await asyncio.sleep(0.01)
+
+    async def send(self):
+        while True:
+            start_processing = time.monotonic_ns()
+
+            if not self.is_running.is_set():
+                logger.log(logging.DEBUG, "Succesfully stopped the sender worker")
+                break
+
+            audio_stream = self.get_audio_stream()
+            if not audio_stream and not SEND_SILENCE:
+                await asyncio.sleep(0.02)  # avoid busy-waiting
+                continue
+
+            try:
+                if audio_stream is None:
+                    payload = self.generate_silence_frames()
+                else:
+                    payload = audio_stream.input_q.get_nowait()
+            except asyncio.QueueEmpty:
+                await asyncio.sleep(0.02)
+                continue
+
+            # if all frames are sent then continue
+            if not payload:
+                await asyncio.sleep(0.02)
+                if audio_stream is None:
+                    continue
+                logger.log(
+                    logging.DEBUG,
+                    f"Sent all frames from source with id: {audio_stream.stream_id}",
+                )
+                audio_stream.stream_done()
+                continue
+
+            encoded_payload = self.__encoder.encode(payload)
+            packet = RtpPacket(
+                payload_type=self.selected_codec,
+                payload=encoded_payload,
+                sequence_number=self.__sequence_number,
+                timestamp=self.__timestamp,
+                ssrc=self.ssrc,
+            ).serialize()
+            if not self.udp_writer:
+                raise ValueError("Couldnt send data. No UdpWriter found")
+            await self.udp_writer.write(packet)
+
+            delay = (1 / self.selected_codec.rate) * 160
+            processing_time = (time.monotonic_ns() - start_processing) / 1e9
+            sleep_time = delay - processing_time
+            self.__sequence_number = (self.__sequence_number + 1) % 65535  # Wrap around at 2^16 - 1
+            self.__timestamp = (self.__timestamp + len(encoded_payload)) % 4294967295  # Wrap around at 2^32 -1
+
+            await asyncio.sleep(sleep_time)
+
+    async def receive(self):
+        if not self.udp_reader:
+            logger.log(logging.CRITICAL, "There is no UdpReader, can't read!")
+            return
+
+        while True: 
+            try:
+                data = await self.udp_reader.read()
+                if data is None:
+                    logger.log(logging.DEBUG, "Receiver handler stopped.")
+                    break
+
+                packet = RtpPacket.parse(data)
+                if packet.payload_type == CodecInfo.EVENT:
+                    # handle rfc 2833 
+                    await self._handle_rfc_2833(packet)
+                    await asyncio.sleep(0.01)
+                    continue
+
+                if packet.payload_type not in CODECS:
+                    logger.log(logging.WARNING, f"Unsupported codecs received, {packet.payload_type}")
+                    await asyncio.sleep(0.01)
+                    continue
+
+                encoded_frame = self.__jitter_buffer.add(packet)
+                # if we have enough encoded buffer then decode
+                if encoded_frame:
+                    if self.__amd_detector and not self.__amd_detector.amd_started.is_set():
+                        self.__amd_detector.amd_started.set()
+                    decoded_frame = self.__decoder.decode(encoded_frame.data)
+                    for output_q in self._output_queues.values():
+                        await output_q.put(decoded_frame)
+
+                await asyncio.sleep(0)
+
+            except asyncio.TimeoutError:
+                await asyncio.sleep(0.01)
+                continue  # this is neccesary to avoid blocking of checking
+                # whether app is runing or not
+
+        # finally put None in to the q to tell stream ended 
+        for output_q in self._output_queues.values():
+            await output_q.put(None)
+
+    async def frame_monitor(self):
+        # first add stream queue to the output _output_queues
+        self._output_queues['frame_monitor'] = stream_q = asyncio.Queue()
+        while True:
+            if not self.is_running.is_set():
+                break
+            if not self.__callbacks:
+                logger.log(logging.DEBUG, "No callbacks passed to RtpHandler.")
+                break
+            try:
+                frame = await stream_q.get()
+                if frame is None:
+                    break
+                if not (callbacks := self.__callbacks.get('frame_monitor')):
+                    break
+                # check for registered callbacks
+                for cb in callbacks:
+                    await cb(frame)
+                await asyncio.sleep(0.1)
+
+            except asyncio.QueueEmpty:
+                await asyncio.sleep(0.1)
+                continue
+
+    def get_audio_stream(self):
+        return self._audio_stream
+
+    def set_audio_stream(self, stream: Union[AudioStream, None]):
+        # if there is previous stream mark it as done
+        if audio_stream := self.get_audio_stream():
+            audio_stream.stream_done()
+        self._audio_stream = stream
+
+        if stream:
+            logger.log(logging.DEBUG, f"Set new stream with id: {stream.stream_id}")
+        else:
+            logger.log(logging.DEBUG, "Set the stream to No stream")
+
+    @property
+    def _rtp_task(self) -> asyncio.Task:
+        return self.__rtp_task
+
+    @_rtp_task.setter
+    def _rtp_task(self, value: asyncio.Task):
+        self.__rtp_task = value
+
+
+class RtpPacket:
+    def __init__(
+        self,
+        payload_type: CodecInfo = CodecInfo.PCMA,
+        marker: int = 0,
+        sequence_number: int = 0,
+        timestamp: int = 0,
+        ssrc: int = 0,
+        payload: bytes = b"",
+    ):
+        self.payload_type = payload_type
+        self.sequence_number = sequence_number
+        self.timestamp = timestamp
+        self.marker = marker
+        self.ssrc = ssrc
+        self.csrc: List[int] = []
+        self.padding_size = 0
+        self.payload = payload
+
+    def serialize(self) -> bytes:
+        packet = b"\x80"
+        packet += chr(int(self.payload_type)).encode("utf8")
+        packet += self.get_header()
+        packet += self.payload
+        return packet
+
+    def get_header(self):
+        seq = self.sequence_number.to_bytes(2, byteorder="big")
+        ts = self.timestamp.to_bytes(4, byteorder="big")
+
+        ssrc = self.ssrc.to_bytes(4, byteorder="big")
+        header = seq + ts + ssrc
+ 
+        return header
+
+    @classmethod
+    def parse(cls, data: bytes):
+        if len(data) < RTP_HEADER_LENGTH:
+            raise ValueError(
+                f"RTP packet length is less than {RTP_HEADER_LENGTH} bytes"
+            )
+
+        v_p_x_cc, m_pt, sequence_number, timestamp, ssrc = unpack("!BBHLL", data[0:12])
+        version = v_p_x_cc >> 6
+        padding = (v_p_x_cc >> 5) & 1
+        extension = (v_p_x_cc >> 4) & 1
+        cc = v_p_x_cc & 0x0F
+        if version != 2:
+            raise ValueError("RTP packet has invalid version")
+        if len(data) < RTP_HEADER_LENGTH + 4 * cc:
+            raise ValueError("RTP packet has truncated CSRC")
+
+        try:
+            payload_type = CodecInfo((m_pt & 0x7F))
+        except ValueError:
+            payload_type = CodecInfo.UNKNOWN
+
+        packet = cls(
+            marker=(m_pt >> 7),
+            payload_type=payload_type,
+            sequence_number=sequence_number,
+            timestamp=timestamp,
+            ssrc=ssrc,
+        )
+
+        pos = RTP_HEADER_LENGTH
+        for _ in range(0, cc):
+            packet.csrc.append(unpack_from("!L", data, pos)[0])
+            pos += 4
+
+        if extension:
+            # not neccesary currently so just pass
+            pass
+
+        if padding:
+            padding_len = data[-1]
+            if not padding_len or padding_len > len(data) - pos:
+                raise ValueError("RTP packet padding length is invalid")
+            packet.padding_size = padding_len
+            packet.payload = data[pos:-padding_len]
+        else:
+            packet.payload = data[pos:]
+
+        return packet
diff --git a/PySIP/sip_call.py b/PySIP/sip_call.py
new file mode 100644
index 0000000..2881b24
--- /dev/null
+++ b/PySIP/sip_call.py
@@ -0,0 +1,755 @@
+import asyncio
+from collections import namedtuple
+from functools import wraps
+import logging
+import random
+from typing import Callable, Dict, List, Literal, Optional
+import wave
+
+from PySIP.call_handler import CallHandler
+from PySIP.exceptions import SIPTransferException
+
+from .rtp_handler import RTP_PORT_RANGE, RTPClient, TransmitType
+from .sip_core import Counter, DialogState, SipCore, SipDialogue, SipMessage
+from pydub import AudioSegment
+import os
+import janus
+
+from .filters import SIPCompatibleMethods, SIPStatus, CallState
+from .utils.logger import logger
+from .codecs import CODECS
+
+__all__ = ["SipCall", "DTMFHandler"]
+
+
+class SipCall:
+    """
+    Represents a VoIP call using SIP protocol.
+
+    Args:
+        username (str): SIP username.
+        route (str): SIP server route.
+        password (str, optional): Authentication password.
+        device_id (str, optional): Calling device ID.
+        tts (bool, optional): Enable Text-to-Speech.
+        text (str, optional): TTS text.
+
+    Methods:
+        :meth:`on_message()`: Start listening for SIP messages.
+        :meth:`signal_handler()`: Handle signals during the call.
+        :meth:`call(callee: str | int)`: Initiate a call.
+
+    Example:
+        voip_call = SipCall(username='user', route='server:port', password='pass')
+        voip_call.call('11234567890')
+    """
+
+    def __init__(
+        self,
+        username: str,
+        password: str,
+        route: str,
+        callee: str,
+        *,
+        connection_type: Literal["TCP", "UDP", "TLS", "TLSv1"] = "UDP",
+        caller_id: str = "",
+    ) -> None:
+        self.username = username
+        self.route = route
+        self.server = route.split(":")[0]
+        self.port = int(route.split(":")[1])
+        self.connection_type = connection_type
+        self.password = password
+        self.callee = callee
+        self.sip_core = SipCore(self.username, route, connection_type, password)
+        self.sip_core.on_message_callbacks.append(self.message_handler)
+        self.sip_core.on_message_callbacks.append(self.error_handler)
+        self._callbacks: Dict[str, List[Callable]] = {}
+        self.call_id = self.sip_core.gen_call_id()
+        self.cseq_counter = Counter(random.randint(1, 2000))
+        self.CTS = "TLS" if "TLS" in connection_type else connection_type
+        self.my_public_ip = self.sip_core.get_public_ip()
+        self.my_private_ip = self.sip_core.get_local_ip()
+        self._rtp_session: Optional[RTPClient] = None
+        self._call_handler = CallHandler(self)
+        self._dtmf_handler = DTMFHandler()
+        self._refer_future: asyncio.Future = asyncio.Future()
+        self.__recorded_audio_bytes: Optional[bytes] = None
+        self.dialogue = SipDialogue(self.call_id, self.sip_core.generate_tag(), "")
+        self.call_state = CallState.INITIALIZING
+
+    async def start(self):
+        _tasks = []
+        try:
+            self.setup_local_session()
+            self.dialogue.username = self.username
+            await self.sip_core.connect()
+            # regiser the callback for when the call is ANSWERED
+            self._register_callback("state_changed_cb", self.on_call_answered)
+            receive_task = asyncio.create_task(
+                self.sip_core.receive(), name="Receive Messages Task"
+            )
+            call_task = asyncio.create_task(
+                self.invite(), name="Call Initialization Task"
+            )
+            call_handler_task = asyncio.create_task(
+                self.call_handler.send_handler(), name="Calld Handler Task"
+            )
+            _tasks.extend([receive_task, call_task, call_handler_task])
+            try:
+                await asyncio.gather(*_tasks, return_exceptions=False)
+            except asyncio.CancelledError:
+                _task = asyncio.current_task()
+                if _task and _task.cancelling() > 0:
+                    raise
+
+        except Exception as e:
+            logger.log(logging.ERROR, e, exc_info=True)
+            return
+
+        finally:
+            for _task in _tasks:
+                if _task.done():
+                    continue
+                _task.cancel()
+                try:
+                    await _task
+                except asyncio.CancelledError:
+                    pass
+
+    async def stop(self, reason: str = "Normal Stop"):
+        # we have to handle three different scenarious when hanged-up
+        # 1st its if the state was in predialog state, in this scenarious
+        # we just close connections and thats all.
+        # 2nd scenario is if the state is initial meaning the dialog is
+        # established but not yet confirmed, thus we send cancel.
+        # 3rd scenario is if the state is confirmed meaning the call was
+        # asnwered and in this scenario we send bye.
+        if self.dialogue.state == DialogState.PREDIALOG:
+            self.sip_core.is_running.clear()
+            await self.sip_core.close_connections()
+            logger.info("The call has ben stopped")
+
+        elif (self.dialogue.state == DialogState.INITIAL) or (
+            self.dialogue.state == DialogState.EARLY
+        ):
+            # not that this will cancel using the latest transaction
+            transaction = self.dialogue.transactions[-1]
+            cancel_message = self.cancel_generator(transaction)
+            await self.sip_core.send(cancel_message)
+            try:
+                await asyncio.wait_for(
+                    self.dialogue.events[DialogState.TERMINATED].wait(), timeout=5
+                )
+                logger.log(logging.INFO, "The call has been cancelled")
+            except asyncio.TimeoutError:
+                logger.log(logging.WARNING, "The call has been cancelled with errors")
+            finally:
+                self.sip_core.is_running.clear()
+                await self.sip_core.close_connections()
+
+        elif self.dialogue.state == DialogState.CONFIRMED:
+            bye_message = self.bye_generator()
+            await self.sip_core.send(bye_message)
+            try:
+                await asyncio.wait_for(
+                    self.dialogue.events[DialogState.TERMINATED].wait(), timeout=5
+                )
+                logger.log(logging.INFO, "The call has been hanged up")
+            except asyncio.TimeoutError:
+                logger.log(logging.WARNING, "The call has been hanged up with errors")
+            finally:
+                self.sip_core.is_running.clear()
+                await self.sip_core.close_connections()
+
+        elif self.dialogue.state == DialogState.TERMINATED:
+            self.sip_core.is_running.clear()
+            await self.sip_core.close_connections()
+            logger.log(
+                logging.WARNING,
+                "The call was already TERMINATED. stop call invoked more than once.",
+            )
+
+        # finally notify the callbacks
+        for cb in self._get_callbacks("hanged_up_cb"):
+            logger.log(logging.DEBUG, f"The call has been hanged up due to: {reason}")
+            await cb(reason)
+
+        # also check for any rtp session and stop it
+        await self._cleanup_rtp()
+
+    async def _cleanup_rtp(self):
+        if not self._rtp_session:
+            return
+
+        if not self._rtp_session._rtp_task:
+            return
+
+        await self._rtp_session._stop()
+        await self._rtp_session._wait_stopped()
+        logger.log(logging.DEBUG, "now cleaning up the rtp..")
+
+        _rtp_task = self._rtp_session._rtp_task
+        try:
+            await _rtp_task
+        except asyncio.CancelledError:
+            pass
+        except Exception as e:
+            logger.log(logging.ERROR, f"Error while cleaning RTP: {e}")
+
+    async def _wait_stopped(self):
+        while True:
+            if not self.sip_core.is_running.is_set():
+                if self.call_state is CallState.INITIALIZING:
+                    await asyncio.sleep(0.1)
+                    continue
+                break
+
+            await asyncio.sleep(0.1)
+
+    def setup_local_session(self):
+        sdp = SipMessage.generate_sdp(
+            self.sip_core.get_local_ip(),
+            random.choice(RTP_PORT_RANGE),
+            random.getrandbits(32),
+            CODECS,
+        )
+        sdp = SipMessage.parse_sdp(SipMessage.sdp_to_dict(sdp))
+        self.dialogue._local_session_info = sdp
+
+    def generate_invite_message(self, auth=False, received_message=None):
+        _, local_port = self.sip_core.get_extra_info("sockname")
+        local_ip = self.my_public_ip  # Corrected the typo from 'puplic' to 'public'
+
+        if auth and received_message:
+            # Handling INVITE with authentication
+            nonce, realm, ip, port = self.extract_auth_details(received_message)
+            new_cseq = next(self.cseq_counter)
+            uri = f"sip:{self.callee}@{self.server}:{self.port};transport={self.CTS}"
+            auth_header = self.generate_auth_header("INVITE", uri, nonce, realm)
+            return self.construct_invite_message(
+                local_ip, local_port, new_cseq, auth_header, received_message
+            )
+
+        else:
+            # Initial INVITE without authentication
+            new_cseq = next(self.cseq_counter)
+            return self.construct_invite_message(local_ip, local_port, new_cseq)
+
+    def extract_auth_details(self, received_message):
+        nonce = received_message.nonce
+        realm = received_message.realm
+        ip = received_message.public_ip
+        port = received_message.rport
+        return nonce, realm, ip, port
+
+    def generate_auth_header(self, method, uri, nonce, realm):
+        response = self.sip_core.generate_response(method, nonce, realm, uri)
+        return (
+            f'Authorization: Digest username="{self.username}", '
+            f'realm="{realm}", nonce="{nonce}", uri="{uri}", '
+            f'response="{response}", algorithm="MD5"\r\n'
+        )
+
+    def construct_invite_message(
+        self, ip, port, cseq, auth_header=None, received_message=None
+    ):
+        # Common INVITE message components
+        tag = self.dialogue.local_tag
+        call_id = self.call_id
+        branch_id = self.sip_core.gen_branch()
+        transaction = self.dialogue.add_transaction(branch_id, "INVITE")
+
+        msg = (
+            f"INVITE sip:{self.callee}@{self.server}:{self.port};transport={self.CTS} SIP/2.0\r\n"
+            f"Via: SIP/2.0/{self.CTS} {ip}:{port};rport;branch={branch_id};alias\r\n"
+            f"Max-Forwards: 70\r\n"
+            f"From: <sip:{self.username}@{self.server}>;tag={tag}\r\n"
+            f"To: sip:{self.callee}@{self.server}\r\n"
+            f"Call-ID: {call_id}\r\n"
+            f"CSeq: {transaction.cseq} INVITE\r\n"
+            f"Contact: <sip:{self.username}@{ip}:{port};transport={self.CTS}>;expires=3600\r\n"
+            f"Allow: {', '.join(SIPCompatibleMethods)}\r\n"
+            "Supported: replaces, timer\r\n"
+            "Content-Type: application/sdp\r\n"
+        )
+
+        # Addang the Authorization header if auth is required
+        if auth_header:
+            msg += auth_header
+
+        body = str(self.dialogue.local_session_info)
+        msg += f"Content-Length: {len(body.encode())}\r\n\r\n{body}"
+
+        return msg
+
+    def ack_generator(self, transaction):
+        _, port = self.sip_core.get_extra_info("sockname")
+        ip = self.my_public_ip
+
+        msg = f"ACK sip:{self.callee}@{self.server}:{self.port};transport={self.CTS} SIP/2.0\r\n"
+        msg += f"Via: SIP/2.0/{self.CTS} {ip}:{port};rport;branch={transaction.branch_id};alias\r\n"
+        msg += "Max-Forwards: 70\r\n"
+        msg += (
+            f"From: sip:{self.username}@{self.server};tag={self.dialogue.local_tag}\r\n"
+        )
+        msg += f"To: sip:{self.callee}@{self.server};tag={self.dialogue.remote_tag}\r\n"
+        msg += f"Call-ID: {self.call_id}\r\n"
+        msg += f"CSeq: {transaction.cseq} ACK\r\n"
+        msg += f"Route: <sip:{self.server}:{self.port};transport={self.CTS};lr>\r\n"
+        msg += "Content-Length: 0\r\n\r\n"
+
+        return msg
+
+    def bye_generator(self):
+        peer_ip, peer_port = self.sip_core.get_extra_info("peername")
+        _, port = self.sip_core.get_extra_info("sockname")
+
+        branch_id = self.sip_core.gen_branch()
+        transaction = self.dialogue.add_transaction(branch_id, "BYE")
+
+        msg = f"BYE sip:{self.callee}@{peer_ip}:{peer_port};transport={self.CTS} SIP/2.0\r\n"
+        msg += (
+            f"Via: SIP/2.0/{self.CTS} {self.my_public_ip}:{port};rport;"
+            + f"branch={branch_id};alias\r\n"
+        )
+        msg += 'Reason: Q.850;cause=16;text="normal call clearing"'
+        msg += "Max-Forwards: 70\r\n"
+        msg += (
+            f"From: sip:{self.username}@{self.server};tag={self.dialogue.local_tag}\r\n"
+        )
+        msg += f"To: sip:{self.callee}@{self.server};tag={self.dialogue.remote_tag}\r\n"
+        msg += f"Call-ID: {self.call_id}\r\n"
+        msg += f"CSeq: {transaction.cseq} BYE\r\n"
+        msg += "Content-Length: 0\r\n\r\n"
+
+        return msg
+
+    def refer_generator(self, refer_to_callee):
+        _, port = self.sip_core.get_extra_info("sockname")
+        ip = self.my_public_ip
+
+        branch_id = self.sip_core.gen_branch()
+        transaction = self.dialogue.add_transaction(branch_id, "REFER")
+        refer_to = f"sip:{refer_to_callee}@{self.server};transport={self.CTS}"
+        referred_by = f"sip:{self.username}@{self.server}"
+
+        msg = f"REFER sip:{self.callee}@{self.server}:{self.port};transport={self.CTS} sip/2.0\r\n"
+        msg += f"Via: sip/2.0/{self.CTS} {ip}:{port};rport;branch={branch_id};alias\r\n"
+        msg += "Max-Forwards: 70\r\n"
+        msg += (
+            f"From: sip:{self.username}@{self.server};tag={self.dialogue.local_tag}\r\n"
+        )
+        msg += f"To: sip:{self.callee}@{self.server};tag={self.dialogue.remote_tag}\r\n"
+        msg += f"Call-ID: {self.call_id}\r\n"
+        msg += f"CSeq: {transaction.cseq} REFER\r\n"
+        msg += f"Refer-To: {refer_to}\r\n"
+        msg += f"Referred-By: {referred_by}\r\n"
+        msg += f"Contact: <sip:{self.username}@{ip}:{port};transport={self.CTS};ob>\r\n"
+        msg += "Content-Length: 0\r\n\r\n"
+
+        return msg
+
+    def cancel_generator(self, transaction):
+        _, port = self.sip_core.get_extra_info("sockname")
+        ip = self.my_public_ip
+
+        msg = f"CANCEL sip:{self.callee}@{self.server}:{self.port};transport={self.CTS} SIP/2.0\r\n"
+        msg += (
+            f"Via: SIP/2.0/{self.CTS} {ip}:{port};"
+            + f"rport;branch={transaction.branch_id};alias\r\n"
+        )
+        msg += "Max-Forwards: 70\r\n"
+        msg += (
+            f"From:sip:{self.username}@{self.server};tag={self.dialogue.local_tag}\r\n"
+        )
+        msg += f"To: sip:{self.callee}@{self.server}\r\n"
+        msg += f"Call-ID: {self.call_id}\r\n"
+        msg += f"CSeq: {transaction.cseq} CANCEL\r\n"
+        msg += "Content-Length: 0\r\n\r\n"
+
+        return msg
+
+    def ok_generator(self, data_parsed: SipMessage):
+        peer_ip, peer_port = self.sip_core.get_extra_info("peername")
+        _, port = self.sip_core.get_extra_info("sockname")
+
+        if data_parsed.is_from_client(self.username):
+            from_header = f"From: <sip:{self.username}@{self.server}>;tag={self.dialogue.local_tag}\r\n"
+            to_header = f"To: <sip:{self.callee}@{self.server}>;tag={self.dialogue.remote_tag}\r\n"
+        else:
+            from_header = f"From: <sip:{self.callee}@{self.server}>;tag={self.dialogue.remote_tag}\r\n"
+            to_header = f"To: <sip:{self.username}@{self.server}>;tag={self.dialogue.local_tag}\r\n"
+
+        msg = "SIP/2.0 200 OK\r\n"
+        msg += "Via: " + data_parsed.get_header("Via") + "\r\n"
+        msg += from_header
+        msg += to_header
+        msg += f"Call-ID: {data_parsed.call_id}\r\n"
+        msg += f"CSeq: {data_parsed.cseq} {data_parsed.method}\r\n"
+        msg += f"Contact: <sip:{self.username}@{self.my_public_ip}:{port};transport={self.CTS.upper()};ob>\r\n"
+        msg += f"Allow: {', '.join(SIPCompatibleMethods)}\r\n"
+        msg += "Supported: replaces, timer\r\n"
+        msg += "Content-Length: 0\r\n\r\n"
+
+        return msg
+
+    async def message_handler(self, msg: SipMessage):
+        # In call events Handling
+
+        # If the call id is not same as the current then return
+        if msg.call_id != self.call_id:
+            return
+
+        if msg.status == SIPStatus(401) and msg.method == "INVITE":
+            # Handling the auth of the invite
+            self.dialogue.remote_tag = msg.to_tag or ""
+            transaction = self.dialogue.find_transaction(msg.branch)
+            if not transaction:
+                return
+            ack_message = self.ack_generator(transaction)
+            await self.sip_core.send(ack_message)
+
+            if self.dialogue.auth_retry_count > self.dialogue.AUTH_RETRY_MAX:
+                await self.stop("Unable to authenticate, check details")
+                return
+            # Then send reinvite with Authorization
+            await self.reinvite(True, msg)
+            await self.update_call_state(CallState.DAILING)
+            self.dialogue.auth_retry_count += 1
+            logger.log(logging.INFO, "Sent INVITE request to the server")
+
+        elif msg.status == SIPStatus(200) and msg.method == "INVITE":
+            # Handling successfull invite response
+            self.dialogue.remote_tag = msg.to_tag or ""  # setting it if not set
+            logger.log(logging.INFO, "INVITE Successfull, dialog is established.")
+            transaction = self.dialogue.add_transaction(
+                self.sip_core.gen_branch(), "ACK"
+            )
+            ack_message = self.ack_generator(transaction)
+            self.dialogue.auth_retry_count = 0  # reset the auth counter
+            await self.sip_core.send(ack_message)
+            self.dialogue.update_state(msg)
+            await self.update_call_state(CallState.ANSWERED)
+            return
+
+        elif str(msg.status).startswith("1") and msg.method == "INVITE":
+            # Handling 1xx profissional responses
+            st = (
+                CallState.RINGING if msg.status is SIPStatus(180) else CallState.DAILING
+            )
+            await self.update_call_state(st)
+            self.dialogue.remote_tag = msg.to_tag or ""  # setting it if not already
+            self.dialogue.auth_retry_count = 0  # reset the auth counter
+            pass
+
+        elif msg.method == "BYE" and not msg.is_from_client(self.username):
+            # Hanlding callee call hangup
+            await self.update_call_state(CallState.ENDED)
+            if not str(msg.data).startswith("BYE"):
+                # Seperating BYE messges from 200 OK to bye messages or etc.
+                self.dialogue.update_state(msg)
+                return
+            ok_message = self.ok_generator(msg)
+            await self.sip_core.send(ok_message)
+            await self.stop("Callee hanged up")
+
+        elif msg.method == "BYE" and msg.is_from_client(self.username):
+            await self.update_call_state(CallState.ENDED)
+
+        elif msg.status == SIPStatus(487) and msg.method == "INVITE":
+            transaction = self.dialogue.find_transaction(msg.branch)
+            if not transaction:
+                return
+            ack_message = self.ack_generator(transaction)
+            await self.sip_core.send(ack_message)
+            await self.update_call_state(CallState.FAILED)
+
+        elif str(msg.status).startswith("2") and msg.method == "REFER":
+            SIPTransferResult = namedtuple("SIPTransferResult", ["code", "description"])
+            if not self._refer_future.done():
+                description = "success"
+                self._refer_future.set_result(
+                    SIPTransferResult(int(msg.status or 0), description)
+                )
+
+        elif str(msg.status).startswith(("4", "5", "6")) and msg.method == "REFER":
+            if not self._refer_future.done():
+                description = (
+                    "client error"
+                    if str(msg.status).startswith("4")
+                    else "server error"
+                )
+        
+                self._refer_future.set_exception(
+                    SIPTransferException(int(msg.status or -1), description)
+                )
+
+        elif str(msg.data).startswith("NOTIFY"):
+            if msg.body_data:
+                _data = msg.body_data.split(' ')
+                logger.log(
+                    logging.DEBUG, "Transfer status: %s",
+                    ' '.join(_data[2:]).replace('\r\n', '')
+                )
+                for _cb in self._get_callbacks("transfer_cb"):
+                    await _cb(SIPStatus(int(_data[1])))
+
+            message = self.ok_generator(msg)
+            await self.sip_core.send(message) 
+
+        # Finally update status and fire events
+        self.dialogue.update_state(msg)
+
+    async def error_handler(self, msg: SipMessage):
+        if not msg.status:
+            return
+        
+        if not self.dialogue.remote_tag:
+            self.dialogue.remote_tag = msg.to_tag or ""
+
+        if not 400 <= msg.status.code <= 699:
+            return
+
+        if msg.status in [SIPStatus(401), SIPStatus(487)]:
+            return
+
+        if msg.status in [SIPStatus(486), SIPStatus(600), SIPStatus(603)]:
+            # handle if busy
+            transaction = self.dialogue.find_transaction(msg.branch)
+            if not transaction:
+                return
+            ack_message = self.ack_generator(transaction)
+            await self.sip_core.send(ack_message)
+            # set the diologue state to TERMINATED and close
+            self.dialogue.state = DialogState.TERMINATED
+            self.dialogue.update_state(msg)
+            await self.update_call_state(CallState.BUSY)
+            if msg.status:
+                await self.stop(msg.status.phrase)
+            else:
+                await self.stop()
+
+        else:
+            # for all other errors just send ack
+            transaction = self.dialogue.find_transaction(msg.branch)
+            if not transaction:
+                return
+            ack_message = self.ack_generator(transaction)
+            await self.sip_core.send(ack_message)
+
+            if msg.status in [SIPStatus.REQUEST_PENDING]:
+                return
+            # set the diologue state to TERMINATED and close
+            self.dialogue.state = DialogState.TERMINATED
+            self.dialogue.update_state(msg)
+            await self.update_call_state(CallState.FAILED)
+            if msg.status:
+                await self.stop(msg.status.phrase)
+            else:
+                await self.stop()
+
+    async def reinvite(self, auth, msg):
+        reinvite_msg = self.generate_invite_message(auth, msg)
+        await self.sip_core.send(reinvite_msg)
+        return
+
+    async def invite(self):
+        msg = self.generate_invite_message()
+        self.last_invite_msg = msg
+
+        await self.sip_core.send(msg)
+        return
+
+    async def update_call_state(self, new_state):
+        if new_state == self.call_state:
+            return
+
+        for cb in self._get_callbacks("state_changed_cb"):
+            await cb(new_state)
+
+        self.call_state = new_state
+        logger.log(logging.DEBUG, f"Call state changed to -> {new_state}")
+
+    def _register_callback(self, cb_type, cb):
+        self._callbacks.setdefault(cb_type, []).append(cb)
+
+    def _get_callbacks(self, cb_type):
+        return self._callbacks.get(cb_type, [])
+
+    def _remove_callback(self, cb_type, cb):
+        callbacks = self._callbacks.get(cb_type, [])
+        if cb in callbacks:
+            callbacks.remove(cb)
+
+    def on_call_hanged_up(self, func):
+        @wraps(func)
+        async def wrapper(reason: str):
+            return await func(reason)
+
+        self._register_callback("hanged_up_cb", wrapper)
+        return wrapper
+
+    def on_call_state_changed(self, func):
+        @wraps(func)
+        async def wrapper(new_state):
+            return await func(new_state)
+
+        self._register_callback("state_changed_cb", wrapper)
+        return wrapper
+
+    async def on_call_answered(self, state: CallState):
+        if state is CallState.ANSWERED:
+            # set-up RTP connections
+            if not self.dialogue.local_session_info:
+                logger.log(logging.CRITICAL, "No local session info defined")
+                await self.stop()
+                return
+            elif not self.dialogue.remote_session_info:
+                logger.log(logging.CRITICAL, "No remote session info defined")
+                await self.stop()
+                return
+
+            local_sdp = self.dialogue.local_session_info
+            remote_sdp = self.dialogue.remote_session_info
+            self._rtp_session = RTPClient(
+                remote_sdp.rtpmap,
+                local_sdp.ip_address,
+                local_sdp.port,
+                remote_sdp.ip_address,
+                remote_sdp.port,
+                TransmitType.SENDRECV,
+                local_sdp.ssrc,
+                self._callbacks,
+            )
+            # start the session
+            _rtp_task = asyncio.create_task(self._rtp_session._start())
+            self._rtp_session._rtp_task = _rtp_task
+            self._register_callback("dtmf_handler", self._dtmf_handler.dtmf_callback)
+            logger.log(logging.INFO, "Done spawned _rtp_task in the background")
+
+    def on_frame_received(self, func):
+        @wraps(func)
+        async def wrapper(frame):
+            return await func(frame)
+
+        self._register_callback("frame_monitor", wrapper)
+        return wrapper
+
+    def on_dtmf_received(self, func):
+        @wraps(func)
+        async def wrapper(dtmf_key):
+            return await func(dtmf_key)
+
+        self._register_callback("dtmf_callback", wrapper)
+        return wrapper
+
+    def on_amd_state_received(self, func):
+        @wraps(func)
+        async def wrapper(amd_state):
+            return await func(amd_state)
+
+        self._register_callback("amd_app", wrapper)
+        return wrapper
+
+    def on_transfer_state_changed(self, func):
+        @wraps(func)
+        async def wrapper(status: SIPStatus):
+            return await func(status)
+
+        self._register_callback("transfer_cb", wrapper)
+        return
+
+    @property
+    def call_handler(self) -> CallHandler:
+        return self._call_handler
+
+    @call_handler.setter
+    def call_handler(self, call_handler: CallHandler):
+        self._call_handler = call_handler
+
+    def process_recorded_audio(self) -> bytes:
+        """Unpacks the recorded audio queue and make into bytes array"""
+        audio_bytes = bytearray()
+        if not self._rtp_session:
+            logger.log(
+                logging.WARNING,
+                "Can not get recorded audio as there is no established session",
+            )
+            return bytes(audio_bytes)
+
+        while True:
+            try:
+                if not (queue := self._rtp_session._output_queues.get("audio_record")):
+                    break
+                if not (frame := queue.get_nowait()):
+                    break
+                audio_bytes.extend(frame)
+            except asyncio.QueueEmpty:
+                break
+        return bytes(audio_bytes)
+
+    def get_recorded_audio(self, filename: Optional[str] = None, format="wav"):
+        """Only wav format supported currently the others wil be added"""
+        if not self._rtp_session:
+            logger.log(
+                logging.WARNING,
+                "Can not get recorded audio as there is no established session",
+            )
+            return
+        if self.__recorded_audio_bytes is None:
+            self.__recorded_audio_bytes = self.process_recorded_audio()
+
+        filename = f'call_{self.call_id}.wav' if not filename else filename
+        with wave.open(filename, "wb") as f:
+            f.setsampwidth(2)
+            f.setframerate(8000)
+            f.setnchannels(1)
+
+            f.writeframes(self.__recorded_audio_bytes)
+
+    @property
+    def recorded_audio_raw(self):
+        if self.__recorded_audio_bytes is None:
+            self.__recorded_audio_bytes = self.process_recorded_audio()
+
+        return self.__recorded_audio_bytes
+
+
+class DTMFHandler:
+    def __init__(self) -> None:
+        self.queue: asyncio.Queue = asyncio.Queue()
+        self.dtmf_queue: asyncio.Queue = asyncio.Queue()
+        self.started_typing_event = asyncio.Event()
+        self.dtmf_codes: List[str] = []
+
+    async def dtmf_callback(self, code: str) -> None:
+        await self.queue.put(code)
+        self.dtmf_codes.append(code)
+
+    async def started_typing(self, event, *args):
+        await self.started_typing_event.wait()
+        self.started_typing_event.clear()
+        event(*args)
+
+    async def get_dtmf(self, length=1, finish_on_key=None) -> str:
+        dtmf_codes: List[str] = []
+
+        if finish_on_key:
+            while True:
+                code = await self.queue.get()
+                if dtmf_codes and code == finish_on_key:
+                    break
+                dtmf_codes.append(code)
+                if not self.started_typing_event.is_set():
+                    self.started_typing_event.set()
+
+        else:
+            for _ in range(length):
+                code = await self.queue.get()
+                dtmf_codes.append(code)
+                if not self.started_typing_event.is_set():
+                    self.started_typing_event.set()
+
+        self.started_typing_event.clear()
+        return "".join(dtmf_codes)
diff --git a/PySIP/sip_client.py b/PySIP/sip_client.py
new file mode 100644
index 0000000..a38c519
--- /dev/null
+++ b/PySIP/sip_client.py
@@ -0,0 +1,262 @@
+import asyncio
+import logging
+import uuid
+import random
+import traceback
+
+from .sip_core import SipCore, SipMessage, Counter
+from .filters import SIPCompatibleMethods, SIPStatus, ConnectionType, SipFilter
+from .utils.logger import logger
+from .exceptions import NoPasswordFound
+
+
+__all__ = [
+    'SipClient'
+]
+
+
+class SipClient:
+
+    def __init__(
+        self, username, server, connection_type: str,
+        password: str):
+        self.username = username
+        self.server = server.split(":")[0]
+        self.port = server.split(":")[1]
+
+        if password:
+            self.password = password
+        else:
+            raise NoPasswordFound("No password was provided please provide password to use for Digest auth.")
+
+        self.CTS = 'TLS' if 'TLS' in connection_type else connection_type
+        self.connection_type = ConnectionType(connection_type)
+        self.reader, self.writer = None, None
+        self.pysip_tasks = []
+        self.sip_core = SipCore(self.username, server, connection_type, password)
+        self.call_id = self.sip_core.gen_call_id()
+        self.sip_core.on_message_callbacks.append(self.message_handler)
+        self.register_counter = Counter(random.randint(1, 2000))
+        self.register_tags = {"local_tag": "", "remote_tag": "", "type": "", "cseq": 0}
+        self.unregistered = asyncio.Event()
+        self.my_public_ip = self.sip_core.get_public_ip()
+        self.my_private_ip = self.sip_core.get_local_ip()
+
+    async def run(self):
+        register_task = None
+        receive_task = None
+        try:
+            await self.sip_core.connect()
+            register_task = asyncio.create_task(self.periodic_register(60), name='Periodic Register')
+            receive_task = asyncio.create_task(self.sip_core.receive(), name='Receive Messages Task')
+
+            try:
+                await asyncio.gather(receive_task, register_task)
+            except asyncio.CancelledError:
+                if receive_task.done():
+                    pass
+                if asyncio.current_task() and asyncio.current_task().cancelling() > 0:
+                    raise
+
+        except Exception as e:
+            logger.log(logging.ERROR, e, exc_info=True)
+            return
+
+        finally:
+            
+            if register_task and not register_task.done():
+                register_task.cancel()
+                try:
+                    await register_task
+                except asyncio.CancelledError:
+                    pass  # Task cancellation is expected
+
+            if receive_task and not receive_task.done():
+                receive_task.cancel()
+                try:
+                    await receive_task
+                except asyncio.CancelledError:
+                    pass  # Task cancellation is expected
+
+    async def stop(self):
+        unregister = self.build_register_message(unregister=True)
+        await self.sip_core.send(unregister)
+        try:
+            await asyncio.wait_for(self.unregistered.wait(), 4)
+            logger.log(logging.INFO, "Sip client has been de-registered from the server")
+        except asyncio.TimeoutError:
+            logger.log(logging.WARNING, "Failed to de-register. Closing the app.")
+
+        self.sip_core.is_running.clear()
+        await self.sip_core.close_connections()
+
+    async def periodic_register(self, delay: float):
+        while True:
+            await self.register()
+
+            sleep_task = asyncio.create_task(asyncio.sleep(delay - 5))
+            event_cleared_task = asyncio.create_task(self.wait_for_event_clear(self.sip_core.is_running))
+
+            _, pending = await asyncio.wait(
+                [sleep_task, event_cleared_task],
+                return_when="FIRST_COMPLETED"
+            )
+
+            for task in pending:
+                task.cancel()
+
+            if not self.sip_core.is_running.is_set():
+                break
+
+        logger.log(logging.DEBUG, "The app will no longer register. Registeration task stopped.")
+
+
+    async def wait_for_event_clear(self, event: asyncio.Event):
+        while True:
+            if not event.is_set():
+                break
+
+            await asyncio.sleep(0.1)
+
+    def build_register_message(self, auth=False, received_message=None, unregister=False):
+        # Generate unique identifiers for the message
+        branch_id = str(uuid.uuid4()).upper()
+        # Initialize transaction
+        call_id = self.call_id
+        
+        # Start building the SIP message based on authentication need
+        if auth:
+            # Handling authenticated REGISTER request
+            unregister = True if self.register_tags['type'] == "UNREGISTER" else False
+            if not received_message:
+                return
+            nonce = received_message.nonce
+            realm = received_message.realm
+            cseq = next(self.register_counter)
+            self.register_tags['cseq'] = cseq
+            to_tag = self.register_tags['remote_tag'] = received_message.to_tag
+            uri = f'sip:{self.server}:{self.port};transport={self.CTS}'
+            response = self.sip_core.generate_response("REGISTER", nonce, realm, uri)
+            
+            # Adjust Via and Contact headers for public IP and port if available
+            my_public_ip = self.my_public_ip
+            ip = received_message.public_ip if not my_public_ip else my_public_ip
+            port = received_message.rport
+            from_tag = self.register_tags['local_tag']
+            expires = ";expires=0" if unregister else ""
+            expires_field = "Expires: 60\r\n" if not unregister else ""
+            
+            # Construct the REGISTER request with Authorization header
+            msg = (f"REGISTER sip:{self.server};transport={self.CTS} SIP/2.0\r\n"
+                   f"Via: SIP/2.0/{self.CTS} {ip}:{port};rport;branch={received_message.branch};alias\r\n"
+                   f"Max-Forwards: 70\r\n"
+                   f"From: <sip:{self.username}@{self.server}>;tag={from_tag}\r\n"
+                   f"To: <sip:{self.username}@{self.server}>;tag={to_tag}\r\n"
+                   f"Call-ID: {call_id}\r\n"
+                   f"CSeq: {cseq} REGISTER\r\n"
+                   f"Contact: <sip:{self.username}@{ip}:{port};transport={self.CTS};ob>{expires}\r\n" 
+                   f"{expires_field}"
+                   f"Authorization: Digest username=\"{self.username}\", realm=\"{realm}\", nonce=\"{nonce}\", uri=\"{uri}\", response=\"{response}\", algorithm=\"MD5\"\r\n"
+                   f"Content-Length: 0\r\n\r\n")
+        else:
+            # Handling unauthenticated REGISTER request
+            ip = self.my_private_ip
+            port = self.port
+            _, my_public_port = self.sip_core.get_extra_info('sockname')
+            if not self.register_tags['local_tag']:
+                self.register_tags['local_tag'] = self.sip_core.generate_tag()
+
+            cseq = next(self.register_counter)
+            expires = ";expires=0" if unregister else ""
+            expires_field = "Expires: 60\r\n" if not unregister else ""
+            self.register_tags["type"] = "UNREGISTER" if unregister else "REGISTER"
+            
+            # Construct the REGISTER request without Authorization header
+            msg = (f"REGISTER sip:{self.server};transport={self.CTS} SIP/2.0\r\n"
+                   f"Via: SIP/2.0/{self.CTS} {ip}:{my_public_port};rport;branch={branch_id};alias\r\n"
+                   f"Max-Forwards: 70\r\n"
+                   f"From: <sip:{self.username}@{self.server}>;tag={self.register_tags['local_tag']}\r\n"
+                   f"To: <sip:{self.username}@{self.server}>\r\n"
+                   f"Call-ID: {call_id}\r\n"
+                   f"CSeq: {cseq} REGISTER\r\n"
+                   f"Contact: <sip:{self.username}@{self.my_public_ip}:{my_public_port};transport={self.CTS};ob>{expires}\r\n"
+                   f"{expires_field}"
+                   f"Content-Length: 0\r\n\r\n")
+
+        return msg 
+
+    def ok_generator(self, data_parsed: SipMessage):
+        peer_ip, peer_port = self.sip_core.get_extra_info("peername")
+        _, port = self.sip_core.get_extra_info('sockname')
+        my_public_ip = self.my_public_ip
+
+        msg = "SIP/2.0 200 OK\r\n"
+        msg += (f"Via: SIP/2.0/{self.CTS} {my_public_ip}:{port};rport;" +
+                f"branch={data_parsed.branch}\r\n")
+
+        if data_parsed.method == "OPTIONS":
+            to_tag = self.sip_core.generate_tag()
+            msg += f"From: {data_parsed.get_header('From')}\r\n"
+            msg += f"To: <sip:{self.username}@{self.server}>;tag={to_tag}\r\n"
+        else:
+            msg += f"From: <sip:{self.username}@{self.server}>;tag={data_parsed.from_tag}\r\n"
+            msg += f"To: <sip:{self.username}@{self.server}>;tag={data_parsed.to_tag}\r\n"
+
+        msg += f"Call-ID: {data_parsed.call_id}\r\n"
+        msg += f"CSeq: {data_parsed.cseq} {data_parsed.method}\r\n"
+        msg += f"Contact: <sip:{self.username}@{my_public_ip}:{port};transport={self.CTS.upper()};ob>\r\n"
+        msg += f"Allow: {', '.join(SIPCompatibleMethods)}\r\n"
+        msg += "Supported: replaces, timer\r\n"
+        msg += "Content-Length: 0\r\n\r\n"
+
+        return msg
+
+    async def ping(self):
+        options_message = "" #TODO Impmelement an options generator when required
+        await self.sip_core.send(options_message)
+
+    
+    async def reregister(self, auth, data):
+        msg = self.build_register_message(auth, data)
+
+        await self.sip_core.send(msg)
+        return
+
+    async def register(self):
+        msg = self.build_register_message()
+
+        await self.sip_core.send(msg)
+        return
+
+    async def message_handler(self, msg: SipMessage):
+        # This is the main message handler inside the class
+        # its like other handlers outside the class that can
+        # be accessed with @:meth:`Client.on_message` the only
+        # difference is that its handled inside the :obj:`Client`
+        # and it's onlt for developer's usage. unlike other handlers
+        # it has no filters for now.
+        to = msg.get_header("To")
+        if (not msg.call_id == self.call_id and
+            self.username not in to): # Filter only current call
+            return # These are just for extra check and not necessary
+
+        await asyncio.sleep(0.001)
+        if msg.status == SIPStatus(401) and msg.method == "REGISTER":
+            # This is the case when we have to send a retegister
+            await self.reregister(True, msg)
+            logger.log(logging.INFO, "Register message has been sent to the server")
+
+        elif msg.status == SIPStatus(200) and msg.method == "REGISTER":
+            # This is when we receive the response for the register
+            logger.log(logging.INFO, "Successfully REGISTERED")
+
+            # In case the response is of Un-register we set this
+            if self.register_tags['type'] == "UNREGISTER":
+                if msg.cseq == self.register_tags['cseq']:
+                    self.unregistered.set()
+
+        elif msg.data.startswith("OPTIONS"): # If we recieve PING then PONG incase of keep-alive required
+            logger.log(logging.DEBUG, "Keep-alive message received from the server. sending OK")
+            options_ok = self.ok_generator(msg)
+            await self.sip_core.send(options_ok)
+
diff --git a/PySIP/sip_core.py b/PySIP/sip_core.py
new file mode 100644
index 0000000..15824e5
--- /dev/null
+++ b/PySIP/sip_core.py
@@ -0,0 +1,807 @@
+import logging
+import re
+import random
+import asyncio
+from functools import wraps
+import socket
+import ssl
+import uuid
+import hashlib
+from typing import List, Optional
+import requests
+
+from .utils.logger import logger
+from .filters import (
+    SipFilter,
+    SIPMessageType,
+    SIPCompatibleMethods,
+    SIPStatus,
+    ConnectionType,
+)
+from .udp_handler import open_udp_connection
+from .filters import (
+    SIPCompatibleMethods,
+    SIPCompatibleVersions,
+    SIPMessageType,
+    SIPStatus,
+)
+from .exceptions import NoPasswordFound
+from .codecs.codec_info import CodecInfo
+from enum import Enum, auto
+
+
+class DialogState(Enum):
+    PREDIALOG = (
+        auto()
+    )  # Custom state meaning before auth is finished, as we inclided it in the dialog
+    INITIAL = auto()  # Before receiving any provisional response
+    EARLY = (
+        auto()
+    )  # After receiving a provisional response but before the final response
+    CONFIRMED = auto()  # After receiving a final response
+    TERMINATED = auto()  # After the dialog has end_of_headers
+
+
+class DTMFMode(Enum):
+    RFC_2833 = auto()
+    INBAND = auto()
+
+
+class SipCore:
+    def __init__(self, username, server, connection_type: str, password: str):
+        self.username = username
+        self.server = server.split(":")[0]
+        self.port = server.split(":")[1]
+        self.connection_type = ConnectionType(connection_type)
+        if password:
+            self.password = password
+        else:
+            raise NoPasswordFound(
+                "No password was provided please provide password to use for Digest auth."
+            )
+
+        self.on_message_callbacks = []
+        self.tags = []
+        self.is_running = asyncio.Event()
+        self.reader, self.writer = None, None
+        self.udp_reader, self.udp_writer = None, None
+
+    def get_public_ip(self) -> str | None:
+        try:
+            external_ip = requests.get(
+                "https://api64.ipify.org?format=json", timeout=8
+            ).json()["ip"]
+        except requests.exceptions.Timeout:
+            external_ip = None
+
+        return external_ip
+
+    def get_local_ip(self):
+        try:
+            # Create a socket object
+            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+            # Connect the socket to a remote server
+            # Here, '8.8.8.8' is the Google Public DNS, and '80' is the port number.
+            s.connect(("8.8.8.8", 80))
+            # Get the local IP address the socket is connected with
+            ip = s.getsockname()[0]
+            # Close the socket
+            s.close()
+        except Exception as e:
+            logger.log(logging.CRITICAL, "The local IP address couldn't be retreived.")
+            raise e
+        return ip
+
+    def get_extra_info(self, name: str):
+        if self.connection_type == ConnectionType.UDP:
+            if not self.udp_writer:
+                raise ValueError("No UdpWriter")
+            peer_info = self.udp_writer.get_extra_info(name)
+            if peer_info:
+                peer_ip, peer_port = peer_info
+                return (peer_ip, peer_port)
+            raise ValueError("No peer info")
+
+        else:
+            if not self.writer:
+                raise ValueError("No StreamWriter")
+            peer_ip, peer_port = self.writer.get_extra_info(name)
+            return (peer_ip, peer_port)
+
+    def generate_tag(self):
+        tag = str(uuid.uuid4()).upper()
+        if tag not in self.tags:
+            self.tags.append(tag)
+            return tag
+        return ""
+
+    def generate_urn_uuid(self):
+        return str(uuid.uuid4()).upper()
+
+    def gen_call_id(self) -> str:
+        call_id = str(uuid.uuid4()).upper()
+        return call_id
+
+    def gen_urn_uuid(self) -> str:
+        """
+        Generate client instance specific urn:uuid
+        """
+        return str(uuid.uuid4())
+
+    def gen_branch(self):
+        return f"z9hG4bK-{str(uuid.uuid4())}"
+
+    def generate_response(self, method, nonce, realm, uri):
+        A1_string = self.username + ":" + realm + ":" + self.password
+        A1_hash = hashlib.md5(A1_string.encode()).hexdigest()
+
+        A2_string = (method + ":" + uri).encode()
+        A2_hash = hashlib.md5(A2_string).hexdigest()
+
+        response_string = (A1_hash + ":" + nonce + ":" + A2_hash).encode()
+        response_hash = hashlib.md5(response_string).hexdigest()
+
+        return response_hash
+
+    async def connect(self):
+        try:
+            if self.connection_type == ConnectionType.TCP:
+                self.is_running.set()
+                self.reader, self.writer = await asyncio.open_connection(
+                    self.server,
+                    self.port,
+                )
+
+            elif self.connection_type == ConnectionType.UDP:
+                self.is_running.set()
+
+                self.udp_reader, self.udp_writer = await open_udp_connection(
+                    (self.server, self.port)
+                )
+
+            elif self.connection_type == ConnectionType.TLS:
+                self.is_running.set()
+                ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+                self.reader, self.writer = await asyncio.open_connection(
+                    self.server, self.port, ssl=ssl_context
+                )
+
+            elif self.connection_type == ConnectionType.TLSv1:
+                self.is_running.set()
+                ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
+                ssl_context.set_ciphers("AES128-SHA")
+                ssl_context.keylog_filename = "tls_keylog.log"
+                self.reader, self.writer = await asyncio.open_connection(
+                    self.server, self.port, ssl=ssl_context
+                )
+
+        except (OSError, ssl.SSLError) as e:
+            logger.log(logging.ERROR, f"Error during connection: {e}", exc_info=True)
+            # Handle the error as needed
+        except Exception as e:
+            logger.log(logging.ERROR, f"Unexpected error: {e}", exc_info=True)
+        except asyncio.CancelledError:
+            logger.log(logging.DEBUG, "The sip_core.connect task has been cancelled.")
+
+    def on_message(self, filters: SipFilter = None):
+        def decorator(func):
+            @wraps(func)
+            async def wrapper(msg):
+                if filters is None:
+                    return await func(msg)
+                else:
+                    if self.evaluate(filters, msg):
+                        return await func(msg)
+
+            self.on_message_callbacks.append(wrapper)
+            return func
+
+        return decorator
+
+    def evaluate(self, sip_filter, msg):
+        # Get conditions list
+        conditions = sip_filter.conditions
+        if conditions and isinstance(conditions, list):
+            if len(conditions) == 1:
+                return conditions[0](msg)
+
+            operator = conditions[1]
+            if operator == "and":
+                return self.evaluate(conditions[0], msg) and self.evaluate(
+                    conditions[2], msg
+                )
+
+            if operator == "or":
+                return self.evaluate(conditions[0], msg) or self.evaluate(
+                    conditions[2], msg
+                )
+
+        elif not conditions:
+            return sip_filter(msg)
+
+        return False
+
+    async def send_to_callbacks(self, data):
+        for callback in self.on_message_callbacks:
+            data_formatted = SipMessage(data)
+            data_formatted.parse()
+
+            await callback(data_formatted)
+
+    async def send(self, msg):
+        if self.connection_type == ConnectionType.UDP:
+            if not self.udp_writer:
+                logger.log(logging.CRITICAL, "There is no UdpWriter, cant write!")
+                return
+            await self.send_to_callbacks(msg)
+            await self.udp_writer.write(msg.encode())
+
+        else:
+            if not self.writer:
+                logger.log(logging.CRITICAL, "There is no StreamWriter, can't write!")
+                return
+            await self.send_to_callbacks(msg)
+            self.writer.write(msg.encode())
+            await self.writer.drain()
+
+    async def receive(self):
+        while True:
+            if not self.is_running.is_set():
+                break
+
+            if self.connection_type == ConnectionType("UDP"):
+                if not self.udp_reader:
+                    logger.log(logging.CRITICAL, "There is no UdpReader, can't read!")
+                    return
+                try:
+                    data = await asyncio.wait_for(self.udp_reader.read(4096), 0.5)
+                except asyncio.TimeoutError:
+                    await asyncio.sleep(0.01)
+                    continue  # this is neccesary to avoid blocking of checking
+                    # whether app is runing or not
+            else:
+                if not self.reader:
+                    logger.log(
+                        logging.CRITICAL, "There is no StreamReader, can't read!"
+                    )
+                    return
+                try:
+                    data = await asyncio.wait_for(self.reader.read(4096), 0.5)
+                except asyncio.TimeoutError:
+                    await asyncio.sleep(0.01)
+                    continue  # very important!!
+
+            sip_messages = self.extract_sip_messages(data)
+
+            for sip_message_data in sip_messages:
+                await self.send_to_callbacks(sip_message_data.decode())
+            await asyncio.sleep(0.1)
+
+    def extract_sip_messages(self, data: bytes) -> List[bytes]:
+        messages = []
+        start = 0
+
+        while start < len(data):
+            end_of_headers = data.find(b"\r\n\r\n", start)
+
+            if end_of_headers == -1:
+                break
+
+            headers = data[start:end_of_headers].decode()
+            content_length = [
+                int(line.split(":")[1].strip())
+                for line in headers.split("\r\n")
+                if line.startswith("Content-Length:")
+            ]
+
+            total_length = (
+                end_of_headers + 4 + content_length[0]
+            )  # 4 for the "\r\n\r\n"
+            if total_length > len(data):
+                break
+
+            message = data[start:total_length]
+            messages.append(message)
+
+            start = total_length
+
+        return messages
+
+    async def close_connections(self):
+        # Close connections
+        try:
+            if self.connection_type == ConnectionType.UDP:
+                if not self.udp_writer:
+                    return
+                if not self.udp_writer.protocol.transport:
+                    return
+                if self.udp_writer.protocol.transport.is_closing():
+                    return
+                self.udp_writer.protocol.transport.close()
+
+            else:
+                if self.writer and not self.writer.is_closing():
+                    self.writer.close()
+                    await self.writer.wait_closed()
+        except Exception as e:
+            logger.log(logging.ERROR, f"Error closing connections: {e}", exc_info=True)
+
+        logger.log(logging.INFO, "Successfully closed connections")
+
+
+class Checksum:
+    def __init__(self, checksum: str, timestamp: str) -> None:
+        self.checksum = checksum
+        self.timestamp = timestamp
+
+
+class Counter:
+    def __init__(self, start: int = 1):
+        self.x = start
+
+    def current(self) -> int:
+        return self.x
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        self.x += 1
+        return self.x
+
+
+class SipDialogue:
+    def __init__(self, call_id, local_tag, remote_tag) -> None:
+        self.username: Optional[str] = None
+        self.call_id = call_id
+        self.local_tag = local_tag  # The tag of the party who initiated the dialogue
+        self.remote_tag = remote_tag  # The tag of the other party
+        self.transactions: List[
+            SipTransaction
+        ] = []  # List to store transactions related to this dialogue
+        self.cseq = Counter(random.randint(1, 2000))
+        self.state = DialogState.PREDIALOG  # Start with the PREDIALOG state
+        self.events = {state: asyncio.Event() for state in DialogState}
+        self.AUTH_RETRY_MAX = 1
+        self.auth_retry_count = 0
+        self._local_session_info: Optional[SDPParser] = None
+        self._remote_session_info: Optional[SDPParser] = None
+
+    def matches(self, call_id, local_tag, remote_tag):
+        # We now check if the provided identifiers match this dialogue's identifiers
+        return (
+            call_id == self.call_id
+            and local_tag == self.local_tag
+            and remote_tag == self.remote_tag
+        )
+
+    def add_transaction(self, branch_id, method="INVITE"):
+        # Create a new transaction and add it to the dialogue's transaction list
+        if method != "ACK":
+            cseq = next(self.cseq)
+        else:
+            cseq = self.cseq.current()
+        transaction = SipTransaction(self.call_id, branch_id, cseq)
+        self.transactions.append(transaction)
+        return transaction
+
+    def find_transaction(self, branch_id):
+        # Find a transaction by its branch ID within this dialogue
+        for transaction in self.transactions:
+            if transaction.branch_id == branch_id:
+                return transaction
+        return None
+
+    def update_state(self, message):
+        # This method should be called with each message received/sent that pertains to this dialog
+        self.previous_state = self.state
+        is_provisional_response = (str(message.status).startswith("18")) and (
+            message.method == "INVITE"
+        )
+        is_final_response = (message.status is SIPStatus(200)) and (
+            message.method == "INVITE"
+        )
+        if (
+            (self.state == DialogState.PREDIALOG)
+            and (message.method == "INVITE")
+            and message.get_header("Authorization")
+        ):
+            self.state = DialogState.INITIAL
+
+        elif self.state == DialogState.INITIAL and is_provisional_response:
+            self.state = DialogState.EARLY
+            if message.body is not None:
+                self._remote_session_info = SDPParser(message.body)
+
+        elif (
+            self.state in [DialogState.INITIAL, DialogState.EARLY] and is_final_response
+        ):
+            self.state = DialogState.CONFIRMED
+            if message.body is not None:
+                self._remote_session_info = SDPParser(message.body)
+        elif message.method == "BYE" and message.status is SIPStatus.OK:
+            self.state = DialogState.TERMINATED
+        elif message.status == SIPStatus(487) and message.method == "INVITE":
+            self.state = DialogState.TERMINATED
+        # finally we set the event for the specific state
+        self.events[self.state].set()
+
+        if self.state != self.previous_state:
+            logger.log(logging.DEBUG, f"Dialog state changed to -> {self.state}") 
+
+    @property
+    def local_session_info(self):
+        return self._local_session_info
+
+    @property
+    def remote_session_info(self):
+        return self._remote_session_info
+
+
+class SipTransaction:
+    def __init__(self, call_id, branch_id, cseq) -> None:
+        self.call_id = call_id
+        self.branch_id = branch_id
+        self.cseq = cseq
+
+
+class SipMessage:
+    def __init__(self, message: str = "") -> None:
+        self.headers = {}
+        self.body = None
+        self.nonce = None
+        self.realm = None
+        self.data = message
+
+        # Initialize properties with default values
+        self._type = None
+        self._cseq = None
+        self._rseq = None
+        self._method = None
+        self._from_tag = None
+        self._to_tag = None
+        self._call_id = None
+        self._status: Optional[SIPStatus] = None
+        self._public_ip = None
+        self._rport = None
+        self._branch = None
+        self._did = None
+
+    @property
+    def type(self):
+        return self._type
+
+    @type.setter
+    def type(self, value):
+        self._type = value
+
+    @property
+    def cseq(self):
+        return self._cseq
+
+    @cseq.setter
+    def cseq(self, value):
+        self._cseq = value
+
+    @property
+    def rseq(self):
+        return self._rseq
+
+    @rseq.setter
+    def rseq(self, value):
+        self._rseq = value
+
+    @property
+    def method(self):
+        return self._method
+
+    @method.setter
+    def method(self, value):
+        self._method = value
+
+    @property
+    def from_tag(self):
+        return self._from_tag
+
+    @from_tag.setter
+    def from_tag(self, value):
+        self._from_tag = value
+
+    @property
+    def to_tag(self):
+        return self._to_tag
+
+    @to_tag.setter
+    def to_tag(self, value):
+        self._to_tag = value
+
+    @property
+    def call_id(self):
+        return self._call_id
+
+    @call_id.setter
+    def call_id(self, value):
+        self._call_id = value
+
+    @property
+    def status(self):
+        return self._status
+
+    @status.setter
+    def status(self, value):
+        self._status = value
+
+    @property
+    def branch(self):
+        return self._branch
+
+    @branch.setter
+    def branch(self, value):
+        self._branch = value
+
+    @property
+    def did(self):
+        return self._did
+
+    @did.setter
+    def did(self, value):
+        self._did = value
+
+    @property
+    def public_ip(self):
+        return self._public_ip
+
+    @public_ip.setter
+    def public_ip(self, value):
+        self._public_ip = value
+
+    @property
+    def rport(self):
+        return self._rport
+
+    @rport.setter
+    def rport(self, value):
+        self._rport = value
+
+    @property
+    def nonce(self):
+        return self._nonce
+
+    @nonce.setter
+    def nonce(self, value):
+        self._nonce = value
+
+    @property
+    def realm(self):
+        return self._realm
+
+    @realm.setter
+    def realm(self, value):
+        self._realm = value
+
+    def parse(self):
+        data = self.data.split("\r\n\r\n")
+        self.headers_data = data[0]
+        try:
+            self.body_data = data[1]
+        except IndexError:
+            self.body_data = ""
+
+        headers_lines = self.headers_data.split("\r\n")
+        for index, line in enumerate(headers_lines):
+            if index == 0:  # First line
+                self.headers["type"] = line  # Set 'type' in headers
+
+            else:
+                key, value = line.split(":", 1)  # Split at first colon
+                self.headers[key.strip()] = value.strip()
+
+        if self.body_data != "":
+            body_lines = self.body_data.split("\r\n")
+            self.body = {}
+            for line in body_lines:
+                if "=" in line:
+                    key, value = line.split("=", 1)
+                    if key.strip() in self.body:
+                        # This expression checks if the ey value already exists in the
+                        # dictionary and if it does then it makes a list of the old value
+                        # and the new value, also it checks if it were already a list and just
+                        # appends the value t it if it was a list.
+                        if not isinstance(self.body[key.strip()], list):
+                            self.body[key.strip()] = [self.body[key], value]
+                        else:
+                            self.body[key.strip()].append(value)
+                    else:
+                        self.body[key.strip()] = value.strip()
+
+        self.set_properties()
+
+    def set_properties(self):
+        """type property, should be LITERAL[Message, Response]"""
+        self.type_header = self.get_header("type").split(" ")
+
+        if self.type_header[0] in SIPCompatibleMethods:
+            self.type = SIPMessageType.MESSAGE
+        elif self.type_header[0] in SIPCompatibleVersions:
+            self.type = SIPMessageType.RESPONSE
+
+        """shared properties for both request/response"""
+        # CSeq
+        cseq = self.get_header("CSeq")
+        self.cseq = int(cseq.split(" ")[0])
+        self.method = cseq.split(" ")[1]
+
+        # From tag
+        from_header = self.get_header("From")
+        from_tag_match = re.search(r";tag=([^;]+)", from_header)
+        self.from_tag = from_tag_match.group(1) if from_tag_match else None
+
+        # To tag
+        to_header = self.get_header("To")
+        to_tag_match = re.search(r";tag=([^;]+)", to_header)
+        self.to_tag = to_tag_match.group(1) if to_tag_match else None
+
+        self.call_id = self.get_header("Call-ID")
+
+        branch_header = self.get_header("Via")
+        self.branch = branch_header.split("branch=")[1].split(";")[0]
+
+        if self.type == SIPMessageType.RESPONSE:
+            try:
+                self.status = SIPStatus(int(self.type_header[1]))
+                via_header = self.get_header("Via")
+                self.public_ip = via_header.split("received=")[1].split(";")[0]
+
+                # RPort
+                self.rport = via_header.split("rport=")[1].split(";")[0]
+                auth_header = self.get_header("WWW-Authenticate")
+                if auth_header:
+                    self.nonce = auth_header.split('nonce="')[1].split('"')[0]
+                    self.realm = auth_header.split('realm="')[1].split('"')[0]
+                # dialog_id
+                contact_header = self.get_header("Contact")
+                if contact_header:
+                    try:
+                        self.did = contact_header.split("did=")[1].split(">")[0]
+                    except IndexError:
+                        pass
+                # RSeq
+                rseq_header = self.get_header("RSeq")
+                if rseq_header:
+                    self.rseq = rseq_header
+            except IndexError:
+                pass
+
+            except ValueError:
+                pass
+
+    def is_from_client(self, uac_username):
+        from_header = self.get_header("From")
+        return str(uac_username) in from_header
+
+    def get_header(self, key) -> str:
+        return self.headers.get(key)
+
+    def get_headers(self):
+        return self.headers
+
+    def get_body(self, key):
+        return self.body.get(key)
+
+    @classmethod
+    def generate_sdp(cls, ip, port, ssrc, supported_codecs):
+        cname = f"host_{random.randint(100, 999)}"
+
+        sdp = f"v=0\r\n"
+        sdp += f"o=- {random.randint(100000000, 999999999)} {random.randint(100000000, 999999999)} IN IP4 {ip}\r\n"
+        sdp += "s=PySIP Call\r\n"
+        sdp += f"m=audio {port} RTP/AVP {' '.join([str(int(i)) for i in supported_codecs])}\r\n"
+        sdp += "b=AS:84\r\n"
+        sdp += "t=0 0\r\n"
+        sdp += "a=X-nat:1\r\n"
+        sdp += f"c=IN IP4 {ip}\r\n"
+        sdp += f"a=rtcp:{port + 1} IN IP4 {ip}\r\n"
+        sdp += f"a=sendrecv\r\n"
+        sdp += cls.get_rtpmap_lines()
+        sdp += cls.get_telephone_event_lines()
+        sdp += f"a=ssrc:{ssrc} cname:{cname}\r\n"
+
+        return sdp
+
+    @classmethod
+    def sdp_to_dict(cls, sdp):
+        body_lines = sdp.split("\r\n")
+        body = {}
+        for line in body_lines:
+            if "=" in line:
+                key, value = line.split("=", 1)
+                if key.strip() in body:
+                    if not isinstance(body[key.strip()], list):
+                        body[key.strip()] = [body[key], value]
+                    else:
+                        body[key.strip()].append(value)
+                else:
+                    body[key.strip()] = value.strip()
+
+        return body
+
+    @classmethod
+    def dict_to_sdp(cls, sdp_dict):
+        sdp_lines = []
+        for key, value in sdp_dict.items():
+            if isinstance(value, list):
+                for v in value:
+                    sdp_lines.append(f"{key}={v}")
+            else:
+                sdp_lines.append(f"{key}={value}")
+        return "\r\n".join(sdp_lines) + "\r\n"
+
+    @staticmethod
+    def get_rtpmap_lines():
+        lines = []
+        lines.append("a=rtpmap:0 PCMU/8000\r\n")
+        lines.append("a=rtpmap:8 PCMA/8000\r\n")
+        return "".join(lines)
+
+    @staticmethod
+    def get_telephone_event_lines():
+        lines = []
+        lines.append("a=rtpmap:121 telephone-event/8000\r\n")
+        lines.append("a=fmtp:121 0-16\r\n")
+        return "".join(lines)
+
+    @classmethod
+    def parse_sdp(cls, sdp):
+        parser = SDPParser(sdp)
+
+        return parser
+
+
+class SDPParser:
+    def __init__(self, sdp):
+        self.sdp = sdp
+        self.ip_address = None
+        self.media_type = None
+        self.transport = None
+        self.port = None
+        self.rtcp_port = None
+        self.ptime = None
+        self.rtpmap = {}
+        self.direction = None
+        self.ssrc = None
+
+        self.parse_sdp(sdp)
+
+    def parse_sdp(self, sdp):
+        self.sdp = sdp
+        self.ip_address = sdp["c"].split(" ")[2]
+        m = sdp["m"].split(" ")
+        self.media_type = m[0]
+        self.transport = m[2]
+        self.port = int(m[1])
+
+        for attr in sdp["a"]:
+            if "rtcp" in attr:
+                self.rtcp_port = int(attr.split(":")[1].split(" ")[0])
+            elif "ptime" in attr:
+                self.ptime = int(attr.split(":")[1])
+            elif "rtpmap" in attr:
+                try:
+                    rtpmap_val = attr.split(" ")
+                    payload_type = int(rtpmap_val[0].split(":")[1])
+                    codec = CodecInfo(payload_type)
+                    self.rtpmap[payload_type] = codec
+
+                except ValueError:
+                    continue
+
+            elif "sendrecv" in attr:
+                self.direction = attr
+            elif "ssrc" in attr:
+                self.ssrc = int(attr.split(" ")[0].split(":")[1])
+
+    def __str__(self):
+        return SipMessage.dict_to_sdp(self.sdp)
+
+    def __repr__(self):
+        return str(self)
diff --git a/PySIP/udp_handler.py b/PySIP/udp_handler.py
index 992a155..862142a 100644
--- a/PySIP/udp_handler.py
+++ b/PySIP/udp_handler.py
@@ -1,39 +1,44 @@
 import asyncio
-from typing import Any
-
+import logging
+from typing import Any, Optional, Tuple
+from .utils.logger import logger
 
 class UdpHandler(asyncio.DatagramProtocol):
-    def __init__(self) -> None:
-        self.transport = None
-        self.data_q =asyncio.Queue()
+    def __init__(self, loop) -> None:
+        self.transport: Optional[asyncio.DatagramTransport] = None
+        self.data_q: asyncio.Queue = asyncio.Queue()
+        self.loop = loop
+        self.count = 0
         super().__init__()
 
-    def connection_made(self, transport: asyncio.DatagramTransport) -> None:
-        print("Successful UDP connection has been made.")
-        self.transport = transport  # Store the transport for later use
+    def connection_made(self, transport) -> None:
+        self.transport = transport
+        logger.log(logging.INFO, "Successful UDP connection has been made.")
 
     def connection_lost(self, exc: Exception | None) -> None:
-        print("Connection has been lost")
+        logger.log(logging.INFO, "UDP Connection has been lost")
         if self.transport:
             self.transport.close()
 
     def error_received(self, exc: Exception) -> None:
-        print("There was an error: ", exc)
+        logger.log(logging.ERROR, "An error received: %s", exc, exc_info=True)
 
     def send_message(self, message: bytes, address: tuple = None) -> None:
+        if not self.transport:
+            logger.log(logging.WARNING, "Unable to send message due to Transport closed")
+            return
         self.transport.sendto(message)
-        # print(f"Message '{message.decode()}' sent to {address}")
 
     def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None:
-        asyncio.ensure_future(self.data_q.put(data))
-        # print('Received: from server: ', data.decode())
+        asyncio.run_coroutine_threadsafe(self.data_q.put(data), self.loop)
+        self.count += 1
 
     async def read(self):
         return await self.data_q.get()
 
 
 class UdpReader:
-    def __init__(self, protocol: asyncio.DatagramProtocol) -> None:
+    def __init__(self, protocol: UdpHandler) -> None:
         self.protocol = protocol
 
     async def read(self, length: int = -1):
@@ -41,21 +46,25 @@ async def read(self, length: int = -1):
 
 
 class UdpWriter:
-    def __init__(self, protocol: asyncio.DatagramProtocol) -> None:
+    def __init__(self, protocol: UdpHandler) -> None:
         self.protocol = protocol
 
-    async def write(self, data: bytes) -> None:
+    async def write(self, data: bytes):
         self.protocol.send_message(data)
 
     def get_extra_info(self, name, default=None):
+        if not self.protocol.transport:
+            logger.log(logging.WARNING, "Can't invoke get_extra_info due to transport closed")
+            return
         return self.protocol.transport.get_extra_info(name, default)
 
 
-async def open_udp_connection(host: str, port: int):
+async def open_udp_connection(remote_addr: Tuple[str, int], local_addr: Tuple[str, int]=None):
     loop = asyncio.get_event_loop()
     transport, protocol = await loop.create_datagram_endpoint(
-        lambda: UdpHandler(),
-        remote_addr=(host, port)
+        lambda: UdpHandler(loop),
+        local_addr,
+        remote_addr
     )
     reader = UdpReader(protocol)
     writer = UdpWriter(protocol)
diff --git a/PySIP/utils/async_utils.py b/PySIP/utils/async_utils.py
index c3d0ec3..539d09f 100644
--- a/PySIP/utils/async_utils.py
+++ b/PySIP/utils/async_utils.py
@@ -1,5 +1,7 @@
 import asyncio
 import functools
+import logging
+from .logger import logger
 
 
 async def _cancel_and_wait(fut, loop):
@@ -54,7 +56,7 @@ async def wait_for(fut, timeout, fut_2):
 
     def start_timeout(waiter, *args):
         nonlocal timeout_handle
-        print("The timout for DTMF has started right now...")
+        logger.log(logging.DEBUG, "The timeout for DTMF has started right now.")
         timeout_handle = loop.call_later(timeout, _release_waiter, waiter)
 
     waiter = loop.create_future()
diff --git a/PySIP/CustomCommuicate.py b/PySIP/utils/edge_tts_utils.py
similarity index 94%
rename from PySIP/CustomCommuicate.py
rename to PySIP/utils/edge_tts_utils.py
index 40cc55f..a0b8d8f 100644
--- a/PySIP/CustomCommuicate.py
+++ b/PySIP/utils/edge_tts_utils.py
@@ -1,8 +1,9 @@
-import asyncio
+import logging
 from typing import Union, Optional
 from pydub import AudioSegment
 import io
 from edge_tts import Communicate
+from .logger import logger
 
 
 class NoPausesFound(Exception):
@@ -111,6 +112,7 @@ async def generate_audio(self, text: str) -> io.BytesIO:
         decoded_chunk: AudioSegment = AudioSegment.from_mp3(temp_chunk)
         decoded_chunk = decoded_chunk.set_channels(self.target_channels)
         decoded_chunk = decoded_chunk.set_frame_rate(self.target_framerate)
+        logger.log(logging.DEBUG, f"audio data -> frame width:-> {decoded_chunk.frame_width} -> samplewidth {decoded_chunk.sample_width} ; framerate -> {decoded_chunk.frame_rate} ; {decoded_chunk.DEFAULT_CODECS}")
         
         decoded_chunk.export(temp_file, format='wav')
         return temp_file
diff --git a/PySIP/utils/logger.py b/PySIP/utils/logger.py
new file mode 100644
index 0000000..e31e96e
--- /dev/null
+++ b/PySIP/utils/logger.py
@@ -0,0 +1,27 @@
+import logging
+from logging.handlers import RotatingFileHandler
+
+def setup_logger():
+    logger = logging.getLogger(__name__)
+    logger.setLevel(logging.DEBUG)
+
+    # console handler
+    ch = logging.StreamHandler()
+    ch.setLevel(logging.DEBUG)
+    logger.addHandler(ch)
+
+    # file handler
+    fh = RotatingFileHandler('PySIP.log')
+    fh.setLevel(logging.DEBUG)
+    logger.addHandler(fh)
+
+    # formatter
+    formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+    ch.setFormatter(formatter)
+    fh.setFormatter(formatter)
+
+    return logger
+
+logger = setup_logger()
+console_handler = logger.handlers[0]
+file_handler = logger.handlers[1]
diff --git a/scripts/BankOTP.py b/scripts/BankOTP.py
index afdd053..51d1f69 100644
--- a/scripts/BankOTP.py
+++ b/scripts/BankOTP.py
@@ -23,7 +23,7 @@ async def call_flow(call_handler: CallHandler):
     if dtmf_result:
         print("The DTMF result is:-> ", dtmf_result)
         stream_id = await call_handler.say("thank you for entering the code")
-        await stream_id.flush()
+        await stream_id.wait_finished()
 
     await call_handler.hangup()
 


Test patch:
diff --git a/test.py b/test.py
index b9b298b..37deb48 100644
--- a/test.py
+++ b/test.py
@@ -1,93 +1,79 @@
-from PySIP.call import VOIP
-from PySIP.call_handler import CallHandler
 import asyncio
-from scripts.BankOTP import call_flow as BankOTPcall
+import logging
+import uuid
+from aiodebug import log_slow_callbacks
+from PySIP.filters import SIPStatus
+from PySIP.sip_call import SipCall
+from PySIP.sip_client import SipClient
+from PySIP.call_handler import CallHandler
+from scripts.BankOTP import call_flow as new_call_flow
 
 
-voip = VOIP(
-    username="111",
-    route="192.168.1.112:5060",
-    connection_type="UDP",
-    from_tag="111",
-    password="12345678",
+client = SipClient(
+    '3001',
+    '192.168.1.112:5060',
+    'UDP',
+    '30013001'
 )
 
-# voip = VOIP(
-#     "12035473096",
-#     "talk.waafi.com:2382",
-#     connection_type="TLSv1",
-#     password="etAywogjnDHPUbfoUY2tovgQ2ekZZM93/LBeO1B9dbGTt7pCXLG7esa8A5pa5/8DY2dDH2LXtKsIyc1pE1YfuA==",
-# )
-
-call_handler = CallHandler(voip)
-
+call = SipCall(
+    '3001',
+    '30013001', 
+    '192.168.1.112:5060',
+    '111'
+)
 
-async def call_flow() -> None:
-    await call_handler.say("Hello and welcome to PySIP")
-    await call_handler.sleep(3.0)
-    await call_handler.say("How are you doing today")
-    await call_handler.say("Please type 55 to continue this call")
+@call.on_call_state_changed
+async def call_state_changed(state):
+    pass
 
-    dtmf_result_found = False
-    for _ in range(3):
-        try:
-            dtmf_result = await call_handler.gather(length=2, timeout=8.0)
+@call.on_call_hanged_up
+async def call_stopped(reason):
+    pass
 
-            if dtmf_result == 55:
-                await call_handler.say("Thank you your code is correct, good bye")
-                print(dtmf_result)
-                dtmf_result_found = True
-                break
-            else:
-                await call_handler.say("Incorrect code please try again")
-                continue
+@call.on_frame_received 
+async def frame_received(frame):
+    pass
 
-        except asyncio.TimeoutError:
-            await call_handler.say("You did not click am keys, please try again")
+@call.on_dtmf_received 
+async def dtmf_received(dtmf_key):
+    print("Received dtmf key: ", dtmf_key)
 
-    if not dtmf_result_found:
-        await call_handler.say("You failed to ented the code, good bye")
+@call.on_amd_state_received 
+async def amd_received(amd_state):
+    print("The amd state is: ", amd_state)
 
-    stream_id = await call_handler.say("hanging up")
-    await stream_id.flush()  # This is only required on the last message and its important beause it makes sure to wait for the last message to be sent before hanging up, otherwise we would not hear the last messahe
-    await call_handler.hangup()
+@call.on_transfer_state_changed 
+async def transfer_state(state):
+    print("Transfer state: ", state)
+    # for example you can hangup on succesful transfer 
+    if state is SIPStatus.OK:
+        await call.call_handler.hangup()
 
-async def call_flow_new():
-    await call_handler.sleep(4)
-    stream_id = await call_handler.say("This long statements will stop if you presss 1. Please try it and press one. When you press one it stops")
-    try:
-        dtmf_result = await call_handler.gather(length=1, timeout=5.0)
-        if dtmf_result == 1:
-            await stream_id.drain()
-            stream_id = await call_handler.say("Previous audio interrupted")
-            await stream_id.flush()
+async def stop_client(client_):
+    await asyncio.sleep(4)
+    await client_.stop()
+    return
 
-        else:
-            await stream_id.drain()
-            stream_id = await call_handler.say("Incorrect key ending the call")
-            await stream_id.flush()
+async def call_flow():
+    await call.call_handler.say("Hello and welcome there Moha Abdi")
+    stream = await call.call_handler.say("Well today was kind of a beautiful day")
+    await stream.wait_finished()
+    await call.call_handler.transfer_to(15125963515)
+    await call.call_handler.hangup()
+    await client.stop()
 
-    except asyncio.TimeoutError:
-        await stream_id.drain()
-        stream_id = await call_handler.say("time out ending the call")
-        await stream_id.flush()
 
-    await call_handler.hangup()
-    
 async def main():
-    # Run the voip.call asynchronously
-    asyncio.get_event_loop().set_debug(False)
-    call_task = asyncio.create_task(voip.call("112"))
-
-    # Concurrently run other tasks
-    call_handler_task = asyncio.create_task(BankOTPcall(call_handler))
-    send_handler = asyncio.create_task(call_handler.send_handler())
-
-    # Wait for all tasks to complete 
-    await asyncio.gather(call_task, call_handler_task, send_handler, return_exceptions=True)
-
-    if voip.received_bytes:
-        print(f"Recorded audio saved to {call_handler.call_id}.mp3")
+    asyncio.get_event_loop().set_debug(True)
+    client_task = asyncio.create_task(client.run())
+    
+    call_task = asyncio.create_task(call.start())
+ 
+    await asyncio.gather(client_task, call_task, call_flow(), return_exceptions=False
+                         )
+    call.get_recorded_audio(f'call_{str(uuid.uuid4())}.wav')
 
 
+log_slow_callbacks.enable(1)
 asyncio.run(main())
diff --git a/tests/async_queue.py b/tests/async_queue.py
new file mode 100644
index 0000000..37508e3
--- /dev/null
+++ b/tests/async_queue.py
@@ -0,0 +1,71 @@
+import asyncio
+from dataclasses import dataclass
+import time
+
+
+async def queue_worker(queue: asyncio.Queue):
+    for _ in range(100):
+        try:
+
+            res = await queue.get()
+            print("received res is: ", res)
+        except Exception as e:
+            print(e)
+
+async def queue_puttere(queue):
+    for _ in range(1000):
+        await queue.put(_)
+
+def wait_for(condition, timeout=1):
+    start = time.time()
+    while time.time() - start < timeout:
+        try:
+            if condition():
+                return True
+        except Exception as e:
+            print(f"Error: {e}")
+            
+        time.sleep(0.1)
+        
+    return False
+
+async def cbb(res):
+    res.id = 9999
+    print(res)
+
+@dataclass 
+class Person:
+    name = "Moha"
+    id: int
+
+async def main():
+    queue = asyncio.Queue()
+
+    task_1 = asyncio.create_task(queue_worker(queue))
+    await asyncio.sleep(1)
+    task_2 = asyncio.create_task(queue_puttere(queue))
+    await asyncio.sleep(0.01)
+    queue.task_done()
+    print('done')
+
+    q = asyncio.Queue()
+    # r = wait_for(q.get_nowait(), 1)
+    # print(r)
+    values = [1, 2, 3]
+    x = [values if values else []]
+    for v in x:
+        print(v)
+    await asyncio.gather(*[])
+    person = Person(789)
+    await cbb(person)
+    print(person.id)
+    p = []
+    p.extend([1, 2, 3])
+
+asyncio.run(main())
+
+
+
+
+
+
diff --git a/tests/jitter_buffer.py b/tests/jitter_buffer.py
new file mode 100644
index 0000000..c1e5704
--- /dev/null
+++ b/tests/jitter_buffer.py
@@ -0,0 +1,74 @@
+import random
+
+# Define a simple JitterBuffer class for simulation
+class JitterBuffer:
+    def __init__(self, capacity: int):
+        self._capacity = capacity
+        self._origin = None
+        self.MAX_MISORDER = 2  # Maximum allowed misorder for this simulation
+
+    def add(self, packet_sequence_number):
+        pli_flag = False
+
+        if self._origin is None:
+            self._origin = packet_sequence_number
+            delta = 0
+            misorder = 0
+        else:
+            delta = packet_sequence_number - self._origin
+            misorder = self._origin - packet_sequence_number
+
+        print(f"Received packet with sequence number: {packet_sequence_number}")
+        print(f"Delta: {delta}, Misorder: {misorder}")
+
+        if misorder < delta:
+            if misorder >= self.MAX_MISORDER:
+                print("Significant misorder detected. Removing excess packets...")
+                # Simulate removing excess packets
+                self._origin = packet_sequence_number
+                delta = misorder = 0
+                pli_flag = True  # Simulate setting PLI flag
+            else:
+                print("Misorder detected, but within acceptable range.")
+                return pli_flag, None
+
+        print("Packet added to buffer.")
+        return pli_flag, packet_sequence_number
+
+# Simulate packet sequence with potential misorder and wraparound
+packet_sequence = list(range(10))
+random.shuffle(packet_sequence)
+
+# Create JitterBuffer instance
+jitter_buffer = JitterBuffer(capacity=10)
+
+# Simulate adding packets to the buffer
+for sequence_number in packet_sequence:
+    pli_flag, _ = jitter_buffer.add(sequence_number)
+    if pli_flag:
+        print("PLI flag set.")
+
+
+x = 65535
+y = 65537
+
+print((x + y) & 65535)
+print((x + y) % 65535)
+print(1 + -2)
+
+
+class Test:
+    def __init__(self, x) -> None:
+        self._ret_st = None 
+
+    @property
+    def ret_st(self):
+        return self._ret_st
+
+    @ret_st.setter
+    def ret_st(self, value):
+        self._ret_st = value
+
+t = Test("duh")
+t.ret_st
+
diff --git a/tests/rtp.py b/tests/rtp.py
new file mode 100644
index 0000000..5bc6a31
--- /dev/null
+++ b/tests/rtp.py
@@ -0,0 +1,139 @@
+import random
+from typing import Optional, Union
+num = 65536888 % 65535
+print(num)
+
+import time
+
+start = time.monotonic_ns()
+st = time.monotonic()
+
+time.sleep(2.3)
+
+en = time.monotonic()
+end = time.monotonic_ns()
+
+print((end - start) / 1000000000)
+print(en - st)
+
+
+class TimestampMapper:
+    def __init__(self) -> None:
+        self._last: Optional[int] = None
+        self._origin: Optional[int] = None
+
+    def map(self, timestamp: int) -> int:
+        if self._origin is None:
+            # first timestamp
+            self._origin = timestamp
+        elif timestamp < self._last:
+            # RTP timestamp wrapped
+            self._origin -= 1 << 32
+            print("wrapped now")
+            print('res is: ', self._origin)
+
+        self._last = timestamp
+        return timestamp - self._origin
+
+
+timestamps = [4294967265, 4294967275, 4294967285, 4294967295, 5, 15, 25]
+ts_mapper = TimestampMapper()
+
+for i in range(len(timestamps)):
+    res = ts_mapper.map(timestamps[i])
+    print(f"tried with: {timestamps[i]}; result is {res}")
+
+
+out_sequence = [1, 2, 4, 3]
+_origin = None
+
+for i in range(len(out_sequence)):
+    if not _origin:
+        _origin = out_sequence[i]
+        delta = 0
+        misorder = 0
+
+    else:
+        delta = out_sequence[i] - _origin
+        misorder = _origin - out_sequence[i]
+
+    print(f"The delta is {delta}, misorder is: {misorder}")
+
+
+x = range(4, 10)
+print(x)
+
+print(random.choice(x))
+from enum import Enum
+
+
+class CodecInfo(Enum):
+    def __new__(
+        cls,
+        value: Union[int, str],
+        clock: int = 0,
+        channel: int = 0,
+        description: str = "",
+    ):
+        obj = object.__new__(cls)
+        obj._value_ = value
+        obj.rate = clock
+        obj.channel = channel
+        obj.description = description
+        return obj
+
+    @property
+    def rate(self) -> int:
+        return self._rate
+
+    @rate.setter
+    def rate(self, value: int) -> None:
+        self._rate = value
+
+    @property
+    def channel(self) -> int:
+        return self._channel
+
+    @channel.setter
+    def channel(self, value: int) -> None:
+        self._channel = value
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @description.setter
+    def description(self, value: str) -> None:
+        self._description = value
+
+    def __int__(self) -> int:
+        try:
+            return int(self.value)
+        except ValueError:
+            pass
+        raise Exception(
+            self.description + " is a dynamically assigned payload"
+        )
+
+    def __str__(self) -> str:
+        if isinstance(self.value, int):
+            return self.description
+        return str(self.value)
+
+    # Audio
+    PCMU = 0, 8000, 1, "PCMU"
+    GSM = 3, 8000, 1, "GSM"
+    G723 = 4, 8000, 1, "G723"
+    DVI4_8000 = 5, 8000, 1, "DVI4"
+    DVI4_16000 = 6, 16000, 1, "DVI4"
+    LPC = 7, 8000, 1, "LPC"
+    PCMA = 8, 8000, 1, "PCMA"
+supportedd = [CodecInfo.PCMA, CodecInfo.PCMU]
+
+print(' '.join([str(int(supported)) for supported in supportedd]))
+
+x = "a=ssrc:1264272126"
+print(int(x.split(' ')[0].split(':')[1]))
+ip = "838388"
+y = f"c=IN IP4 {ip}\r\n"
+print(y.split(' ')[2])
diff --git a/tests/test_executor.py b/tests/test_executor.py
new file mode 100644
index 0000000..f251ca9
--- /dev/null
+++ b/tests/test_executor.py
@@ -0,0 +1,32 @@
+import asyncio
+import time
+
+def new_test(queue, loop):
+    time.sleep(4)
+    asyncio.run_coroutine_threadsafe(queue.put(8), loop)
+    
+async def sleep_test():
+    queue = asyncio.Queue()
+    loop = asyncio.get_event_loop()
+    print('going to sleep')
+    res = await loop.run_in_executor(None, new_test, queue, loop)
+    # time.sleep(5)
+    print(await queue.get())
+    print('waking up')
+
+async def parallel():
+    # run two sleep_tests in parallel and wait until both finish
+    await asyncio.gather(sleep_test(), sleep_test())
+    x = {'moha': asyncio.Queue()}
+
+    for name, q in x.items():
+        print(name)
+
+    y = bytearray()
+    print(bytes(y))
+
+
+    
+
+
+asyncio.run(parallel())