Skip to content

Part 4: Advanced Features

Time: 20 minutes | Difficulty: Advanced

Your softphone can make and receive calls. Now let's add the professional features that make a truly production-ready application.

What You'll Learn

  • Call transfers (blind and attended)
  • Conference calling with participant management
  • Real-time transcription
  • Call quality monitoring
  • Recording basics

Feature 1: Call Transfers

Call transfers let you redirect an active call to another party.

Transfer Types

TypeDescriptionUse Case
Blind TransferImmediately transfer, you drop offQuick handoff to colleague
Attended TransferTalk to target first, then transferWarm handoff with context

Using useCallTransfer

vue
<script setup lang="ts">
import { useCallTransfer } from 'vuesip'
import type { TransferType } from 'vuesip'

const { transferState, transferType, isTransferring, transferError, transferCall, clearTransfer } =
  useCallTransfer(sessionRef)

// Blind transfer - immediate handoff
async function blindTransfer(targetNumber: string) {
  try {
    const result = await transferCall(`sip:${targetNumber}@provider.com`, {
      type: 'blind' as TransferType,
      target: `sip:${targetNumber}@provider.com`,
    })

    if (result.success) {
      console.log('Transfer successful!')
    } else {
      console.error('Transfer failed:', result.error)
    }
  } catch (err) {
    console.error('Transfer error:', err)
  }
}
</script>

Building a Transfer UI

vue
<template>
  <div class="transfer-panel" v-if="isActive">
    <h3>Transfer Call</h3>

    <!-- Transfer Target Input -->
    <div class="transfer-input">
      <input
        v-model="transferTarget"
        placeholder="Enter transfer target"
        :disabled="isTransferring"
      />
    </div>

    <!-- Transfer Buttons -->
    <div class="transfer-actions">
      <button
        @click="handleBlindTransfer"
        :disabled="!transferTarget || isTransferring"
        class="transfer-btn blind"
      >
        <span class="icon">Blind Transfer</span>
        <span class="desc">Transfer immediately</span>
      </button>

      <button
        @click="handleAttendedTransfer"
        :disabled="!transferTarget || isTransferring"
        class="transfer-btn attended"
      >
        <span class="icon">Attended Transfer</span>
        <span class="desc">Consult first, then transfer</span>
      </button>
    </div>

    <!-- Transfer Status -->
    <div v-if="isTransferring" class="transfer-status">
      <span class="spinner"></span>
      Transfer in progress: {{ transferState }}
    </div>

    <div v-if="transferError" class="transfer-error">
      {{ transferError }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useCallTransfer } from 'vuesip'

const props = defineProps<{
  session: any
  isActive: boolean
}>()

const { transferState, isTransferring, transferError, transferCall, clearTransfer } =
  useCallTransfer(() => props.session)

const transferTarget = ref('')

async function handleBlindTransfer() {
  await transferCall(`sip:${transferTarget.value}@provider.com`, {
    type: 'blind',
    target: `sip:${transferTarget.value}@provider.com`,
  })
}

async function handleAttendedTransfer() {
  // Attended transfer creates a consultation call first
  await transferCall(`sip:${transferTarget.value}@provider.com`, {
    type: 'attended',
    target: `sip:${transferTarget.value}@provider.com`,
  })
}
</script>

Feature 2: Conference Calling

Conference calling connects multiple parties in a single call.

Using useConference

vue
<script setup lang="ts">
import { useConference } from 'vuesip'

const {
  conference,
  state,
  participants,
  participantCount,
  isActive,
  isLocked,
  isRecording,
  localParticipant,

  // Methods
  createConference,
  joinConference,
  leaveConference,
  addParticipant,
  removeParticipant,
  muteParticipant,
  lockConference,
  unlockConference,
  startRecording,
  stopRecording,
} = useConference(sipClient)

// Create a new conference
async function startConference() {
  try {
    const conferenceId = await createConference({
      maxParticipants: 10,
      startMuted: false,
      recordOnJoin: false,
    })
    console.log('Conference created:', conferenceId)
  } catch (err) {
    console.error('Failed to create conference:', err)
  }
}

