Skip to content

Commit 77ae0f7

Browse files
committed
Fix PySIP-to-PySIP calls on same device and recording issues
- Fix RTP port collision: randomize starting port and check availability - Fix message handler pass-through in _send_invite() for incoming calls - Fix recording to run in background while greeting plays simultaneously - Fix silence timeout to only trigger after receiving actual packets - Add proper callback chaining so DTMF detection works during recording - Add debug logging for recording troubleshooting
1 parent 2fea907 commit 77ae0f7

4 files changed

Lines changed: 138 additions & 45 deletions

File tree

PySIP/call.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -719,25 +719,39 @@ async def _send_invite(self, timeout: float) -> SIPResponse | None:
719719
# Create response future
720720
response_future: asyncio.Future = asyncio.get_running_loop().create_future()
721721

722+
old_handler = self._transport.get_data_handler()
723+
722724
def on_response(data: bytes, addr: Address) -> None:
723725
try:
724726
parser = SIPParser()
725727
msg = parser.parse(data)
726-
if hasattr(msg, 'status_code') and msg.call_id == self._call_id:
727-
if msg.status_code == 100:
728-
pass # Ignore TRYING
729-
elif msg.status_code == 180 or msg.status_code == 183:
730-
self._state = CallState.RINGING
731-
self._emit_event("ringing")
732-
# Handle early media on 183
733-
if msg.status_code == 183 and self._early_media and msg.body:
734-
self._remote_sdp = msg.body
735-
elif not response_future.done():
736-
response_future.set_result(msg)
728+
if hasattr(msg, 'status_code'):
729+
# It's a response
730+
if msg.call_id == self._call_id:
731+
# Our response
732+
if msg.status_code == 100:
733+
pass # Ignore TRYING
734+
elif msg.status_code == 180 or msg.status_code == 183:
735+
self._state = CallState.RINGING
736+
self._emit_event("ringing")
737+
# Handle early media on 183
738+
if msg.status_code == 183 and self._early_media and msg.body:
739+
self._remote_sdp = msg.body
740+
elif not response_future.done():
741+
response_future.set_result(msg)
742+
else:
743+
# Not our response, pass to old handler
744+
if old_handler:
745+
old_handler(data, addr)
746+
else:
747+
# It's a request (e.g., incoming INVITE), pass to old handler
748+
if old_handler:
749+
old_handler(data, addr)
737750
except Exception as e:
738751
logger.error(f"Error parsing response: {e}")
739-
740-
old_handler = self._transport.get_data_handler()
752+
# On error, try passing to old handler
753+
if old_handler:
754+
old_handler(data, addr)
741755
self._transport.set_data_handler(on_response)
742756

743757
try:

