fix(whatsapp-call): correct audio duration + diarized transcripts

Two bugs tracked to the same root cause: the media-server's recorder was
writing both the customer and agent RTP streams into a single oggwriter.
The two streams have unrelated SSRCs, sequence numbers, and timestamp
bases, so the resulting OGG carried non-monotonic granule positions.
Browsers compute duration from granule positions → the audio element
reported inflated times. Whisper parsed the broken container and
returned garbage, which showed up as nonsense transcripts.

media-server changes
- Recorder no longer writes to a combined oggwriter during the call.
  Each direction is captured into its own clean OGG (customer-only and
  agent-only).
- At Finalize, the two files are mixed with ffmpeg into a single
  combined.ogg that has coherent OGG pages and correct duration. If
  ffmpeg isn't on PATH we fall back to the customer-side file alone so
  the pipeline doesn't break on bare hosts.
- GET /sessions/:id/recording now accepts ?side=customer|agent so Rails
  can fetch the per-direction files for diarization.

Rails changes
- MediaServerClient#download_recording takes a `side:` keyword.
- CallTranscriptionService prefers per-side transcription when the
  session has a media_session_id: downloads both files, runs Whisper
  with response_format=verbose_json for segment timestamps, interleaves
  by timestamp and labels lines Customer: / Agent:. Falls back to the
  combined recording if per-side downloads fail.
- Lowered Whisper temperature from 0.4 to 0.2 for more deterministic
  transcripts.

