Call Recording
Record calls for quality assurance, compliance, dispute resolution, and AI training purposes.
Configuration
Enable Recording
{
"agent": {
"name": "Customer Support",
"recording": {
"enabled": true,
"format": "mp3",
"channels": "dual",
"storage": "s3",
"retention_days": 90
}
}
}
Recording Options
| Option | Values | Description |
|---|---|---|
enabled |
true/false | Enable recording |
format |
mp3, wav, ogg | Audio format |
channels |
mono, dual, stereo | Channel mode |
storage |
s3, gcs, azure | Storage provider |
retention_days |
1-365 | Auto-deletion period |
Channel Modes
Mono (Single Channel)
Both parties mixed into one channel:
[Bot + User Mixed] ────────────────────────────►
Best for: Simple playback, smaller file size.
Dual Channel
Separate channels for each party:
Channel 1 (Bot): [Bot audio] ─────────────────►
Channel 2 (User): [User audio] ────────────────►
Best for: Transcription, analysis, speaker separation.
Stereo
True stereo with spatial separation:
Left: [Bot audio] ──────────────────────────►
Right: [User audio] ──────────────────────────►
Best for: Review, quality assurance.
Implementation
Recording Handler
type CallRecorder struct {
storage Storage
format string
sampleRate int
channels int
}
func (r *CallRecorder) StartRecording(callID string) *Recording {
return &Recording{
CallID: callID,
StartTime: time.Now(),
Encoder: NewEncoder(r.format, r.sampleRate, r.channels),
Buffer: &bytes.Buffer{},
}
}
func (r *CallRecorder) AddAudio(rec *Recording, audio []byte, source string) {
if r.channels == 2 {
// Dual channel - track source
rec.Encoder.AddChannel(audio, source)
} else {
// Mono - mix together
rec.Encoder.AddMixed(audio)
}
}
func (r *CallRecorder) StopRecording(rec *Recording) (string, error) {
// Finalize encoding
data, err := rec.Encoder.Finalize()
if err != nil {
return "", err
}
// Generate storage path
path := fmt.Sprintf("recordings/%s/%s.%s",
rec.StartTime.Format("2006/01/02"),
rec.CallID,
r.format,
)
// Upload to storage
url, err := r.storage.Upload(path, data)
if err != nil {
return "", err
}
return url, nil
}
Integration with Pipeline
func (p *Pipeline) processWithRecording(ctx context.Context) {
if !p.agent.Recording.Enabled {
p.processWithoutRecording(ctx)
return
}
// Start recording
recording := p.recorder.StartRecording(p.callID)
defer func() {
url, err := p.recorder.StopRecording(recording)
if err != nil {
log.Error("Recording failed: %v", err)
return
}
p.storeRecordingURL(url)
}()
// Process with recording
for {
select {
case audio := <-p.userAudio:
p.recorder.AddAudio(recording, audio, "user")
p.processUserAudio(audio)
case audio := <-p.botAudio:
p.recorder.AddAudio(recording, audio, "bot")
p.sendBotAudio(audio)
case <-ctx.Done():
return
}
}
}
Storage Providers
Amazon S3
{
"recording": {
"storage": "s3",
"s3Config": {
"bucket": "my-recordings-bucket",
"region": "us-east-1",
"prefix": "voice-agent/"
}
}
}
type S3Storage struct {
client *s3.Client
bucket string
prefix string
}
func (s *S3Storage) Upload(path string, data []byte) (string, error) {
key := s.prefix + path
_, err := s.client.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String("audio/mpeg"),
})
if err != nil {
return "", err
}
return fmt.Sprintf("s3://%s/%s", s.bucket, key), nil
}
Google Cloud Storage
{
"recording": {
"storage": "gcs",
"gcsConfig": {
"bucket": "my-recordings-bucket",
"prefix": "voice-agent/"
}
}
}
Azure Blob Storage
{
"recording": {
"storage": "azure",
"azureConfig": {
"container": "recordings",
"connectionString": "env:AZURE_STORAGE_CONNECTION"
}
}
}
Compliance Features
Recording Consent
func (p *Pipeline) announceRecording() {
if p.agent.Recording.RequireConsent {
p.tts.Speak("This call may be recorded for quality assurance purposes.")
time.Sleep(2 * time.Second)
}
}
func (p *Pipeline) handleConsentResponse(transcript string) bool {
negative := []string{"no", "don't record", "stop recording"}
for _, phrase := range negative {
if strings.Contains(strings.ToLower(transcript), phrase) {
p.recorder.Disable()
p.tts.Speak("Recording has been disabled for this call.")
return false
}
}
return true
}
PCI-DSS Compliance
Pause recording during sensitive information:
func (p *Pipeline) handleSensitiveData() {
// Pause recording
p.recorder.Pause()
defer p.recorder.Resume()
p.tts.Speak("For security, recording is paused. Please enter your card number.")
// Collect sensitive data
cardNumber := p.collectDTMF(16)
// Resume recording
p.tts.Speak("Thank you. Recording has resumed.")
}
GDPR Compliance
type RecordingMetadata struct {
CallID string
CustomerPhone string
ConsentGiven bool
Purpose string
RetentionDays int
CreatedAt time.Time
ExpiresAt time.Time
}
func (r *CallRecorder) StoreMetadata(meta RecordingMetadata) error {
return r.db.Create(&meta)
}
// Data subject access request
func (r *CallRecorder) ExportRecordings(customerPhone string) ([]Recording, error) {
return r.db.Find(&Recording{CustomerPhone: customerPhone})
}
// Right to erasure
func (r *CallRecorder) DeleteRecordings(customerPhone string) error {
recordings, _ := r.ExportRecordings(customerPhone)
for _, rec := range recordings {
r.storage.Delete(rec.URL)
r.db.Delete(&rec)
}
return nil
}
Retention and Lifecycle
type RetentionPolicy struct {
DefaultDays int
ByCategory map[string]int
AutoDelete bool
}
var policy = RetentionPolicy{
DefaultDays: 90,
ByCategory: map[string]int{
"complaint": 365,
"legal": 2555, // 7 years
"training": 180,
"standard": 90,
},
AutoDelete: true,
}
func (r *CallRecorder) ApplyRetention() {
cutoff := time.Now().AddDate(0, 0, -policy.DefaultDays)
expired, _ := r.db.Find(&Recording{
ExpiresAt: lt(cutoff),
})
for _, rec := range expired {
r.storage.Delete(rec.URL)
r.db.Delete(&rec)
log.Info("Deleted expired recording: %s", rec.CallID)
}
}
Accessing Recordings
Via API
# List recordings for a call
curl https://api.edesy.in/calls/call_abc123/recordings \
-H "Authorization: Bearer $API_KEY"
# Get recording URL
curl https://api.edesy.in/recordings/rec_xyz789 \
-H "Authorization: Bearer $API_KEY"
# Download recording
curl -O $(curl -s https://api.edesy.in/recordings/rec_xyz789/url \
-H "Authorization: Bearer $API_KEY" | jq -r '.url')
Via Webhook
{
"event": "call.ended",
"data": {
"call_id": "call_abc123",
"recording": {
"id": "rec_xyz789",
"url": "https://storage.example.com/recordings/call_abc123.mp3",
"duration_seconds": 185,
"size_bytes": 1485762,
"format": "mp3"
}
}
}
Best Practices
1. Dual Channel for Analysis
{
"recording": {
"channels": "dual"
}
}
Enables:
- Separate transcription per speaker
- Sentiment analysis per party
- Crosstalk detection
- Speaker diarization
2. Secure Storage
// Encrypt at rest
config := S3Config{
ServerSideEncryption: "aws:kms",
KMSKeyID: os.Getenv("KMS_KEY_ID"),
}
// Signed URLs for access
func (s *S3Storage) GetSignedURL(path string, expiry time.Duration) string {
presigner := s3.NewPresignClient(s.client)
req, _ := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiry
})
return req.URL
}
3. Metadata Tagging
metadata := map[string]string{
"call_id": callID,
"agent_id": agentID,
"customer_id": customerID,
"duration": fmt.Sprintf("%d", duration),
"outcome": outcome,
"tags": strings.Join(tags, ","),
}
s3Client.PutObject(&s3.PutObjectInput{
Bucket: bucket,
Key: key,
Body: data,
Metadata: metadata,
})
Next Steps
- Transcripts - Get call transcripts
- Webhooks - Recording notifications
- Compliance - Security best practices