Transcribe in batches instead of one file per whisper run

Every whisper.cpp invocation loads the model from scratch, which for a large
model takes longer than recognising a few seconds of speech. Running it once per
file meant that across seventeen thousand recordings the loading would dominate
the work entirely. Files now go in batches of sixteen — 40 files took 3 runs
instead of 40 in a stub test, with each file still getting its own result.

A batch has to be one language, since -l applies to the whole invocation, so work
is grouped by the language derived from the wavs/<code>/ path. Results come back
as files next to the inputs (-otxt) rather than on stdout, because with several
files in one run stdout cannot be split per file. Cancellation now lands between
batches rather than between files, which at sixteen files is close enough.

Thread count is left at whisper's own default and exposed as
catalog.whisper.threads: raising it buys speed at the cost of heat, and on a
fanless machine that turns into throttling anyway.

Docker: whisper.cpp bumped to v1.9.2 to match what Homebrew installs. The library
naming changed there — versioned sonames like libggml.so.0 — and the copy pattern
had to widen to match, otherwise the binary could not start.

Verified with a stub in place of whisper-cli, on the host and inside the
container: batching holds, each file gets its own text, and the temp directory
works as uid 10001. Nothing was left in the transcript table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-21 16:06:08 +02:00
co-authored by Claude Opus 5
parent cf6fd4e55e
commit 930cfb3462
2 changed files with 134 additions and 38 deletions
+2 -2
View File
@@ -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
@@ -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.
*
* <p>Praca idzie w tle, po jednym pliku, i da się ją przerwać w dowolnym momencie
* <p>Pliki idą <b>wsadami</b>, 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.
*
* <p>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<String> 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<Job> 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<Path> inputs = new ArrayList<>();
List<Job> 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<List<Job>> batches(List<Job> jobs) {
java.util.Map<String, List<Job>> byLang = new java.util.LinkedHashMap<>();
for (Job job : jobs) {
byLang.computeIfAbsent(job.lang(), k -> new ArrayList<>()).add(job);
}
List<List<Job>> out = new ArrayList<>();
for (List<Job> 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<Path> 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<Path> inputs, String lang) throws Exception {
List<String> 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 {