Setup: ffmpeg is now required for correct combined playback and
transcription. Install on macOS with `brew install ffmpeg`; on Linux
install the distro's ffmpeg package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tanmay Deep Sharma
2026-04-21 18:51:19 +07:00
co-authored by Claude Opus 4.7
parent c6c3ba054e
commit 9679cd2b35
5 changed files with 216 additions and 57 deletions
@@ -28,23 +28,76 @@ class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
account.usage_limits[:captain][:responses][:current_available].positive?
end
# Transcribe per-direction recordings separately when possible so lines can
# be attributed to Customer vs Agent. Falls back to the combined recording
# if the media server isn't available or per-side files are missing.
def transcribe_audio
temp_file_path = fetch_audio_file
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: { model: WHISPER_MODEL, file: file, temperature: 0.4 }
)
transcribed_text = response['text']
if call.media_session_id.present?
diarized = diarized_transcript
return diarized if diarized.present?
end
transcribed_text
ensure
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
transcribe_combined
end
def fetch_audio_file
def diarized_transcript
temp_dir = Rails.root.join('tmp/uploads/call-transcriptions')
FileUtils.mkdir_p(temp_dir)
customer_path = File.join(temp_dir, "#{call.media_session_id}_customer.ogg")
agent_path = File.join(temp_dir, "#{call.media_session_id}_agent.ogg")
client = Whatsapp::MediaServerClient.new
File.binwrite(customer_path, client.download_recording(call.media_session_id, side: 'customer'))
File.binwrite(agent_path, client.download_recording(call.media_session_id, side: 'agent'))
segments = transcribe_segments(customer_path, 'Customer') +
transcribe_segments(agent_path, 'Agent')
return nil if segments.empty?
segments.sort_by { |s| s[:start] }
.map { |s| "[#{format_ts(s[:start])}] #{s[:speaker]}: #{s[:text].strip}" }
.reject { |line| line.end_with?(': ') }
.join("\n")
rescue Whatsapp::MediaServerClient::ConnectionError, Whatsapp::MediaServerClient::SessionError => e
Rails.logger.warn "[WHATSAPP CALL] Per-side recording unavailable, falling back to combined transcription: #{e.message}"
nil
ensure
FileUtils.rm_f(customer_path) if defined?(customer_path) && customer_path
FileUtils.rm_f(agent_path) if defined?(agent_path) && agent_path
end
def transcribe_segments(file_path, speaker)
return [] unless File.exist?(file_path) && File.size(file_path).positive?
File.open(file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
model: WHISPER_MODEL,
file: file,
temperature: 0.2,
response_format: 'verbose_json',
timestamp_granularities: ['segment']
}
)
(response['segments'] || []).map do |seg|
{ speaker: speaker, start: seg['start'].to_f, text: seg['text'].to_s }
end
end
end
def transcribe_combined
temp_file_path = fetch_combined_recording
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: { model: WHISPER_MODEL, file: file, temperature: 0.2 }
)
return response['text']
end
ensure
FileUtils.rm_f(temp_file_path) if defined?(temp_file_path) && temp_file_path
end
def fetch_combined_recording
blob = call.recording.blob
temp_dir = Rails.root.join('tmp/uploads/call-transcriptions')
FileUtils.mkdir_p(temp_dir)
@@ -59,6 +112,13 @@ class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
temp_file_path
end
def format_ts(seconds)
total = seconds.to_i
mins = total / 60
secs = total % 60
format('%<mins>02d:%<secs>02d', mins: mins, secs: secs)
end
def update_call_and_message(transcribed_text)
return if transcribed_text.blank?
@@ -30,10 +30,12 @@ class Whatsapp::MediaServerClient
post("/sessions/#{session_id}/terminate")
end
def download_recording(session_id)
response = execute_request(:get, "/sessions/#{session_id}/recording")
def download_recording(session_id, side: nil)
path = "/sessions/#{session_id}/recording"
path = "#{path}?side=#{side}" if side
response = execute_request(:get, path)
unless response.success?
Rails.logger.error "[MEDIA SERVER] Recording download failed: status=#{response.code}"
Rails.logger.error "[MEDIA SERVER] Recording download failed: side=#{side.inspect} status=#{response.code}"
raise SessionError, "Recording download failed (#{response.code})"
end
response.body
@@ -3,9 +3,11 @@
package media
import (
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
@@ -14,23 +16,27 @@ import (
"github.com/pion/webrtc/v4/pkg/media/oggwriter"
)
// Recorder writes incoming Opus RTP packets to an OGG container file in real
// time. Two separate OGG files are maintained -- one for each audio channel
// (customer and agent) -- to enable stereo separation for transcription.
// A combined mono file is also written for playback convenience.
// Recorder writes incoming Opus RTP packets to two per-direction OGG files
// (customer and agent). At finalize time the two files are mixed into a single
// stereo combined.ogg via ffmpeg so playback and Whisper transcription receive
// a file with coherent OGG pages and correct duration.
//
// Writing both streams to a single oggwriter produces a file with non-monotonic
// granule positions — the two RTP streams have unrelated clocks and sequence
// numbers — which breaks both the reported audio duration in browsers and the
// transcription output.
type Recorder struct {
sessionID string
dir string
// combinedWriter writes all audio to a single OGG file (for playback).
combinedWriter *oggwriter.OggWriter
combinedFile string
// combinedFile is produced by ffmpeg at finalize; never written to directly.
combinedFile string
// customerWriter writes only customer audio (for transcription L channel).
// customerWriter writes only customer audio (Meta-side track).
customerWriter *oggwriter.OggWriter
customerFile string
// agentWriter writes only agent audio (for transcription R channel).
// agentWriter writes only agent audio (browser-side track).
agentWriter *oggwriter.OggWriter
agentFile string
@@ -40,10 +46,10 @@ type Recorder struct {
}
// NewRecorder creates a new recorder that writes OGG/Opus files to the given
// directory. Three files are created:
// - {sessionID}.ogg (combined audio for playback)
// - {sessionID}_customer.ogg (customer channel only)
// - {sessionID}_agent.ogg (agent channel only)
// directory. Three files are tracked:
// - {sessionID}_customer.ogg (customer channel only, written live)
// - {sessionID}_agent.ogg (agent channel only, written live)
// - {sessionID}.ogg (combined stereo, produced at Finalize via ffmpeg)
func NewRecorder(sessionID, dir string) (*Recorder, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create recordings directory: %w", err)
@@ -53,34 +59,26 @@ func NewRecorder(sessionID, dir string) (*Recorder, error) {
customerFile := filepath.Join(dir, sessionID+"_customer.ogg")
agentFile := filepath.Join(dir, sessionID+"_agent.ogg")
// Opus at 48kHz, mono for each individual channel.
combinedWriter, err := oggwriter.New(combinedFile, 48000, 1)
if err != nil {
return nil, fmt.Errorf("create combined OGG writer: %w", err)
}
customerWriter, err := oggwriter.New(customerFile, 48000, 1)
if err != nil {
combinedWriter.Close()
return nil, fmt.Errorf("create customer OGG writer: %w", err)
}
agentWriter, err := oggwriter.New(agentFile, 48000, 1)
if err != nil {
combinedWriter.Close()
customerWriter.Close()
return nil, fmt.Errorf("create agent OGG writer: %w", err)
}
slog.Info("recorder: started",
"session_id", sessionID,
"combined_file", combinedFile,
"customer_file", customerFile,
"agent_file", agentFile,
)
return &Recorder{
sessionID: sessionID,
dir: dir,
combinedWriter: combinedWriter,
combinedFile: combinedFile,
customerWriter: customerWriter,
customerFile: customerFile,
@@ -91,8 +89,7 @@ func NewRecorder(sessionID, dir string) (*Recorder, error) {
}
// WriteCustomerRTP writes an RTP packet from the customer's audio stream
// (Meta-side, Peer A) to the recording. The packet is written to both the
// combined file and the customer-only channel file.
// (Meta-side, Peer A) to the customer-only OGG file.
func (r *Recorder) WriteCustomerRTP(pkt *rtp.Packet) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -101,17 +98,14 @@ func (r *Recorder) WriteCustomerRTP(pkt *rtp.Packet) error {
return nil
}
if err := r.combinedWriter.WriteRTP(pkt); err != nil {
return fmt.Errorf("write customer RTP to combined: %w", err)
}
if err := r.customerWriter.WriteRTP(pkt); err != nil {
return fmt.Errorf("write customer RTP to channel: %w", err)
return fmt.Errorf("write customer RTP: %w", err)
}
return nil
}
// WriteAgentRTP writes an RTP packet from the agent's audio stream
// (browser-side, Peer B) to the recording.
// (browser-side, Peer B) to the agent-only OGG file.
func (r *Recorder) WriteAgentRTP(pkt *rtp.Packet) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -120,17 +114,17 @@ func (r *Recorder) WriteAgentRTP(pkt *rtp.Packet) error {
return nil
}
if err := r.combinedWriter.WriteRTP(pkt); err != nil {
return fmt.Errorf("write agent RTP to combined: %w", err)
}
if err := r.agentWriter.WriteRTP(pkt); err != nil {
return fmt.Errorf("write agent RTP to channel: %w", err)
return fmt.Errorf("write agent RTP: %w", err)
}
return nil
}
// Finalize closes all OGG writers and flushes data to disk. After finalization,
// further writes are silently ignored. This method is idempotent.
// Finalize closes the per-direction writers, then merges them into a single
// stereo combined.ogg via ffmpeg. If ffmpeg isn't on PATH the combined file
// is produced by copying the customer-side file as a fallback so that at
// least one side is playable. Transcription quality degrades in the fallback
// but the pipeline does not break.
func (r *Recorder) Finalize() error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -141,9 +135,6 @@ func (r *Recorder) Finalize() error {
r.finalized = true
var errs []error
if err := r.combinedWriter.Close(); err != nil {
errs = append(errs, fmt.Errorf("close combined writer: %w", err))
}
if err := r.customerWriter.Close(); err != nil {
errs = append(errs, fmt.Errorf("close customer writer: %w", err))
}
@@ -151,6 +142,10 @@ func (r *Recorder) Finalize() error {
errs = append(errs, fmt.Errorf("close agent writer: %w", err))
}
if err := r.buildCombinedFile(); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("finalize recorder: %v", errs)
}
@@ -158,10 +153,82 @@ func (r *Recorder) Finalize() error {
slog.Info("recorder: finalized",
"session_id", r.sessionID,
"duration", time.Since(r.startedAt).Round(time.Second),
"combined_file", r.combinedFile,
)
return nil
}
// buildCombinedFile mixes the two per-direction OGG files into a single
// stereo combined.ogg so playback and transcription receive a coherent file
// with correct duration metadata.
func (r *Recorder) buildCombinedFile() error {
customerExists := fileNonEmpty(r.customerFile)
agentExists := fileNonEmpty(r.agentFile)
if !customerExists && !agentExists {
return errors.New("build combined file: no per-direction recordings to merge")
}
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
slog.Warn("recorder: ffmpeg not found, falling back to single-side combined file",
"session_id", r.sessionID,
)
src := r.customerFile
if !customerExists {
src = r.agentFile
}
return copyFile(src, r.combinedFile)
}
args := []string{"-y", "-loglevel", "error"}
if customerExists {
args = append(args, "-i", r.customerFile)
}
if agentExists {
args = append(args, "-i", r.agentFile)
}
switch {
case customerExists && agentExists:
// Mix two mono streams into one mono track. amix pads the shorter input
// with silence so the output duration matches the longer input, and
// uses the longer input as the reference for timing so the OGG pages
// carry correct granule positions.
args = append(args,
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]",
"-map", "[aout]",
)
default:
// Only one side — just remux to produce correct OGG duration headers.
args = append(args, "-map", "0:a")
}
args = append(args, "-c:a", "libopus", "-b:a", "48000", "-ar", "48000", "-ac", "1", r.combinedFile)
cmd := exec.Command(ffmpegPath, args...)
out, cmdErr := cmd.CombinedOutput()
if cmdErr != nil {
return fmt.Errorf("ffmpeg mix failed: %w (%s)", cmdErr, string(out))
}
return nil
}
func fileNonEmpty(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Size() > 0
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("read %s: %w", src, err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", dst, err)
}
return nil
}
// CombinedFilePath returns the filesystem path of the combined recording file.
func (r *Recorder) CombinedFilePath() string {
return r.combinedFile
@@ -445,7 +445,9 @@ func (h *Handlers) TerminateSession(w http.ResponseWriter, r *http.Request) {
}
// GetRecording handles GET /sessions/{id}/recording. It serves the combined
// recording file as a binary OGG download.
// recording file as a binary OGG download. An optional ?side=customer|agent
// query parameter returns the per-direction recording instead, which Rails
// uses to produce speaker-separated transcripts.
func (h *Handlers) GetRecording(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("id")
sess := h.manager.GetSession(sessionID)
@@ -454,7 +456,19 @@ func (h *Handlers) GetRecording(w http.ResponseWriter, r *http.Request) {
return
}
filePath := sess.RecordingFilePath()
var filePath, filename string
switch r.URL.Query().Get("side") {
case "customer":
filePath = sess.RecorderCustomerPath()
filename = sessionID + "_customer.ogg"
case "agent":
filePath = sess.RecorderAgentPath()
filename = sessionID + "_agent.ogg"
default:
filePath = sess.RecordingFilePath()
filename = sessionID + ".ogg"
}
if filePath == "" {
writeError(w, http.StatusNotFound, "no recording available")
return
@@ -474,7 +488,7 @@ func (h *Handlers) GetRecording(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "audio/ogg")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.ogg"`, sessionID))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
http.ServeContent(w, r, filePath, stat.ModTime(), f)
}
@@ -398,6 +398,22 @@ func (s *Session) RecordingFilePath() string {
return s.Recorder.CombinedFilePath()
}
// RecorderCustomerPath returns the path to the customer-only recording.
func (s *Session) RecorderCustomerPath() string {
if s.Recorder == nil {
return ""
}
return s.Recorder.CustomerFilePath()
}
// RecorderAgentPath returns the path to the agent-only recording.
func (s *Session) RecorderAgentPath() string {
if s.Recorder == nil {
return ""
}
return s.Recorder.AgentFilePath()
}
// GetInjectorTarget returns the appropriate write target for audio injection
// based on the target parameter. Returns nil if the target is unavailable.
func (s *Session) GetInjectorTarget(target string) media.InjectorTarget {