chore: refactor file upload mechanism to avoid NFS dependency (#496)

* chore: refactor file upload mechanism to avoid NFS dependency

* add regression test for deregistration of calls

* fix: fix minio upload issue

* fix: make transcript upload async
This commit is contained in:
Abhishek 2026-07-03 20:01:52 +05:30 committed by GitHub
parent 79a4a3c9f1
commit a54ab519b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 370 additions and 401 deletions

View file

@ -16,6 +16,7 @@ from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggr
from api.services.pipecat.tracing_config import get_trace_url
from api.services.posthog_client import capture_event
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow_run_artifacts import upload_workflow_run_artifacts
from api.tasks.arq import enqueue_job
from api.tasks.function_names import FunctionNames
from pipecat.frames.frames import (
@ -361,50 +362,49 @@ def register_event_handlers(
except Exception as e:
logger.error(f"Error saving workflow run logs: {e}", exc_info=True)
# Write buffers to temp files and enqueue combined processing task
audio_temp_path = None
user_audio_temp_path = None
bot_audio_temp_path = None
transcript_temp_path = None
# Upload artifacts straight from the in-memory buffers so nothing has
# to cross a process/host boundary via temp files. Must complete
# before the completion job is enqueued so QA and webhooks see the
# artifacts in storage.
try:
mixed_audio_wav = None
user_audio_wav = None
bot_audio_wav = None
if not in_memory_audio_buffers.mixed.is_empty:
audio_temp_path = (
await in_memory_audio_buffers.mixed.write_to_temp_file()
)
mixed_audio_wav = await in_memory_audio_buffers.mixed.to_wav_bytes()
else:
logger.debug("Audio buffer is empty, skipping upload")
if not in_memory_audio_buffers.user.is_empty:
user_audio_temp_path = (
await in_memory_audio_buffers.user.write_to_temp_file()
)
user_audio_wav = await in_memory_audio_buffers.user.to_wav_bytes()
else:
logger.debug("User audio buffer is empty, skipping upload")
if not in_memory_audio_buffers.bot.is_empty:
bot_audio_temp_path = (
await in_memory_audio_buffers.bot.write_to_temp_file()
)
bot_audio_wav = await in_memory_audio_buffers.bot.to_wav_bytes()
else:
logger.debug("Bot audio buffer is empty, skipping upload")
transcript_temp_path = in_memory_logs_buffer.write_transcript_to_temp_file()
if not transcript_temp_path:
transcript_text = in_memory_logs_buffer.generate_transcript_text()
if not transcript_text:
logger.debug("No transcript events in logs buffer, skipping upload")
await upload_workflow_run_artifacts(
workflow_run_id,
mixed_audio_wav=mixed_audio_wav,
user_audio_wav=user_audio_wav,
bot_audio_wav=bot_audio_wav,
transcript_text=transcript_text,
)
except Exception as e:
logger.error(f"Error preparing buffers for S3 upload: {e}", exc_info=True)
logger.error(f"Error uploading call artifacts: {e}", exc_info=True)
# Combined task: uploads artifacts, runs integrations (including QA),
# then calculates cost (so QA token usage is captured in usage_info)
# Combined task: runs integrations (including QA), then calculates
# cost (so QA token usage is captured in usage_info)
await enqueue_job(
FunctionNames.PROCESS_WORKFLOW_COMPLETION,
workflow_run_id,
audio_temp_path,
transcript_temp_path,
user_audio_temp_path,
bot_audio_temp_path,
)
# Return the buffer so it can be passed to other handlers

View file

@ -1,5 +1,5 @@
import asyncio
import tempfile
import io
import wave
from datetime import UTC, datetime
from typing import List, Optional
@ -15,7 +15,7 @@ from pipecat.utils.enums import RealtimeFeedbackType
class InMemoryAudioBuffer:
"""Buffer audio data in memory during a call, then write to temp file on disconnect."""
"""Buffer audio data in memory during a call, then encode to WAV bytes on disconnect."""
def __init__(self, workflow_run_id: int, sample_rate: int, num_channels: int = 1):
self._workflow_run_id = workflow_run_id
@ -41,28 +41,30 @@ class InMemoryAudioBuffer:
f"Appended {len(pcm_data)} bytes to audio buffer. Total size: {self._total_size}"
)
async def write_to_temp_file(self) -> str:
"""Write audio data to a temporary WAV file and return the path."""
async def to_wav_bytes(self) -> bytes:
"""Encode the buffered PCM data as an in-memory WAV file."""
async with self._lock:
temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
logger.debug(
f"Writing audio buffer to temp file {temp_file.name} for workflow {self._workflow_run_id}"
)
chunks = list(self._chunks)
# Write WAV header and PCM data
with wave.open(temp_file.name, "wb") as wf:
def _encode() -> bytes:
wav_io = io.BytesIO()
with wave.open(wav_io, "wb") as wf:
wf.setnchannels(self._num_channels)
wf.setsampwidth(2) # 16-bit audio
wf.setframerate(self._sample_rate)
# Concatenate all chunks
for chunk in self._chunks:
for chunk in chunks:
wf.writeframes(chunk)
return wav_io.getvalue()
logger.info(
f"Successfully wrote {self._total_size} bytes of audio to {temp_file.name}"
)
return temp_file.name
# Encoding is mostly memcpy but can touch ~100MB; keep it off the event loop
data = await asyncio.to_thread(_encode)
logger.info(
f"Encoded {self._total_size} bytes of audio to {len(data)} WAV bytes "
f"for workflow {self._workflow_run_id}"
)
return data
@property
def is_empty(self) -> bool:
@ -172,27 +174,6 @@ class InMemoryLogsBuffer:
"""
return _generate_transcript_text(self._sorted_events())
def write_transcript_to_temp_file(self) -> Optional[str]:
"""Write transcript to a temporary text file and return the path.
Returns None if there are no transcript events.
"""
content = self.generate_transcript_text()
if not content:
return None
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
logger.debug(
f"Writing transcript to temp file {temp_file.name} for workflow {self._workflow_run_id}"
)
temp_file.write(content)
temp_file.close()
logger.info(
f"Successfully wrote {len(content)} chars of transcript to {temp_file.name}"
)
return temp_file.name
@property
def is_empty(self) -> bool:
"""Check if the buffer is empty."""