|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import logging |
| 4 | +from contextlib import suppress |
| 5 | +from typing import Any |
| 6 | + |
| 7 | +from griptape.artifacts.audio_url_artifact import AudioUrlArtifact |
| 8 | +from griptape_nodes.exe_types.core_types import ParameterGroup, ParameterMode |
| 9 | +from griptape_nodes.exe_types.param_types.parameter_audio import ParameterAudio |
| 10 | +from griptape_nodes.exe_types.param_types.parameter_dict import ParameterDict |
| 11 | +from griptape_nodes.exe_types.param_types.parameter_float import ParameterFloat |
| 12 | +from griptape_nodes.exe_types.param_types.parameter_string import ParameterString |
| 13 | +from griptape_nodes.files.file import File, FileLoadError |
| 14 | +from griptape_nodes.traits.options import Options |
| 15 | +from griptape_nodes.traits.slider import Slider |
| 16 | + |
| 17 | +from griptape_nodes_library.griptape_proxy_node import GriptapeProxyNode |
| 18 | + |
| 19 | +logger = logging.getLogger(__name__) |
| 20 | + |
| 21 | +__all__ = ["OpenAiAudioTranscription"] |
| 22 | + |
| 23 | +MODEL_CHOICES = ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"] |
| 24 | +DEFAULT_MODEL = "gpt-4o-mini-transcribe" |
| 25 | + |
| 26 | +MODEL_MAPPING = { |
| 27 | + "gpt-4o-transcribe": "gpt-4o-transcribe", |
| 28 | + "gpt-4o-mini-transcribe": "gpt-4o-mini-transcribe", |
| 29 | + "whisper-1": "whisper-1", |
| 30 | +} |
| 31 | + |
| 32 | +RESPONSE_FORMAT_CHOICES = ["json", "verbose_json"] |
| 33 | +DEFAULT_RESPONSE_FORMAT = "json" |
| 34 | + |
| 35 | + |
| 36 | +class OpenAiAudioTranscription(GriptapeProxyNode): |
| 37 | + """Transcribe audio to text using OpenAI models via Griptape Cloud proxy. |
| 38 | +
|
| 39 | + Supports GPT-4o transcription models and Whisper for converting speech to text. |
| 40 | +
|
| 41 | + Inputs: |
| 42 | + - audio: Audio file to transcribe (mp3, mp4, mpeg, mpga, m4a, wav, webm, flac) |
| 43 | + - model: Transcription model to use |
| 44 | + - language: ISO-639-1 language code to improve accuracy |
| 45 | + - prompt: Optional text to guide transcription style |
| 46 | + - response_format: Output format (json or verbose_json) |
| 47 | + - temperature: Sampling temperature (0 = deterministic) |
| 48 | +
|
| 49 | + Outputs: |
| 50 | + - generation_id (str): Generation ID from the API |
| 51 | + - provider_response (dict): Verbatim response from the proxy |
| 52 | + - output (str): Transcribed text from the audio |
| 53 | + - words (list): Word-level timing data (only with verbose_json) |
| 54 | + - segments (list): Segment-level data with timing (only with verbose_json) |
| 55 | + - detected_language (str): Detected language of the audio |
| 56 | + - duration (float): Duration of the audio in seconds |
| 57 | + """ |
| 58 | + |
| 59 | + def __init__(self, **kwargs: Any) -> None: |
| 60 | + super().__init__(**kwargs) |
| 61 | + self.category = "API Nodes" |
| 62 | + self.description = "Transcribe audio to text using OpenAI models" |
| 63 | + |
| 64 | + # --- INPUT PARAMETERS --- |
| 65 | + self.add_parameter( |
| 66 | + ParameterAudio( |
| 67 | + name="audio", |
| 68 | + default_value=None, |
| 69 | + tooltip="Audio to transcribe. Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, flac. Max 25MB.", |
| 70 | + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, |
| 71 | + ui_options={"clickable_file_browser": True, "expander": True}, |
| 72 | + ) |
| 73 | + ) |
| 74 | + |
| 75 | + self.add_parameter( |
| 76 | + ParameterString( |
| 77 | + name="model", |
| 78 | + default_value=DEFAULT_MODEL, |
| 79 | + tooltip="Transcription model to use", |
| 80 | + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, |
| 81 | + traits={Options(choices=MODEL_CHOICES)}, |
| 82 | + ui_options={"display_name": "Model"}, |
| 83 | + ) |
| 84 | + ) |
| 85 | + |
| 86 | + self.add_parameter( |
| 87 | + ParameterString( |
| 88 | + name="language", |
| 89 | + default_value=None, |
| 90 | + tooltip="ISO-639-1 language code (e.g. en, es, fr). Providing this improves accuracy and speed.", |
| 91 | + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, |
| 92 | + placeholder_text="Auto-detect", |
| 93 | + ui_options={"display_name": "Language"}, |
| 94 | + ) |
| 95 | + ) |
| 96 | + |
| 97 | + self.add_parameter( |
| 98 | + ParameterString( |
| 99 | + name="prompt", |
| 100 | + default_value=None, |
| 101 | + tooltip="Optional text to guide transcription style or provide context. Should match the audio language.", |
| 102 | + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, |
| 103 | + multiline=True, |
| 104 | + placeholder_text="Optional context for the transcription...", |
| 105 | + ui_options={"display_name": "Prompt"}, |
| 106 | + ) |
| 107 | + ) |
| 108 | + |
| 109 | + # Advanced parameters |
| 110 | + with ParameterGroup(name="Advanced", ui_options={"collapsed": True}) as advanced_group: |
| 111 | + ParameterString( |
| 112 | + name="response_format", |
| 113 | + default_value=DEFAULT_RESPONSE_FORMAT, |
| 114 | + tooltip="Output format. Use verbose_json for word/segment timestamps.", |
| 115 | + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, |
| 116 | + traits={Options(choices=RESPONSE_FORMAT_CHOICES)}, |
| 117 | + ui_options={"display_name": "Response Format"}, |
| 118 | + ) |
| 119 | + |
| 120 | + ParameterFloat( |
| 121 | + name="temperature", |
| 122 | + default_value=0.0, |
| 123 | + tooltip="Sampling temperature between 0 and 1. Higher values produce more random output.", |
| 124 | + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, |
| 125 | + traits={Slider(min_val=0.0, max_val=1.0)}, |
| 126 | + ui_options={"display_name": "Temperature"}, |
| 127 | + ) |
| 128 | + self.add_node_element(advanced_group) |
| 129 | + |
| 130 | + # --- OUTPUT PARAMETERS --- |
| 131 | + self.add_parameter( |
| 132 | + ParameterString( |
| 133 | + name="generation_id", |
| 134 | + tooltip="Generation ID from the API", |
| 135 | + allowed_modes={ParameterMode.OUTPUT}, |
| 136 | + hide_property=True, |
| 137 | + hide=True, |
| 138 | + ) |
| 139 | + ) |
| 140 | + |
| 141 | + self.add_parameter( |
| 142 | + ParameterDict( |
| 143 | + name="provider_response", |
| 144 | + tooltip="Verbatim response from Griptape model proxy", |
| 145 | + allowed_modes={ParameterMode.OUTPUT}, |
| 146 | + hide_property=True, |
| 147 | + hide=True, |
| 148 | + ) |
| 149 | + ) |
| 150 | + |
| 151 | + self.add_parameter( |
| 152 | + ParameterString( |
| 153 | + name="output", |
| 154 | + tooltip="Transcribed text from the audio", |
| 155 | + default_value=None, |
| 156 | + allowed_modes={ParameterMode.OUTPUT, ParameterMode.PROPERTY}, |
| 157 | + settable=False, |
| 158 | + multiline=True, |
| 159 | + placeholder_text="Transcribed text will appear here...", |
| 160 | + ui_options={"display_name": "Output", "pulse_on_run": True}, |
| 161 | + ) |
| 162 | + ) |
| 163 | + |
| 164 | + self.add_parameter( |
| 165 | + ParameterDict( |
| 166 | + name="words", |
| 167 | + tooltip="Word-level timing data (only with verbose_json response format)", |
| 168 | + default_value=None, |
| 169 | + allowed_modes={ParameterMode.OUTPUT}, |
| 170 | + hide_property=True, |
| 171 | + hide=True, |
| 172 | + ) |
| 173 | + ) |
| 174 | + |
| 175 | + self.add_parameter( |
| 176 | + ParameterDict( |
| 177 | + name="segments", |
| 178 | + tooltip="Segment-level data with timing (only with verbose_json response format)", |
| 179 | + default_value=None, |
| 180 | + allowed_modes={ParameterMode.OUTPUT}, |
| 181 | + hide_property=True, |
| 182 | + hide=True, |
| 183 | + ) |
| 184 | + ) |
| 185 | + |
| 186 | + self.add_parameter( |
| 187 | + ParameterString( |
| 188 | + name="detected_language", |
| 189 | + tooltip="Detected language of the audio", |
| 190 | + default_value=None, |
| 191 | + allowed_modes={ParameterMode.OUTPUT}, |
| 192 | + hide_property=True, |
| 193 | + hide=True, |
| 194 | + ) |
| 195 | + ) |
| 196 | + |
| 197 | + self.add_parameter( |
| 198 | + ParameterFloat( |
| 199 | + name="duration", |
| 200 | + tooltip="Duration of the audio in seconds", |
| 201 | + default_value=None, |
| 202 | + allowed_modes={ParameterMode.OUTPUT}, |
| 203 | + hide_property=True, |
| 204 | + hide=True, |
| 205 | + ) |
| 206 | + ) |
| 207 | + |
| 208 | + # Status parameters MUST be last |
| 209 | + self._create_status_parameters( |
| 210 | + result_details_tooltip="Details about the transcription result or any errors", |
| 211 | + result_details_placeholder="Transcription status will appear here...", |
| 212 | + parameter_group_initially_collapsed=True, |
| 213 | + ) |
| 214 | + |
| 215 | + def _get_api_model_id(self) -> str: |
| 216 | + """Get the API model ID for this generation.""" |
| 217 | + model = self.get_parameter_value("model") or DEFAULT_MODEL |
| 218 | + return MODEL_MAPPING.get(str(model), str(model)) |
| 219 | + |
| 220 | + def _log(self, message: str) -> None: |
| 221 | + with suppress(Exception): |
| 222 | + logger.info(message) |
| 223 | + |
| 224 | + async def _resolve_audio_data_uri(self, audio_value: Any) -> str | None: |
| 225 | + """Resolve an audio parameter value to a base64 data URI. |
| 226 | +
|
| 227 | + Args: |
| 228 | + audio_value: The value from the audio parameter (AudioUrlArtifact, dict, or string) |
| 229 | +
|
| 230 | + Returns: |
| 231 | + str | None: The base64 data URI, or None if resolution failed |
| 232 | + """ |
| 233 | + if audio_value is None: |
| 234 | + return None |
| 235 | + |
| 236 | + # Handle AudioUrlArtifact |
| 237 | + if isinstance(audio_value, AudioUrlArtifact): |
| 238 | + audio_url = audio_value.value |
| 239 | + elif isinstance(audio_value, dict): |
| 240 | + audio_url = audio_value.get("value") or audio_value.get("url", "") |
| 241 | + elif isinstance(audio_value, str): |
| 242 | + audio_url = audio_value |
| 243 | + else: |
| 244 | + return None |
| 245 | + |
| 246 | + if not audio_url: |
| 247 | + return None |
| 248 | + |
| 249 | + try: |
| 250 | + return await File(audio_url).aread_data_uri(fallback_mime="audio/mpeg") |
| 251 | + except FileLoadError as e: |
| 252 | + self._log(f"Failed to load audio: {e}") |
| 253 | + return None |
| 254 | + |
| 255 | + async def _build_payload(self) -> dict[str, Any]: |
| 256 | + """Build the request payload for OpenAI audio transcription.""" |
| 257 | + audio_value = self.get_parameter_value("audio") |
| 258 | + if audio_value is None: |
| 259 | + msg = "No audio provided. Please connect an audio source." |
| 260 | + raise ValueError(msg) |
| 261 | + |
| 262 | + audio_data_uri = await self._resolve_audio_data_uri(audio_value) |
| 263 | + if not audio_data_uri: |
| 264 | + msg = "Failed to load audio file. Please check the audio input." |
| 265 | + raise ValueError(msg) |
| 266 | + |
| 267 | + language = self.get_parameter_value("language") |
| 268 | + prompt = self.get_parameter_value("prompt") |
| 269 | + response_format = self.get_parameter_value("response_format") or DEFAULT_RESPONSE_FORMAT |
| 270 | + temperature = self.get_parameter_value("temperature") |
| 271 | + |
| 272 | + payload: dict[str, Any] = { |
| 273 | + "file": audio_data_uri, |
| 274 | + "response_format": response_format, |
| 275 | + } |
| 276 | + |
| 277 | + if language: |
| 278 | + payload["language"] = language |
| 279 | + |
| 280 | + if prompt: |
| 281 | + payload["prompt"] = prompt |
| 282 | + |
| 283 | + if temperature is not None and temperature != 0.0: |
| 284 | + payload["temperature"] = temperature |
| 285 | + |
| 286 | + if response_format == "verbose_json": |
| 287 | + payload["timestamp_granularities"] = ["word", "segment"] |
| 288 | + |
| 289 | + self._log("Built transcription payload") |
| 290 | + return payload |
| 291 | + |
| 292 | + async def _parse_result(self, result_json: dict[str, Any], generation_id: str) -> None: |
| 293 | + """Parse the OpenAI transcription result and set output parameters.""" |
| 294 | + text = result_json.get("text") |
| 295 | + if text is None: |
| 296 | + self._set_safe_defaults() |
| 297 | + self._set_status_results( |
| 298 | + was_successful=False, |
| 299 | + result_details="Transcription completed but no text was found in the response.", |
| 300 | + ) |
| 301 | + return |
| 302 | + |
| 303 | + self.parameter_output_values["output"] = text |
| 304 | + |
| 305 | + # Verbose JSON fields |
| 306 | + words = result_json.get("words") |
| 307 | + if words is not None: |
| 308 | + self.parameter_output_values["words"] = words |
| 309 | + |
| 310 | + segments = result_json.get("segments") |
| 311 | + if segments is not None: |
| 312 | + self.parameter_output_values["segments"] = segments |
| 313 | + |
| 314 | + detected_language = result_json.get("language") |
| 315 | + if detected_language is not None: |
| 316 | + self.parameter_output_values["detected_language"] = detected_language |
| 317 | + |
| 318 | + duration = result_json.get("duration") |
| 319 | + if duration is not None: |
| 320 | + self.parameter_output_values["duration"] = duration |
| 321 | + |
| 322 | + self._set_status_results(was_successful=True, result_details="Transcription completed successfully.") |
| 323 | + |
| 324 | + def _set_safe_defaults(self) -> None: |
| 325 | + """Set safe default values for outputs.""" |
| 326 | + self.parameter_output_values["generation_id"] = "" |
| 327 | + self.parameter_output_values["provider_response"] = None |
| 328 | + self.parameter_output_values["output"] = None |
| 329 | + self.parameter_output_values["words"] = None |
| 330 | + self.parameter_output_values["segments"] = None |
| 331 | + self.parameter_output_values["detected_language"] = None |
| 332 | + self.parameter_output_values["duration"] = None |
0 commit comments