diff --git a/Dockerfile b/Dockerfile index c021906..9392b9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,13 +24,13 @@ RUN ./gradlew --no-daemon installDist \ FROM debian:bookworm-slim AS whisper RUN apt-get update \ && apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \ - && git clone --depth 1 --branch v1.7.4 https://github.com/ggerganov/whisper.cpp /src \ + && git clone --depth 1 --branch v1.9.2 https://github.com/ggerganov/whisper.cpp /src \ && cmake -S /src -B /src/build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \ -DGGML_NATIVE=OFF -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \ && cmake --build /src/build --target whisper-cli -j "$(nproc)" \ && mkdir -p /out/bin /out/lib \ && cp /src/build/bin/whisper-cli /out/bin/ \ - && find /src/build \( -name 'libwhisper.so*' -o -name 'libggml*.so' \) \ + && find /src/build \( -name 'libwhisper.so*' -o -name 'libggml*.so*' \) \ -exec cp -P {} /out/lib/ \; \ && rm -rf /var/lib/apt/lists/* /src diff --git a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java index a3f1260..cd93130 100644 --- a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java +++ b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java @@ -24,7 +24,13 @@ import java.util.concurrent.atomic.AtomicBoolean; * wydania ani z faktami detektorów: to hipoteza maszyny o tym, co słychać, a nie * zapis z nośnika. * - *

Praca idzie w tle, po jednym pliku, i da się ją przerwać w dowolnym momencie — + *

Pliki idą wsadami, a nie po jednym: każde uruchomienie whisper.cpp + * wczytuje model od nowa, co przy dużym modelu trwa dłużej niż rozpoznanie + * kilkusekundowej kwestii. Przy siedemnastu tysiącach nagrań samo wczytywanie + * zdominowałoby całą pracę. Wsad musi mieć jeden język, bo {@code -l} dotyczy + * całego wywołania. + * + *

