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 {