Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
emoji-decoration from a song's description while preserving every
language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.
Admin pages:
- /admin/lyrics toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu extracted GPU section, encoder picker, FFmpeg path
- /admin/backup extracted users-and-settings export/import
- /admin/settings now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.
Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
and /videos/{id}?playlist={token}. Dispatched after-response so it
never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.
Player polish:
- Floating mini-player is draggable, persists its position in
localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
(channel tabs, etc.) gets re-executed after content swaps.
Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
140 lines
5.2 KiB
PHP
140 lines
5.2 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Video;
|
|
use App\Models\VideoAudioTrack;
|
|
use App\Services\LlmLyricsService;
|
|
use App\Services\NasSyncService;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Bake heavy emoji decoration into the saved lyrics JSON using the active LLM.
|
|
* Original words are preserved verbatim; emojis are layered on top (in-line +
|
|
* trailing, multiple per line) per the admin's decoration prompt.
|
|
*
|
|
* Runs as its own job so a flaky LLM call can never delay or fail a successful
|
|
* transcription. Safe to re-run — already-decorated lines are skipped, so a
|
|
* second pass only fills in gaps.
|
|
*/
|
|
class DecorateLyricsJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $timeout = 600;
|
|
public int $tries = 1;
|
|
|
|
/** Languages written without spaces between words (mirrors transcribe.py). */
|
|
private const SPACELESS_LANGS = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];
|
|
|
|
public function __construct(public int $videoId, public ?int $trackId = null)
|
|
{
|
|
$this->onQueue('video-processing');
|
|
}
|
|
|
|
public function handle(LlmLyricsService $llm, NasSyncService $nas): void
|
|
{
|
|
// Two-layer toggle: the admin's per-pipeline switch (Lyrics Pipeline
|
|
// page) gates this job, and the LLM-service-level switch (AI/LLM page)
|
|
// gates the LLM call inside it. Either being OFF skips decoration.
|
|
if (\App\Models\Setting::get('lyrics_llm_decorate', 'true') !== 'true') return;
|
|
if (! $llm->decorateEnabled()) return;
|
|
|
|
$video = Video::find($this->videoId);
|
|
if (! $video) return;
|
|
|
|
$track = $this->trackId ? VideoAudioTrack::find($this->trackId) : null;
|
|
if ($this->trackId && ! $track) return;
|
|
|
|
$data = $nas->getLyrics($video, $track);
|
|
if (! is_array($data) || empty($data['lines'])) return;
|
|
if (($data['status'] ?? null) !== 'ready') return;
|
|
|
|
// Decorate only lines that haven't been decorated yet — a re-run fills
|
|
// gaps cheaply instead of re-stamping the whole song.
|
|
$texts = [];
|
|
$indices = [];
|
|
foreach ($data['lines'] as $i => $ln) {
|
|
if (! empty($ln['decorated'])) continue;
|
|
$t = (string) ($ln['text'] ?? '');
|
|
if (trim($t) === '') continue;
|
|
$texts[] = $t;
|
|
$indices[] = $i;
|
|
}
|
|
if (! $texts) return;
|
|
|
|
try {
|
|
$decorated = $llm->decorateLines($texts);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('DecorateLyricsJob: LLM call failed: ' . $e->getMessage(), [
|
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
|
]);
|
|
return;
|
|
}
|
|
if (! $decorated) return;
|
|
|
|
$applied = 0;
|
|
foreach ($decorated as $localIdx => $newText) {
|
|
if (! isset($indices[$localIdx])) continue;
|
|
$globalIdx = $indices[$localIdx];
|
|
$line = &$data['lines'][$globalIdx];
|
|
|
|
$line['text'] = $newText;
|
|
$line['decorated'] = true;
|
|
// The words array no longer matches the new (emoji-laced) text. We
|
|
// redistribute the existing [start,end] window evenly across the
|
|
// new tokens so the karaoke word-highlight still tracks the audio.
|
|
// Tokens that are pure emoji get the same per-slot timing as words.
|
|
$lang = (string) ($line['lang'] ?? ($data['language'] ?? 'en'));
|
|
$line['words'] = $this->redistributeWords(
|
|
(float) ($line['start'] ?? 0),
|
|
(float) ($line['end'] ?? 0),
|
|
$newText, $lang
|
|
);
|
|
unset($line);
|
|
$applied++;
|
|
}
|
|
|
|
if (! $applied) return;
|
|
|
|
$data['decorated_at'] = now()->toIso8601String();
|
|
$nas->putLyrics($video, $track, $data);
|
|
|
|
Log::info('DecorateLyricsJob: done', [
|
|
'video_id' => $this->videoId, 'track_id' => $this->trackId,
|
|
'decorated' => $applied,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Evenly distribute [start,end] across the line's tokens. Words for spaced
|
|
* languages, characters for spaceless scripts (Thai/CJK/…). Used after
|
|
* decoration so the karaoke word-highlight still tracks the audio.
|
|
*/
|
|
private function redistributeWords(float $start, float $end, string $text, string $lang): array
|
|
{
|
|
if ($text === '' || $end <= $start) return [];
|
|
$spaceless = in_array($lang, self::SPACELESS_LANGS, true);
|
|
$tokens = $spaceless
|
|
? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
|
|
: preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
|
|
$n = count($tokens ?: []);
|
|
if ($n === 0) return [];
|
|
$slot = ($end - $start) / $n;
|
|
$out = [];
|
|
foreach ($tokens as $i => $t) {
|
|
$out[] = [
|
|
'start' => round($start + $i * $slot, 3),
|
|
'end' => round($start + ($i + 1) * $slot, 3),
|
|
'text' => $t,
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
}
|