| @@ -118,8 +118,78 @@ | ||
| 118 | 118 | ); |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | 121 | /** |
| 122 | + * Transcribe audio or video. | |
| 123 | + * | |
| 124 | + * Gemini has no dedicated speech endpoint — the media rides in the ordinary | |
| 125 | + * `generateContent` call as an `inline_data` part beside a text instruction, | |
| 126 | + * and the model returns the transcript as its answer. Inline data is base64, | |
| 127 | + * which inflates the payload by roughly a third; the caller caps media at | |
| 128 | + * 15 MB for this platform so the request stays inside Gemini's inline | |
| 129 | + * ceiling. (Files API upload would lift that, and is the obvious next step | |
| 130 | + * if longer recordings are ever needed.) | |
| 131 | + * | |
| 132 | + * @param array $file `[ 'path', 'filename', 'mime' ]` | |
| 133 | + * @param array $options `[ 'model', 'timeout', 'max_tokens' ]` | |
| 134 | + * @return string|\WP_Error | |
| 135 | + */ | |
| 136 | + public function transcribe( $file, $options = array() ) { | |
| 137 | + if ( empty( $this->api_key ) ) { | |
| 138 | + return new \WP_Error( 'no_api_key', sprintf( __( '%s API key is not configured.', 'betterdocs' ), $this->label() ) ); | |
| 139 | + } | |
| 140 | + | |
| 141 | + $bytes = @file_get_contents( $file['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp upload, not a remote fetch. | |
| 142 | + if ( false === $bytes ) { | |
| 143 | + return new \WP_Error( 'api_error', __( 'Could not read the uploaded file.', 'betterdocs' ) ); | |
| 144 | + } | |
| 145 | + | |
| 146 | + $model = ! empty( $options['model'] ) ? (string) $options['model'] : 'gemini-flash-latest'; | |
| 147 | + $timeout = isset( $options['timeout'] ) ? (int) $options['timeout'] : 120; | |
| 148 | + | |
| 149 | + $payload = array( | |
| 150 | + 'contents' => array( | |
| 151 | + array( | |
| 152 | + 'role' => 'user', | |
| 153 | + 'parts' => array( | |
| 154 | + array( 'text' => __( 'Transcribe the speech in this recording verbatim as plain text. Do not summarise, translate or add commentary. If there is no speech, reply with nothing at all.', 'betterdocs' ) ), | |
| 155 | + array( 'inline_data' => array( | |
| 156 | + 'mime_type' => $file['mime'], | |
| 157 | + 'data' => base64_encode( $bytes ), | |
| 158 | + ) ), | |
| 159 | + ), | |
| 160 | + ), | |
| 161 | + ), | |
| 162 | + 'generationConfig' => array( | |
| 163 | + 'maxOutputTokens' => isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : 8192, | |
| 164 | + ), | |
| 165 | + ); | |
| 166 | + | |
| 167 | + unset( $bytes ); | |
| 168 | + | |
| 169 | + $url = $this->base_url() . '/models/' . rawurlencode( $model ) . ':generateContent'; | |
| 170 | + $status = null; | |
| 171 | + $data = $this->post_json( | |
| 172 | + $url, | |
| 173 | + array( 'Content-Type' => 'application/json', 'x-goog-api-key' => $this->api_key ), | |
| 174 | + $payload, | |
| 175 | + $timeout, | |
| 176 | + $status | |
| 177 | + ); | |
| 178 | + | |
| 179 | + if ( is_wp_error( $data ) ) { | |
| 180 | + return $data; | |
| 181 | + } | |
| 182 | + | |
| 183 | + if ( ! empty( $data['error'] ) ) { | |
| 184 | + $raw = isset( $data['error']['message'] ) ? $data['error']['message'] : __( 'Unknown API error.', 'betterdocs' ); | |
| 185 | + return new \WP_Error( 'provider_error', $this->classify_http_error( $status, $raw, $model ) ); | |
| 186 | + } | |
| 187 | + | |
| 188 | + return $this->extract_text( $data ); | |
| 189 | + } | |
| 190 | + | |
| 191 | + /** | |
| 122 | 192 | * Concatenate all text parts of the first candidate. |
| 123 | 193 | * |
| 124 | 194 | * @param array $data |
| 125 | 195 | * @return string |