Files
2026-07-16 13:36:11 +02:00

1054 lines
36 KiB
Python

import logging
from typing import Any, Literal
from llama_index.core.base.llms.types import AudioBlock, TextBlock
from llama_index.core.callbacks import CallbackManager
from llama_index.core.llms import LLM, ChatMessage, MessageRole
from llama_index.core.program.utils import FlexibleModel
from llama_index.core.workflow import Event, StartEvent, StopEvent, Workflow, step
from pydantic import BaseModel, Field
from workflows.errors import WorkflowRuntimeError
from workflows.resource import ResourceManager
from private_gpt.artifact_index.artifact_exception import ModelNotAvailableError
from private_gpt.components.llm.models import MODEL_NOT_AVAILABLE_EXCEPTION_TYPES
from private_gpt.components.prompts.prompt_builder import PromptBuilderService
from private_gpt.components.workflows.types import AnyContext
from private_gpt.di import get_global_injector
from private_gpt.utils.dependencies import format_missing_dependency_message
from private_gpt.utils.retry import retry_context
logger = logging.getLogger(__name__)
logger.setLevel(logging.FATAL)
_DEFAULT_NUM_WORKERS = 4
_DEFAULT_RETRY_NUMBER = 3
_JITTER = (15.0, 30.0)
AUDIO_NOT_PROCESSABLE = "[Audio not processable]"
_DEFAULT_MAX_AUDIO_DURATION_SECONDS = 28.0
_DEFAULT_CHUNK_OVERLAP_SECONDS = 2.0
class AudioChunk(BaseModel):
audio_block: AudioBlock = Field(description="Audio block for this chunk")
start_offset: float = Field(description="Start time offset in seconds")
end_offset: float = Field(description="End time offset in seconds")
chunk_index: int = Field(description="Index of this chunk in the sequence")
total_chunks: int = Field(description="Total number of chunks")
def __repr__(self) -> str:
return (
f"AudioChunk(chunk_index={self.chunk_index}, "
f"total_chunks={self.total_chunks}, "
f"start_offset={self.start_offset:.2f}s, "
f"end_offset={self.end_offset:.2f}s)"
)
def __str__(self) -> str:
return self.__repr__()
class TimestampSegment(BaseModel):
start: float = Field(description="Start time in seconds")
end: float = Field(description="End time in seconds")
speaker: str | None = Field(default=None, description="Speaker identifier")
text: str | None = Field(description="Transcribed text for this segment")
def __str__(self) -> str:
return f"[{self.start:.2f}-{self.end:.2f}] ({self.speaker}): {self.text}"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TimestampSegment":
return cls(
start=data.get("start", 0.0),
end=data.get("end", 0.0),
speaker=data.get("speaker"),
text=data.get("text", ""),
)
class TranscriptionStrategy(BaseModel):
type: Literal[
"speech",
"music",
"conversation",
"lecture",
"podcast",
"interview",
"ambient",
"mixed",
] = Field(description="Primary audio content type detected")
confidence: float = Field(
default=0.0, ge=0.0, le=1.0, description="Confidence in the detection (0-1)"
)
language: str = Field(default="en", description="Detected language code")
has_multiple_speakers: bool = Field(
default=False, description="Whether audio contains multiple speakers"
)
has_background_noise: bool = Field(
default=False, description="Whether audio has significant background noise"
)
enhance_audio: bool | None = Field(
default=None, description="Whether to apply audio enhancement"
)
speaker_diarization: bool | None = Field(
default=None, description="Whether to perform speaker diarization"
)
class TranscriptionContent(BaseModel):
timestamps: list[TimestampSegment] = Field(
description="Timestamped segments with speaker and text"
)
is_complete: bool | None = Field(
default=None, description="True if transcription is finished"
)
class TranscriptionEvaluation(BaseModel):
score: float = Field(
ge=0.0, le=1.0, description="Overall transcription quality score"
)
issues_found: list[str] = Field(
default_factory=list, description="Issues identified in transcription"
)
clarity: float = Field(
default=0.0, ge=0.0, le=1.0, description="Audio clarity score"
)
class ChunkWorkflowInputEvent(StartEvent):
chunk: AudioChunk
user_query: str | None
strategy: TranscriptionStrategy
enable_speaker_diarization: bool
max_iterations: int
enable_evaluation: bool
kwargs: dict[str, Any] = Field(default_factory=dict)
def __repr__(self) -> str:
return (
f"ChunkWorkflowInputEvent(chunk_index={self.chunk.chunk_index}, "
f"max_iterations={self.max_iterations}, "
f"enable_evaluation={self.enable_evaluation})"
)
def __str__(self) -> str:
return self.__repr__()
class Config:
arbitrary_types_allowed = True
class ChunkTranscriptionResultEvent(Event):
timestamps: list[TimestampSegment]
is_complete: bool
class ChunkEvaluationResultEvent(Event):
timestamps: list[TimestampSegment]
evaluation: TranscriptionEvaluation | None
should_retry: bool
class ChunkWorkflowResultEvent(StopEvent):
chunk_index: int
timestamps: list[TimestampSegment]
is_complete: bool
class AudioProcessingInputEvent(StartEvent):
audio_blocks: list[AudioBlock]
user_query: str | None = None
max_iterations: int = 3
enable_preprocessing: bool = True
enable_evaluation: bool = True
enable_speaker_diarization: bool = False
max_audio_duration: float = _DEFAULT_MAX_AUDIO_DURATION_SECONDS
chunk_overlap: float = _DEFAULT_CHUNK_OVERLAP_SECONDS
kwargs: dict[str, Any] = Field(default_factory=dict)
def __repr__(self) -> str:
return (
f"AudioProcessingInputEvent(num_audio_blocks={len(self.audio_blocks)}, "
f"max_iterations={self.max_iterations}, "
f"enable_preprocessing={self.enable_preprocessing}, "
f"enable_evaluation={self.enable_evaluation})"
)
def __str__(self) -> str:
return self.__repr__()
class WorkflowInitializedEvent(Event):
audio_blocks: list[AudioBlock]
def __repr__(self) -> str:
return f"WorkflowInitializedEvent(num_audio_blocks={len(self.audio_blocks)})"
def __str__(self) -> str:
return self.__repr__()
class AudioChunkedEvent(Event):
audio_chunks: list[AudioChunk]
class StrategyInferredEvent(Event):
strategy: TranscriptionStrategy
audio_chunks: list[AudioChunk]
class AudioPreprocessedEvent(Event):
processed_chunks: list[AudioChunk]
class AudioProcessingResultEvent(StopEvent):
transcript: str
speakers: list[str] | None = None
timestamps: list[TimestampSegment] | None = None
evaluation: TranscriptionEvaluation | None = None
class ChunkTranscriptionWorkflow(Workflow):
def __init__(
self,
audio_multimodal_llm: LLM,
prompt_builder: PromptBuilderService,
timeout: float | None = None,
num_max_retries: int = _DEFAULT_RETRY_NUMBER,
retry_jitter: tuple[float, float] = _JITTER,
**kwargs: Any,
) -> None:
super().__init__(timeout=timeout, **kwargs)
self._llm = audio_multimodal_llm
self._prompt_builder = prompt_builder
self._num_max_retries = num_max_retries
self._retry_jitter = retry_jitter
@step
async def init_chunk_workflow(
self, ctx: AnyContext, ev: ChunkWorkflowInputEvent
) -> ChunkTranscriptionResultEvent | ChunkWorkflowResultEvent:
await ctx.store.set("chunk", ev.chunk)
await ctx.store.set("user_query", ev.user_query)
await ctx.store.set("strategy", ev.strategy)
await ctx.store.set("enable_speaker_diarization", ev.enable_speaker_diarization)
await ctx.store.set("max_iterations", ev.max_iterations)
await ctx.store.set("enable_evaluation", ev.enable_evaluation)
await ctx.store.set("kwargs", ev.kwargs)
await ctx.store.set("iteration", 1)
await ctx.store.set("all_timestamps", [])
await ctx.store.set("continue_content", None)
await ctx.store.set("issues_found", [])
return await self._transcribe_and_iterate(ctx)
async def _transcribe_and_iterate(
self, ctx: AnyContext
) -> ChunkTranscriptionResultEvent | ChunkWorkflowResultEvent:
chunk = await ctx.store.get("chunk")
iteration = await ctx.store.get("iteration")
max_iterations = await ctx.store.get("max_iterations")
if iteration > max_iterations:
all_timestamps = await ctx.store.get("all_timestamps", [])
return ChunkWorkflowResultEvent(
chunk_index=chunk.chunk_index,
timestamps=all_timestamps,
is_complete=True,
)
user_query = await ctx.store.get("user_query")
strategy = await ctx.store.get("strategy")
enable_speaker_diarization = await ctx.store.get("enable_speaker_diarization")
continue_content = await ctx.store.get("continue_content")
issues_found = await ctx.store.get("issues_found")
kwargs = await ctx.store.get("kwargs")
transcription_content = await self._transcribe_chunk(
chunk=chunk,
user_query=user_query,
strategy=strategy,
enable_speaker_diarization=enable_speaker_diarization,
continue_content=continue_content,
issues_found=issues_found,
iteration=iteration,
**kwargs,
)
timestamps = getattr(transcription_content, "timestamps", [])
is_complete = getattr(transcription_content, "is_complete", False)
adjusted_timestamps: list[TimestampSegment] = []
for segment in timestamps:
adjusted_segment = TimestampSegment(
start=segment.start + chunk.start_offset,
end=segment.end + chunk.start_offset,
speaker=segment.speaker,
text=segment.text,
)
adjusted_timestamps.append(adjusted_segment)
enable_evaluation = await ctx.store.get("enable_evaluation")
if enable_evaluation:
return ChunkTranscriptionResultEvent(
timestamps=adjusted_timestamps,
is_complete=is_complete,
)
all_timestamps = await ctx.store.get("all_timestamps", [])
all_timestamps.extend(adjusted_timestamps)
await ctx.store.set("all_timestamps", all_timestamps)
if is_complete or iteration >= max_iterations:
return ChunkWorkflowResultEvent(
chunk_index=chunk.chunk_index,
timestamps=all_timestamps,
is_complete=True,
)
await ctx.store.set("iteration", iteration + 1)
continue_content = " ".join(seg.text for seg in all_timestamps if seg.text)
await ctx.store.set("continue_content", continue_content)
await ctx.store.set("issues_found", [])
return await self._transcribe_and_iterate(ctx)
@step
async def evaluate_chunk(
self, ctx: AnyContext, ev: ChunkTranscriptionResultEvent
) -> ChunkEvaluationResultEvent:
chunk = await ctx.store.get("chunk")
iteration = await ctx.store.get("iteration")
max_iterations = await ctx.store.get("max_iterations")
all_timestamps = await ctx.store.get("all_timestamps", [])
all_timestamps.extend(ev.timestamps)
await ctx.store.set("all_timestamps", all_timestamps)
if iteration > max_iterations:
return ChunkEvaluationResultEvent(
timestamps=all_timestamps,
evaluation=None,
should_retry=False,
)
transcript = " ".join(seg.text for seg in ev.timestamps if seg.text)
audio_blocks = [chunk.audio_block]
evaluation = await self._evaluate_transcription(
transcript=transcript,
audio_blocks=audio_blocks,
iteration=iteration,
)
should_retry = False
if evaluation and (evaluation.score < 0.7 or evaluation.issues_found):
if iteration < max_iterations:
should_retry = True
return ChunkEvaluationResultEvent(
timestamps=all_timestamps,
evaluation=evaluation,
should_retry=should_retry,
)
@step
async def decide_next_action(
self, ctx: AnyContext, ev: ChunkEvaluationResultEvent
) -> ChunkTranscriptionResultEvent | ChunkWorkflowResultEvent:
chunk = await ctx.store.get("chunk")
iteration = await ctx.store.get("iteration")
max_iterations = await ctx.store.get("max_iterations")
if ev.should_retry and iteration < max_iterations:
await ctx.store.set("iteration", iteration + 1)
continue_content = " ".join(seg.text for seg in ev.timestamps if seg.text)
await ctx.store.set("continue_content", continue_content)
issues_found = ev.evaluation.issues_found if ev.evaluation else []
await ctx.store.set("issues_found", issues_found)
await ctx.store.set("all_timestamps", [])
return await self._transcribe_and_iterate(ctx)
return ChunkWorkflowResultEvent(
chunk_index=chunk.chunk_index,
timestamps=ev.timestamps,
is_complete=True,
)
async def _transcribe_chunk(
self,
chunk: AudioChunk,
user_query: str | None,
strategy: TranscriptionStrategy,
enable_speaker_diarization: bool,
continue_content: str | None,
issues_found: list[str],
iteration: int,
**kwargs: Any,
) -> TranscriptionContent | FlexibleModel:
template = self._prompt_builder.create_audio_transcription_prompt(
user_query=user_query,
last_content=continue_content,
audio_type=strategy.type,
confidence=strategy.confidence,
language=strategy.language,
has_multiple_speakers=strategy.has_multiple_speakers,
enable_speaker_diarization=enable_speaker_diarization,
errors=issues_found or None,
)
messages = [
ChatMessage(
role=MessageRole.SYSTEM, blocks=[TextBlock(text=template.format())]
),
ChatMessage(
role=MessageRole.USER,
blocks=[
TextBlock(
text=(
f"This is chunk {chunk.chunk_index + 1} of {chunk.total_chunks}. "
f"Time range: {chunk.start_offset:.2f}s - {chunk.end_offset:.2f}s."
"\nTranscribe the content from this audio:"
)
),
chunk.audio_block,
],
),
]
response: TranscriptionContent | FlexibleModel = await self._astructured_chat(
TranscriptionContent,
messages,
seed=iteration + chunk.chunk_index,
allow_flexible=True,
**kwargs,
)
return response
async def _evaluate_transcription(
self,
transcript: str,
audio_blocks: list[AudioBlock],
iteration: int,
) -> TranscriptionEvaluation | None:
if not transcript:
return None
eval_template = self._prompt_builder.create_audio_evaluation_prompt()
messages = [
ChatMessage(
role=MessageRole.SYSTEM, blocks=[TextBlock(text=eval_template.format())]
),
ChatMessage(
role=MessageRole.USER,
blocks=[
TextBlock(text=f"Transcript: {transcript}"),
*audio_blocks,
],
),
]
result: TranscriptionEvaluation | FlexibleModel = await self._astructured_chat(
TranscriptionEvaluation,
messages,
seed=iteration,
)
if not isinstance(result, TranscriptionEvaluation):
result = TranscriptionEvaluation(**result.model_dump())
return result
async def _astructured_chat(
self,
response_model: type[BaseModel],
messages: list[ChatMessage],
**kwargs: Any,
) -> Any:
try:
async with retry_context(
tries=self._num_max_retries,
jitter=self._retry_jitter,
logger=logger,
) as retry:
seed = kwargs.pop("seed", None) or 0
count = 0
async def _call() -> Any:
nonlocal count
count += 1
structured_chat = getattr(self._llm, "astructured_chat", None)
if not callable(structured_chat):
raise NotImplementedError(
"LLM does not support structured chat."
)
new_kwargs = kwargs.copy()
new_kwargs["seed"] = str(seed) + str(count)
return await structured_chat(response_model, messages, **new_kwargs)
return await retry(_call)
except MODEL_NOT_AVAILABLE_EXCEPTION_TYPES as e:
raise ModelNotAvailableError(
"Model server is not available or request failed."
) from e
except Exception:
raise
class AudioProcessingWorkflow(Workflow):
def __init__(
self,
audio_multimodal_llm: LLM,
prompt_builder: PromptBuilderService | None = None,
callback_manager: CallbackManager | None = None,
timeout: float | None = 360000.0,
disable_validation: bool = False,
verbose: bool = False,
resource_manager: ResourceManager | None = None,
num_concurrent_runs: int | None = None,
max_workers: int = _DEFAULT_NUM_WORKERS,
num_max_retries: int = _DEFAULT_RETRY_NUMBER,
retry_jitter: tuple[float, float] = _JITTER,
**kwargs: Any,
) -> None:
super().__init__(
timeout=timeout,
disable_validation=disable_validation,
verbose=verbose,
resource_manager=resource_manager,
num_concurrent_runs=num_concurrent_runs,
)
self._llm = audio_multimodal_llm
self._prompt_builder = prompt_builder or get_global_injector().get(
PromptBuilderService
)
self._kwargs = kwargs
if callback_manager:
self._llm.callback_manager = callback_manager
self._max_concurrent_chunks = max_workers
self._num_max_retries = num_max_retries
self._retry_jitter = retry_jitter
@step
async def init_workflow(
self, ctx: AnyContext, ev: AudioProcessingInputEvent
) -> WorkflowInitializedEvent:
await ctx.store.set("user_query", ev.user_query)
await ctx.store.set("max_iterations", ev.max_iterations)
await ctx.store.set("enable_preprocessing", ev.enable_preprocessing)
await ctx.store.set("enable_evaluation", ev.enable_evaluation)
await ctx.store.set("enable_speaker_diarization", ev.enable_speaker_diarization)
await ctx.store.set("max_audio_duration", ev.max_audio_duration)
await ctx.store.set("chunk_overlap", ev.chunk_overlap)
await ctx.store.set("kwargs", ev.kwargs)
return WorkflowInitializedEvent(audio_blocks=ev.audio_blocks)
@step
async def chunk_audio(
self, ctx: AnyContext, ev: WorkflowInitializedEvent
) -> AudioChunkedEvent:
max_duration = await ctx.store.get("max_audio_duration")
chunk_overlap = await ctx.store.get("chunk_overlap")
all_chunks: list[AudioChunk] = []
for audio_block in ev.audio_blocks:
chunks = await self._split_audio_block(
audio_block, max_duration, chunk_overlap
)
all_chunks.extend(chunks)
await ctx.store.set("audio_chunks", all_chunks)
return AudioChunkedEvent(audio_chunks=all_chunks)
@step
async def infer_strategy(
self, ctx: AnyContext, ev: AudioChunkedEvent
) -> StrategyInferredEvent | AudioPreprocessedEvent:
iteration = 1
enable_preprocessing = await ctx.store.get("enable_preprocessing")
kwargs = await ctx.store.get("kwargs")
sample_blocks = [ev.audio_chunks[0].audio_block] if ev.audio_chunks else []
strategy = await self._infer_strategy(sample_blocks, seed=iteration, **kwargs)
await ctx.store.set("strategy", strategy)
return (
StrategyInferredEvent(strategy=strategy, audio_chunks=ev.audio_chunks)
if enable_preprocessing
else AudioPreprocessedEvent(processed_chunks=ev.audio_chunks)
)
@step
async def preprocess_audio(
self, ctx: AnyContext, ev: StrategyInferredEvent
) -> AudioPreprocessedEvent:
enable_preprocessing = await ctx.store.get("enable_preprocessing")
processed_chunks = ev.audio_chunks
if enable_preprocessing and ev.strategy.enhance_audio:
processed_chunks = [
AudioChunk(
audio_block=await self._preprocess_audio(
chunk.audio_block, ev.strategy
),
start_offset=chunk.start_offset,
end_offset=chunk.end_offset,
chunk_index=chunk.chunk_index,
total_chunks=chunk.total_chunks,
)
for chunk in ev.audio_chunks
]
await ctx.store.set("strategy", ev.strategy)
return AudioPreprocessedEvent(processed_chunks=processed_chunks)
@step
async def transcribe_all_chunks(
self, ctx: AnyContext, ev: AudioPreprocessedEvent
) -> AudioProcessingResultEvent:
import asyncio
strategy = await ctx.store.get("strategy")
max_iterations = await ctx.store.get("max_iterations")
user_query = await ctx.store.get("user_query")
enable_speaker_diarization = await ctx.store.get("enable_speaker_diarization")
enable_evaluation = await ctx.store.get("enable_evaluation")
kwargs = await ctx.store.get("kwargs")
if strategy.type in ["ambient"] and strategy.confidence < 0.3:
response = (
"\n\n"
+ self._prompt_builder.create_audio_transcription_response(
user_query=user_query,
transcript="",
speakers=None,
)
.format()
.strip()
+ "\n\n"
)
return AudioProcessingResultEvent(
transcript=response if response.strip() else AUDIO_NOT_PROCESSABLE,
speakers=None,
timestamps=None,
evaluation=None,
)
semaphore = asyncio.Semaphore(self._max_concurrent_chunks)
async def process_chunk_with_semaphore(
chunk: AudioChunk,
) -> ChunkWorkflowResultEvent:
async with semaphore:
child_workflow = ChunkTranscriptionWorkflow(
audio_multimodal_llm=self._llm,
prompt_builder=self._prompt_builder,
num_max_retries=self._num_max_retries,
retry_jitter=self._retry_jitter,
)
return await child_workflow.run(
chunk=chunk,
user_query=user_query,
strategy=strategy,
enable_speaker_diarization=enable_speaker_diarization,
max_iterations=max_iterations,
enable_evaluation=enable_evaluation,
kwargs=kwargs,
)
tasks = [process_chunk_with_semaphore(chunk) for chunk in ev.processed_chunks]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_timestamps: list[TimestampSegment] = []
for result in results:
if isinstance(result, Exception):
logger.error(f"Chunk processing failed: {result}")
continue
if isinstance(result, ChunkWorkflowResultEvent):
all_timestamps.extend(result.timestamps)
all_timestamps.sort(key=lambda x: x.start)
merged_timestamps = self._merge_and_group_timestamps([all_timestamps])
transcript = self._build_transcript_from_timestamps(merged_timestamps)
speakers = self._extract_speakers_from_timestamps(merged_timestamps)
response = (
"\n\n"
+ self._prompt_builder.create_audio_transcription_response(
user_query=user_query,
transcript=transcript,
speakers=speakers,
)
.format()
.strip()
+ "\n\n"
)
if not response.strip():
response = AUDIO_NOT_PROCESSABLE
return AudioProcessingResultEvent(
transcript=response,
speakers=speakers,
timestamps=merged_timestamps if merged_timestamps else None,
evaluation=None,
)
async def _astructured_chat(
self,
response_model: type[BaseModel],
messages: list[ChatMessage],
**kwargs: Any,
) -> Any:
try:
async with retry_context(
tries=self._num_max_retries,
jitter=self._retry_jitter,
logger=logger,
) as retry:
seed = kwargs.pop("seed", None) or 0
count = 0
async def _call() -> Any:
nonlocal count
count += 1
structured_chat = getattr(self._llm, "astructured_chat", None)
if not callable(structured_chat):
raise NotImplementedError(
"LLM does not support structured chat."
)
new_kwargs = kwargs.copy()
new_kwargs["seed"] = str(seed) + str(count)
return await structured_chat(response_model, messages, **new_kwargs)
return await retry(_call)
except MODEL_NOT_AVAILABLE_EXCEPTION_TYPES as e:
raise ModelNotAvailableError(
"Model server is not available or request failed."
) from e
async def _infer_strategy(
self, audio_blocks: list[AudioBlock], **kwargs: Any
) -> TranscriptionStrategy:
strategy_prompt = self._prompt_builder.create_audio_strategy_prompt()
messages = [
ChatMessage(
role=MessageRole.SYSTEM,
blocks=[TextBlock(text=strategy_prompt.format())],
),
ChatMessage(
role=MessageRole.USER,
blocks=[
TextBlock(text="Analyze the following audio:"),
*audio_blocks,
],
),
]
strategy: TranscriptionStrategy | FlexibleModel = await self._astructured_chat(
TranscriptionStrategy, messages, **kwargs
)
if not isinstance(strategy, TranscriptionStrategy):
strategy = TranscriptionStrategy(**strategy.model_dump())
return strategy
async def _split_audio_block(
self,
audio_block: AudioBlock,
max_duration: float,
overlap: float,
) -> list[AudioChunk]:
try:
import io
from pydub import AudioSegment # ty:ignore[unresolved-import]
audio_data = audio_block.resolve_audio()
if isinstance(audio_data, bytes):
audio_segment = AudioSegment.from_file(io.BytesIO(audio_data))
else:
audio_segment = AudioSegment.from_file(audio_data)
duration_seconds = len(audio_segment) / 1000.0
if duration_seconds <= max_duration:
return [
AudioChunk(
audio_block=audio_block,
start_offset=0.0,
end_offset=duration_seconds,
chunk_index=0,
total_chunks=1,
)
]
max_duration_ms = int(max_duration * 1000)
overlap_ms = int(overlap * 1000)
step_size_ms = max_duration_ms - overlap_ms
chunks: list[AudioChunk] = []
start_ms = 0
chunk_index = 0
while start_ms < len(audio_segment):
end_ms = min(start_ms + max_duration_ms, len(audio_segment))
chunk_segment = audio_segment[start_ms:end_ms]
output_buffer = io.BytesIO()
chunk_segment.export(output_buffer, format="wav")
output_buffer.seek(0)
chunks.append(
AudioChunk(
audio_block=AudioBlock(
audio=output_buffer.getvalue(), format="audio/wav"
),
start_offset=start_ms / 1000.0,
end_offset=end_ms / 1000.0,
chunk_index=chunk_index,
total_chunks=0,
)
)
chunk_index += 1
start_ms += step_size_ms
if end_ms >= len(audio_segment):
break
total_chunks = len(chunks)
for chunk in chunks:
chunk.total_chunks = total_chunks
logger.info(
f"Split audio ({duration_seconds:.2f}s) into {total_chunks} chunks "
f"(max {max_duration}s, overlap {overlap}s)"
)
return chunks
except ImportError:
logger.warning(
"%s Returning the audio as a single chunk.",
format_missing_dependency_message(
"Audio chunking",
extras="media",
),
)
return [
AudioChunk(
audio_block=audio_block,
start_offset=0.0,
end_offset=0.0,
chunk_index=0,
total_chunks=1,
)
]
except Exception:
return [
AudioChunk(
audio_block=audio_block,
start_offset=0.0,
end_offset=0.0,
chunk_index=0,
total_chunks=1,
)
]
async def _preprocess_audio(
self, audio_block: AudioBlock, strategy: TranscriptionStrategy
) -> AudioBlock:
if not strategy.enhance_audio:
return audio_block
try:
import io
import noisereduce as nr # ty:ignore[unresolved-import]
import numpy as np
from pydub import AudioSegment # ty:ignore[unresolved-import]
from pydub.effects import normalize # ty:ignore[unresolved-import]
audio_data = audio_block.resolve_audio()
if isinstance(audio_data, bytes):
audio_segment = AudioSegment.from_file(io.BytesIO(audio_data))
else:
audio_segment = AudioSegment.from_file(audio_data)
samples = np.array(audio_segment.get_array_of_samples())
sample_rate = audio_segment.frame_rate
if strategy.has_background_noise:
samples = nr.reduce_noise(
y=samples.astype(float),
sr=sample_rate,
stationary=True,
prop_decrease=0.8,
)
enhanced_segment = AudioSegment(
samples.tobytes(),
frame_rate=sample_rate,
sample_width=audio_segment.sample_width,
channels=audio_segment.channels,
)
enhanced_segment = normalize(enhanced_segment)
output_buffer = io.BytesIO()
enhanced_segment.export(output_buffer, format="wav")
output_buffer.seek(0)
return AudioBlock(audio=output_buffer.getvalue(), format="audio/wav")
except ImportError:
logger.warning(
format_missing_dependency_message(
"Audio preprocessing",
extras="media",
)
)
return audio_block
except Exception as e:
logger.error(f"Audio preprocessing failed: {e}")
return audio_block
def _merge_and_group_timestamps(
self, results: list[list[TimestampSegment]]
) -> list[TimestampSegment]:
if not results:
return []
all_timestamps: list[TimestampSegment] = []
for result in results:
all_timestamps.extend(result)
if not all_timestamps:
return []
all_timestamps.sort(key=lambda x: x.start)
grouped: list[TimestampSegment] = []
current_group: TimestampSegment | None = None
for segment in all_timestamps:
if (
current_group is None
or current_group.speaker != segment.speaker
or segment.start - current_group.end > 1.0
):
if current_group is not None:
grouped.append(current_group)
current_group = TimestampSegment(
start=segment.start,
end=segment.end,
speaker=segment.speaker,
text=segment.text,
)
else:
current_group.end = segment.end
current_group.text = f"{current_group.text} {segment.text}".strip()
if current_group is not None:
grouped.append(current_group)
return grouped
def _build_transcript_from_timestamps(
self, timestamps: list[TimestampSegment]
) -> str:
if not timestamps:
return ""
return " ".join(segment.text for segment in timestamps if segment.text)
def _extract_speakers_from_timestamps(
self, timestamps: list[TimestampSegment]
) -> list[str] | None:
speakers = [
segment.speaker for segment in timestamps if segment.speaker is not None
]
unique_speakers = list(dict.fromkeys(speakers))
return unique_speakers if unique_speakers else None
async def transcribe_audio(
audio_multimodal_llm: LLM,
audio_blocks: list[AudioBlock] | None = None,
user_query: str | None = None,
max_iterations: int = 1,
enable_preprocessing: bool = False,
enable_evaluation: bool = False,
enable_speaker_diarization: bool = False,
max_audio_duration: float = _DEFAULT_MAX_AUDIO_DURATION_SECONDS,
chunk_overlap: float = _DEFAULT_CHUNK_OVERLAP_SECONDS,
**kwargs: Any,
) -> AudioProcessingResultEvent | None:
if not audio_blocks:
return None
workflow = AudioProcessingWorkflow(audio_multimodal_llm, **kwargs)
try:
result: AudioProcessingResultEvent = await workflow.run(
audio_blocks=audio_blocks,
user_query=user_query,
max_iterations=max_iterations,
enable_preprocessing=enable_preprocessing,
enable_evaluation=enable_evaluation,
enable_speaker_diarization=enable_speaker_diarization,
max_audio_duration=max_audio_duration,
chunk_overlap=chunk_overlap,
kwargs=kwargs,
)
except WorkflowRuntimeError as e:
exception: BaseException = e.__cause__ or e
raise exception # noqa: B904
return result
async def process_audio_in_message(
audio_multimodal_llm: LLM,
message: ChatMessage,
user_query: str | None = None,
enable_preprocessing: bool = False,
enable_evaluation: bool = False,
enable_speaker_diarization: bool = False,
max_audio_duration: float = _DEFAULT_MAX_AUDIO_DURATION_SECONDS,
chunk_overlap: float = _DEFAULT_CHUNK_OVERLAP_SECONDS,
**kwargs: Any,
) -> str | None:
audio_blocks = [block for block in message.blocks if isinstance(block, AudioBlock)]
if not audio_blocks:
return message.content or ""
result = await transcribe_audio(
audio_multimodal_llm,
audio_blocks,
user_query,
enable_preprocessing=enable_preprocessing,
enable_evaluation=enable_evaluation,
enable_speaker_diarization=enable_speaker_diarization,
max_audio_duration=max_audio_duration,
chunk_overlap=chunk_overlap,
**kwargs,
)
return result.transcript if result and result.transcript else None