// Add a participant
async function inviteParticipant(number: string) {
  try {
    await addParticipant(`sip:${number}@provider.com`)
    console.log('Participant invited')
  } catch (err) {
    console.error('Failed to add participant:', err)
  }
}
</script>

Building a Conference UI

vue
<template>
  <div class="conference-panel">
    <div class="conference-header">
      <h3>Conference Call</h3>
      <span class="participant-count"> {{ participantCount }} participants </span>
    </div>

    <!-- Conference Controls -->
    <div v-if="!isActive" class="start-conference">
      <button @click="handleCreateConference" class="create-btn">Start Conference</button>
    </div>

    <!-- Active Conference -->
    <div v-else class="active-conference">
      <!-- Participant Grid -->
      <div class="participant-grid">
        <div
          v-for="participant in participants"
          :key="participant.id"
          :class="[
            'participant-card',
            {
              speaking: participant.isSpeaking,
              muted: participant.isMuted,
              self: participant.isSelf,
            },
          ]"
        >
          <div class="avatar">
            {{ participant.displayName?.charAt(0) || '?' }}
          </div>
          <div class="name">
            {{ participant.displayName || participant.uri }}
            <span v-if="participant.isSelf" class="you-badge">(You)</span>
          </div>
          <div class="status">
            <span v-if="participant.isMuted" class="muted-icon">Muted</span>
            <span v-if="participant.isSpeaking" class="speaking-icon"> Speaking </span>
          </div>
          <div v-if="!participant.isSelf" class="actions">
            <button @click="handleMuteParticipant(participant.id)" class="action-btn">
              {{ participant.isMuted ? 'Unmute' : 'Mute' }}
            </button>
            <button @click="handleRemoveParticipant(participant.id)" class="action-btn remove">
              Remove
            </button>
          </div>
        </div>
      </div>

      <!-- Add Participant -->
      <div class="add-participant">
        <input v-model="newParticipant" placeholder="Number to add" />
        <button @click="handleAddParticipant" :disabled="!newParticipant">Add</button>
      </div>

      <!-- Conference Actions -->
      <div class="conference-actions">
        <button @click="handleToggleLock" :class="{ active: isLocked }">
          {{ isLocked ? 'Unlock' : 'Lock' }}
        </button>
        <button @click="handleToggleRecording" :class="{ active: isRecording }">
          {{ isRecording ? 'Stop Recording' : 'Start Recording' }}
        </button>
        <button @click="handleLeaveConference" class="leave-btn">Leave Conference</button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useConference } from 'vuesip'

const props = defineProps<{ sipClient: any }>()

const {
  state,
  participants,
  participantCount,
  isActive,
  isLocked,
  isRecording,
  createConference,
  leaveConference,
  addParticipant,
  removeParticipant,
  muteParticipant,
  lockConference,
  unlockConference,
  startRecording,
  stopRecording,
} = useConference(props.sipClient)

const newParticipant = ref('')

async function handleCreateConference() {
  await createConference({ maxParticipants: 10 })
}

async function handleAddParticipant() {
  if (!newParticipant.value) return
  await addParticipant(`sip:${newParticipant.value}@provider.com`)
  newParticipant.value = ''
}

async function handleRemoveParticipant(participantId: string) {
  await removeParticipant(participantId)
}

async function handleMuteParticipant(participantId: string) {
  const participant = participants.value.find((p) => p.id === participantId)
  if (participant) {
    await muteParticipant(participantId, !participant.isMuted)
  }
}

async function handleToggleLock() {
  if (isLocked.value) {
    await unlockConference()
  } else {
    await lockConference()
  }
}

async function handleToggleRecording() {
  if (isRecording.value) {
    await stopRecording()
  } else {
    await startRecording()
  }
}

async function handleLeaveConference() {
  await leaveConference()
}
</script>

<style scoped>
.participant-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 1rem;
  margin: 1rem 0;
}

