From b99f239553718493be09c23545c505dff5232106 Mon Sep 17 00:00:00 2001 From: Patryk Gensch <43010113+patryk025@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:59:55 +0200 Subject: [PATCH] Splice short lines into 30-second windows before transcribing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rexcatalog/transcribe/Transcriber.java | 419 ++++++++++++++---- 1 file changed, 322 insertions(+), 97 deletions(-) diff --git a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java index f4b6d4e..abaf29b 100644 --- a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java +++ b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java @@ -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. * - *

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. + *

Kwestie są sklejane w okna, 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. + * + *

Okno 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 @@ -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 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 pending = new ArrayList<>(); - List inputs = new ArrayList<>(); - List ready = new ArrayList<>(); - for (Job job : batch) { + for (List 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> 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()))); + /** Przetwarza zebrane okna i zwraca zaktualizowane liczniki {@code {zrobione, błędy}}. */ + private int[] flush(Tool tool, Path workDir, List 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 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 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> byLanguage(List jobs) { + Map> 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 jobs = new ArrayList<>(); + private final List spans = new ArrayList<>(); + private final ByteArrayOutputStream pcm = new ByteArrayOutputStream(); + + Window(String lang) { + this.lang = lang; + } + + String lang() { + return lang; + } + + List 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 split(List segments, List texts) { + Map 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 result = new LinkedHashMap<>(); + out.forEach((sha1, text) -> result.put(sha1, text.toString().trim())); + return result; + } + + Map 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 ranges, List 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 ranges = new ArrayList<>(); + List 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 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 inputs, String lang) throws Exception { + private void runWhisper(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")); + "-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) {