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) {