.participant-card {
  padding: 1rem;
  background: #1e293b;
  border-radius: 8px;
  text-align: center;
}

.participant-card.speaking {
  border: 2px solid #22c55e;
}

.participant-card.muted {
  opacity: 0.7;
}

.avatar {
  width: 48px;
  height: 48px;
  background: #3b82f6;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
  margin: 0 auto 0.5rem;
}
</style>

Feature 3: Real-Time Transcription

Add live transcription to your calls using the Web Speech API or external providers.

Using useTranscription

vue
<script setup lang="ts">
import { useTranscription } from 'vuesip'

const {
  // State
  isTranscribing,
  transcript,
  currentUtterance,
  error,
  detectedLanguage,

  // Controls
  start,
  stop,
  pause,
  resume,
  clear,

  // Features
  addKeywordRule,
  removeKeywordRule,
  exportTranscript,

  // Participants
  setLocalEnabled,
  setRemoteEnabled,
} = useTranscription({
  // Provider configuration
  provider: 'web-speech', // or 'whisper', 'deepgram', etc.
  language: 'en-US',

  // Enable for both parties
  localEnabled: true,
  remoteEnabled: true,

  // Participant names for transcript
  localName: 'Agent',
  remoteName: 'Customer',

  // Keyword detection
  keywords: [
    { phrase: 'cancel', action: 'escalate', priority: 'high' },
    { phrase: 'refund', action: 'flag', priority: 'medium' },
  ],

  // Callbacks
  onKeywordDetected: (match) => {
    console.log('Keyword detected:', match.rule.phrase)
    // Show alert, flag for supervisor, etc.
  },
})

// Start transcription when call connects
watch(callState, async (state) => {
  if (state === 'active') {
    await start()
  } else if (state === 'ended') {
    await stop()
  }
})
</script>

Building a Transcription Panel

vue
<template>
  <div class="transcription-panel">
    <div class="transcription-header">
      <h3>Live Transcription</h3>
      <div class="controls">
        <button v-if="!isTranscribing" @click="start" class="start-btn">Start</button>
        <button v-else @click="stop" class="stop-btn">Stop</button>
        <button @click="handleExport" :disabled="!transcript.length">Export</button>
      </div>
    </div>

    <!-- Live Transcript -->
    <div class="transcript-container" ref="transcriptContainer">
      <div v-for="entry in transcript" :key="entry.id" :class="['transcript-entry', entry.speaker]">
        <div class="speaker-label">
          {{ entry.speakerLabel }}
          <span class="timestamp">{{ formatTime(entry.timestamp) }}</span>
        </div>
        <div class="text">{{ entry.text }}</div>
        <div v-if="entry.confidence < 0.8" class="low-confidence">(Low confidence)</div>
      </div>

      <!-- Current utterance (in progress) -->
      <div v-if="currentUtterance" class="current-utterance">
        <span class="typing-indicator"></span>
        {{ currentUtterance }}
      </div>
    </div>

    <!-- Keyword Alerts -->
    <div v-if="keywordMatches.length" class="keyword-alerts">
      <div
        v-for="match in keywordMatches"
        :key="match.timestamp"
        :class="['keyword-alert', match.rule.priority]"
      >
        Keyword detected: "{{ match.matchedText }}"
        <span class="action">Action: {{ match.rule.action }}</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useTranscription } from 'vuesip'

const {
  isTranscribing,
  transcript,
  currentUtterance,
  start,
  stop,
  exportTranscript,
  addKeywordRule,
} = useTranscription({
  provider: 'web-speech',
  language: 'en-US',
  localName: 'Me',
  remoteName: 'Caller',
})

const transcriptContainer = ref<HTMLElement | null>(null)
const keywordMatches = ref<any[]>([])

// Auto-scroll to bottom on new entries
watch(transcript, async () => {
  await nextTick()
  if (transcriptContainer.value) {
    transcriptContainer.value.scrollTop = transcriptContainer.value.scrollHeight
  }
})

function formatTime(timestamp: number) {
  const date = new Date(timestamp)
  return date.toLocaleTimeString([], {
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
  })
}

async function handleExport() {
  // Export as SRT subtitle format
  const srt = exportTranscript('srt')
  downloadFile(srt, 'transcript.srt', 'text/plain')
}

function downloadFile(content: string, filename: string, type: string) {
  const blob = new Blob([content], { type })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  a.click()
  URL.revokeObjectURL(url)
}
</script>

<style scoped>
.transcript-container {
  max-height: 300px;
  overflow-y: auto;
  padding: 1rem;
  background: #0f172a;
  border-radius: 8px;
}

.transcript-entry {
  margin-bottom: 1rem;
  padding: 0.5rem;
  border-radius: 4px;
}

.transcript-entry.local {
  background: rgba(59, 130, 246, 0.2);
  margin-left: 2rem;
}

.transcript-entry.remote {
  background: rgba(34, 197, 94, 0.2);
  margin-right: 2rem;
}

.speaker-label {
  font-size: 0.75rem;
  color: #94a3b8;
  margin-bottom: 0.25rem;
}

.timestamp {
  margin-left: 0.5rem;
}

.current-utterance {
  color: #94a3b8;
  font-style: italic;
}

.typing-indicator {
  display: inline-block;
  width: 8px;
  height: 8px;
  background: #3b82f6;
  border-radius: 50%;
  animation: pulse 1s infinite;
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

.keyword-alert {
  padding: 0.5rem;
  border-radius: 4px;
  margin-top: 0.5rem;
}

.keyword-alert.high {
  background: rgba(239, 68, 68, 0.2);
  border: 1px solid #ef4444;
}

.keyword-alert.medium {
  background: rgba(245, 158, 11, 0.2);
  border: 1px solid #f59e0b;
}
</style>

Feature 4: Call Quality Monitoring

Monitor call quality in real-time to detect and handle issues.

Using Call Quality Metrics

vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useCallSession } from 'vuesip'

const { session, getStats } = useCallSession()

const qualityMetrics = ref({
  bitrate: 0,
  packetLoss: 0,
  jitter: 0,
  roundTripTime: 0,
  audioLevel: 0,
})

const qualityScore = computed(() => {
  // Calculate overall quality score (0-100)
  const { packetLoss, jitter, roundTripTime } = qualityMetrics.value

  let score = 100

  // Packet loss penalty
  if (packetLoss > 0) score -= packetLoss * 20
  if (packetLoss > 5) score -= 30

  // Jitter penalty (>30ms is noticeable)
  if (jitter > 30) score -= 10
  if (jitter > 50) score -= 20

  // RTT penalty (>200ms is noticeable)
  if (roundTripTime > 200) score -= 10
  if (roundTripTime > 400) score -= 20

  return Math.max(0, Math.min(100, score))
})

const qualityLabel = computed(() => {
  const score = qualityScore.value
  if (score >= 80) return 'Excellent'
  if (score >= 60) return 'Good'
  if (score >= 40) return 'Fair'
  if (score >= 20) return 'Poor'
  return 'Bad'
})

// Poll for stats every second
let statsInterval: NodeJS.Timer | null = null

onMounted(() => {
  statsInterval = setInterval(async () => {
    if (session.value) {
      try {
        const stats = await getStats()
        if (stats) {
          qualityMetrics.value = {
            bitrate: stats.audio?.bitrate || 0,
            packetLoss: stats.audio?.packetsLost || 0,
            jitter: stats.audio?.jitter || 0,
            roundTripTime: stats.audio?.roundTripTime || 0,
            audioLevel: stats.audio?.audioLevel || 0,
          }
        }
      } catch (err) {
        console.error('Failed to get stats:', err)
      }
    }
  }, 1000)
})

onUnmounted(() => {
  if (statsInterval) {
    clearInterval(statsInterval)
  }
})
</script>

<template>
  <div class="quality-panel">
    <div class="quality-header">
      <h4>Call Quality</h4>
      <div :class="['quality-badge', qualityLabel.toLowerCase()]">
        {{ qualityLabel }}
      </div>
    </div>

    <div class="quality-meter">
      <div
        class="quality-bar"
        :style="{ width: `${qualityScore}%` }"
        :class="qualityLabel.toLowerCase()"
      ></div>
    </div>

    <div class="metrics-grid">
      <div class="metric">
        <span class="label">Bitrate</span>
        <span class="value">{{ qualityMetrics.bitrate }} kbps</span>
      </div>
      <div class="metric">
        <span class="label">Packet Loss</span>
        <span class="value" :class="{ warning: qualityMetrics.packetLoss > 2 }">
          {{ qualityMetrics.packetLoss.toFixed(1) }}%
        </span>
      </div>
      <div class="metric">
        <span class="label">Jitter</span>
        <span class="value" :class="{ warning: qualityMetrics.jitter > 30 }">
          {{ qualityMetrics.jitter.toFixed(0) }} ms
        </span>
      </div>
      <div class="metric">
        <span class="label">Round Trip</span>
        <span class="value" :class="{ warning: qualityMetrics.roundTripTime > 200 }">
          {{ qualityMetrics.roundTripTime.toFixed(0) }} ms
        </span>
      </div>
    </div>

    <!-- Quality Alert -->
    <div v-if="qualityScore < 40" class="quality-alert">
      Poor call quality detected. Consider:
      <ul>
        <li v-if="qualityMetrics.packetLoss > 2">High packet loss - check network</li>
        <li v-if="qualityMetrics.jitter > 50">High jitter - unstable connection</li>
        <li v-if="qualityMetrics.roundTripTime > 300">High latency - network congestion</li>
      </ul>
    </div>
  </div>
</template>

<style scoped>
.quality-panel {
  padding: 1rem;
  background: #1e293b;
  border-radius: 8px;
}

.quality-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.5rem;
}

.quality-badge {
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 600;
}

.quality-badge.excellent {
  background: #22c55e;
}
.quality-badge.good {
  background: #3b82f6;
}
.quality-badge.fair {
  background: #f59e0b;
}
.quality-badge.poor {
  background: #ef4444;
}
.quality-badge.bad {
  background: #7f1d1d;
}

.quality-meter {
  height: 8px;
  background: #0f172a;
  border-radius: 4px;
  overflow: hidden;
  margin-bottom: 1rem;
}

.quality-bar {
  height: 100%;
  transition: width 0.3s ease;
}

.quality-bar.excellent {
  background: #22c55e;
}
.quality-bar.good {
  background: #3b82f6;
}
.quality-bar.fair {
  background: #f59e0b;
}
.quality-bar.poor {
  background: #ef4444;
}

.metrics-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.5rem;
}

.metric {
  display: flex;
  justify-content: space-between;
  padding: 0.25rem 0;
}

.metric .label {
  color: #94a3b8;
  font-size: 0.875rem;
}

.metric .value {
  font-weight: 500;
}

.metric .value.warning {
  color: #f59e0b;
}

.quality-alert {
  margin-top: 1rem;
  padding: 0.75rem;
  background: rgba(239, 68, 68, 0.1);
  border: 1px solid #ef4444;
  border-radius: 4px;
  font-size: 0.875rem;
}

.quality-alert ul {
  margin: 0.5rem 0 0 1rem;
  padding: 0;
}
</style>

Feature 5: Call Recording

Basic call recording using the MediaRecorder API.

vue
<script setup lang="ts">
import { ref, computed } from 'vue'

const isRecording = ref(false)
const recordedChunks = ref<Blob[]>([])
const mediaRecorder = ref<MediaRecorder | null>(null)

const canRecord = computed(() => {
  return 'MediaRecorder' in window
})