PySIP/features/recording/recorder.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ async def record(
139139
audio_chunks: list[bytes] = []
140140
start_time = time.time()
141141
last_voice_time = start_time
142+
packets_received = 0 # Track packet count for debugging
142143

143144
# Calculate limits
144145
max_bytes = int(self._max_size_mb * 1024 * 1024)
@@ -155,11 +156,13 @@ async def record(
155156
call_codec = PCMUCodec()
156157

157158
def on_rtp_packet(data: bytes, addr) -> None:
158-
nonlocal bytes_recorded, last_voice_time
159+
nonlocal bytes_recorded, last_voice_time, packets_received
159160

160161
if stop_event.is_set():
161162
return
162163

164+
packets_received += 1
165+
163166
# Extract payload (skip RTP header)
164167
payload = data[12:] if len(data) > 12 else data
165168

@@ -184,43 +187,68 @@ def on_rtp_packet(data: bytes, addr) -> None:
184187
if bytes_recorded >= max_bytes:
185188
logger.warning("Recording size limit reached")
186189
stop_event.set()
190+
191+
# Log progress periodically
192+
if packets_received == 1:
193+
logger.debug(f"First RTP packet received from {addr}")
194+
elif packets_received % 500 == 0:
195+
logger.debug(f"Recording: {packets_received} packets, {bytes_recorded} bytes")
187196

188197
except Exception as e:
189198
logger.debug(f"Recording decode error: {e}")
190199

191-
# Hook into RTP session
200+
# Hook into RTP session - chain callbacks so both recording and
201+
# the call's own handler (DTMF, etc.) work simultaneously
192202
old_callback = None
193203
if call._rtp_session:
194204
old_callback = call._rtp_session._on_packet
195-
call._rtp_session.on_packet(on_rtp_packet)
205+
206+
def chained_callback(data: bytes, addr) -> None:
207+
# First, call the recording handler
208+
on_rtp_packet(data, addr)
209+
# Then, call the original handler (for DTMF, etc.)
210+
if old_callback:
211+
old_callback(data, addr)
212+
213+
call._rtp_session.on_packet(chained_callback)
214+
logger.debug(f"Recording started, RTP callback chained (had old: {old_callback is not None})")
215+
else:
216+
logger.warning("No RTP session available for recording!")
196217

197218
try:
198219
# Record until conditions met
199220
while not stop_event.is_set():
200221
# Check duration
201222
elapsed = time.time() - start_time
202223
if elapsed >= max_duration:
203-
logger.debug("Recording max duration reached")
224+
logger.debug(f"Recording max duration reached ({elapsed:.1f}s, {packets_received} packets)")
204225
break
205226

206-
# Check silence timeout
207-
if silence_timeout:
227+
# Check silence timeout - but only if we've received at least some packets
228+
# This prevents timeout before audio stream even starts
229+
if silence_timeout and packets_received > 0:
208230
silence_duration = time.time() - last_voice_time
209231
if silence_duration >= silence_timeout:
210-
logger.debug("Recording silence timeout")
232+
logger.debug(f"Recording silence timeout ({silence_duration:.1f}s silence, {packets_received} packets)")
211233
break
212234

213235
# Check call state
214236
if not call.is_active:
215-
logger.debug("Call ended during recording")
237+
logger.debug(f"Call ended during recording ({packets_received} packets received)")
216238
break
217239

218240
await asyncio.sleep(0.1)
219241

220242
finally:
221-
# Restore callback
222-
if call._rtp_session and old_callback:
223-
call._rtp_session.on_packet(old_callback)
243+
# Restore original callback
244+
if call._rtp_session:
245+
if old_callback:
246+
call._rtp_session.on_packet(old_callback)
247+
logger.debug("Recording stopped, original RTP callback restored")
248+
else:
249+
# No old callback - just clear
250+
call._rtp_session._on_packet = None
251+
logger.debug("Recording stopped, RTP callback cleared")
224252

225253
# Combine audio
226254
if audio_chunks:
@@ -230,6 +258,8 @@ def on_rtp_packet(data: bytes, addr) -> None:
230258

231259
duration_ms = int((time.time() - start_time) * 1000)
232260

261+
logger.info(f"Recording complete: {packets_received} packets, {len(audio)} bytes, {duration_ms}ms")
262+
233263
return Recording(
234264
audio=audio,
235265
duration_ms=duration_ms,

PySIP/session/manager.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ def __init__(
7676
local_port: int = 5060,
7777
rtp_port_range: tuple[int, int] = (10000, 20000),
7878
):
79+
import random
80+
7981
self._transport = transport
8082
self._config = config or CallManagerConfig()
8183
self._calls: dict[str, "Call"] = {} # call_id -> Call
@@ -86,7 +88,11 @@ def __init__(
8688
self._local_ip = local_ip
8789
self._local_port = local_port
8890
self._rtp_port_range = rtp_port_range
89-
self._next_rtp_port = rtp_port_range[0]
91+
# Randomize starting port to avoid collisions when multiple clients
92+
# start on the same machine
93+
range_size = (rtp_port_range[1] - rtp_port_range[0]) // 2
94+
random_offset = random.randint(0, range_size - 1) * 2 # Even ports only
95+
self._next_rtp_port = rtp_port_range[0] + random_offset
9096

9197
@property
9298
def active_calls(self) -> int:
@@ -152,14 +158,41 @@ async def stop(self) -> None:
152158
logger.info("CallManager stopped")
153159

154160
def _allocate_rtp_port(self) -> int:
155-
"""Allocate next RTP port."""
156-
port = self._next_rtp_port
157-
self._next_rtp_port += 2 # RTP uses even ports
161+
"""
162+
Allocate next available RTP port.
158163
159-
if self._next_rtp_port >= self._rtp_port_range[1]:
160-
self._next_rtp_port = self._rtp_port_range[0]
164+
Tests port availability before returning to avoid
165+
'address already in use' errors when multiple clients
166+
run on the same machine.
167+
"""
168+
import socket
169+
import random
161170

162-
return port
171+
# Get the range size and try all ports if needed
172+
range_start, range_end = self._rtp_port_range
173+
range_size = (range_end - range_start) // 2 # Divided by 2 since RTP uses even ports
174+
175+
for _ in range(range_size):
176+
port = self._next_rtp_port
177+
self._next_rtp_port += 2 # RTP uses even ports
178+
179+
if self._next_rtp_port >= range_end:
180+
self._next_rtp_port = range_start
181+
182+
# Test if port is available
183+
try:
184+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
185+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
186+
sock.bind(("0.0.0.0", port))
187+
sock.close()
188+
return port
189+
except OSError:
190+
# Port in use, try next one
191+
continue
192+
193+
# Fallback: return a random port in range and hope for the best
194+
# This shouldn't happen in normal operation
195+
return random.randrange(range_start, range_end, 2)
163196

164197
async def create_call(
165198
self,

examples/record_call.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,21 @@
2323

2424
import argparse
2525
import asyncio
26+
import logging
2627
import os
2728
import sys
2829

2930
# Add parent directory to path for development
3031
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
3132

33+
# Enable debug logging for recording
34+
logging.basicConfig(
35+
level=logging.INFO,
36+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
37+
)
38+
# Set recording module to DEBUG for detailed info
39+
logging.getLogger('PySIP.features.recording').setLevel(logging.DEBUG)
40+
3241
from dotenv import load_dotenv
3342
from PySIP import SIPClient
3443
from PySIP.exceptions import (
@@ -181,25 +190,30 @@ async def handle_inbound_call(call, args):
181190
await call.answer()
182191
print("Call answered!")
183192

184-
# Welcome message
193+
# Small delay to ensure RTP session is fully ready
194+
await asyncio.sleep(0.2)
195+
196+
# Start recording in background task so we can play greeting simultaneously
197+
print("\nRecording started...")
198+
print(f"(Recording for up to {args.max_duration}s, or {args.silence_timeout}s of silence)")
199+
200+
recording_task = asyncio.create_task(
201+
call.record(
202+
max_duration=args.max_duration,
203+
silence_timeout=args.silence_timeout,
204+
)
205+
)
206+
207+
# Play welcome message WHILE recording (caller's audio is being captured)
185208
await call.say(
186209
"Hello! This call is being recorded. "
187-
"Please say something after the beep. "
210+
"Please say something. "
188211
f"Recording will stop after {int(args.silence_timeout)} seconds of silence "
189212
f"or {int(args.max_duration)} seconds total."
190213
)
191214

192-
# Play a beep sound
193-
await call.say("Beep!")
194-
195-
# Start recording
196-
print("\nRecording started...")
197-
print(f"(Recording for up to {args.max_duration}s, or {args.silence_timeout}s of silence)")
198-
199-
recording = await call.record(
200-
max_duration=args.max_duration,
201-
silence_timeout=args.silence_timeout,
202-
)
215+
# Wait for recording to complete
216+
recording = await recording_task
203217

204218
print(f"\nRecording finished!")
205219
print(f" Duration: {recording.duration_seconds:.1f} seconds")
@@ -210,7 +224,7 @@ async def handle_inbound_call(call, args):
210224
recording.save(filename)
211225
print(f" Saved to: {filename}")
212226

213-
# Thank you message
227+
# Thank you message (after recording is done)
214228
await call.say(
215229
"Thank you for your recording. "
216230
"The audio has been saved. Goodbye!"
@@ -222,6 +236,8 @@ async def handle_inbound_call(call, args):
222236

223237
except Exception as e:
224238
print(f"Error handling call: {e}")
239+
import traceback
240+
traceback.print_exc()
225241
try:
226242
await call.hangup()
227243
except Exception:

0 commit comments

Comments
 (0)