Stream Whisper uploads with curl so large audio does not OOM.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Closure;
|
||||
use CURLFile;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Streams large audio uploads to Whisper with curl (disk → network),
|
||||
* and delivers response body chunks without buffering the request in PHP.
|
||||
*
|
||||
* Laravel Http's stream => true uses Guzzle's StreamHandler, which casts the
|
||||
* multipart body to a string and OOMs on big recordings.
|
||||
*/
|
||||
class LocalWhisperCurlClient
|
||||
{
|
||||
/**
|
||||
* POST /audio/transcriptions and stream the response body.
|
||||
*
|
||||
* @param Closure(string): bool $onBodyChunk Return false to abort the transfer.
|
||||
* @param Closure(string): void|null $onContentType Invoked when the response Content-Type header arrives.
|
||||
* @return array{status: int, content_type: string}
|
||||
*/
|
||||
public function streamTranscription(
|
||||
string $url,
|
||||
string $apiKey,
|
||||
string $path,
|
||||
string $filename,
|
||||
string $mimeType,
|
||||
string $model,
|
||||
int $timeout,
|
||||
Closure $onBodyChunk,
|
||||
?Closure $onContentType = null,
|
||||
): array {
|
||||
if (! function_exists('curl_init')) {
|
||||
throw new RuntimeException('The curl PHP extension is required for local Whisper transcription.');
|
||||
}
|
||||
|
||||
$contentType = '';
|
||||
$abort = false;
|
||||
|
||||
$handle = curl_init($url);
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to initialize curl for local Whisper.');
|
||||
}
|
||||
|
||||
try {
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer '.$apiKey,
|
||||
'Accept: application/json, text/event-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => [
|
||||
'file' => new CURLFile($path, $mimeType, $filename),
|
||||
'model' => $model,
|
||||
'response_format' => 'verbose_json',
|
||||
'stream' => 'true',
|
||||
'without_timestamps' => 'false',
|
||||
'timestamp_granularities[]' => 'word',
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_HEADER => false,
|
||||
CURLOPT_TIMEOUT => max(1, $timeout),
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HEADERFUNCTION => static function ($ch, string $header) use (&$contentType, $onContentType): int {
|
||||
if (stripos($header, 'Content-Type:') === 0) {
|
||||
$contentType = trim(substr($header, strlen('Content-Type:')));
|
||||
|
||||
if ($onContentType !== null) {
|
||||
$onContentType($contentType);
|
||||
}
|
||||
}
|
||||
|
||||
return strlen($header);
|
||||
},
|
||||
CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use ($onBodyChunk, &$abort): int {
|
||||
if ($abort) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($onBodyChunk($chunk) === false) {
|
||||
$abort = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strlen($chunk);
|
||||
},
|
||||
]);
|
||||
|
||||
$ok = curl_exec($handle);
|
||||
$errno = curl_errno($handle);
|
||||
$error = curl_error($handle);
|
||||
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
|
||||
if ($abort) {
|
||||
return [
|
||||
'status' => $status > 0 ? $status : 499,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
if ($ok === false && $errno !== 0) {
|
||||
throw new RuntimeException(
|
||||
'Local transcription request failed: '.$error.' (curl '.$errno.')'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
} finally {
|
||||
curl_close($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user