async function startRecording(stream: MediaStream) {
  if (!canRecord.value) {
    console.error('MediaRecorder not supported')
    return
  }

  recordedChunks.value = []

  const options = { mimeType: 'audio/webm;codecs=opus' }
  mediaRecorder.value = new MediaRecorder(stream, options)

  mediaRecorder.value.ondataavailable = (event) => {
    if (event.data.size > 0) {
      recordedChunks.value.push(event.data)
    }
  }

  mediaRecorder.value.onstop = () => {
    const blob = new Blob(recordedChunks.value, { type: 'audio/webm' })
    downloadRecording(blob)
  }

  mediaRecorder.value.start(1000) // Collect data every second
  isRecording.value = true
}

function stopRecording() {
  if (mediaRecorder.value && isRecording.value) {
    mediaRecorder.value.stop()
    isRecording.value = false
  }
}

function downloadRecording(blob: Blob) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = `call-recording-${Date.now()}.webm`
  a.click()
  URL.revokeObjectURL(url)
}
</script>

<template>
  <div class="recording-controls">
    <button v-if="!isRecording" @click="startRecording(localStream)" :disabled="!canRecord">
      Start Recording
    </button>
    <button v-else @click="stopRecording" class="recording">
      <span class="recording-dot"></span>
      Stop Recording
    </button>
  </div>
</template>

Legal Notice

Recording calls may be subject to legal requirements:

  • Two-party consent: Some jurisdictions require all parties to consent
  • One-party consent: Only one party needs to consent
  • Business use: Different rules may apply

Always check local laws and inform callers when recording.

Putting It All Together

Here's how to integrate these features into your softphone:

vue
<template>
  <div class="advanced-softphone">
    <!-- Main Softphone UI (from Part 2) -->
    <Softphone @call-connected="handleCallConnected" @call-ended="handleCallEnded" />

    <!-- Advanced Features Panel (shown during calls) -->
    <div v-if="isCallActive" class="features-panel">
      <!-- Tab Navigation -->
      <div class="tabs">
        <button
          v-for="tab in tabs"
          :key="tab.id"
          :class="{ active: activeTab === tab.id }"
          @click="activeTab = tab.id"
        >
          {{ tab.label }}
        </button>
      </div>

      <!-- Tab Content -->
      <div class="tab-content">
        <TransferPanel v-if="activeTab === 'transfer'" :session="session" />
        <ConferencePanel v-if="activeTab === 'conference'" :client="sipClient" />
        <TranscriptionPanel v-if="activeTab === 'transcription'" />
        <QualityPanel v-if="activeTab === 'quality'" :session="session" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Softphone from './Softphone.vue'
import TransferPanel from './TransferPanel.vue'
import ConferencePanel from './ConferencePanel.vue'
import TranscriptionPanel from './TranscriptionPanel.vue'
import QualityPanel from './QualityPanel.vue'

const isCallActive = ref(false)
const activeTab = ref('quality')

const tabs = [
  { id: 'quality', label: 'Quality' },
  { id: 'transcription', label: 'Transcript' },
  { id: 'transfer', label: 'Transfer' },
  { id: 'conference', label: 'Conference' },
]

function handleCallConnected(session: any) {
  isCallActive.value = true
}

function handleCallEnded() {
  isCallActive.value = false
}
</script>

What You Learned

  • Call Transfers: Blind and attended transfer implementation
  • Conferencing: Multi-party call management with participant controls
  • Transcription: Real-time speech-to-text with keyword detection
  • Quality Monitoring: WebRTC stats for call quality assessment
  • Recording: Basic call recording with MediaRecorder

Next Steps

Congratulations! You've completed the VueSIP tutorial. You now have the knowledge to build production-ready VoIP applications.

Continue Learning

Real-World Considerations

As you move to production:

  1. Security: Implement proper authentication, use HTTPS, secure credentials
  2. Error Handling: Add comprehensive error handling and user feedback
  3. Accessibility: Ensure your UI is accessible (ARIA labels, keyboard navigation)
  4. Performance: Optimize for mobile devices and slow networks
  5. Testing: Write unit tests and E2E tests for critical flows

Community

  • GitHub: VueSIP Repository
  • Issues: Report bugs or request features
  • Discussions: Ask questions and share tips

Released under the MIT License.