Stream Whisper uploads with curl so large audio does not OOM.

This commit is contained in:
ben
2026-08-13 20:29:59 +02:00
parent 1898be4def
commit e77a18c49f
7 changed files with 488 additions and 42 deletions
+120
View File
@@ -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);
}
}
}