Created
March 17, 2025 11:29
-
-
Save coderkhalide/26d64b403e2b12b625cf8df5393d3e47 to your computer and use it in GitHub Desktop.
Working SIP phone node js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const express = require('express'); | |
const http = require('http'); | |
const WebSocket = require('ws'); | |
const cors = require('cors'); | |
const dgram = require('dgram'); | |
const os = require('os'); | |
const crypto = require('crypto'); | |
// Create Express app and HTTP server | |
const app = express(); | |
const server = http.createServer(app); | |
// Enable CORS and JSON parsing | |
app.use(cors()); | |
app.use(express.json()); | |
// SIP configuration | |
const SIP_USER = '120'; | |
const SIP_PASS = 'Natur0$120'; | |
const SIP_SERVER = '27.147.188.250'; | |
const SIP_PORT = 5060; | |
const WS_PORT = 5002; | |
const RTP_PORT = 10000; // Port for RTP media | |
// Get local IP address | |
function getLocalIP() { | |
const interfaces = os.networkInterfaces(); | |
for (let ifname in interfaces) { | |
const iface = interfaces[ifname]; | |
for (let i = 0; i < iface.length; i++) { | |
const { address, family, internal } = iface[i]; | |
if (family === 'IPv4' && !internal) { | |
return address; | |
} | |
} | |
} | |
return '127.0.0.1'; | |
} | |
const LOCAL_IP = getLocalIP(); | |
console.log(`Using local IP address: ${LOCAL_IP}`); | |
// Create UDP socket for direct SIP communication | |
const udpSocket = dgram.createSocket('udp4'); | |
let localPort = 0; | |
// Create UDP socket for RTP media | |
const rtpSocket = dgram.createSocket('udp4'); | |
let rtcpSocket = dgram.createSocket('udp4'); | |
let rtpLocalPort = RTP_PORT; | |
let rtcpLocalPort = RTP_PORT + 1; | |
let remoteRtpPort = null; | |
let remoteRtpAddress = null; | |
// Track call state | |
let currentCallId = null; | |
let currentCallTag = null; | |
let remoteTag = null; | |
let authRealm = null; | |
let authNonce = null; | |
let registerSeq = 1; | |
let inviteSeq = 1; | |
let registered = false; | |
let callActive = false; | |
let audioBuffers = []; | |
// Bind UDP socket to random port | |
udpSocket.bind(() => { | |
localPort = udpSocket.address().port; | |
console.log(`UDP socket bound to port ${localPort}`); | |
}); | |
// Bind RTP sockets | |
rtpSocket.bind(rtpLocalPort, () => { | |
console.log(`RTP socket bound to port ${rtpLocalPort}`); | |
}); | |
rtcpSocket.bind(rtcpLocalPort, () => { | |
console.log(`RTCP socket bound to port ${rtcpLocalPort}`); | |
}); | |
// Handle RTP incoming packets | |
rtpSocket.on('message', (msg, rinfo) => { | |
console.log(`Received RTP packet from ${rinfo.address}:${rinfo.port}, length: ${msg.length} bytes`); | |
// Store remote RTP info if not already set | |
if (!remoteRtpAddress || !remoteRtpPort) { | |
remoteRtpAddress = rinfo.address; | |
remoteRtpPort = rinfo.port; | |
console.log(`Set remote RTP endpoint to ${remoteRtpAddress}:${remoteRtpPort}`); | |
} | |
// Forward audio data to connected WebSocket clients | |
if (callActive) { | |
broadcast({ | |
type: 'audio_data', | |
data: Array.from(msg) | |
}); | |
} | |
}); | |
// Handle RTCP incoming packets | |
rtcpSocket.on('message', (msg, rinfo) => { | |
console.log(`Received RTCP packet from ${rinfo.address}:${rinfo.port}, length: ${msg.length} bytes`); | |
}); | |
// Handle incoming UDP messages for SIP | |
udpSocket.on('message', (msg, rinfo) => { | |
const message = msg.toString(); | |
console.log(`Received SIP message from ${rinfo.address}:${rinfo.port}:`); | |
console.log(message.split('\r\n').slice(0, 10).join('\r\n') + '...'); | |
// Broadcast to WebSocket clients | |
broadcast({ | |
type: 'sip_message', | |
direction: 'incoming', | |
message: message | |
}); | |
// Extract headers from SIP message | |
const headers = parseSipMessage(message); | |
// Handle SIP responses | |
if (message.startsWith('SIP/2.0')) { | |
// Extract response code | |
const statusLine = message.split('\r\n')[0]; | |
const statusCode = parseInt(statusLine.split(' ')[1]); | |
const statusText = statusLine.split(' ').slice(2).join(' '); | |
// Handle authentication challenge | |
if (statusCode === 401 || statusCode === 407) { | |
console.log(`Received authentication challenge`); | |
// Extract auth params from WWW-Authenticate or Proxy-Authenticate header | |
const authHeader = message.match(/WWW-Authenticate:\s*([^\r\n]+)/i) || | |
message.match(/Proxy-Authenticate:\s*([^\r\n]+)/i); | |
if (authHeader) { | |
const authStr = authHeader[1]; | |
const realm = authStr.match(/realm="([^"]+)"/i); | |
const nonce = authStr.match(/nonce="([^"]+)"/i); | |
if (realm && nonce) { | |
authRealm = realm[1]; | |
authNonce = nonce[1]; | |
console.log(`Auth params: realm=${authRealm}, nonce=${authNonce}`); | |
// Check if this is for REGISTER | |
if (headers.cseq && headers.cseq.includes('REGISTER')) { | |
sendAuthenticatedRegister(); | |
} | |
// Check if this is for INVITE | |
else if (headers.cseq && headers.cseq.includes('INVITE')) { | |
sendAuthenticatedInvite(headers['to-uri'] || headers.to); | |
} | |
} | |
} | |
} | |
// Handle successful registration | |
else if (statusCode === 200) { | |
// Extract headers we need | |
const callId = headers['call-id']; | |
const cseq = headers.cseq; | |
// Check if this is a response to REGISTER | |
if (cseq && cseq.includes('REGISTER')) { | |
console.log(`Registration successful`); | |
registered = true; | |
broadcast({ | |
type: 'status', | |
message: 'Successfully registered', | |
status: 'Registered' | |
}); | |
} | |
// Check if this is a response to INVITE | |
else if (cseq && cseq.includes('INVITE')) { | |
console.log(`Call connected`); | |
callActive = true; | |
// Extract remote media info from SDP if available | |
const sdpContent = extractSdpContent(message); | |
if (sdpContent) { | |
const remoteMediaInfo = parseSdpForMedia(sdpContent); | |
if (remoteMediaInfo.address && remoteMediaInfo.port) { | |
remoteRtpAddress = remoteMediaInfo.address; | |
remoteRtpPort = remoteMediaInfo.port; | |
console.log(`Set remote RTP endpoint to ${remoteRtpAddress}:${remoteRtpPort} from SDP`); | |
} | |
} | |
// Get tags from To and From headers for proper dialog | |
if (headers.to) { | |
const toTag = headers.to.match(/tag=([^;]+)/); | |
if (toTag) remoteTag = toTag[1]; | |
} | |
// Send ACK | |
sendAck(headers); | |
broadcast({ | |
type: 'status', | |
message: 'Call connected', | |
status: 'Connected' | |
}); | |
} | |
// Check if this is a response to BYE | |
else if (cseq && cseq.includes('BYE')) { | |
console.log(`Call ended`); | |
currentCallId = null; | |
currentCallTag = null; | |
remoteTag = null; | |
callActive = false; | |
broadcast({ | |
type: 'status', | |
message: 'Call ended', | |
status: 'Idle' | |
}); | |
} | |
} | |
// Handle ringing | |
else if (statusCode === 180) { | |
console.log(`Phone is ringing`); | |
broadcast({ | |
type: 'status', | |
message: 'Phone is ringing', | |
status: 'Ringing' | |
}); | |
} | |
// Handle various error responses | |
else if (statusCode >= 400) { | |
console.log(`Error: ${statusCode} ${statusText}`); | |
broadcast({ | |
type: 'status', | |
message: `Error: ${statusCode} ${statusText}`, | |
status: 'Error' | |
}); | |
} | |
} | |
// Handle SIP requests | |
else { | |
const requestLine = message.split('\r\n')[0]; | |
const method = requestLine.split(' ')[0]; | |
// Handle BYE request | |
if (method === 'BYE') { | |
console.log('Call ended by remote party'); | |
// Send 200 OK response to BYE | |
const okResponse = createOkResponse(headers); | |
sendSipMessage(okResponse); | |
currentCallId = null; | |
currentCallTag = null; | |
remoteTag = null; | |
callActive = false; | |
broadcast({ | |
type: 'status', | |
message: 'Call ended by remote party', | |
status: 'Idle' | |
}); | |
} | |
} | |
}); | |
// Extract SDP content from SIP message | |
function extractSdpContent(message) { | |
const parts = message.split('\r\n\r\n'); | |
if (parts.length > 1) { | |
return parts[1]; | |
} | |
return null; | |
} | |
// Parse SDP to get media information | |
function parseSdpForMedia(sdp) { | |
const mediaInfo = {}; | |
// Get connection address | |
const connectionMatch = sdp.match(/c=IN IP4 ([^\r\n]+)/); | |
if (connectionMatch) { | |
mediaInfo.address = connectionMatch[1]; | |
} | |
// Get media port | |
const mediaMatch = sdp.match(/m=audio (\d+) RTP/); | |
if (mediaMatch) { | |
mediaInfo.port = parseInt(mediaMatch[1]); | |
} | |
return mediaInfo; | |
} | |
// Parse SIP message into headers | |
function parseSipMessage(message) { | |
const lines = message.split('\r\n'); | |
const headers = {}; | |
for (let i = 1; i < lines.length; i++) { | |
const line = lines[i]; | |
if (!line) break; // End of headers | |
const colonIndex = line.indexOf(':'); | |
if (colonIndex > 0) { | |
const name = line.substring(0, colonIndex).toLowerCase(); | |
const value = line.substring(colonIndex + 1).trim(); | |
headers[name] = value; | |
} | |
} | |
// Extract some common URIs | |
if (headers.to) { | |
const toUriMatch = headers.to.match(/<([^>]+)>/); | |
if (toUriMatch) headers['to-uri'] = toUriMatch[1]; | |
} | |
if (headers.from) { | |
const fromUriMatch = headers.from.match(/<([^>]+)>/); | |
if (fromUriMatch) headers['from-uri'] = fromUriMatch[1]; | |
} | |
return headers; | |
} | |
// Create 200 OK response | |
function createOkResponse(headers) { | |
return [ | |
`SIP/2.0 200 OK`, | |
`Via: ${headers.via}`, | |
`From: ${headers.from}`, | |
`To: ${headers.to}`, | |
`Call-ID: ${headers['call-id']}`, | |
`CSeq: ${headers.cseq}`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Content-Length: 0`, | |
'', | |
'' | |
].join('\r\n'); | |
} | |
// Send ACK for successful INVITE | |
function sendAck(headers) { | |
const branch = `z9hG4bK${Math.floor(Math.random() * 1e10)}`; | |
const toUri = headers['to-uri'] || `sip:${SIP_SERVER}`; | |
const ack = [ | |
`ACK ${toUri} SIP/2.0`, | |
`Via: SIP/2.0/UDP ${LOCAL_IP}:${localPort};branch=${branch}`, | |
`From: <sip:${SIP_USER}@${SIP_SERVER}>;tag=${currentCallTag || ''}`, | |
`To: ${headers.to}`, | |
`Call-ID: ${headers['call-id']}`, | |
`CSeq: ${inviteSeq} ACK`, | |
`Contact: <sip:${SIP_USER}@${LOCAL_IP}:${localPort};transport=udp>`, | |
`Max-Forwards: 70`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Content-Length: 0`, | |
'', | |
'' | |
].join('\r\n'); | |
console.log('Sending ACK:'); | |
console.log(ack.split('\r\n').slice(0, 8).join('\r\n')); | |
sendSipMessage(ack); | |
} | |
// Send SIP message | |
function sendSipMessage(message) { | |
const buffer = Buffer.from(message); | |
udpSocket.send(buffer, 0, buffer.length, SIP_PORT, SIP_SERVER, (err) => { | |
if (err) { | |
console.error(`Error sending SIP message: ${err.message}`); | |
} | |
}); | |
// Broadcast to clients | |
broadcast({ | |
type: 'sip_message', | |
direction: 'outgoing', | |
message: message | |
}); | |
} | |
// Generate MD5 digest for authentication | |
function generateAuthResponse(method, uri) { | |
// MD5(username:realm:password) | |
const ha1 = crypto.createHash('md5') | |
.update(`${SIP_USER}:${authRealm}:${SIP_PASS}`) | |
.digest('hex'); | |
// MD5(method:uri) | |
const ha2 = crypto.createHash('md5') | |
.update(`${method}:${uri}`) | |
.digest('hex'); | |
// MD5(ha1:nonce:ha2) | |
return crypto.createHash('md5') | |
.update(`${ha1}:${authNonce}:${ha2}`) | |
.digest('hex'); | |
} | |
// Send authenticated REGISTER request | |
function sendAuthenticatedRegister() { | |
const callId = currentCallId || `${Math.floor(Math.random() * 1e10)}@${LOCAL_IP}`; | |
currentCallId = callId; | |
const branch = `z9hG4bK${Math.floor(Math.random() * 1e10)}`; | |
const tag = currentCallTag || `${Math.floor(Math.random() * 1e10)}`; | |
currentCallTag = tag; | |
registerSeq++; | |
const uri = `sip:${SIP_SERVER}`; | |
const authResponse = generateAuthResponse('REGISTER', uri); | |
const register = [ | |
`REGISTER ${uri} SIP/2.0`, | |
`Via: SIP/2.0/UDP ${LOCAL_IP}:${localPort};branch=${branch}`, | |
`From: <sip:${SIP_USER}@${SIP_SERVER}>;tag=${tag}`, | |
`To: <sip:${SIP_USER}@${SIP_SERVER}>`, | |
`Call-ID: ${callId}`, | |
`CSeq: ${registerSeq} REGISTER`, | |
`Contact: <sip:${SIP_USER}@${LOCAL_IP}:${localPort};transport=udp>`, | |
`Authorization: Digest username="${SIP_USER}", realm="${authRealm}", nonce="${authNonce}", uri="${uri}", response="${authResponse}", algorithm=MD5`, | |
`Max-Forwards: 70`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Allow: INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, OPTIONS`, | |
`Expires: 3600`, | |
`Content-Length: 0`, | |
'', | |
'' | |
].join('\r\n'); | |
console.log(`Sending authenticated REGISTER request`); | |
console.log(register.split('\r\n').slice(0, 10).join('\r\n') + '...'); | |
sendSipMessage(register); | |
} | |
// Send authenticated INVITE | |
function sendAuthenticatedInvite(destination) { | |
const callId = currentCallId; | |
const branch = `z9hG4bK${Math.floor(Math.random() * 1e10)}`; | |
const tag = currentCallTag; | |
inviteSeq++; | |
const uri = `sip:${SIP_SERVER}`; | |
const authResponse = generateAuthResponse('INVITE', uri); | |
// Create SDP content | |
const sdp = createSdpContent(); | |
const invite = [ | |
`INVITE ${destination} SIP/2.0`, | |
`Via: SIP/2.0/UDP ${LOCAL_IP}:${localPort};branch=${branch}`, | |
`From: <sip:${SIP_USER}@${SIP_SERVER}>;tag=${tag}`, | |
`To: <${destination}>`, | |
`Call-ID: ${callId}`, | |
`CSeq: ${inviteSeq} INVITE`, | |
`Contact: <sip:${SIP_USER}@${LOCAL_IP}:${localPort};transport=udp>`, | |
`Authorization: Digest username="${SIP_USER}", realm="${authRealm}", nonce="${authNonce}", uri="${uri}", response="${authResponse}", algorithm=MD5`, | |
`Max-Forwards: 70`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Allow: INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, OPTIONS`, | |
`Content-Type: application/sdp`, | |
`Content-Length: ${sdp.length}`, | |
'', | |
sdp | |
].join('\r\n'); | |
console.log(`Sending authenticated INVITE request`); | |
console.log(invite.split('\r\n').slice(0, 10).join('\r\n') + '...'); | |
sendSipMessage(invite); | |
} | |
// Send RTP packet | |
function sendRtpPacket(data) { | |
if (!remoteRtpAddress || !remoteRtpPort || !callActive) { | |
console.log('Cannot send RTP packet: no active call or remote endpoint unknown'); | |
return; | |
} | |
rtpSocket.send(data, 0, data.length, remoteRtpPort, remoteRtpAddress, (err) => { | |
if (err) { | |
console.error(`Error sending RTP packet: ${err.message}`); | |
} else { | |
console.log(`Sent RTP packet to ${remoteRtpAddress}:${remoteRtpPort}, length: ${data.length} bytes`); | |
} | |
}); | |
} | |
// Create WebSocket server | |
const wss = new WebSocket.Server({ server }); | |
const clients = new Set(); | |
// Handle WebSocket connections | |
wss.on('connection', (ws) => { | |
clients.add(ws); | |
ws.send(JSON.stringify({ | |
type: 'status', | |
message: 'Connected to SIP server', | |
status: 'Connected' | |
})); | |
ws.on('message', (message) => { | |
try { | |
const data = JSON.parse(message); | |
if (data.type === 'audio_data' && callActive) { | |
// Convert base64 audio data to buffer | |
const audioData = Buffer.from(data.data); | |
// Send as RTP packet | |
sendRtpPacket(audioData); | |
} | |
} catch (e) { | |
console.error('Error processing WebSocket message:', e); | |
} | |
}); | |
ws.on('close', () => { | |
clients.delete(ws); | |
}); | |
}); | |
// Broadcast message to all connected clients | |
function broadcast(message) { | |
clients.forEach(client => { | |
if (client.readyState === WebSocket.OPEN) { | |
client.send(JSON.stringify(message)); | |
} | |
}); | |
} | |
// Register endpoint | |
app.post('/register', (req, res) => { | |
// Reset registration state | |
registerSeq = 1; | |
authRealm = null; | |
authNonce = null; | |
// Create initial REGISTER message | |
const callId = `${Math.floor(Math.random() * 1e10)}@${LOCAL_IP}`; | |
currentCallId = callId; | |
const branch = `z9hG4bK${Math.floor(Math.random() * 1e10)}`; | |
const tag = `${Math.floor(Math.random() * 1e10)}`; | |
currentCallTag = tag; | |
const register = [ | |
`REGISTER sip:${SIP_SERVER} SIP/2.0`, | |
`Via: SIP/2.0/UDP ${LOCAL_IP}:${localPort};branch=${branch}`, | |
`From: <sip:${SIP_USER}@${SIP_SERVER}>;tag=${tag}`, | |
`To: <sip:${SIP_USER}@${SIP_SERVER}>`, | |
`Call-ID: ${callId}`, | |
`CSeq: ${registerSeq} REGISTER`, | |
`Contact: <sip:${SIP_USER}@${LOCAL_IP}:${localPort};transport=udp>`, | |
`Max-Forwards: 70`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Allow: INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, OPTIONS`, | |
`Expires: 3600`, | |
`Content-Length: 0`, | |
'', | |
'' | |
].join('\r\n'); | |
console.log(`Sending initial REGISTER request`); | |
console.log(register.split('\r\n').slice(0, 10).join('\r\n') + '...'); | |
// Send REGISTER message | |
const message = Buffer.from(register); | |
udpSocket.send(message, 0, message.length, SIP_PORT, SIP_SERVER, (err) => { | |
if (err) { | |
console.error(`Error sending REGISTER: ${err.message}`); | |
return res.status(500).json({ success: false, message: `Error: ${err.message}` }); | |
} | |
console.log('REGISTER sent successfully (expect 401 challenge)'); | |
broadcast({ | |
type: 'status', | |
message: 'Registration in progress...', | |
status: 'Registering' | |
}); | |
res.json({ success: true, message: 'Registration request sent' }); | |
}); | |
}); | |
// Make a call | |
app.post('/call', (req, res) => { | |
const { destination } = req.body; | |
if (!destination) { | |
return res.status(400).json({ error: 'Missing destination number' }); | |
} | |
if (!registered) { | |
return res.status(400).json({ error: 'Not registered with SIP server' }); | |
} | |
console.log(`Making call to ${destination}...`); | |
// Reset call state | |
inviteSeq = 1; | |
remoteTag = null; | |
remoteRtpAddress = null; | |
remoteRtpPort = null; | |
callActive = false; | |
// Create initial INVITE message | |
const callId = `${Math.floor(Math.random() * 1e10)}@${LOCAL_IP}`; | |
currentCallId = callId; | |
const branch = `z9hG4bK${Math.floor(Math.random() * 1e10)}`; | |
const tag = `${Math.floor(Math.random() * 1e10)}`; | |
currentCallTag = tag; | |
// Create SDP content | |
const sdp = createSdpContent(); | |
const destinationUri = `sip:${destination}@${SIP_SERVER}`; | |
const invite = [ | |
`INVITE ${destinationUri} SIP/2.0`, | |
`Via: SIP/2.0/UDP ${LOCAL_IP}:${localPort};branch=${branch}`, | |
`From: <sip:${SIP_USER}@${SIP_SERVER}>;tag=${tag}`, | |
`To: <${destinationUri}>`, | |
`Call-ID: ${callId}`, | |
`CSeq: ${inviteSeq} INVITE`, | |
`Contact: <sip:${SIP_USER}@${LOCAL_IP}:${localPort};transport=udp>`, | |
`Max-Forwards: 70`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Allow: INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, OPTIONS`, | |
`Content-Type: application/sdp`, | |
`Content-Length: ${sdp.length}`, | |
'', | |
sdp | |
].join('\r\n'); | |
console.log(`Sending initial INVITE request`); | |
console.log(invite.split('\r\n').slice(0, 10).join('\r\n') + '...'); | |
// Send INVITE message | |
const message = Buffer.from(invite); | |
udpSocket.send(message, 0, message.length, SIP_PORT, SIP_SERVER, (err) => { | |
if (err) { | |
console.error(`Error sending INVITE: ${err.message}`); | |
return res.status(500).json({ success: false, message: `Error: ${err.message}` }); | |
} | |
console.log('INVITE sent successfully (expect 401 challenge)'); | |
broadcast({ | |
type: 'status', | |
message: 'Call request sent...', | |
status: 'Calling' | |
}); | |
res.json({ success: true, message: 'Call initiated', callId }); | |
}); | |
}); | |
// Hang up call | |
app.post('/hangup', (req, res) => { | |
if (!currentCallId) { | |
return res.status(400).json({ error: 'No active call' }); | |
} | |
// Create BYE message | |
const branch = `z9hG4bK${Math.floor(Math.random() * 1e10)}`; | |
const bye = [ | |
`BYE sip:${SIP_SERVER} SIP/2.0`, | |
`Via: SIP/2.0/UDP ${LOCAL_IP}:${localPort};branch=${branch}`, | |
`From: <sip:${SIP_USER}@${SIP_SERVER}>;tag=${currentCallTag}`, | |
`To: <sip:${SIP_SERVER}>${remoteTag ? ';tag=' + remoteTag : ''}`, | |
`Call-ID: ${currentCallId}`, | |
`CSeq: ${inviteSeq + 1} BYE`, | |
`Max-Forwards: 70`, | |
`User-Agent: SIP-WebClient/1.0`, | |
`Content-Length: 0`, | |
'', | |
'' | |
].join('\r\n'); | |
console.log(`Sending BYE request`); | |
console.log(bye); | |
// Send BYE message | |
const message = Buffer.from(bye); | |
udpSocket.send(message, 0, message.length, SIP_PORT, SIP_SERVER, (err) => { | |
if (err) { | |
console.error(`Error sending BYE: ${err.message}`); | |
return res.status(500).json({ success: false, message: `Error: ${err.message}` }); | |
} | |
console.log('BYE sent successfully'); | |
callActive = false; | |
broadcast({ | |
type: 'status', | |
message: 'Hangup request sent', | |
status: 'Hanging Up' | |
}); | |
res.json({ success: true, message: 'Hangup request sent' }); | |
}); | |
}); | |
// Create SDP content for the call | |
function createSdpContent() { | |
const sessionId = Math.floor(Date.now() / 1000); | |
return [ | |
'v=0', | |
`o=${SIP_USER} ${sessionId} ${sessionId} IN IP4 ${LOCAL_IP}`, | |
's=SIP Call', | |
`c=IN IP4 ${LOCAL_IP}`, | |
't=0 0', | |
`m=audio ${rtpLocalPort} RTP/AVP 0 8 101`, | |
'a=rtpmap:0 PCMU/8000', | |
'a=rtpmap:8 PCMA/8000', | |
'a=rtpmap:101 telephone-event/8000', | |
'a=fmtp:101 0-16', | |
'a=ptime:20', | |
'a=sendrecv' | |
].join('\r\n'); | |
} | |
// Serve the web client | |
app.get('/', (req, res) => { | |
res.send(` | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>SIP Web Client with Audio</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/opus-recorder/0.5.0/recorder.min.js"></script> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
max-width: 800px; | |
margin: 0 auto; | |
padding: 20px; | |
} | |
.container { | |
display: flex; | |
flex-direction: column; | |
gap: 20px; | |
} | |
.card { | |
border: 1px solid #ddd; | |
border-radius: 8px; | |
padding: 15px; | |
background-color: #f9f9f9; | |
} | |
.status { | |
padding: 10px 15px; | |
border-radius: 5px; | |
background-color: #e0e0e0; | |
font-weight: bold; | |
margin-bottom: 15px; | |
} | |
.btn { | |
padding: 10px 15px; | |
border: none; | |
border-radius: 5px; | |
background-color: #4CAF50; | |
color: white; | |
cursor: pointer; | |
font-weight: bold; | |
} | |
.btn:disabled { | |
background-color: #cccccc; | |
cursor: not-allowed; | |
} | |
.input-group { | |
display: flex; | |
gap: 10px; | |
margin-bottom: 10px; | |
} | |
input { | |
padding: 10px; | |
border: 1px solid #ddd; | |
border-radius: 5px; | |
flex-grow: 1; | |
} | |
.log { | |
height: 300px; | |
overflow-y: auto; | |
background-color: #f0f0f0; | |
padding: 10px; | |
border: 1px solid #ddd; | |
border-radius: 5px; | |
font-family: monospace; | |
font-size: 12px; | |
} | |
.log-entry { | |
margin-bottom: 5px; | |
border-bottom: 1px solid #e0e0e0; | |
padding-bottom: 5px; | |
} | |
.info { | |
background-color: #e3f2fd; | |
padding: 10px 15px; | |
border-radius: 5px; | |
margin-top: 20px; | |
font-size: 14px; | |
} | |
.audio-controls { | |
margin-top: 15px; | |
} | |
#volumeMeter { | |
width: 100%; | |
height: 20px; | |
background-color: #ddd; | |
border-radius: 10px; | |
overflow: hidden; | |
} | |
#volumeFill { | |
height: 100%; | |
background-color: #4CAF50; | |
width: 0%; | |
transition: width 0.1s; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>SIP Client with Audio Support</h1> | |
<div class="card"> | |
<div id="status" class="status">Status: Disconnected</div> | |
<div> | |
<button id="registerBtn" class="btn" onclick="register()">Register</button> | |
</div> | |
</div> | |
<div class="card"> | |
<h2>Make a Call</h2> | |
<div class="input-group"> | |
<input type="text" id="destination" placeholder="Enter phone number"> | |
<button id="callBtn" class="btn" onclick="makeCall()" disabled>Call</button> | |
<button id="hangupBtn" class="btn" onclick="hangup()" disabled style="background-color: #f44336;">Hang Up</button> | |
</div> | |
<div class="audio-controls"> | |
<h3>Audio Controls</h3> | |
<div> | |
<button id="micBtn" class="btn" onclick="toggleMicrophone()" disabled>Enable Microphone</button> | |
<button id="muteBtn" class="btn" onclick="toggleMute()" disabled>Mute</button> | |
</div> | |
<div style="margin-top: 10px;"> | |
<label for="volumeSlider">Speaker Volume:</label> | |
<input type="range" id="volumeSlider" min="0" max="1" step="0.1" value="0.5" oninput="adjustVolume()"> | |
</div> | |
<div style="margin-top: 10px;"> | |
<p>Microphone Level:</p> | |
<div id="volumeMeter"> | |
<div id="volumeFill"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="card"> | |
<h2>Logs</h2> | |
<div id="log" class="log"></div> | |
</div> | |
<div class="info"> | |
<strong>Note:</strong> This client handles SIP signaling with authentication using the local IP ${LOCAL_IP} | |
and includes real-time audio support. Make sure to grant microphone permissions when prompted. | |
Audio is transmitted using RTP protocol on port ${rtpLocalPort}. | |
</div> | |
</div> | |
<script> | |
let registered = false; | |
let inCall = false; | |
let microphoneEnabled = false; | |
let muted = false; | |
let micStream = null; | |
let audioContext = null; | |
let mediaRecorder = null; | |
let analyserNode = null; | |
let audioPlayer = null; | |
let audioQueue = []; | |
let rtpSequence = 0; | |
let rtpTimestamp = Math.floor(Math.random() * 1000000); | |
let rtpSsrc = Math.floor(Math.random() * 1000000); | |
// Audio output | |
let audioBufferSources = []; | |
let audioPlaying = false; | |
// Connect WebSocket | |
const ws = new WebSocket('ws://' + window.location.host); | |
ws.onopen = () => { | |
addLog('Connected to server'); | |
initAudio(); | |
}; | |
ws.onmessage = (event) => { | |
const data = JSON.parse(event.data); | |
if (data.type === 'log') { | |
addLog(data.message); | |
} | |
else if (data.type === 'sip_message') { | |
addLog((data.direction === 'incoming' ? '<<< Received' : '>>> Sent') + ' SIP Message:'); | |
const shortMessage = message.split('\\r\\n').slice(0, 5).join('\\r\\n') + '...'; | |
addLog(shortMessage); | |
} | |
else if (data.type === 'status') { | |
addLog(data.message); | |
document.getElementById('status').textContent = 'Status: ' + data.status; | |
if (data.status === 'Registered') { | |
registered = true; | |
document.getElementById('registerBtn').disabled = true; | |
document.getElementById('callBtn').disabled = false; | |
document.getElementById('micBtn').disabled = false; | |
} | |
else if (data.status === 'Connected' || data.status === 'Ringing') { | |
inCall = true; | |
document.getElementById('callBtn').disabled = true; | |
document.getElementById('hangupBtn').disabled = false; | |
if (microphoneEnabled) { | |
document.getElementById('muteBtn').disabled = false; | |
} | |
} | |
else if (data.status === 'Idle' || data.status === 'Error') { | |
inCall = false; | |
document.getElementById('callBtn').disabled = !registered; | |
document.getElementById('hangupBtn').disabled = true; | |
document.getElementById('muteBtn').disabled = true; | |
// Stop recording if needed | |
stopRecording(); | |
} | |
} | |
else if (data.type === 'audio_data' && inCall) { | |
// Process incoming audio data | |
if (audioContext) { | |
try { | |
// Convert array back to buffer and play | |
const audioBuffer = new Uint8Array(data.data); | |
// Remove RTP header (first 12 bytes) | |
const audioData = audioBuffer.slice(12); | |
// Play audio | |
playAudioData(audioData); | |
} catch (e) { | |
console.error('Error processing audio data:', e); | |
} | |
} | |
} | |
}; | |
ws.onclose = () => { | |
addLog('Disconnected from server'); | |
document.getElementById('status').textContent = 'Status: Disconnected'; | |
}; | |
// Initialize audio context and player | |
function initAudio() { | |
try { | |
// Create audio context | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
// Create gain node for volume control | |
gainNode = audioContext.createGain(); | |
gainNode.gain.value = document.getElementById('volumeSlider').value; | |
gainNode.connect(audioContext.destination); | |
addLog('Audio system initialized'); | |
} catch (e) { | |
addLog('Error initializing audio: ' + e.message); | |
} | |
} | |
// Play audio data | |
function playAudioData(audioData) { | |
if (!audioContext) return; | |
try { | |
// For G.711 PCMU/PCMA codec, we need to decode the data | |
// This is a simplified implementation - in a real app, we'd use a proper codec | |
// Create a buffer with the decoded audio | |
const buffer = audioContext.createBuffer(1, audioData.length, 8000); | |
const channel = buffer.getChannelData(0); | |
// PCMU (µ-law) basic decoding - this is a very simplified version | |
// In a real implementation, you'd use a proper G.711 codec library | |
for (let i = 0; i < audioData.length; i++) { | |
// Convert 8-bit µ-law to float | |
channel[i] = ((audioData[i] - 128) / 128.0); | |
} | |
// Create a buffer source | |
const source = audioContext.createBufferSource(); | |
source.buffer = buffer; | |
source.connect(gainNode); | |
// Start playing | |
source.start(); | |
// Store for cleanup | |
audioBufferSources.push(source); | |
// Clean up old sources | |
if (audioBufferSources.length > 10) { | |
audioBufferSources.shift(); | |
} | |
} catch (e) { | |
console.error('Error playing audio:', e); | |
} | |
} | |
// Toggle microphone access | |
function toggleMicrophone() { | |
if (microphoneEnabled) { | |
stopMicrophone(); | |
document.getElementById('micBtn').textContent = "Enable Microphone"; | |
microphoneEnabled = false; | |
} else { | |
startMicrophone(); | |
} | |
} | |
// Start microphone access | |
function startMicrophone() { | |
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { | |
navigator.mediaDevices.getUserMedia({ audio: true, video: false }) | |
.then(stream => { | |
micStream = stream; | |
microphoneEnabled = true; | |
document.getElementById('micBtn').textContent = "Disable Microphone"; | |
addLog('Microphone access granted'); | |
// Set up audio analyzer for volume meter | |
setupAudioAnalyzer(stream); | |
// If in a call, enable recording | |
if (inCall) { | |
startRecording(); | |
document.getElementById('muteBtn').disabled = false; | |
} | |
}) | |
.catch(err => { | |
addLog('Error accessing microphone: ' + err.message); | |
}); | |
} else { | |
addLog('getUserMedia not supported in this browser'); | |
} | |
} | |
// Stop microphone access | |
function stopMicrophone() { | |
if (micStream) { | |
stopRecording(); | |
micStream.getTracks().forEach(track => { | |
track.stop(); | |
}); | |
micStream = null; | |
// Reset volume meter | |
document.getElementById('volumeFill').style.width = '0%'; | |
addLog('Microphone disabled'); | |
document.getElementById('muteBtn').disabled = true; | |
} | |
} | |
// Set up audio analyzer for volume meter | |
function setupAudioAnalyzer(stream) { | |
if (!audioContext) return; | |
const source = audioContext.createMediaStreamSource(stream); | |
analyserNode = audioContext.createAnalyser(); | |
analyserNode.fftSize = 256; | |
source.connect(analyserNode); | |
// Start updating volume meter | |
updateVolumeMeter(); | |
} | |
// Update volume meter display | |
function updateVolumeMeter() { | |
if (!analyserNode || !microphoneEnabled) return; | |
const dataArray = new Uint8Array(analyserNode.frequencyBinCount); | |
analyserNode.getByteFrequencyData(dataArray); | |
// Calculate average volume | |
let sum = 0; | |
for (let i = 0; i < dataArray.length; i++) { | |
sum += dataArray[i]; | |
} | |
const average = sum / dataArray.length; | |
// Update volume meter display (0-100%) | |
const volumePercent = Math.min(100, Math.max(0, average * 100 / 255)); | |
document.getElementById('volumeFill').style.width = volumePercent + '%'; | |
// Schedule next update | |
requestAnimationFrame(updateVolumeMeter); | |
} | |
// Start recording audio from microphone | |
function startRecording() { | |
if (!micStream || !microphoneEnabled || !inCall) return; | |
try { | |
// Create simple DataChannel-based recorder | |
const recorder = new MediaRecorder(micStream, { | |
mimeType: 'audio/webm' | |
}); | |
// Set up data handler | |
recorder.ondataavailable = (event) => { | |
if (event.data.size > 0 && !muted) { | |
// Convert to ArrayBuffer and send | |
event.data.arrayBuffer().then(buffer => { | |
const audioData = new Uint8Array(buffer); | |
// Create a simple RTP-like packet | |
// In real implementation, we'd use proper RTP encoding | |
// This is a simplified version | |
const rtpHeader = new Uint8Array(12); | |
rtpHeader[0] = 0x80; // RTP version 2 | |
rtpHeader[1] = 0x00; // PT: PCMU | |
// Sequence number (2 bytes) | |
rtpHeader[2] = (rtpSequence >> 8) & 0xFF; | |
rtpHeader[3] = rtpSequence & 0xFF; | |
rtpSequence = (rtpSequence + 1) & 0xFFFF; | |
// Timestamp (4 bytes) | |
rtpHeader[4] = (rtpTimestamp >> 24) & 0xFF; | |
rtpHeader[5] = (rtpTimestamp >> 16) & 0xFF; | |
rtpHeader[6] = (rtpTimestamp >> 8) & 0xFF; | |
rtpHeader[7] = rtpTimestamp & 0xFF; | |
rtpTimestamp += 160; // 20ms at 8000Hz | |
// SSRC (4 bytes) | |
rtpHeader[8] = (rtpSsrc >> 24) & 0xFF; | |
rtpHeader[9] = (rtpSsrc >> 16) & 0xFF; | |
rtpHeader[10] = (rtpSsrc >> 8) & 0xFF; | |
rtpHeader[11] = rtpSsrc & 0xFF; | |
// Combine header and payload | |
const rtpPacket = new Uint8Array(rtpHeader.length + audioData.length); | |
rtpPacket.set(rtpHeader); | |
rtpPacket.set(audioData, rtpHeader.length); | |
// Send to server | |
ws.send(JSON.stringify({ | |
type: 'audio_data', | |
data: Array.from(rtpPacket) | |
})); | |
}); | |
} | |
}; | |
// Start recording | |
recorder.start(20); // Capture in 20ms chunks for real-time transmission | |
mediaRecorder = recorder; | |
addLog('Started audio recording'); | |
} catch (e) { | |
addLog('Error starting recorder: ' + e.message); | |
} | |
} | |
// Stop recording | |
function stopRecording() { | |
if (mediaRecorder && mediaRecorder.state !== 'inactive') { | |
mediaRecorder.stop(); | |
mediaRecorder = null; | |
addLog('Stopped audio recording'); | |
} | |
} | |
// Toggle mute | |
function toggleMute() { | |
muted = !muted; | |
document.getElementById('muteBtn').textContent = muted ? "Unmute" : "Mute"; | |
addLog(muted ? 'Microphone muted' : 'Microphone unmuted'); | |
} | |
// Adjust volume | |
function adjustVolume() { | |
const volume = document.getElementById('volumeSlider').value; | |
if (gainNode) { | |
gainNode.gain.value = volume; | |
} | |
addLog('Speaker volume set to: ' + Math.round(volume * 100) + '%'); | |
} | |
// Add log entry | |
function addLog(message) { | |
const log = document.getElementById('log'); | |
const entry = document.createElement('div'); | |
entry.className = 'log-entry'; | |
entry.textContent = new Date().toLocaleTimeString() + ': ' + message; | |
log.appendChild(entry); | |
log.scrollTop = log.scrollHeight; | |
} | |
// Register | |
function register() { | |
addLog('Registering...'); | |
document.getElementById('registerBtn').disabled = true; | |
fetch('/register', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({}) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (!data.success) { | |
addLog('Registration failed: ' + data.message); | |
document.getElementById('registerBtn').disabled = false; | |
} | |
}) | |
.catch(error => { | |
addLog('Error: ' + error.message); | |
document.getElementById('registerBtn').disabled = false; | |
}); | |
} | |
// Make a call | |
function makeCall() { | |
const destination = document.getElementById('destination').value; | |
if (!destination) { | |
alert('Please enter a destination number'); | |
return; | |
} | |
addLog('Making call to ' + destination); | |
document.getElementById('callBtn').disabled = true; | |
fetch('/call', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ destination }) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (!data.success) { | |
addLog('Call failed: ' + data.message); | |
document.getElementById('callBtn').disabled = !registered; | |
} else { | |
// Start recording if microphone is enabled | |
if (microphoneEnabled) { | |
startRecording(); | |
document.getElementById('muteBtn').disabled = false; | |
} | |
} | |
}) | |
.catch(error => { | |
addLog('Error: ' + error.message); | |
document.getElementById('callBtn').disabled = !registered; | |
}); | |
} | |
// Hang up | |
function hangup() { | |
addLog('Hanging up...'); | |
document.getElementById('hangupBtn').disabled = true; | |
fetch('/hangup', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({}) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (!data.success) { | |
addLog('Hangup failed: ' + data.message); | |
document.getElementById('hangupBtn').disabled = false; | |
} else { | |
// Stop recording | |
stopRecording(); | |
document.getElementById('muteBtn').disabled = true; | |
} | |
}) | |
.catch(error => { | |
addLog('Error: ' + error.message); | |
document.getElementById('hangupBtn').disabled = false; | |
}); | |
} | |
</script> | |
</body> | |
</html> | |
`); | |
}); | |
// Start the server | |
const PORT = process.env.PORT || 5002; | |
server.listen(PORT, () => { | |
console.log(`Server running on port ${PORT}`); | |
console.log(`Open http://localhost:${PORT} in your browser to use the SIP client`); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment