Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
352 lines
14 KiB
PHP
352 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Events\RecordingTranscriptionUpdated;
|
|
use App\Jobs\TranscribeRecording;
|
|
use App\Models\Recording;
|
|
use App\Models\User;
|
|
use App\Services\TranscriptionService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Laravel\Ai\Transcription;
|
|
use Tests\TestCase;
|
|
|
|
class TranscriptionBroadcastTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected User $user;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->actingAs($this->user);
|
|
}
|
|
|
|
public function test_report_progress_broadcasts_transcription_updated(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Broadcast progress',
|
|
'original_filename' => 'progress.mp3',
|
|
'file_path' => 'recordings/progress.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
$recording->reportProgress('Transcribing locally…', 50);
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
|
|
return $event->recording->is($recording)
|
|
&& $event->broadcastWith()['percent'] === 50
|
|
&& $event->broadcastWith()['progress'] === 'Transcribing locally…';
|
|
});
|
|
}
|
|
|
|
public function test_successful_transcription_broadcasts_done_status(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('recordings/sample.mp3', 'fake-audio-bytes');
|
|
|
|
Transcription::fake(['Hello from the recorder.']);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Sample',
|
|
'original_filename' => 'sample.mp3',
|
|
'file_path' => 'recordings/sample.mp3',
|
|
'file_size_bytes' => 12,
|
|
'transcription_status' => 'pending',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
|
|
$payload = $event->broadcastWith();
|
|
|
|
return $event->recording->is($recording)
|
|
&& $payload['status'] === 'done'
|
|
&& $payload['percent'] === 100
|
|
&& $payload['has_transcript'] === true
|
|
&& ($payload['word_count'] ?? 0) > 0;
|
|
});
|
|
}
|
|
|
|
public function test_failed_transcription_broadcasts_failure(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('recordings/bad.mp3', 'fake-audio-bytes');
|
|
|
|
Transcription::fake(function () {
|
|
throw new \RuntimeException('Provider unavailable');
|
|
});
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Bad',
|
|
'original_filename' => 'bad.mp3',
|
|
'file_path' => 'recordings/bad.mp3',
|
|
'file_size_bytes' => 12,
|
|
'transcription_status' => 'pending',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
try {
|
|
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
|
|
$this->fail('Expected transcription to throw');
|
|
} catch (\RuntimeException $e) {
|
|
$this->assertSame('Provider unavailable', $e->getMessage());
|
|
}
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
|
|
$payload = $event->broadcastWith();
|
|
|
|
return $event->recording->is($recording)
|
|
&& $payload['status'] === 'failed'
|
|
&& $payload['error'] === 'Provider unavailable';
|
|
});
|
|
}
|
|
|
|
public function test_cancel_transcription_broadcasts_cancelled_status(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Cancel me',
|
|
'original_filename' => 'cancel.mp3',
|
|
'file_path' => 'recordings/cancel.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
$recording->cancelTranscription();
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
|
|
return $event->recording->is($recording)
|
|
&& $event->broadcastWith()['status'] === 'cancelled';
|
|
});
|
|
}
|
|
|
|
public function test_broadcast_event_uses_private_recording_channel(): void
|
|
{
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Channel',
|
|
'original_filename' => 'channel.mp3',
|
|
'file_path' => 'recordings/channel.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'pending',
|
|
]);
|
|
|
|
$event = new RecordingTranscriptionUpdated($recording);
|
|
|
|
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
|
|
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
|
|
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
|
|
}
|
|
|
|
public function test_broadcast_payload_omits_transcript_text(): void
|
|
{
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Long text',
|
|
'original_filename' => 'long.mp3',
|
|
'file_path' => 'recordings/long.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_percent' => 70,
|
|
'transcript' => str_repeat('word ', 5000),
|
|
]);
|
|
|
|
$payload = (new RecordingTranscriptionUpdated($recording))->broadcastWith();
|
|
|
|
$this->assertArrayNotHasKey('transcript', $payload);
|
|
$this->assertArrayNotHasKey('whisper', $payload);
|
|
$this->assertTrue($payload['has_transcript']);
|
|
$this->assertSame(70, $payload['percent']);
|
|
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
|
|
}
|
|
|
|
public function test_report_progress_broadcasts_transcript_deltas_for_append(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Deltas',
|
|
'original_filename' => 'delta.mp3',
|
|
'file_path' => 'recordings/delta.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
'transcript' => 'Previous finished transcript',
|
|
]);
|
|
|
|
$recording->reportProgress('Transcribing locally…', 55, partialTranscript: 'Hello ');
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
|
$payload = $event->broadcastWith();
|
|
|
|
return ($payload['transcript_delta'] ?? null) === 'Hello '
|
|
&& ($payload['transcript_replace'] ?? false) === true
|
|
&& ! array_key_exists('transcript', $payload);
|
|
});
|
|
|
|
$recording->refresh()->reportProgress('Transcribing locally…', 60, partialTranscript: 'Hello from whisper.');
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
|
$payload = $event->broadcastWith();
|
|
|
|
return ($payload['transcript_delta'] ?? null) === 'from whisper.'
|
|
&& ($payload['transcript_replace'] ?? true) === false;
|
|
});
|
|
}
|
|
|
|
public function test_report_progress_broadcasts_whisper_delta_without_full_verbose_snapshot(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Verbose live',
|
|
'original_filename' => 'delta.mp3',
|
|
'file_path' => 'recordings/delta.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: 'Hello', whisperDelta: [
|
|
'language' => 'en',
|
|
'duration' => 12.5,
|
|
'segment' => [
|
|
'id' => 0,
|
|
'start' => 0.0,
|
|
'end' => 1.2,
|
|
'text' => 'Hello',
|
|
'avg_logprob' => -0.2,
|
|
'words' => [
|
|
['word' => 'Hello', 'start' => 0.0, 'end' => 1.2, 'probability' => 0.91],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$recording->refresh();
|
|
$this->assertSame('en', $recording->transcription_verbose['language']);
|
|
$this->assertCount(1, $recording->transcription_verbose['segments']);
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
|
$payload = $event->broadcastWith();
|
|
|
|
return ($payload['whisper_delta']['language'] ?? null) === 'en'
|
|
&& ($payload['whisper_delta']['segment']['text'] ?? null) === 'Hello'
|
|
&& ! array_key_exists('whisper', $payload)
|
|
&& ! array_key_exists('transcript', $payload)
|
|
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? []);
|
|
});
|
|
}
|
|
|
|
public function test_full_whisper_segments_snapshot_is_persisted_but_not_broadcast(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Snapshot',
|
|
'original_filename' => 'snap.mp3',
|
|
'file_path' => 'recordings/snap.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
$recording->reportProgress('Transcribing locally…', 90, partialTranscript: 'Hello world', whisperDelta: [
|
|
'language' => 'en',
|
|
'duration' => 4.2,
|
|
'segments' => [
|
|
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
|
['id' => 1, 'text' => 'world', 'start' => 1.0, 'end' => 2.0],
|
|
],
|
|
]);
|
|
|
|
$recording->refresh();
|
|
$this->assertCount(2, $recording->transcription_verbose['segments']);
|
|
|
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
|
$payload = $event->broadcastWith();
|
|
|
|
return ($payload['whisper_delta']['language'] ?? null) === 'en'
|
|
&& ($payload['whisper_delta']['duration'] ?? null) === 4.2
|
|
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? [])
|
|
&& ! array_key_exists('whisper', $payload);
|
|
});
|
|
}
|
|
|
|
public function test_report_progress_replaces_verbose_segments_when_snapshot_arrives(): void
|
|
{
|
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Replace segments',
|
|
'original_filename' => 'replace.mp3',
|
|
'file_path' => 'recordings/replace.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
$recording->reportProgress('Transcribing locally…', 40, whisperDelta: [
|
|
'segment' => ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
|
]);
|
|
$recording->refresh()->reportProgress('Transcribing locally…', 99, whisperDelta: [
|
|
'language' => 'en',
|
|
'segments' => [
|
|
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'avg_logprob' => -0.1],
|
|
],
|
|
]);
|
|
|
|
$this->assertCount(1, $recording->refresh()->transcription_verbose['segments']);
|
|
$this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']);
|
|
}
|
|
|
|
public function test_oversized_transcript_delta_is_not_broadcast(): void
|
|
{
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'title' => 'Huge delta',
|
|
'original_filename' => 'huge.mp3',
|
|
'file_path' => 'recordings/huge.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
]);
|
|
|
|
$huge = str_repeat('a', RecordingTranscriptionUpdated::MAX_DELTA_BYTES + 1);
|
|
$payload = (new RecordingTranscriptionUpdated($recording, $huge, true))->broadcastWith();
|
|
|
|
$this->assertArrayNotHasKey('transcript_delta', $payload);
|
|
$this->assertArrayNotHasKey('transcript', $payload);
|
|
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
|
|
}
|
|
}
|