Praca idzie w tle i da się ją przerwać w dowolnym momencie — * to, co już policzone, zostaje w bazie. Dzięki temu można puścić kolekcję na noc * i zatrzymać rano bez straty. */ @@ -33,6 +39,19 @@ public final class Transcriber { /** Whisper przyjmuje 16 kHz mono; nagrania z płyt mają 22 050 Hz, więc idą przez ffmpeg. */ private static final int WHISPER_RATE = 16000; + /** + * Ile plików na jedno uruchomienie. Większy wsad lepiej amortyzuje wczytanie + * modelu, ale przerwanie działa dopiero między wsadami. + */ + private static final int BATCH = Integer.getInteger("catalog.whisper.batch", 16); + + /** + * Wątki dla whisper.cpp. Zostawiamy jego własną wartość domyślną: na laptopie + * bez wentylatora podniesienie jej przyspiesza kosztem grzania i po chwili + * i tak wchodzi throttling. + */ + private static final String THREADS = System.getProperty("catalog.whisper.threads", "4"); + /** Nazwy, pod którymi whisper.cpp bywa instalowany. */ private static final List BINARIES = List.of("whisper-cli", "whisper-cpp", "whisper", "main"); @@ -214,36 +233,62 @@ public final class Transcriber { try { workDir = Files.createTempDirectory("rex-transkrypcja"); - for (Job job : jobs) { + for (List batch : batches(jobs)) { if (cancelled.get()) { break; } - progress = new Progress("pracuje", jobs.size(), done, failed, job.name(), - System.currentTimeMillis() - started, progress.note()); - try { - byte[] raw; - synchronized (lock) { - MediaIndex.Clip clip = media.read(job.sha1()); - raw = clip == null ? null : clip.bytes(); - } - if (raw == null) { + progress = new Progress("pracuje", jobs.size(), done, failed, + batch.get(0).name(), System.currentTimeMillis() - started, + progress.note()); + + List inputs = new ArrayList<>(); + List ready = new ArrayList<>(); + for (Job job : batch) { + try { + byte[] raw; + synchronized (lock) { + MediaIndex.Clip clip = media.read(job.sha1()); + raw = clip == null ? null : clip.bytes(); + } + if (raw == null) { + failed++; + continue; + } + inputs.add(convert(workDir, raw, ready.size())); + ready.add(job); + } catch (Exception e) { + System.err.printf(" ! %s: %s%n", job.name(), e.getMessage()); failed++; - continue; } - String text = transcribe(tool, workDir, raw, job.lang()); - if (text == null || text.isBlank()) { - // cisza albo sam efekt dźwiękowy — zapisujemy pustkę, - // żeby nie liczyć tego pliku przy każdym kolejnym przebiegu - text = ""; - } - synchronized (lock) { - store(job, text, tool); - } - done++; - } catch (Exception e) { - System.err.printf(" ! %s: %s%n", job.name(), e.getMessage()); - failed++; } + if (ready.isEmpty()) { + continue; + } + + try { + transcribe(tool, inputs, ready.get(0).lang()); + } catch (Exception e) { + System.err.printf(" ! wsad %d plików: %s%n", ready.size(), e.getMessage()); + failed += ready.size(); + clean(inputs); + continue; + } + + for (int i = 0; i < ready.size(); i++) { + try { + String text = readResult(inputs.get(i)); + synchronized (lock) { + // pusty wynik też zapisujemy: cisza albo sam efekt dźwiękowy, + // inaczej ten plik wracałby przy każdym kolejnym przebiegu + store(ready.get(i), text == null ? "" : text, tool); + } + done++; + } catch (Exception e) { + System.err.printf(" ! %s: %s%n", ready.get(i).name(), e.getMessage()); + failed++; + } + } + clean(inputs); } } catch (Exception e) { progress = new Progress("błąd", jobs.size(), done, failed, null, @@ -257,9 +302,52 @@ public final class Transcriber { done, failed, null, System.currentTimeMillis() - started, progress.note()); } - /** ffmpeg przepróbkowuje do 16 kHz mono, whisper.cpp wypisuje tekst na standardowe wyjście. */ - private String transcribe(Tool tool, Path workDir, byte[] raw, String lang) throws Exception { - Path wav = workDir.resolve("klip.wav"); + /** Dzieli pracę na wsady jednego języka — {@code -l} obowiązuje całe wywołanie. */ + static List> batches(List jobs) { + java.util.Map> byLang = new java.util.LinkedHashMap<>(); + for (Job job : jobs) { + byLang.computeIfAbsent(job.lang(), k -> new ArrayList<>()).add(job); + } + List> out = new ArrayList<>(); + for (List group : byLang.values()) { + for (int i = 0; i < group.size(); i += BATCH) { + out.add(group.subList(i, Math.min(i + BATCH, group.size()))); + } + } + return out; + } + + private static void clean(List inputs) { + for (Path input : inputs) { + try { + Files.deleteIfExists(input); + Files.deleteIfExists(resultPath(input)); + } catch (IOException ignored) { + // katalog tymczasowy i tak zniknie na końcu + } + } + } + + /** whisper.cpp dopisuje rozszerzenie do pełnej nazwy wejścia: klip3.wav → klip3.wav.txt. */ + private static Path resultPath(Path input) { + return input.resolveSibling(input.getFileName() + ".txt"); + } + + private static String readResult(Path input) throws IOException { + Path result = resultPath(input); + if (!Files.isReadable(result)) { + // starsze wydania whisper.cpp podmieniały rozszerzenie zamiast dopisywać + String name = input.getFileName().toString(); + result = input.resolveSibling(name.substring(0, name.lastIndexOf('.')) + ".txt"); + } + return Files.isReadable(result) + ? Files.readString(result, StandardCharsets.UTF_8).trim() + : ""; + } + + /** ffmpeg przepróbkowuje do 16 kHz mono — whisper.cpp innego wejścia nie przyjmuje. */ + private Path convert(Path workDir, byte[] raw, int index) throws Exception { + Path wav = workDir.resolve("klip" + index + ".wav"); Files.deleteIfExists(wav); Process ffmpeg = new ProcessBuilder("ffmpeg", "-hide_banner", "-loglevel", "error", @@ -272,21 +360,29 @@ public final class Transcriber { } catch (IOException ignored) { // ffmpeg potrafi zamknąć wejście wcześniej, gdy nagłówek mu wystarczy } - String ffmpegOutput = new String(ffmpeg.getInputStream().readAllBytes(), - StandardCharsets.UTF_8); + String output = new String(ffmpeg.getInputStream().readAllBytes(), StandardCharsets.UTF_8); if (ffmpeg.waitFor() != 0) { - throw new IOException("ffmpeg: " + ffmpegOutput.trim()); + throw new IOException("ffmpeg: " + output.trim()); } + return wav; + } - Process whisper = new ProcessBuilder(tool.binary(), "-m", tool.model(), - "-l", lang, "-f", wav.toString(), "-nt", "-np") - .start(); - String text = new String(whisper.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + /** + * Jedno uruchomienie whisper.cpp na cały wsad. Wynik idzie do plików obok wejść + * ({@code -otxt}), bo przy wielu plikach naraz nie da się rozdzielić tego, co + * program wypisuje na standardowe wyjście. + */ + private void transcribe(Tool tool, List inputs, String lang) throws Exception { + List command = new ArrayList<>(List.of(tool.binary(), + "-m", tool.model(), "-l", lang, "-t", THREADS, "-nt", "-np", "-otxt")); + inputs.forEach(path -> command.add(path.toString())); + + Process whisper = new ProcessBuilder(command).start(); + whisper.getInputStream().readAllBytes(); String errors = new String(whisper.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); if (whisper.waitFor() != 0) { throw new IOException("whisper: " + errors.trim()); } - return text.trim(); } private void store(Job job, String text, Tool tool) throws Exception {