Stream Whisper uploads with curl so large audio does not OOM.
This commit is contained in:
@@ -99,6 +99,8 @@ VITE_REVERB_SCHEME=http
|
|||||||
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
||||||
LOCAL_WHISPER_API_KEY=not-needed
|
LOCAL_WHISPER_API_KEY=not-needed
|
||||||
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
||||||
|
# curl streams large uploads off disk; http is for tests (Http::fake)
|
||||||
|
LOCAL_WHISPER_TRANSPORT=curl
|
||||||
TRANSCRIPTION_TIMEOUT=600
|
TRANSCRIPTION_TIMEOUT=600
|
||||||
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
|
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
|
||||||
DB_QUEUE_RETRY_AFTER=660
|
DB_QUEUE_RETRY_AFTER=660
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ namespace App\Services;
|
|||||||
use App\Models\Recording;
|
use App\Models\Recording;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Http\Client\Response;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Ai\Transcription;
|
use Laravel\Ai\Transcription;
|
||||||
@@ -13,7 +12,10 @@ use RuntimeException;
|
|||||||
|
|
||||||
class TranscriptionService
|
class TranscriptionService
|
||||||
{
|
{
|
||||||
public function __construct(private WhisperTranscriptionStream $stream) {}
|
public function __construct(
|
||||||
|
private WhisperTranscriptionStream $stream,
|
||||||
|
private LocalWhisperCurlClient $curl,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transcribe a recording with the local faster-whisper server.
|
* Transcribe a recording with the local faster-whisper server.
|
||||||
@@ -56,8 +58,31 @@ class TranscriptionService
|
|||||||
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
|
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
|
||||||
$filename = $recording->original_filename ?: basename($path);
|
$filename = $recording->original_filename ?: basename($path);
|
||||||
|
|
||||||
|
if ($this->usesHttpTransport()) {
|
||||||
|
return $this->transcribeViaHttp($recording, $report, $message, $model, $filename, $path, $shouldContinue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->transcribeViaCurl($recording, $report, $message, $model, $filename, $path, $shouldContinue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laravel Http client path (used in tests via Http::fake).
|
||||||
|
*
|
||||||
|
* Does not set Guzzle stream => true, which would string-cast the multipart upload.
|
||||||
|
*
|
||||||
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
|
* @param (Closure(): bool)|null $shouldContinue
|
||||||
|
*/
|
||||||
|
private function transcribeViaHttp(
|
||||||
|
Recording $recording,
|
||||||
|
Closure $report,
|
||||||
|
string $message,
|
||||||
|
string $model,
|
||||||
|
string $filename,
|
||||||
|
string $path,
|
||||||
|
?Closure $shouldContinue,
|
||||||
|
): string {
|
||||||
$response = $this->localWhisperRequest($filename, $path)
|
$response = $this->localWhisperRequest($filename, $path)
|
||||||
->withOptions(['stream' => true])
|
|
||||||
->post('audio/transcriptions', [
|
->post('audio/transcriptions', [
|
||||||
'model' => $model,
|
'model' => $model,
|
||||||
'response_format' => 'verbose_json',
|
'response_format' => 'verbose_json',
|
||||||
@@ -68,7 +93,7 @@ class TranscriptionService
|
|||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
|
'Local transcription failed (HTTP '.$response->status().'): '.$this->truncateBody($response->body())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,55 +103,79 @@ class TranscriptionService
|
|||||||
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
||||||
'recording_id' => $recording->id,
|
'recording_id' => $recording->id,
|
||||||
'content_type' => $contentType,
|
'content_type' => $contentType,
|
||||||
|
'transport' => 'http',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->extractTranscriptText($response, $report, $message);
|
return $this->extractTranscriptText($response->json(), $report, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->consumeWhisperStream($response, $report, $message, $recording, $shouldContinue);
|
return $this->consumeSseBody($response->body(), $report, $message, $recording, $shouldContinue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Curl path for production: streams the file upload and SSE response chunks.
|
||||||
|
*
|
||||||
* @param Closure(string, int, ?string, ?array): void $report
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
* @param (Closure(): bool)|null $shouldContinue
|
* @param (Closure(): bool)|null $shouldContinue
|
||||||
*/
|
*/
|
||||||
private function consumeWhisperStream(
|
private function transcribeViaCurl(
|
||||||
Response $response,
|
Recording $recording,
|
||||||
Closure $report,
|
Closure $report,
|
||||||
string $message,
|
string $message,
|
||||||
Recording $recording,
|
string $model,
|
||||||
|
string $filename,
|
||||||
|
string $path,
|
||||||
?Closure $shouldContinue,
|
?Closure $shouldContinue,
|
||||||
): string {
|
): string {
|
||||||
$body = $response->toPsrResponse()->getBody();
|
$config = config('ai.providers.local-whisper');
|
||||||
|
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
|
||||||
|
$timeout = (int) config('ai.transcription_timeout', 600);
|
||||||
|
$apiKey = (string) ($config['key'] ?? 'not-needed');
|
||||||
|
|
||||||
$buffer = '';
|
$buffer = '';
|
||||||
|
$rawBody = '';
|
||||||
$accumulated = '';
|
$accumulated = '';
|
||||||
$lastPercent = 0;
|
$lastPercent = 0;
|
||||||
$idleReads = 0;
|
$isSse = null;
|
||||||
|
|
||||||
try {
|
$result = $this->curl->streamTranscription(
|
||||||
while (! $body->eof()) {
|
$baseUrl.'/audio/transcriptions',
|
||||||
|
$apiKey,
|
||||||
|
$path,
|
||||||
|
$filename,
|
||||||
|
$recording->audioMimeType(),
|
||||||
|
$model,
|
||||||
|
$timeout,
|
||||||
|
function (string $chunk) use (
|
||||||
|
&$buffer,
|
||||||
|
&$rawBody,
|
||||||
|
&$accumulated,
|
||||||
|
&$lastPercent,
|
||||||
|
&$isSse,
|
||||||
|
$report,
|
||||||
|
$message,
|
||||||
|
$recording,
|
||||||
|
$shouldContinue,
|
||||||
|
): bool {
|
||||||
if ($shouldContinue !== null && ! $shouldContinue()) {
|
if ($shouldContinue !== null && ! $shouldContinue()) {
|
||||||
$body->close();
|
return false;
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$chunk = $body->read(8192);
|
if ($isSse === null) {
|
||||||
|
$rawBody .= $chunk;
|
||||||
|
|
||||||
if ($chunk === '') {
|
return true;
|
||||||
$idleReads++;
|
|
||||||
|
|
||||||
if ($idleReads >= 40) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usleep(50_000);
|
if ($isSse === false) {
|
||||||
|
$rawBody .= $chunk;
|
||||||
|
|
||||||
continue;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$idleReads = 0;
|
if ($rawBody !== '') {
|
||||||
$buffer .= $chunk;
|
$buffer .= $rawBody;
|
||||||
|
$rawBody = '';
|
||||||
|
|
||||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
@@ -140,6 +189,63 @@ class TranscriptionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$buffer .= $chunk;
|
||||||
|
|
||||||
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||||
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
|
$payload,
|
||||||
|
$accumulated,
|
||||||
|
$lastPercent,
|
||||||
|
$recording,
|
||||||
|
$report,
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
function (string $contentType) use (&$isSse): void {
|
||||||
|
$isSse = str_contains(strtolower($contentType), 'text/event-stream');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['status'] >= 400 || $result['status'] === 0) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Local transcription failed (HTTP '.$result['status'].'): '.$this->truncateBody($rawBody)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = strtolower($result['content_type']);
|
||||||
|
$isSse ??= str_contains($contentType, 'text/event-stream');
|
||||||
|
|
||||||
|
if (! $isSse) {
|
||||||
|
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
||||||
|
'recording_id' => $recording->id,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
'transport' => 'curl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = json_decode($rawBody, true);
|
||||||
|
|
||||||
|
return $this->extractTranscriptText(is_array($json) ? $json : null, $report, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rawBody !== '') {
|
||||||
|
$buffer .= $rawBody;
|
||||||
|
$rawBody = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||||
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
|
$payload,
|
||||||
|
$accumulated,
|
||||||
|
$lastPercent,
|
||||||
|
$recording,
|
||||||
|
$report,
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
||||||
[$accumulated, $lastPercent] = $this->ingestEvent(
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
$payload,
|
$payload,
|
||||||
@@ -150,8 +256,58 @@ class TranscriptionService
|
|||||||
$message,
|
$message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
$response->close();
|
if ($accumulated === '') {
|
||||||
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $accumulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function usesHttpTransport(): bool
|
||||||
|
{
|
||||||
|
return config('ai.local_whisper_transport', 'curl') === 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
|
* @param (Closure(): bool)|null $shouldContinue
|
||||||
|
*/
|
||||||
|
private function consumeSseBody(
|
||||||
|
string $body,
|
||||||
|
Closure $report,
|
||||||
|
string $message,
|
||||||
|
Recording $recording,
|
||||||
|
?Closure $shouldContinue,
|
||||||
|
): string {
|
||||||
|
if ($shouldContinue !== null && ! $shouldContinue()) {
|
||||||
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer = $body;
|
||||||
|
$accumulated = '';
|
||||||
|
$lastPercent = 0;
|
||||||
|
|
||||||
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||||
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
|
$payload,
|
||||||
|
$accumulated,
|
||||||
|
$lastPercent,
|
||||||
|
$recording,
|
||||||
|
$report,
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
||||||
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
|
$payload,
|
||||||
|
$accumulated,
|
||||||
|
$lastPercent,
|
||||||
|
$recording,
|
||||||
|
$report,
|
||||||
|
$message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($accumulated === '') {
|
if ($accumulated === '') {
|
||||||
@@ -227,12 +383,11 @@ class TranscriptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param array<string, mixed>|null $json
|
||||||
* @param Closure(string, int, ?string, ?array): void $report
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
*/
|
*/
|
||||||
private function extractTranscriptText(Response $response, ?Closure $report = null, string $message = ''): string
|
private function extractTranscriptText(?array $json, ?Closure $report = null, string $message = ''): string
|
||||||
{
|
{
|
||||||
$json = $response->json();
|
|
||||||
|
|
||||||
if (! is_array($json)) {
|
if (! is_array($json)) {
|
||||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||||
}
|
}
|
||||||
@@ -251,4 +406,15 @@ class TranscriptionService
|
|||||||
|
|
||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function truncateBody(string $body): string
|
||||||
|
{
|
||||||
|
$body = trim($body);
|
||||||
|
|
||||||
|
if (strlen($body) <= 2000) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($body, 0, 2000).'…';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ return [
|
|||||||
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
|
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
|
||||||
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
|
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| "curl" streams large uploads off disk (default). "http" uses Laravel's
|
||||||
|
| HTTP client and is intended for Http::fake() in tests.
|
||||||
|
*/
|
||||||
|
'local_whisper_transport' => env('LOCAL_WHISPER_TRANSPORT', 'curl'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Caching
|
| Caching
|
||||||
|
|||||||
@@ -36,5 +36,6 @@
|
|||||||
<env name="REVERB_PUBLIC_HOST" value="reverb.testing.example" force="true"/>
|
<env name="REVERB_PUBLIC_HOST" value="reverb.testing.example" force="true"/>
|
||||||
<env name="REVERB_PUBLIC_PORT" value="443" force="true"/>
|
<env name="REVERB_PUBLIC_PORT" value="443" force="true"/>
|
||||||
<env name="REVERB_PUBLIC_SCHEME" value="https" force="true"/>
|
<env name="REVERB_PUBLIC_SCHEME" value="https" force="true"/>
|
||||||
|
<env name="LOCAL_WHISPER_TRANSPORT" value="http"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Recording;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\TranscriptionService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LocalWhisperCurlTransportTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_curl_transport_streams_sse_from_real_http_endpoint(): void
|
||||||
|
{
|
||||||
|
if (! function_exists('curl_init')) {
|
||||||
|
$this->markTestSkipped('curl extension required');
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::fake('local');
|
||||||
|
Storage::disk('local')->put('recordings/large.mp3', str_repeat('A', 2_000_000));
|
||||||
|
|
||||||
|
$stub = base_path('tests/fixtures/whisper-sse-stub.php');
|
||||||
|
$host = '127.0.0.1';
|
||||||
|
$port = $this->reservePort($host);
|
||||||
|
$docRoot = base_path('tests/fixtures');
|
||||||
|
|
||||||
|
$server = Process::timeout(30)
|
||||||
|
->path($docRoot)
|
||||||
|
->start([
|
||||||
|
PHP_BINARY,
|
||||||
|
'-S',
|
||||||
|
"{$host}:{$port}",
|
||||||
|
'whisper-sse-stub.php',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->waitForServer($host, $port);
|
||||||
|
|
||||||
|
config([
|
||||||
|
'ai.local_whisper_transport' => 'curl',
|
||||||
|
'ai.providers.local-whisper.url' => "http://{$host}:{$port}/v1",
|
||||||
|
'ai.providers.local-whisper.key' => 'test-key',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Curl transport',
|
||||||
|
'original_filename' => 'large.mp3',
|
||||||
|
'file_path' => 'recordings/large.mp3',
|
||||||
|
'file_size_bytes' => 2_000_000,
|
||||||
|
'duration_seconds' => 40,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$partials = [];
|
||||||
|
|
||||||
|
$text = app(TranscriptionService::class)->transcribe(
|
||||||
|
$recording,
|
||||||
|
function (string $message, int $percent, ?string $partial = null) use (&$partials): void {
|
||||||
|
if ($partial !== null) {
|
||||||
|
$partials[] = $partial;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('Hello from whisper.', $text);
|
||||||
|
$this->assertContains('Hello ', $partials);
|
||||||
|
$this->assertContains('Hello from whisper.', $partials);
|
||||||
|
} finally {
|
||||||
|
$server->signal(SIGTERM);
|
||||||
|
$server->wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reservePort(string $host): int
|
||||||
|
{
|
||||||
|
$socket = stream_socket_server("tcp://{$host}:0");
|
||||||
|
|
||||||
|
if ($socket === false) {
|
||||||
|
$this->fail('Unable to reserve a local TCP port for the Whisper stub.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = stream_socket_get_name($socket, false);
|
||||||
|
fclose($socket);
|
||||||
|
|
||||||
|
if (! is_string($name) || ! str_contains($name, ':')) {
|
||||||
|
$this->fail('Unable to determine reserved port.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) substr($name, strrpos($name, ':') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitForServer(string $host, int $port): void
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + 5;
|
||||||
|
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
$errno = 0;
|
||||||
|
$errstr = '';
|
||||||
|
$fp = @fsockopen($host, $port, $errno, $errstr, 0.2);
|
||||||
|
|
||||||
|
if (is_resource($fp)) {
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(50_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fail("Whisper stub server did not start on {$host}:{$port}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny Whisper-compatible stub for curl transport tests.
|
||||||
|
* Speaks OpenAI-style /v1/audio/transcriptions SSE.
|
||||||
|
*/
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
|
|
||||||
|
if ($uri !== '/v1/audio/transcriptions' && $uri !== '/audio/transcriptions') {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo '{"error":"not found"}';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the multipart body without keeping it in a string longer than needed.
|
||||||
|
$stdin = fopen('php://input', 'rb');
|
||||||
|
if (is_resource($stdin)) {
|
||||||
|
while (! feof($stdin)) {
|
||||||
|
fread($stdin, 8192);
|
||||||
|
}
|
||||||
|
fclose($stdin);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: text/event-stream');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
|
||||||
|
echo "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \",\"end\":10}\n\n";
|
||||||
|
echo "data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\",\"end\":30}\n\n";
|
||||||
|
echo "data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\",\"end\":40}\n\n";
|
||||||
|
flush();
|
||||||
Reference in New Issue
Block a user