Skip to content

Commit 97a87f7

Browse files
authored
Merge pull request #198 from shridarpatil/feat/per-org-hold-music-ringback
feat: per-org hold music & ringback upload with auto-transcoding
2 parents af9e52f + e2a59c0 commit 97a87f7

File tree

11 files changed

+397
-26
lines changed

11 files changed

+397
-26
lines changed

cmd/whatomate/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,7 @@ func setupRoutes(g *fastglue.Fastglue, app *handlers.App, lo logf.Logger, basePa
732732
// Organization Settings
733733
g.GET("/api/org/settings", app.GetOrganizationSettings)
734734
g.PUT("/api/org/settings", app.UpdateOrganizationSettings)
735+
g.POST("/api/org/audio", app.UploadOrgAudio)
735736

736737
// Organizations
737738
g.GET("/api/organizations", app.ListOrganizations)

docker/Dockerfile

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
# Build stage
1+
# Frontend build stage
2+
FROM node:22-alpine AS frontend-builder
3+
4+
WORKDIR /app/frontend
5+
6+
# Copy frontend dependency files first for caching
7+
COPY frontend/package.json frontend/package-lock.json ./
8+
RUN npm ci
9+
10+
# Copy frontend source and build
11+
COPY frontend/ .
12+
RUN npm run build
13+
14+
# Go build stage
215
FROM golang:1.24.5-alpine AS builder
316

417
WORKDIR /app
@@ -13,6 +26,9 @@ RUN go mod download
1326
# Copy source code
1427
COPY . .
1528

29+
# Embed frontend build into Go binary
30+
COPY --from=frontend-builder /app/frontend/dist/ ./internal/frontend/dist/
31+
1632
# Build the binary
1733
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o whatomate ./cmd/whatomate
1834

@@ -34,9 +50,9 @@ FROM debian:bookworm-slim
3450

3551
WORKDIR /app
3652

37-
# Install runtime dependencies (TTS: espeak-ng, opus-tools)
53+
# Install runtime dependencies (TTS: espeak-ng, opus-tools; transcoding: ffmpeg)
3854
RUN apt-get update && apt-get install -y --no-install-recommends \
39-
ca-certificates tzdata espeak-ng opus-tools \
55+
ca-certificates tzdata espeak-ng opus-tools ffmpeg \
4056
&& rm -rf /var/lib/apt/lists/*
4157

4258
# Copy binary from builder

docker/Dockerfile.goreleaser

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ FROM debian:bookworm-slim
1919

2020
WORKDIR /app
2121

22-
# Install runtime dependencies (TTS: espeak-ng, opus-tools)
22+
# Install runtime dependencies (TTS: espeak-ng, opus-tools; transcoding: ffmpeg)
2323
RUN apt-get update && apt-get install -y --no-install-recommends \
24-
ca-certificates tzdata espeak-ng opus-tools \
24+
ca-certificates tzdata espeak-ng opus-tools ffmpeg \
2525
&& rm -rf /var/lib/apt/lists/*
2626

2727
# Copy pre-built binary from GoReleaser

frontend/src/i18n/locales/en.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,16 @@
530530
"maxCallDurationDesc": "Maximum allowed call duration before auto-disconnect",
531531
"transferTimeout": "Transfer Timeout (seconds)",
532532
"transferTimeoutDesc": "How long to wait for an agent to accept a transferred call",
533-
"callingSaved": "Calling settings saved"
533+
"callingSaved": "Calling settings saved",
534+
"holdMusic": "Hold Music",
535+
"holdMusicDesc": "Audio played to callers while on hold during transfers",
536+
"ringbackTone": "Ringback Tone",
537+
"ringbackToneDesc": "Audio played to agents while waiting for the remote party to answer",
538+
"uploadAudio": "Upload",
539+
"currentFile": "Current file",
540+
"noFileUploaded": "Using default",
541+
"audioUploaded": "Audio file uploaded successfully",
542+
"audioUploadFailed": "Failed to upload audio file"
534543
},
535544
"users": {
536545
"title": "User Management",

frontend/src/services/api.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,16 @@ export const organizationService = {
571571
calling_enabled?: boolean
572572
max_call_duration?: number
573573
transfer_timeout_secs?: number
574-
}) => api.put('/org/settings', data)
574+
hold_music_file?: string
575+
ringback_file?: string
576+
}) => api.put('/org/settings', data),
577+
uploadOrgAudio: (file: File, type: 'hold_music' | 'ringback') => {
578+
const formData = new FormData()
579+
formData.append('file', file)
580+
return api.post(`/org/audio?type=${type}`, formData, {
581+
headers: { 'Content-Type': 'multipart/form-data' }
582+
})
583+
}
575584
}
576585

577586
// Organizations

frontend/src/views/settings/SettingsView.vue

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
1212
import { PageHeader } from '@/components/shared'
1313
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
1414
import { toast } from 'vue-sonner'
15-
import { Settings, Bell, Loader2, Globe, Phone } from 'lucide-vue-next'
15+
import { Settings, Bell, Loader2, Globe, Phone, Upload, Play, Music } from 'lucide-vue-next'
1616
import { usersService, organizationService } from '@/services/api'
1717
1818
const { t } = useI18n()
@@ -39,9 +39,20 @@ const notificationSettings = ref({
3939
const callingSettings = ref({
4040
calling_enabled: false,
4141
max_call_duration: 300,
42-
transfer_timeout_secs: 120
42+
transfer_timeout_secs: 120,
43+
hold_music_file: '',
44+
ringback_file: ''
4345
})
4446
47+
const isUploadingHoldMusic = ref(false)
48+
const isUploadingRingback = ref(false)
49+
const holdMusicInput = ref<HTMLInputElement | null>(null)
50+
const ringbackInput = ref<HTMLInputElement | null>(null)
51+
const holdMusicAudio = ref<HTMLAudioElement | null>(null)
52+
const ringbackAudio = ref<HTMLAudioElement | null>(null)
53+
const playingHoldMusic = ref(false)
54+
const playingRingback = ref(false)
55+
4556
onMounted(async () => {
4657
try {
4758
const [orgResponse, userResponse] = await Promise.all([
@@ -61,7 +72,9 @@ onMounted(async () => {
6172
callingSettings.value = {
6273
calling_enabled: orgData.settings?.calling_enabled || false,
6374
max_call_duration: orgData.settings?.max_call_duration || 300,
64-
transfer_timeout_secs: orgData.settings?.transfer_timeout_secs || 120
75+
transfer_timeout_secs: orgData.settings?.transfer_timeout_secs || 120,
76+
hold_music_file: orgData.settings?.hold_music_file || '',
77+
ringback_file: orgData.settings?.ringback_file || ''
6578
}
6679
}
6780
@@ -129,6 +142,52 @@ async function saveCallingSettings() {
129142
isSubmitting.value = false
130143
}
131144
}
145+
146+
async function uploadAudio(type: 'hold_music' | 'ringback', event: Event) {
147+
const input = event.target as HTMLInputElement
148+
const file = input?.files?.[0]
149+
if (!file) return
150+
151+
const isHold = type === 'hold_music'
152+
if (isHold) isUploadingHoldMusic.value = true
153+
else isUploadingRingback.value = true
154+
155+
try {
156+
const response = await organizationService.uploadOrgAudio(file, type)
157+
const data = response.data.data || response.data
158+
if (isHold) callingSettings.value.hold_music_file = data.filename
159+
else callingSettings.value.ringback_file = data.filename
160+
toast.success(t('settings.audioUploaded'))
161+
} catch (error) {
162+
toast.error(t('settings.audioUploadFailed'))
163+
} finally {
164+
if (isHold) isUploadingHoldMusic.value = false
165+
else isUploadingRingback.value = false
166+
input.value = ''
167+
}
168+
}
169+
170+
function togglePlayAudio(type: 'hold_music' | 'ringback') {
171+
const isHold = type === 'hold_music'
172+
const filename = isHold ? callingSettings.value.hold_music_file : callingSettings.value.ringback_file
173+
if (!filename) return
174+
175+
const audioRef = isHold ? holdMusicAudio : ringbackAudio
176+
const playingRef = isHold ? playingHoldMusic : playingRingback
177+
178+
if (playingRef.value && audioRef.value) {
179+
audioRef.value.pause()
180+
audioRef.value.currentTime = 0
181+
playingRef.value = false
182+
return
183+
}
184+
185+
const audio = new Audio(`/api/ivr-flows/audio/${filename}`)
186+
audioRef.value = audio
187+
playingRef.value = true
188+
audio.play()
189+
audio.onended = () => { playingRef.value = false }
190+
}
132191
</script>
133192

134193
<template>
@@ -320,6 +379,73 @@ async function saveCallingSettings() {
320379
<p class="text-xs text-white/40 light:text-gray-500">{{ $t('settings.transferTimeoutDesc') }}</p>
321380
</div>
322381
</div>
382+
<Separator class="bg-white/[0.08] light:bg-gray-200" />
383+
<!-- Hold Music Upload -->
384+
<div class="space-y-3" :class="{ 'opacity-50 pointer-events-none': !callingSettings.calling_enabled }">
385+
<div>
386+
<Label class="text-white/70 light:text-gray-700 flex items-center gap-2">
387+
<Music class="h-4 w-4" />
388+
{{ $t('settings.holdMusic') }}
389+
</Label>
390+
<p class="text-xs text-white/40 light:text-gray-500 mt-1">{{ $t('settings.holdMusicDesc') }}</p>
391+
</div>
392+
<div class="flex items-center gap-3">
393+
<span class="text-sm text-white/50 light:text-gray-500">
394+
{{ callingSettings.hold_music_file ? `${$t('settings.currentFile')}: ${callingSettings.hold_music_file}` : $t('settings.noFileUploaded') }}
395+
</span>
396+
<Button
397+
v-if="callingSettings.hold_music_file"
398+
variant="ghost"
399+
size="sm"
400+
class="h-8 w-8 p-0 text-white/50 hover:text-white light:text-gray-500 light:hover:text-gray-900"
401+
@click="togglePlayAudio('hold_music')"
402+
>
403+
<Play class="h-4 w-4" />
404+
</Button>
405+
</div>
406+
<div class="flex items-center gap-2">
407+
<input ref="holdMusicInput" type="file" accept=".ogg,.opus,.mp3,.wav" class="hidden" @change="uploadAudio('hold_music', $event)" />
408+
<Button variant="outline" size="sm" class="bg-white/[0.04] border-white/[0.1] text-white/70 hover:bg-white/[0.08] hover:text-white light:bg-white light:border-gray-200 light:text-gray-700 light:hover:bg-gray-50" @click="holdMusicInput?.click()" :disabled="isUploadingHoldMusic">
409+
<Loader2 v-if="isUploadingHoldMusic" class="mr-2 h-4 w-4 animate-spin" />
410+
<Upload v-else class="mr-2 h-4 w-4" />
411+
{{ $t('settings.uploadAudio') }}
412+
</Button>
413+
<span class="text-xs text-white/30 light:text-gray-400">.ogg, .opus, .mp3, .wav (max 5MB)</span>
414+
</div>
415+
</div>
416+
<!-- Ringback Tone Upload -->
417+
<div class="space-y-3" :class="{ 'opacity-50 pointer-events-none': !callingSettings.calling_enabled }">
418+
<div>
419+
<Label class="text-white/70 light:text-gray-700 flex items-center gap-2">
420+
<Phone class="h-4 w-4" />
421+
{{ $t('settings.ringbackTone') }}
422+
</Label>
423+
<p class="text-xs text-white/40 light:text-gray-500 mt-1">{{ $t('settings.ringbackToneDesc') }}</p>
424+
</div>
425+
<div class="flex items-center gap-3">
426+
<span class="text-sm text-white/50 light:text-gray-500">
427+
{{ callingSettings.ringback_file ? `${$t('settings.currentFile')}: ${callingSettings.ringback_file}` : $t('settings.noFileUploaded') }}
428+
</span>
429+
<Button
430+
v-if="callingSettings.ringback_file"
431+
variant="ghost"
432+
size="sm"
433+
class="h-8 w-8 p-0 text-white/50 hover:text-white light:text-gray-500 light:hover:text-gray-900"
434+
@click="togglePlayAudio('ringback')"
435+
>
436+
<Play class="h-4 w-4" />
437+
</Button>
438+
</div>
439+
<div class="flex items-center gap-2">
440+
<input ref="ringbackInput" type="file" accept=".ogg,.opus,.mp3,.wav" class="hidden" @change="uploadAudio('ringback', $event)" />
441+
<Button variant="outline" size="sm" class="bg-white/[0.04] border-white/[0.1] text-white/70 hover:bg-white/[0.08] hover:text-white light:bg-white light:border-gray-200 light:text-gray-700 light:hover:bg-gray-50" @click="ringbackInput?.click()" :disabled="isUploadingRingback">
442+
<Loader2 v-if="isUploadingRingback" class="mr-2 h-4 w-4 animate-spin" />
443+
<Upload v-else class="mr-2 h-4 w-4" />
444+
{{ $t('settings.uploadAudio') }}
445+
</Button>
446+
<span class="text-xs text-white/30 light:text-gray-400">.ogg, .opus, .mp3, .wav (max 5MB)</span>
447+
</div>
448+
</div>
323449
<div class="flex justify-end pt-4">
324450
<Button variant="outline" size="sm" class="bg-white/[0.04] border-white/[0.1] text-white/70 hover:bg-white/[0.08] hover:text-white light:bg-white light:border-gray-200 light:text-gray-700 light:hover:bg-gray-50" @click="saveCallingSettings" :disabled="isSubmitting">
325451
<Loader2 v-if="isSubmitting" class="mr-2 h-4 w-4 animate-spin" />

internal/calling/outgoing.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,18 @@ func (m *Manager) HandleOutgoingCallWebhook(callID, event, sdpAnswer string) {
330330
session.Status = models.CallStatusRinging
331331
session.mu.Unlock()
332332

333+
// Start ringback tone on the agent's speaker while the remote phone rings
334+
ringbackFile := m.getOrgRingback(session.OrganizationID)
335+
if ringbackFile != "" {
336+
session.mu.Lock()
337+
if session.AgentAudioTrack != nil && session.RingbackPlayer == nil {
338+
player := NewAudioPlayer(session.AgentAudioTrack)
339+
session.RingbackPlayer = player
340+
go func() { _ = player.PlayFileLoop(ringbackFile) }()
341+
}
342+
session.mu.Unlock()
343+
}
344+
333345
m.db.Model(&models.CallLog{}).
334346
Where("id = ?", session.CallLogID).
335347
Update("status", models.CallStatusRinging)
@@ -342,7 +354,12 @@ func (m *Manager) HandleOutgoingCallWebhook(callID, event, sdpAnswer string) {
342354
})
343355

344356
case "accepted", "in_call", "connect":
357+
// Stop ringback tone
345358
session.mu.Lock()
359+
if session.RingbackPlayer != nil {
360+
session.RingbackPlayer.Stop()
361+
session.RingbackPlayer = nil
362+
}
346363
session.Status = models.CallStatusAnswered
347364
session.mu.Unlock()
348365

internal/calling/session.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"sync"
89
"time"
910

@@ -50,6 +51,9 @@ type CallSession struct {
5051
TransferCancel context.CancelFunc
5152
BridgeStarted chan struct{} // closed when bridge takes over caller track
5253

54+
// Ringback (outgoing calls)
55+
RingbackPlayer *AudioPlayer
56+
5357
// Outgoing call fields
5458
Direction models.CallDirection
5559
AgentID uuid.UUID
@@ -230,6 +234,33 @@ func (m *Manager) getOrgTransferTimeout(orgID uuid.UUID) int {
230234
return m.config.TransferTimeoutSecs
231235
}
232236

237+
// getOrgHoldMusic returns the hold music file path for a session's organization,
238+
// falling back to the global config default.
239+
func (m *Manager) getOrgHoldMusic(orgID uuid.UUID) string {
240+
var org models.Organization
241+
if err := m.db.Where("id = ?", orgID).First(&org).Error; err == nil && org.Settings != nil {
242+
if v, ok := org.Settings["hold_music_file"].(string); ok && v != "" {
243+
return filepath.Join(m.config.AudioDir, v)
244+
}
245+
}
246+
return filepath.Join(m.config.AudioDir, m.config.HoldMusicFile)
247+
}
248+
249+
// getOrgRingback returns the ringback file path for a session's organization,
250+
// falling back to the global config default.
251+
func (m *Manager) getOrgRingback(orgID uuid.UUID) string {
252+
var org models.Organization
253+
if err := m.db.Where("id = ?", orgID).First(&org).Error; err == nil && org.Settings != nil {
254+
if v, ok := org.Settings["ringback_file"].(string); ok && v != "" {
255+
return filepath.Join(m.config.AudioDir, v)
256+
}
257+
}
258+
if m.config.RingbackFile != "" {
259+
return filepath.Join(m.config.AudioDir, m.config.RingbackFile)
260+
}
261+
return ""
262+
}
263+
233264
// cleanupSession removes a session and releases WebRTC resources
234265
func (m *Manager) cleanupSession(callID string) {
235266
m.mu.Lock()
@@ -253,6 +284,9 @@ func (m *Manager) cleanupSession(callID string) {
253284
if session.HoldPlayer != nil {
254285
session.HoldPlayer.Stop()
255286
}
287+
if session.RingbackPlayer != nil {
288+
session.RingbackPlayer.Stop()
289+
}
256290
if session.IVRPlayer != nil {
257291
session.IVRPlayer.Stop()
258292
}

internal/calling/transfer.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package calling
33
import (
44
"context"
55
"fmt"
6-
"path/filepath"
76
"time"
87

98
"github.com/google/uuid"
@@ -57,8 +56,8 @@ func (m *Manager) initiateTransfer(session *CallSession, waAccount string, teamT
5756
session.TransferStatus = models.CallTransferStatusWaiting
5857
session.mu.Unlock()
5958

60-
// Start hold music on the caller's audio track
61-
holdFile := filepath.Join(m.config.AudioDir, m.config.HoldMusicFile)
59+
// Start hold music on the caller's audio track (org-level override or global default)
60+
holdFile := m.getOrgHoldMusic(session.OrganizationID)
6261
player := NewAudioPlayer(session.AudioTrack)
6362

6463
session.mu.Lock()

0 commit comments

Comments
 (0)