Splice short lines into 30-second windows before transcribing

Whisper always encodes a full 30-second window, whatever the clip length, and the
average line here is 4.4 seconds. Fed one at a time, 19.5 hours of speech costs
what 135 hours of continuous audio would. Lines are now concatenated with a
second of silence between them, filling a window with about five, and the result
is cut back apart using the timestamps from whisper's JSON output. A segment goes
to the line it overlaps most, so a line split across several segments is
reassembled and a segment straying into the silence still lands correctly.

Measured on 48 real lines, same set through both paths:

  speech (39)      similarity 0.92, no text landed under the wrong file
  non-speech (9)   similarity 0.54 — both modes invent music annotations

Splicing turns out to be slightly *more* accurate on speech, because the model
sees context: "Jeden raz czułem" becomes "Niejeden raz czułem", "muszę to pościć"
becomes "puścić", "SOO?" becomes "Co?".

On the whole collection this projects to 5.8 h → 3.5 h, a 1.7× gain rather than
the 5× I claimed earlier from counting encoder windows alone. Encoding a window
and decoding a line cost about the same, 0.65 s each, and decoding is per line no
matter how the audio is packed — so only half the work can be folded away.

Window size, gap and batch size are all settable, and catalog.whisper.concat=false
restores the file-at-a-time path, which is how the two were compared. Output moved
from -otxt to -oj because only the JSON carries the timestamps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-21 16:59:55 +02:00
co-authored by Claude Opus 5
parent a773d791a7
commit b99f239553
@@ -3,8 +3,15 @@ package pl.genschu.rexcatalog.transcribe;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.media.MediaIndex;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -12,8 +19,11 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
@@ -24,11 +34,14 @@ 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>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>Kwestie są <b>sklejane w okna</b>, a nie podawane po jednej. Whisper koduje
* zawsze pełne trzydzieści sekund, niezależnie od tego, czy nagranie trwa dwie
* sekundy, czy dwadzieścia — a w tej kolekcji średnia kwestia ma 4,4 s. Podawane
* pojedynczo, dziewiętnaście godzin mowy kosztowałoby tyle, co sto trzydzieści
* pięć godzin ciągłego nagrania. Sklejone z odstępami ciszy mieszczą się po kilka
* w jednym oknie, a wynik rozcinamy z powrotem po znacznikach czasu.
*
* <p>Okno 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
@@ -40,10 +53,30 @@ public final class Transcriber {
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.
* Ile okien na jedno uruchomienie whisper.cpp. Każde uruchomienie wczytuje
* model od nowa, więc kilka okien naraz amortyzuje ten koszt; przerwanie
* działa dopiero między wsadami.
*/
private static final int BATCH = Integer.getInteger("catalog.whisper.batch", 16);
private static final int BATCH = Integer.getInteger("catalog.whisper.batch", 8);
/**
* Ile dźwięku upychamy w jedno okno. Whisper i tak liczy trzydzieści sekund,
* ale zostawiamy zapas, żeby ostatnia kwestia nie wypadła poza okno.
*/
private static final int WINDOW_MS = Integer.getInteger("catalog.whisper.window", 27_000);
/**
* Cisza między sklejonymi kwestiami. Musi wystarczyć, żeby model potraktował
* je jako osobne wypowiedzi — przy zbyt krótkiej przerwie zlewa dwie kwestie
* w jeden segment i tekst trafia pod niewłaściwy plik.
*/
private static final int GAP_MS = Integer.getInteger("catalog.whisper.gap", 1000);
/** Wyłącznik sklejania — do porównania wyników z trybem plik po pliku. */
private static final boolean CONCAT =
!"false".equals(System.getProperty("catalog.whisper.concat"));
private static final int BYTES_PER_MS = WHISPER_RATE * 2 / 1000;
/**
* Wątki dla whisper.cpp. Zostawiamy jego własną wartość domyślną: na laptopie
@@ -244,17 +277,14 @@ public final class Transcriber {
try {
workDir = Files.createTempDirectory("rex-transkrypcja");
for (List<Job> batch : batches(jobs)) {
if (cancelled.get()) {
break;
}
progress = new Progress("pracuje", jobs.size(), done, failed,
batch.get(0).name(), System.currentTimeMillis() - started,
progress.note());
List<Window> pending = new ArrayList<>();
List<Path> inputs = new ArrayList<>();
List<Job> ready = new ArrayList<>();
for (Job job : batch) {
for (List<Job> group : byLanguage(jobs)) {
for (Job job : group) {
if (cancelled.get()) {
break;
}
byte[] pcm;
try {
byte[] raw;
synchronized (lock) {
@@ -265,41 +295,39 @@ public final class Transcriber {
failed++;
continue;
}
inputs.add(convert(workDir, raw, ready.size()));
ready.add(job);
pcm = toPcm(raw);
} catch (Exception e) {
System.err.printf(" ! %s: %s%n", job.name(), e.getMessage());
failed++;
continue;
}
Window last = pending.isEmpty() ? null : pending.get(pending.size() - 1);
if (last == null || !last.accepts(job, pcm)) {
last = new Window(job.lang());
pending.add(last);
}
last.add(job, pcm);
if (pending.size() >= BATCH) {
int[] result = flush(tool, workDir, pending, jobs.size(), done, failed,
started);
done = result[0];
failed = result[1];
pending.clear();
}
}
if (ready.isEmpty()) {
continue;
// język się zmienia, więc niedomknięte okno musi pójść teraz
if (!pending.isEmpty()) {
int[] result = flush(tool, workDir, pending, jobs.size(), done, failed,
started);
done = result[0];
failed = result[1];
pending.clear();
}
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;
if (cancelled.get()) {
break;
}
for (int i = 0; i < ready.size(); i++) {
try {
String text = withoutHallucinations(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,
@@ -313,26 +341,236 @@ public final class Transcriber {
done, failed, null, System.currentTimeMillis() - started, progress.note());
}
/** 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())));
/** Przetwarza zebrane okna i zwraca zaktualizowane liczniki {@code {zrobione, błędy}}. */
private int[] flush(Tool tool, Path workDir, List<Window> windows, int total, int done,
int failed, long started) {
progress = new Progress("pracuje", total, done, failed,
windows.get(0).first(), System.currentTimeMillis() - started, progress.note());
List<Path> inputs = new ArrayList<>();
for (int i = 0; i < windows.size(); i++) {
try {
inputs.add(windows.get(i).write(workDir, i));
} catch (Exception e) {
System.err.printf(" ! okno %d: %s%n", i, e.getMessage());
failed += windows.get(i).jobs().size();
return new int[]{done, failed};
}
}
return out;
try {
runWhisper(tool, inputs, windows.get(0).lang());
} catch (Exception e) {
System.err.printf(" ! whisper: %s%n", e.getMessage());
for (Window window : windows) {
failed += window.jobs().size();
}
clean(inputs);
return new int[]{done, failed};
}
for (int i = 0; i < windows.size(); i++) {
Window window = windows.get(i);
Map<String, String> texts;
try {
texts = window.split(readSegments(inputs.get(i)));
} catch (Exception e) {
System.err.printf(" ! okno %d: %s%n", i, e.getMessage());
failed += window.jobs().size();
continue;
}
for (Job job : window.jobs()) {
try {
synchronized (lock) {
store(job, withoutHallucinations(texts.getOrDefault(job.sha1(), "")),
tool);
}
done++;
} catch (Exception e) {
System.err.printf(" ! %s: %s%n", job.name(), e.getMessage());
failed++;
}
}
}
clean(inputs);
return new int[]{done, failed};
}
private static List<List<Job>> byLanguage(List<Job> jobs) {
Map<String, List<Job>> grouped = new LinkedHashMap<>();
for (Job job : jobs) {
grouped.computeIfAbsent(job.lang(), k -> new ArrayList<>()).add(job);
}
return new ArrayList<>(grouped.values());
}
/**
* Okno: kilka kwestii sklejonych ciszą w jeden strumień, razem z zapisem tego,
* gdzie która się zaczyna. Bez tych pozycji nie dałoby się przypisać wyniku
* z powrotem do pliku.
*/
private static final class Window {
private final String lang;
private final List<Job> jobs = new ArrayList<>();
private final List<int[]> spans = new ArrayList<>();
private final ByteArrayOutputStream pcm = new ByteArrayOutputStream();
Window(String lang) {
this.lang = lang;
}
String lang() {
return lang;
}
List<Job> jobs() {
return jobs;
}
String first() {
return jobs.isEmpty() ? null : jobs.get(0).name();
}
/** Puste okno przyjmie wszystko — także kwestię dłuższą niż samo okno. */
boolean accepts(Job job, byte[] audio) {
if (!lang.equals(job.lang())) {
return false;
}
if (jobs.isEmpty()) {
return true;
}
// z wyłączonym sklejaniem każde okno niesie jedną kwestię; reszta
// ścieżki zostaje ta sama, więc oba tryby da się porównać wprost
return CONCAT && lengthMs() + GAP_MS + msOf(audio) <= WINDOW_MS;
}
void add(Job job, byte[] audio) {
try {
if (!jobs.isEmpty()) {
pcm.write(new byte[GAP_MS * BYTES_PER_MS]);
}
int start = lengthMs();
pcm.write(audio);
spans.add(new int[]{start, lengthMs()});
jobs.add(job);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
private int lengthMs() {
return pcm.size() / BYTES_PER_MS;
}
private static int msOf(byte[] audio) {
return audio.length / BYTES_PER_MS;
}
Path write(Path workDir, int index) throws IOException {
Path wav = workDir.resolve("okno" + index + ".wav");
byte[] data = pcm.toByteArray();
try (OutputStream out = Files.newOutputStream(wav)) {
out.write(wavHeader(data.length));
out.write(data);
}
return wav;
}
/**
* Rozdziela segmenty whispera z powrotem na kwestie. Segment trafia tam,
* gdzie ma największe pokrycie — jedna kwestia bywa rozbita na kilka
* segmentów, a segment potrafi zahaczyć o ciszę.
*/
Map<String, String> split(List<int[]> segments, List<String> texts) {
Map<String, StringBuilder> out = new LinkedHashMap<>();
for (int i = 0; i < segments.size(); i++) {
int best = -1;
int bestOverlap = 0;
for (int j = 0; j < spans.size(); j++) {
int overlap = Math.min(segments.get(i)[1], spans.get(j)[1])
- Math.max(segments.get(i)[0], spans.get(j)[0]);
if (overlap > bestOverlap) {
bestOverlap = overlap;
best = j;
}
}
if (best < 0) {
continue;
}
out.computeIfAbsent(jobs.get(best).sha1(), k -> new StringBuilder())
.append(texts.get(i).trim()).append(' ');
}
Map<String, String> result = new LinkedHashMap<>();
out.forEach((sha1, text) -> result.put(sha1, text.toString().trim()));
return result;
}
Map<String, String> split(Segments segments) {
return split(segments.ranges(), segments.texts());
}
}
/** Nagłówek RIFF dla 16 kHz mono, 16 bitów — dokładnie tego oczekuje whisper.cpp. */
private static byte[] wavHeader(int dataLength) {
ByteBuffer header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN);
header.put("RIFF".getBytes(StandardCharsets.US_ASCII));
header.putInt(36 + dataLength);
header.put("WAVEfmt ".getBytes(StandardCharsets.US_ASCII));
header.putInt(16);
header.putShort((short) 1);
header.putShort((short) 1);
header.putInt(WHISPER_RATE);
header.putInt(WHISPER_RATE * 2);
header.putShort((short) 2);
header.putShort((short) 16);
header.put("data".getBytes(StandardCharsets.US_ASCII));
header.putInt(dataLength);
return header.array();
}
/** Segmenty odczytane z wyniku whispera: zakresy czasu i odpowiadający im tekst. */
record Segments(List<int[]> ranges, List<String> texts) {
}
private static Segments readSegments(Path window) throws IOException {
Path json = jsonFor(window);
if (!Files.isReadable(json)) {
throw new IOException("whisper nie zapisał wyniku dla " + window.getFileName());
}
JsonObject root = JsonParser
.parseString(Files.readString(json, StandardCharsets.UTF_8))
.getAsJsonObject();
List<int[]> ranges = new ArrayList<>();
List<String> texts = new ArrayList<>();
for (JsonElement element : root.getAsJsonArray("transcription")) {
JsonObject segment = element.getAsJsonObject();
JsonObject offsets = segment.getAsJsonObject("offsets");
ranges.add(new int[]{offsets.get("from").getAsInt(), offsets.get("to").getAsInt()});
texts.add(segment.get("text").getAsString());
}
return new Segments(ranges, texts);
}
/**
* Bez {@code -of} whisper.cpp dopisuje rozszerzenie do pełnej nazwy wejścia:
* {@code okno0.wav} → {@code okno0.wav.json}. Starsze wydania podmieniały je
* zamiast dopisywać, więc sprawdzamy obie postacie.
*/
private static Path jsonFor(Path window) {
String name = window.getFileName().toString();
Path dopisane = window.resolveSibling(name + ".json");
if (Files.isReadable(dopisane)) {
return dopisane;
}
return window.resolveSibling(name.substring(0, name.lastIndexOf('.')) + ".json");
}
private static void clean(List<Path> inputs) {
for (Path input : inputs) {
try {
Files.deleteIfExists(input);
Files.deleteIfExists(resultPath(input));
Files.deleteIfExists(jsonFor(input));
} catch (IOException ignored) {
// katalog tymczasowy i tak zniknie na końcu
}
@@ -358,60 +596,47 @@ public final class Transcriber {
return out.toString().trim();
}
/** 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);
/** ffmpeg przepróbkowuje do 16 kHz mono; surowe próbki sklejamy sami. */
private byte[] toPcm(byte[] raw) throws Exception {
Process ffmpeg = new ProcessBuilder("ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", "pipe:0", "-ar", String.valueOf(WHISPER_RATE), "-ac", "1",
"-c:a", "pcm_s16le", "-y", wav.toString())
.redirectErrorStream(true)
"-f", "s16le", "pipe:1")
.start();
try (OutputStream in = ffmpeg.getOutputStream()) {
in.write(raw);
} catch (IOException ignored) {
// ffmpeg potrafi zamknąć wejście wcześniej, gdy nagłówek mu wystarczy
}
String output = new String(ffmpeg.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
Thread feeder = new Thread(() -> {
try (OutputStream in = ffmpeg.getOutputStream()) {
in.write(raw);
} catch (IOException ignored) {
// ffmpeg potrafi zamknąć wejście, gdy nagłówek mu wystarczy
}
});
feeder.setDaemon(true);
feeder.start();
byte[] pcm = ffmpeg.getInputStream().readAllBytes();
String errors = new String(ffmpeg.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
if (ffmpeg.waitFor() != 0) {
throw new IOException("ffmpeg: " + output.trim());
throw new IOException("ffmpeg: " + errors.trim());
}
return wav;
feeder.join(1000);
return pcm;
}
/**
* 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.
* Jedno uruchomienie whisper.cpp na kilka okien. Wynik zapisujemy w JSON-ie
* ({@code -oj}), bo tylko tam są znaczniki czasu potrzebne do rozcięcia okna
* z powrotem na kwestie.
*/
private void transcribe(Tool tool, List<Path> inputs, String lang) throws Exception {
private void runWhisper(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"));
"-m", tool.model(), "-l", lang, "-t", THREADS, "-np", "-oj"));
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());
throw new IOException(errors.trim());
}
}
@@ -523,7 +748,7 @@ public final class Transcriber {
return;
}
try (var stream = Files.walk(directory)) {
stream.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
stream.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {