Call Recording

Record voice agent calls for quality assurance, compliance, and training purposes.

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

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