Index audio, dialogue tables and script→asset references

Turns the catalogue from a file listing into something you can read: a script
line like SNDQUESTION:FILENAME=KRET_E511.WAV now shows how long the recording
is, who speaks it and at which event, and plays it straight from the disc image.

Four sources feed that:

- Audio headers. RIFF/WAVE and Ogg Vorbis parsed in-process from the image
  stream, no temp files. WAV needs only the first 4 kB, so 3.3 GB of samples
  never reaches the CPU. Not routed through :core — SoundLoader wants a
  FileHandle and a live Gdx.audio, and its contribution is a standard RIFF
  header. 13 884 recordings, 20.2 h.

- wavs/wav.snd. Reksio i Kapitan Nemo packs its whole voice cast into one
  79 MB container that :core does not read, so its speech was invisible here.
  Flat length-prefixed entries holding Ogg Vorbis (oggenc.exe ships on the
  disc). The parser walks the file to its exact last byte: 3274 entries.
  Payloads stay in place — offset and length are enough to serve them, and
  seeking 82 MB into the ISO costs 45 ms.

- dialogi.dta. Pipe-separated CP1250 giving every line a speaker and the event
  that triggers it, so "what is in this file" is answerable without any speech
  recognition. Scene-definition tables share the format, so a row only counts
  as dialogue when column 0 is an identifier rather than a path. 3819 lines,
  98% resolving to real audio.

- Script references. OBJECT:FIELD=VALUE is uniform even inside CODE={...},
  which the decoder folds onto one line, so one pass catches declarations and
  names woven into behaviour code alike. 19 735 references; 97% resolve.
  Names built by concatenation (+"_DEF.DTA") are excluded, but a real leading
  underscore (_WZIECIE_JABLKA.WAV) is kept.

Languages now come from install.ini's [Language] section, with LCIDs translated
through :core's LangCodeConverter rather than a second table here — that is what
settles wavs/slo/ as Slovak. Promote copies them into edition_language, only
ever adding, so curated entries survive.

Transcription is wired to whisper.cpp but never runs on its own: a button with a
progress bar, a stop that keeps what is already computed, and a transcribe
command. Results are generated, not read off the disc, so they live in their own
table with the model and tool named, and the UI labels them as machine guesses.
The pipeline was verified with a stub binary — ffmpeg hands whisper exactly
16 kHz mono, progress and cancellation work, and per-language selection follows
wavs/<code>/. No real transcripts were stored.

Also fixes a pre-existing bug: .cols set display:grid, which beat the browser's
[hidden] rule, so the Skrypty section never actually hid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-21 13:50:12 +02:00
co-authored by Claude Opus 5
parent 285b4ed316
commit 6feb2254b2
15 changed files with 3167 additions and 5 deletions
+4
View File
@@ -20,6 +20,10 @@ WORKDIR /app
# obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen
VOLUME ["/data"]
# Transkrypcji mowy w obrazie nie ma i nie będzie: whisper.cpp plus model to
# kilka gigabajtów, a katalog jest w pełni użyteczny bez nich — mówcę i kontekst
# kwestii bierzemy z dialogi.dta. Front powie wprost, że narzędzia brakuje.
# Kto chce transkrybować, robi to na hoście: rex-catalog transcribe.
# file.encoding ustawia entrypoint przez JAVA_OPTS; JAVA_TOOL_OPTIONS robiłoby
# to samo, ale JVM wypisuje wtedy "Picked up..." przy każdym uruchomieniu
ENV CATALOG_DATA=/data
@@ -4,7 +4,12 @@ import pl.genschu.rexcatalog.catalog.Editor;
import pl.genschu.rexcatalog.catalog.Promoter;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.ingest.Ingestor;
import pl.genschu.rexcatalog.ingest.LanguageDetector;
import pl.genschu.rexcatalog.ingest.MetadataDetector;
import pl.genschu.rexcatalog.media.AudioIndexer;
import pl.genschu.rexcatalog.media.DialogueIndexer;
import pl.genschu.rexcatalog.script.RefIndexer;
import pl.genschu.rexcatalog.transcribe.Transcriber;
import pl.genschu.rexcatalog.mcp.McpServer;
import pl.genschu.rexcatalog.web.WebServer;
import pl.genschu.rexcatalog.script.ScriptDecoder;
@@ -45,6 +50,8 @@ public final class Main {
case "list" -> list(dataDir);
case "stats" -> stats(dataDir);
case "decode" -> decode(dataDir, args);
case "probe" -> probe(dataDir, args);
case "transcribe" -> transcribe(dataDir, args);
case "analyze" -> analyze(dataDir);
case "promote" -> promote(dataDir);
case "titles" -> titles(dataDir);
@@ -145,7 +152,102 @@ public final class Main {
}
}
/**
* Czyta nagłówki zasobów i buduje powiązania między nimi: fakty o dźwięku,
* zawartość archiwów, wiersze dialogów i odwołania ze skryptów.
*/
private static void probe(Path dataDir, String[] args) throws Exception {
boolean force = args.length > 1 && args[1].equals("--force");
Headless.boot();
try (Database db = Database.open(dataDir)) {
AudioIndexer audio = new AudioIndexer(db);
DialogueIndexer dialogue = new DialogueIndexer(db);
RefIndexer refs = new RefIndexer(db, dataDir);
if (!force && audio.pendingCount() == 0 && dialogue.pendingCount() == 0
&& refs.pendingCount() == 0) {
System.out.println("Nic do przerobienia — wszystko już zbadane.");
return;
}
long started = System.currentTimeMillis();
System.out.println("Czytam nagłówki dźwięku i archiwa...");
AudioIndexer.Stats audioStats = audio.run(force);
System.out.printf(" %d plików dźwiękowych, %d archiwów (%d wpisów), błędów: %d%n",
audioStats.audio(), audioStats.archives(), audioStats.entries(),
audioStats.failed());
System.out.println("Czytam tabele dialogów...");
DialogueIndexer.Stats dialogueStats = dialogue.run(force);
System.out.printf(" %d plików, %d wierszy, błędów: %d%n",
dialogueStats.files(), dialogueStats.lines(), dialogueStats.failed());
System.out.println("Zbieram odwołania ze skryptów...");
RefIndexer.Stats refStats = refs.run(force);
System.out.printf(" %d skryptów, %d odwołań, błędów: %d%n",
refStats.scripts(), refStats.refs(), refStats.failed());
System.out.printf("%nGotowe (%ds).%n", (System.currentTimeMillis() - started) / 1000);
}
}
/**
* Transkrypcja mowy. Osobna komenda, nie część {@code probe} — trwa godzinami
* i wymaga narzędzi spoza tego repozytorium, więc uruchamia się ją świadomie.
*/
private static void transcribe(Path dataDir, String[] args) throws Exception {
Transcriber.Tool tool = Transcriber.detect();
if (!tool.ready()) {
System.err.println(tool.note());
System.err.println("""
Ścieżki można wskazać wprost:
-Dcatalog.whisper.bin=/opt/homebrew/bin/whisper-cli
-Dcatalog.whisper.model=~/modele/ggml-large-v3.bin""");
return;
}
Headless.boot();
Integer edition = null;
String editionArg = option(args, "--edition", null);
if (editionArg != null) {
edition = Integer.parseInt(editionArg);
}
int limit = Integer.parseInt(option(args, "--limit", "0"));
boolean redo = Arrays.asList(args).contains("--redo");
try (Database db = Database.open(dataDir)) {
Object lock = new Object();
Transcriber transcriber = new Transcriber(db, lock);
System.out.printf("Model: %s%n", Path.of(tool.model()).getFileName());
transcriber.start(new Transcriber.Scope(edition, limit, redo));
Runtime.getRuntime().addShutdownHook(new Thread(transcriber::stop));
Transcriber.Progress last = null;
while (transcriber.running()) {
Transcriber.Progress now = transcriber.progress();
if (last == null || now.done() != last.done() || now.failed() != last.failed()) {
System.out.printf("\r %d/%d błędów: %d %-40s", now.done() + now.failed(),
now.total(), now.failed(),
now.current() == null ? "" : truncate(now.current(), 40));
System.out.flush();
last = now;
}
Thread.sleep(500);
}
Transcriber.Progress end = transcriber.progress();
System.out.printf("%n%s: %d przetranskrybowanych, %d nieudanych%n",
end.state(), end.done(), end.failed());
if (end.note() != null && end.total() == 0) {
System.out.println(end.note());
}
}
}
private static void analyze(Path dataDir) throws Exception {
// LanguageDetector czyta install.ini wprost z obrazu, więc potrzebuje :core
Headless.boot();
try (Database db = Database.open(dataDir)) {
List<MetadataDetector.RootResult> results =
new MetadataDetector(db, dataDir, CORE_VERSION).run();
@@ -173,9 +275,37 @@ public final class Main {
}
System.out.println();
}
languages(db);
}
}
/** Wersje językowe z install.ini — osobny detektor, bo czyta obraz, nie cache. */
private static void languages(Database db) throws Exception {
List<String> drift = LanguageDetector.verifyAgainstCore();
if (!drift.isEmpty()) {
System.err.println("! tablica języków rozjechała się z :core: "
+ String.join("; ", drift));
}
List<LanguageDetector.RootResult> results =
new LanguageDetector(db, CORE_VERSION).run();
if (results.isEmpty()) {
return;
}
System.out.println("Wersje językowe z install.ini\n");
for (LanguageDetector.RootResult result : results) {
String where = result.rootPath().isEmpty()
? result.copyName()
: result.copyName() + " / " + result.rootPath();
System.out.printf(" %-46s %-18s %s%n", truncate(where, 46),
String.join(" ", result.languages()),
result.note() != null ? "! " + result.note()
: "dubbing: " + String.join(" ", result.audioLanguages()));
}
System.out.println();
}
private static void promote(Path dataDir) throws Exception {
try (Database db = Database.open(dataDir)) {
List<Promoter.Assignment> assignments = new Promoter(db).run();
@@ -502,6 +632,9 @@ public final class Main {
stats statystyki bazy
decode [--force] odszyfruj skrypty do cache'u i indeksu
probe [--force] zbadaj dźwięk, archiwa, dialogi i odwołania
transcribe [--edition N] [--limit N] [--redo]
rozpoznaj mowę przez whisper.cpp (wynik maszynowy)
analyze wyciągnij metadane z application.def
promote utwórz tytuły i wydania z wykrytych faktów
titles lista tytułów z metadanymi
@@ -52,6 +52,7 @@ public final class Promoter {
long titleId = upsertTitle(conn, naming);
long editionId = upsertEdition(conn, fingerprint, titleId, naming, root);
linkRoot(conn, root.copyId(), root.rootPath(), editionId);
applyLanguages(conn, root.copyId(), root.rootPath(), editionId);
assignments.add(new Assignment(root.copyName(), root.rootPath(),
naming.title(), naming.label(), fingerprint, naming.needsReview()));
@@ -147,6 +148,50 @@ public final class Promoter {
return colon > 0 ? title.substring(0, colon).trim() : null;
}
/**
* Przenosi wykryte wersje językowe do wydania.
*
* <p>Tylko dopisuje: {@code DO NOTHING} przy konflikcie i żadnego kasowania,
* bo lista języków jest kuratorska. Detektor dokłada to, co widać na płycie
* ({@code install.ini}, podkatalogi {@code wavs/}), a ręczne wpisy zostają
* nietknięte.
*/
private void applyLanguages(Connection conn, long copyId, String rootPath, long editionId)
throws Exception {
try (PreparedStatement read = conn.prepareStatement("""
SELECT field, value FROM detection
WHERE copy_id = ? AND root_path = ? AND detector = 'LanguageDetector'
AND field IN ('languages', 'audio_languages')
""");
PreparedStatement write = conn.prepareStatement("""
INSERT INTO edition_language (edition_id, lang, role)
VALUES (?, ?, ?)
ON CONFLICT(edition_id, lang, role) DO NOTHING
""")) {
read.setLong(1, copyId);
read.setString(2, rootPath);
try (ResultSet rs = read.executeQuery()) {
while (rs.next()) {
String role = rs.getString("field").equals("audio_languages") ? "audio" : "ui";
String value = rs.getString("value");
if (value == null || value.isBlank()) {
continue;
}
for (String lang : value.split(",")) {
if (lang.isBlank()) {
continue;
}
write.setLong(1, editionId);
write.setString(2, lang.trim());
write.setString(3, role);
write.executeUpdate();
}
}
}
}
}
private long upsertTitle(Connection conn, Naming naming) throws Exception {
String slug = slug(naming.title());
// nazwa nie jest nadpisywana: klucz to slug, więc ręczna zmiana nazwy przetrwa
@@ -281,6 +281,96 @@ public final class Database implements AutoCloseable {
blob_sha1 UNINDEXED,
tokenize = "unicode61 remove_diacritics 2 tokenchars '_'"
)
""",
// Zawartość archiwów, które trzymają wiele zasobów w jednym pliku —
// wavs/wav.snd z Kapitana Nemo to 3274 kwestie w Ogg Vorbisie. Bez tego
// cała warstwa głosowa jednej gry jest w katalogu niewidoczna.
//
// Payloadów nie wypakowujemy: offset i length wystarczą, żeby je odczytać
// z obrazu płyty na żądanie. sha1 liczymy przy indeksowaniu, bo daje
// wspólny identyfikator dla faktów i transkryptów.
"""
CREATE TABLE IF NOT EXISTS archive_entry (
container_sha1 TEXT NOT NULL REFERENCES blob(sha1),
name TEXT NOT NULL,
name_raw TEXT,
offset INTEGER NOT NULL,
size INTEGER NOT NULL,
sha1 TEXT,
format TEXT,
tool_version TEXT NOT NULL,
PRIMARY KEY (container_sha1, name)
)
""",
"CREATE INDEX IF NOT EXISTS idx_archive_sha1 ON archive_entry(sha1)",
// Fakty odczytane z nagłówka zasobu. Bez klucza obcego do blob, bo sha1
// bywa też odciskiem wpisu w archiwum, którego w blob nie ma — obie
// przestrzenie są adresowane treścią, więc się nie pomieszają.
"""
CREATE TABLE IF NOT EXISTS media (
sha1 TEXT PRIMARY KEY,
kind TEXT NOT NULL,
codec TEXT,
duration_ms INTEGER,
sample_rate INTEGER,
channels INTEGER,
bits INTEGER,
note TEXT,
tool_version TEXT NOT NULL,
probed_at TEXT
)
""",
// Odwołania skryptu do zasobów: OBIEKT:FILENAME=PLIK.WAV. Zapisujemy je
// niezależnie od tego, czy plik da się znaleźć — nierozwiązane odwołanie
// jest samo w sobie informacją o wydaniu.
"""
CREATE TABLE IF NOT EXISTS script_ref (
script_sha1 TEXT NOT NULL REFERENCES blob(sha1),
object TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL,
name TEXT NOT NULL,
format TEXT,
line INTEGER,
PRIMARY KEY (script_sha1, object, field, value)
)
""",
"CREATE INDEX IF NOT EXISTS idx_script_ref_name ON script_ref(name)",
// dialogi.dta: trigger | identyfikator kwestii | rozmówca | flagi.
// Daje każdej kwestii mówcę i kontekst zdarzenia bez transkrypcji.
"""
CREATE TABLE IF NOT EXISTS dialogue_line (
dta_sha1 TEXT NOT NULL REFERENCES blob(sha1),
line INTEGER NOT NULL,
section TEXT,
trigger TEXT,
audio_name TEXT,
speaker TEXT,
extra TEXT,
PRIMARY KEY (dta_sha1, line)
)
""",
"CREATE INDEX IF NOT EXISTS idx_dialogue_audio ON dialogue_line(audio_name)",
// Transkrypty są WYGENEROWANE, nie odczytane z płyty — dlatego osobna
// tabela z nazwą modelu i narzędzia. Nigdy nie mieszają się z metadanymi
// wydania ani z faktami detektorów.
"""
CREATE TABLE IF NOT EXISTS transcript (
sha1 TEXT PRIMARY KEY,
text TEXT NOT NULL,
lang TEXT,
model TEXT NOT NULL,
tool TEXT NOT NULL,
duration_ms INTEGER,
created_at TEXT
)
""",
"""
CREATE VIRTUAL TABLE IF NOT EXISTS transcript_fts USING fts5(
body,
sha1 UNINDEXED,
tokenize = "unicode61 remove_diacritics 2 tokenchars '_'"
)
"""
};
}
@@ -0,0 +1,392 @@
package pl.genschu.rexcatalog.ingest;
import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher;
import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem;
import pl.genschu.bloomooemulator.utils.LangCodeConverter;
import pl.genschu.rexcatalog.db.Database;
import java.io.File;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Ustala wersje językowe wydania na podstawie {@code install.ini} i układu katalogu
* {@code wavs/}.
*
* <p>Sekcja {@code [Language]} instalatora wymienia identyfikatory LCID Windows:
* <pre>
* 0415 = Polish
* 040e = Hungarian
* 0405 = Czech
* 041b = Slovak
* </pre>
* Tłumaczenie LCID na kod silnika bierzemy z {@link LangCodeConverter} w {@code :core},
* żeby katalog i emulator nie rozjechały się we własnych tabelach. To ta klasa
* rozstrzyga, że {@code slo} oznacza słowacki, a nie słoweński.
*
* <p>Dubbing widać dodatkowo w drzewie plików: <i>Wojna Trojańska</i> ma
* {@code wavs/cze/}, {@code wavs/hun/} i {@code wavs/slo/} po 543 pliki każdy,
* a polski leży wprost w {@code wavs/}. Język bez własnego podkatalogu, a wymieniony
* w instalatorze, jest więc językiem podstawowym płyty.
*/
public final class LanguageDetector {
private static final String DETECTOR = "LanguageDetector";
private static final Charset CP1250 = Charset.forName("windows-1250");
/** Wiersze {@code [Language]}: klucz to czterocyfrowy LCID, reszta to numery porządkowe. */
private static final Pattern LCID_LINE =
Pattern.compile("^\\s*([0-9A-Fa-f]{4})\\s*=\\s*(.+?)\\s*$");
private static final Pattern SECTION = Pattern.compile("^\\s*\\[(.+?)]\\s*$");
/**
* LCID → ISO 639-1, dla języków, które silnik faktycznie obsługuje.
*
* <p>Kody dwuliterowe, bo takie przyjmuje {@code lang add}. Kluczem jest LCID,
* a nie kod silnika, bo ten drugi bywa spolszczony ({@code niem} to niemiecki).
* Zgodność z {@code :core} pilnuje {@link #verifyAgainstCore()}.
*/
private static final Map<String, String> LCID_TO_ISO = Map.of(
"0402", "bg",
"0405", "cs",
"0407", "de",
"040E", "hu",
"0415", "pl",
"0418", "ro",
"0419", "ru",
"041B", "sk");
private final Database db;
private final String coreVersion;
public LanguageDetector(Database db, String coreVersion) {
this.db = db;
this.coreVersion = coreVersion;
}
public record RootResult(String copyName, String rootPath, String note,
List<String> languages, List<String> audioLanguages) {
}
private record Target(long copyId, String copyName, String container, String rootPath,
String filePath) {
}
/**
* Sprawdza, czy nasza tabela LCID → ISO opisuje te same języki co {@code :core}.
* Rozjazd oznacza, że emulator zmienił mapowanie i katalog też musi.
*/
public static List<String> verifyAgainstCore() {
List<String> problems = new ArrayList<>();
for (String lcid : LCID_TO_ISO.keySet()) {
String engine = LangCodeConverter.lcidToIsoCode(lcid);
if ("unknown".equals(engine)) {
problems.add(lcid + ": :core nie zna już tego LCID");
}
}
return problems;
}
public List<RootResult> run() throws Exception {
List<Target> targets = targets();
List<RootResult> results = new ArrayList<>();
Connection conn = db.connection();
boolean previousAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
try (PreparedStatement delete = conn.prepareStatement(
"DELETE FROM detection WHERE copy_id = ? AND root_path = ? AND detector = ?");
PreparedStatement insert = conn.prepareStatement("""
INSERT INTO detection
(copy_id, root_path, field, value, confidence, evidence,
detector, core_version, detected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""")) {
String now = Instant.now().toString();
Map<String, IFileSystem> open = new LinkedHashMap<>();
for (Target target : targets) {
delete.setLong(1, target.copyId());
delete.setString(2, target.rootPath());
delete.setString(3, DETECTOR);
delete.executeUpdate();
IFileSystem fs;
try {
fs = open.computeIfAbsent(target.container(), path -> {
try {
return AssetSourceDispatcher.openAssets(new File(path));
} catch (Exception e) {
throw new IllegalStateException(e.getMessage(), e);
}
});
} catch (RuntimeException e) {
results.add(new RootResult(target.copyName(), target.rootPath(),
"nie otwieram obrazu: " + e.getMessage(), List.of(), List.of()));
continue;
}
byte[] raw;
try (InputStream in = fs.open(target.filePath())) {
raw = in.readAllBytes();
} catch (Exception e) {
results.add(new RootResult(target.copyName(), target.rootPath(),
"nie czytam install.ini: " + e.getMessage(), List.of(), List.of()));
continue;
}
Map<String, String> declared = parseLanguages(new String(raw, CP1250));
if (declared.isEmpty()) {
results.add(new RootResult(target.copyName(), target.rootPath(),
"install.ini bez sekcji [Language]", List.of(), List.of()));
continue;
}
Set<String> audioDirs = audioDirectories(target.copyId(), target.rootPath());
List<String> languages = new ArrayList<>();
List<String> audio = new ArrayList<>();
List<String> withoutDirectory = new ArrayList<>();
StringBuilder evidence = new StringBuilder("install.ini [Language]:");
for (Map.Entry<String, String> entry : declared.entrySet()) {
String lcid = entry.getKey().toUpperCase(Locale.ROOT);
String iso = LCID_TO_ISO.get(lcid);
String engine = LangCodeConverter.lcidToIsoCode(lcid).toLowerCase(Locale.ROOT);
String code = iso != null ? iso : lcid;
languages.add(code);
evidence.append(' ').append(entry.getValue())
.append(" (").append(lcid).append(')');
if (audioDirs.contains(engine)) {
audio.add(code);
} else {
withoutDirectory.add(code);
}
}
// język bez własnego podkatalogu ma dubbing wprost w wavs/ — ale tylko
// wtedy, gdy jest jeden; przy dwóch nie wiadomo, który to
if (withoutDirectory.size() == 1) {
audio.add(0, withoutDirectory.get(0));
}
write(insert, target, "languages", String.join(",", languages), 0.95,
evidence.toString(), now);
if (!audio.isEmpty()) {
write(insert, target, "audio_languages", String.join(",", audio), 0.9,
audioEvidence(audioDirs, withoutDirectory), now);
}
results.add(new RootResult(target.copyName(), target.rootPath(), null,
languages, audio));
}
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(previousAutoCommit);
}
return results;
}
private static String audioEvidence(Set<String> dirs, List<String> withoutDirectory) {
StringBuilder out = new StringBuilder();
if (!dirs.isEmpty()) {
out.append("podkatalogi wavs/: ").append(String.join(", ", dirs));
}
if (withoutDirectory.size() == 1) {
if (!out.isEmpty()) {
out.append("; ");
}
out.append("bez podkatalogu, dźwięk w wavs/: ").append(withoutDirectory.get(0));
}
return out.toString();
}
private void write(PreparedStatement insert, Target target, String field, String value,
double confidence, String evidence, String now) throws Exception {
insert.setLong(1, target.copyId());
insert.setString(2, target.rootPath());
insert.setString(3, field);
insert.setString(4, value);
insert.setDouble(5, confidence);
insert.setString(6, evidence);
insert.setString(7, DETECTOR);
insert.setString(8, coreVersion);
insert.setString(9, now);
insert.executeUpdate();
}
/** Czyta sekcję {@code [Language]}, pomijając numery porządkowe i pole {@code Nr}. */
static Map<String, String> parseLanguages(String text) {
Map<String, String> out = new LinkedHashMap<>();
boolean inside = false;
for (String line : text.split("\r?\n")) {
Matcher section = SECTION.matcher(line);
if (section.matches()) {
inside = section.group(1).equalsIgnoreCase("Language");
continue;
}
if (!inside) {
continue;
}
Matcher entry = LCID_LINE.matcher(line);
if (entry.matches()) {
out.put(entry.group(1), entry.group(2));
}
}
return out;
}
/** Podkatalogi {@code wavs/} w obrębie korzenia gry — nazwane kodem silnika. */
private Set<String> audioDirectories(long copyId, String rootPath) throws Exception {
String prefix = (rootPath.isEmpty() ? "" : rootPath + "/") + "wavs/";
Set<String> out = new LinkedHashSet<>();
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT DISTINCT path FROM file WHERE copy_id = ? AND path LIKE ?")) {
st.setLong(1, copyId);
st.setString(2, prefix + "%/%");
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
String rest = rs.getString("path").substring(prefix.length());
int slash = rest.indexOf('/');
if (slash > 0) {
out.add(rest.substring(0, slash));
}
}
}
}
// sfx to efekty dźwiękowe, nie wersja językowa
out.remove("sfx");
return out;
}
/** {@code install.ini} pasujący do korzenia gry — najpłytszy w obrębie prefiksu. */
/**
* Dopasowuje {@code install.ini} do korzenia gry.
*
* <p>Zwykle plik leży w obrębie korzenia, ale na dwupacku instalator gry
* głównej stoi w korzeniu płyty, a druga gra ma własny w swoim podkatalogu
* ({@code odyseusz/install.ini}). Dlatego korzeń, który nie ma własnego pliku,
* sięga po ten z płyty — o ile nie należy on do żadnego innego korzenia.
*/
private List<Target> targets() throws Exception {
Map<Long, Copy> copies = copies();
List<Candidate> candidates = candidates(copies.keySet());
List<Target> out = new ArrayList<>();
for (Copy copy : copies.values()) {
for (String rootPath : copy.roots()) {
String prefix = rootPath.isEmpty() ? "" : rootPath + "/";
Candidate best = shallowest(candidates, copy.id(),
canonical -> canonical.startsWith(prefix));
if (best == null) {
// instalator gry głównej dwupacka: w korzeniu płyty, nieprzypisany
best = shallowest(candidates, copy.id(), canonical -> copy.roots().stream()
.noneMatch(root -> !root.isEmpty()
&& canonical.startsWith(root + "/")));
}
if (best != null) {
out.add(new Target(copy.id(), copy.displayName(), copy.container(),
rootPath, best.filePath()));
}
}
}
return out;
}
private record Copy(long id, String displayName, String container, List<String> roots) {
}
private record Candidate(long copyId, String canonical, String filePath) {
}
private static Candidate shallowest(List<Candidate> candidates, long copyId,
java.util.function.Predicate<String> accepts) {
Candidate best = null;
for (Candidate candidate : candidates) {
if (candidate.copyId() != copyId || !accepts.test(candidate.canonical())) {
continue;
}
if (best == null || depth(candidate.canonical()) < depth(best.canonical())) {
best = candidate;
}
}
return best;
}
private Map<Long, Copy> copies() throws Exception {
Map<Long, Copy> out = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT DISTINCT g.copy_id, g.root_path, c.display_name, c.path AS container
FROM game_root g
JOIN copy c ON c.id = g.copy_id
WHERE c.status <> 'unreadable'
ORDER BY g.copy_id, g.root_path
""");
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("copy_id");
out.computeIfAbsent(id, key -> new Copy(key, rsString(rs, "display_name"),
rsString(rs, "container"), new ArrayList<>()))
.roots().add(rs.getString("root_path"));
}
}
return out;
}
private List<Candidate> candidates(Set<Long> knownCopies) throws Exception {
List<Candidate> out = new ArrayList<>();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT f.copy_id, f.path AS canonical, COALESCE(f.path_raw, f.path) AS file_path
FROM file f
WHERE f.path LIKE '%install.ini'
ORDER BY f.copy_id, f.path
""");
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
long copyId = rs.getLong("copy_id");
if (knownCopies.contains(copyId)) {
out.add(new Candidate(copyId, rs.getString("canonical"),
rs.getString("file_path")));
}
}
}
return out;
}
/** {@code computeIfAbsent} nie przepuszcza wyjątku SQL, a kolumny są tu zawsze obecne. */
private static String rsString(ResultSet rs, String column) {
try {
return rs.getString(column);
} catch (java.sql.SQLException e) {
throw new IllegalStateException(e);
}
}
private static int depth(String path) {
return (int) path.chars().filter(c -> c == '/').count();
}
}
@@ -94,6 +94,27 @@ public final class Tools {
), List.of("path_contains")),
args -> findFile(string(args, "path_contains"), integer(args, "limit", 50))));
tools.add(new Tool("get_script_assets",
"Zasoby, do których odwołuje się skrypt: pliki dźwiękowe, animacje, obrazy "
+ "i tablice. Przy kwestiach mówionych podaje długość nagrania, "
+ "postać, która je wypowiada, i zdarzenie, przy którym padają — "
+ "a jeśli transkrypcja została uruchomiona, także rozpoznaną treść.",
schema(Map.of(
"ref", property("string", "SHA-1 skryptu albo fragment ścieżki"),
"format", property("string", "Zawęź do formatu, np. WAV")
), List.of("ref")),
args -> scriptAssets(string(args, "ref"), string(args, "format"))));
tools.add(new Tool("find_audio",
"Szuka nagrań po nazwie pliku, po postaci, która mówi, albo po treści "
+ "transkryptu. Zwraca długość, format, grę i — o ile jest — "
+ "rozpoznany tekst. Obejmuje też kwestie schowane w archiwum wav.snd.",
schema(Map.of(
"query", property("string", "Fragment nazwy, imię postaci albo słowa z kwestii"),
"limit", property("integer", "Maksymalna liczba trafień (domyślnie 30)")
), List.of("query")),
args -> findAudio(string(args, "query"), integer(args, "limit", 30))));
tools.add(new Tool("collection_stats",
"Statystyki kolekcji: liczba kopii, plików, unikalnych blobów, "
+ "rozkład formatów i stopień współdzielenia zasobów między grami.",
@@ -105,6 +126,183 @@ public final class Tools {
// ---------- implementacje ----------
/**
* Odwołania skryptu rozwiązane do konkretnych zasobów. Nierozwiązane też
* pokazujemy — nazwa, której na płycie nie ma, mówi coś o wydaniu.
*/
private String scriptAssets(String ref, String format) throws Exception {
List<String> candidates = search.resolve(ref);
if (candidates.isEmpty()) {
return "Nic nie pasuje do: " + ref;
}
if (candidates.size() > 1) {
StringBuilder sb = new StringBuilder("Niejednoznaczne — podaj SHA-1:\n");
for (String sha1 : candidates) {
sb.append(" ").append(sha1).append(" ")
.append(String.join(", ", search.gamesFor(sha1))).append('\n');
}
return sb.toString();
}
String sha1 = candidates.get(0);
StringBuilder sb = new StringBuilder();
sb.append(String.join(", ", search.pathsFor(sha1))).append('\n')
.append(String.join(", ", search.gamesFor(sha1))).append("\n\n");
String sql = """
WITH kopia AS (SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1)
SELECT r.object, r.field, r.value, r.format, r.line,
COALESCE(
(SELECT f2.blob_sha1 FROM file f2, kopia
WHERE f2.copy_id = kopia.copy_id
AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1),
(SELECT ae.sha1 FROM archive_entry ae
JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia
WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1)
) AS target,
(SELECT d.speaker FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1)
AS speaker,
(SELECT d.trigger FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1)
AS trigger
FROM script_ref r WHERE r.script_sha1 = ?
""" + (format == null || format.isBlank() ? "" : " AND r.format = ?\n")
+ "ORDER BY r.format, r.line";
int missing = 0;
int shown = 0;
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
st.setString(1, sha1);
st.setString(2, sha1);
if (format != null && !format.isBlank()) {
st.setString(3, format.toUpperCase(java.util.Locale.ROOT));
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
String target = rs.getString("target");
if (target == null) {
missing++;
continue;
}
sb.append(String.format("%-26s %-9s %s%n", rs.getString("value"),
rs.getString("format"),
rs.getString("object") + ":" + rs.getString("field")));
String detail = audioDetail(target, rs.getString("speaker"),
rs.getString("trigger"));
if (!detail.isEmpty()) {
sb.append(" ").append(detail).append('\n');
}
shown++;
}
}
}
if (shown == 0 && missing == 0) {
return sb + "Ten skrypt nie odwołuje się do żadnych zasobów.";
}
if (missing > 0) {
sb.append(String.format("%n%d odwołań bez pliku na płycie.%n", missing));
}
return sb.toString();
}
/** Jedna linijka faktów o nagraniu: ile trwa, kto mówi, co słychać. */
private String audioDetail(String sha1, String speaker, String trigger) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT m.duration_ms, m.codec, t.text, t.model
FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1
WHERE m.sha1 = ? AND m.kind = 'audio'
""")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
if (!rs.next()) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append(seconds(rs.getInt("duration_ms"))).append(' ')
.append(rs.getString("codec"));
if (speaker != null) {
sb.append(" · mówi ").append(speaker);
}
if (trigger != null) {
sb.append(" · przy ").append(trigger);
}
String text = rs.getString("text");
if (text != null && !text.isBlank()) {
sb.append("\n „").append(text.replace('\n', ' ').trim())
.append("” (rozpoznane maszynowo, ").append(rs.getString("model"))
.append(')');
}
return sb.toString();
}
}
}
private String findAudio(String query, int limit) throws Exception {
StringBuilder sb = new StringBuilder();
int found = 0;
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT klip.name, klip.copy, klip.sha1, m.duration_ms, m.codec,
(SELECT d.speaker FROM dialogue_line d
WHERE d.audio_name = klip.short LIMIT 1) AS speaker,
(SELECT d.trigger FROM dialogue_line d
WHERE d.audio_name = klip.short LIMIT 1) AS trigger,
t.text AS transcript
FROM (
SELECT f.blob_sha1 AS sha1, COALESCE(f.path_raw, f.path) AS name,
CASE WHEN instr(f.path, '/') > 0
THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '')
ELSE f.path END AS short,
c.display_name AS copy
FROM file f JOIN blob b ON b.sha1 = f.blob_sha1
JOIN copy c ON c.id = f.copy_id WHERE b.format = 'WAV'
UNION
SELECT ae.sha1, ae.name_raw, ae.name, c.display_name
FROM archive_entry ae
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
) AS klip
JOIN media m ON m.sha1 = klip.sha1
LEFT JOIN transcript t ON t.sha1 = klip.sha1
WHERE instr(lower(klip.name), lower(?)) > 0
OR (t.text IS NOT NULL AND instr(lower(t.text), lower(?)) > 0)
OR EXISTS (SELECT 1 FROM dialogue_line d
WHERE d.audio_name = klip.short
AND lower(d.speaker) = lower(?))
GROUP BY klip.sha1 ORDER BY klip.name LIMIT ?
""")) {
st.setString(1, query);
st.setString(2, query);
st.setString(3, query);
st.setInt(4, Math.min(limit, 200));
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
sb.append(String.format("%-26s %7s %s%n", rs.getString("name"),
seconds(rs.getInt("duration_ms")), rs.getString("copy")));
String speaker = rs.getString("speaker");
if (speaker != null) {
sb.append(" mówi ").append(speaker);
if (rs.getString("trigger") != null) {
sb.append(" · przy ").append(rs.getString("trigger"));
}
sb.append('\n');
}
String text = rs.getString("transcript");
if (text != null && !text.isBlank()) {
sb.append("").append(text.replace('\n', ' ').trim()).append("\n");
}
sb.append(" sha1: ").append(rs.getString("sha1")).append('\n');
found++;
}
}
}
return found == 0 ? "Nic nie pasuje do: " + query
: found + " nagrań:\n\n" + sb;
}
private static String seconds(int ms) {
return ms <= 0 ? "" : String.format(java.util.Locale.ROOT, "%.1f s", ms / 1000.0);
}
private String listTitles() throws Exception {
StringBuilder sb = new StringBuilder();
try (PreparedStatement st = db.connection().prepareStatement("""
@@ -0,0 +1,273 @@
package pl.genschu.rexcatalog.media;
import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher;
import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem;
import pl.genschu.rexcatalog.db.Database;
import java.io.File;
import java.io.InputStream;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Wypełnia tabele {@code media} i {@code archive_entry} faktami z nagłówków dźwięku.
*
* <p>Jednostką pracy jest blob, tak jak przy skryptach — ten sam plik w kilku
* obrazach czytamy raz. Dla luźnych WAV-ów wystarczy początek pliku, więc 3,3 GB
* próbek nie przechodzi przez procesor; archiwa {@code .snd} czytamy w całości,
* bo liczymy odcisk każdego wpisu.
*/
public final class AudioIndexer {
/**
* Wersja własnych parserów, nie {@code :core} — te nagłówki czytamy sami,
* więc unieważnia je zmiana tego kodu, a nie bump emulatora.
*/
public static final String VERSION = "media-1";
private static final String KIND = "audio";
private final Database db;
public AudioIndexer(Database db) {
this.db = db;
}
public record Stats(int audio, int archives, int entries, int failed) {
}
private record Pending(String pathInContainer, String sha1, String format) {
}
public Stats run(boolean force) throws Exception {
Map<String, List<Pending>> byContainer = pendingWork(force);
if (byContainer.isEmpty()) {
return new Stats(0, 0, 0, 0);
}
Connection conn = db.connection();
boolean previousAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
int audio = 0;
int archives = 0;
int entries = 0;
int failed = 0;
Set<String> done = new HashSet<>();
try (PreparedStatement mediaStmt = conn.prepareStatement("""
INSERT INTO media (sha1, kind, codec, duration_ms, sample_rate, channels,
bits, note, tool_version, probed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sha1) DO UPDATE SET
kind = excluded.kind, codec = excluded.codec,
duration_ms = excluded.duration_ms, sample_rate = excluded.sample_rate,
channels = excluded.channels, bits = excluded.bits, note = excluded.note,
tool_version = excluded.tool_version, probed_at = excluded.probed_at
""");
PreparedStatement entryStmt = conn.prepareStatement("""
INSERT INTO archive_entry (container_sha1, name, name_raw, offset, size,
sha1, format, tool_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(container_sha1, name) DO UPDATE SET
name_raw = excluded.name_raw, offset = excluded.offset,
size = excluded.size, sha1 = excluded.sha1,
format = excluded.format, tool_version = excluded.tool_version
""")) {
for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) {
File container = new File(group.getKey());
IFileSystem fs;
try {
fs = AssetSourceDispatcher.openAssets(container);
} catch (Exception e) {
System.err.printf(" ! nie otwieram %s: %s%n",
container.getName(), e.getMessage());
failed += group.getValue().size();
continue;
}
for (Pending pending : group.getValue()) {
if (!done.add(pending.sha1())) {
continue;
}
try {
if ("SND".equals(pending.format())) {
entries += indexArchive(fs, pending, entryStmt, mediaStmt);
archives++;
} else {
probeLooseFile(fs, pending, mediaStmt);
audio++;
}
} catch (Exception e) {
System.err.printf(" ! %s (%s): %s%n", pending.pathInContainer(),
container.getName(), e.getMessage());
failed++;
}
}
conn.commit();
}
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(previousAutoCommit);
}
return new Stats(audio, archives, entries, failed);
}
/** Luźny plik dźwiękowy: czytamy tyle, ile trzeba na nagłówek, i nic więcej. */
private void probeLooseFile(IFileSystem fs, Pending pending, PreparedStatement mediaStmt)
throws Exception {
byte[] head;
try (InputStream in = fs.open(pending.pathInContainer())) {
head = in.readNBytes(AudioProbe.WAV_HEAD);
}
writeMedia(mediaStmt, pending.sha1(), AudioProbe.probe(head));
}
/** Archiwum: każdy wpis dostaje własny odcisk, pozycję w kontenerze i fakty. */
private int indexArchive(IFileSystem fs, Pending pending, PreparedStatement entryStmt,
PreparedStatement mediaStmt) throws Exception {
int[] count = {0};
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
try (InputStream in = fs.open(pending.pathInContainer())) {
SndArchive.walk(in, (entry, payload) -> {
try {
sha1.reset();
String digest = hex(sha1.digest(payload));
AudioProbe.Info info = AudioProbe.probe(payload);
entryStmt.setString(1, pending.sha1());
entryStmt.setString(2, entry.name());
entryStmt.setString(3, entry.nameRaw());
entryStmt.setLong(4, entry.offset());
entryStmt.setInt(5, entry.size());
entryStmt.setString(6, digest);
entryStmt.setString(7, formatOf(entry.name(), info));
entryStmt.setString(8, VERSION);
entryStmt.executeUpdate();
writeMedia(mediaStmt, digest, info);
count[0]++;
} catch (Exception e) {
throw new java.io.IOException(
"wpis " + entry.nameRaw() + ": " + e.getMessage(), e);
}
});
}
return count[0];
}
/**
* Rozszerzenie nazwy bywa mylące — wpisy w {@code wav.snd} nazywają się
* {@code *.wav}, a w środku mają Vorbisa. Wierzymy zawartości.
*/
private static String formatOf(String name, AudioProbe.Info info) {
if ("vorbis".equals(info.codec())) {
return "OGG";
}
int dot = name.lastIndexOf('.');
return dot < 0 ? null : name.substring(dot + 1).toUpperCase(Locale.ROOT);
}
private void writeMedia(PreparedStatement stmt, String sha1, AudioProbe.Info info)
throws Exception {
stmt.setString(1, sha1);
stmt.setString(2, KIND);
stmt.setString(3, info.codec());
if (info.durationMs() < 0) {
stmt.setNull(4, java.sql.Types.INTEGER);
} else {
stmt.setInt(4, info.durationMs());
}
stmt.setInt(5, info.sampleRate());
stmt.setInt(6, info.channels());
stmt.setInt(7, info.bits());
stmt.setString(8, info.note());
stmt.setString(9, VERSION);
stmt.setString(10, Instant.now().toString());
stmt.executeUpdate();
}
/**
* Zbiera pliki do przerobienia, pogrupowane po kontenerze — żeby każdy obraz
* otwierać raz. Luźne WAV-y sprawdzamy po tabeli {@code media}, archiwa po tym,
* czy mają już wpisy z aktualną wersją parsera.
*/
private Map<String, List<Pending>> pendingWork(boolean force) throws Exception {
String condition = force ? "1 = 1" : """
(
(b.format = 'WAV' AND (m.sha1 IS NULL OR m.tool_version <> ?))
OR
(b.format = 'SND' AND NOT EXISTS (
SELECT 1 FROM archive_entry ae
WHERE ae.container_sha1 = b.sha1 AND ae.tool_version = ?))
)
""";
String sql = """
SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container,
f.blob_sha1, b.format
FROM copy c
JOIN file f ON f.copy_id = c.id
JOIN blob b ON b.sha1 = f.blob_sha1
LEFT JOIN media m ON m.sha1 = b.sha1
WHERE c.status <> 'unreadable'
AND b.format IN ('WAV', 'SND')
AND %s
ORDER BY c.id
""".formatted(condition);
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
if (!force) {
st.setString(1, VERSION);
st.setString(2, VERSION);
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
byContainer.computeIfAbsent(rs.getString("container"), k -> new ArrayList<>())
.add(new Pending(rs.getString("in_container"),
rs.getString("blob_sha1"), rs.getString("format")));
}
}
}
return byContainer;
}
/** Ile plików czeka na przerobienie — do komunikatu przed startem. */
public int pendingCount() throws Exception {
int total = 0;
Set<String> unique = new HashSet<>();
for (List<Pending> list : pendingWork(false).values()) {
for (Pending pending : list) {
if (unique.add(pending.sha1())) {
total++;
}
}
}
return total;
}
private static String hex(byte[] bytes) {
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
out.append(Character.forDigit((b >> 4) & 0xF, 16));
out.append(Character.forDigit(b & 0xF, 16));
}
return out.toString();
}
}
@@ -0,0 +1,195 @@
package pl.genschu.rexcatalog.media;
import java.nio.charset.StandardCharsets;
/**
* Odczyt nagłówków dźwięku: RIFF/WAVE oraz Ogg Vorbis.
*
* <p>Bez {@code :core} i bez libGDX — {@code SoundLoader} z emulatora wymaga
* {@code FileHandle} i działającego {@code Gdx.audio}, a jego wkładem jest
* parsowanie standardowego nagłówka RIFF. Tutaj czytamy prosto ze strumienia
* z obrazu płyty, bez plików tymczasowych.
*
* <p>Dla WAV wystarczy początek pliku: długość dźwięku stoi w nagłówku porcji
* {@code data}, więc 3,3 GB próbek nie musi przejść przez procesor.
*/
public final class AudioProbe {
/** Ile bajtów początku pliku wystarcza, by znaleźć porcje {@code fmt } i {@code data}. */
public static final int WAV_HEAD = 4096;
private AudioProbe() {
}
/**
* @param codec nazwa kodeka albo jego numer, gdy nieznany
* @param durationMs długość w milisekundach, {@code -1} gdy nie do wyliczenia
* @param note co poszło nie tak, gdy nagłówek był nietypowy
*/
public record Info(String codec, int durationMs, int sampleRate, int channels,
int bits, String note) {
public static Info unreadable(String note) {
return new Info(null, -1, 0, 0, 0, note);
}
}
/** Rozpoznaje kontener po magicznych bajtach i wybiera właściwy parser. */
public static Info probe(byte[] data) {
if (data.length >= 4 && matches(data, 0, "OggS")) {
return probeOgg(data);
}
if (data.length >= 12 && matches(data, 0, "RIFF") && matches(data, 8, "WAVE")) {
return probeWav(data);
}
return Info.unreadable("nierozpoznany kontener dźwięku");
}
// ---------- RIFF / WAVE ----------
private static Info probeWav(byte[] data) {
int position = 12;
int format = 0;
int channels = 0;
int sampleRate = 0;
int bits = 0;
long byteRate = 0;
long dataSize = -1;
while (position + 8 <= data.length) {
String chunkId = new String(data, position, 4, StandardCharsets.US_ASCII);
long chunkSize = u32(data, position + 4);
int body = position + 8;
if (chunkId.equals("fmt ") && body + 16 <= data.length) {
format = u16(data, body);
channels = u16(data, body + 2);
sampleRate = (int) u32(data, body + 4);
byteRate = u32(data, body + 8);
bits = u16(data, body + 14);
} else if (chunkId.equals("data")) {
dataSize = chunkSize;
break;
}
// porcje są wyrównane do parzystej granicy
position = body + (int) chunkSize + (int) (chunkSize & 1);
if (chunkSize <= 0) {
break;
}
}
if (sampleRate <= 0 || channels <= 0) {
return Info.unreadable("brak czytelnej porcji fmt");
}
int durationMs = -1;
if (dataSize >= 0) {
// dla kompresji (ADPCM, MP3 w kontenerze WAV) liczba próbek nie wynika
// z liczby bajtów, ale byteRate jest w nagłówku deklarowany zawsze
long bytesPerSecond = byteRate > 0
? byteRate
: (long) sampleRate * channels * Math.max(bits, 8) / 8;
if (bytesPerSecond > 0) {
durationMs = (int) (dataSize * 1000 / bytesPerSecond);
}
}
return new Info(wavCodec(format), durationMs, sampleRate, channels, bits,
dataSize < 0 ? "nie znaleziono porcji data w nagłówku" : null);
}
private static String wavCodec(int format) {
return switch (format) {
case 0x0001 -> "pcm";
case 0x0002 -> "ms-adpcm";
case 0x0011 -> "ima-adpcm";
case 0x0055 -> "mp3";
case 0x0161 -> "wma";
case 0xFFFE -> "pcm-extensible";
default -> "wav-0x%04X".formatted(format);
};
}
// ---------- Ogg Vorbis ----------
/**
* Wymaga całego payloadu, bo długość dźwięku stoi w pozycji ziarna ostatniej
* strony. Wpisy w {@code wav.snd} mają średnio 24 kB, więc to nie jest problem.
*/
public static Info probeOgg(byte[] data) {
int channels = 0;
int sampleRate = 0;
long lastGranule = -1;
int position = 0;
boolean sawIdentification = false;
while (position + 27 <= data.length && matches(data, position, "OggS")) {
int segmentCount = data[position + 26] & 0xFF;
int segmentTable = position + 27;
if (segmentTable + segmentCount > data.length) {
break;
}
int payloadSize = 0;
for (int i = 0; i < segmentCount; i++) {
payloadSize += data[segmentTable + i] & 0xFF;
}
int payload = segmentTable + segmentCount;
long granule = u64(data, position + 6);
if (granule != -1L) {
lastGranule = granule;
}
if (!sawIdentification && payloadSize >= 16 && payload + 16 <= data.length
&& (data[payload] & 0xFF) == 0x01 && matches(data, payload + 1, "vorbis")) {
channels = data[payload + 11] & 0xFF;
sampleRate = (int) u32(data, payload + 12);
sawIdentification = true;
}
position = payload + payloadSize;
}
if (!sawIdentification || sampleRate <= 0) {
return Info.unreadable("brak nagłówka identyfikacyjnego Vorbisa");
}
// ziarno równe zeru to nie brak informacji, tylko pusty strumień —
// w wav.snd leży kilkanaście takich plików, nazwanych wprost _pusty0.wav
int durationMs = lastGranule >= 0
? (int) (lastGranule * 1000 / sampleRate)
: -1;
return new Info("vorbis", durationMs, sampleRate, channels, 0, null);
}
// ---------- pomocnicze ----------
private static boolean matches(byte[] data, int offset, String ascii) {
if (offset + ascii.length() > data.length) {
return false;
}
for (int i = 0; i < ascii.length(); i++) {
if ((data[offset + i] & 0xFF) != ascii.charAt(i)) {
return false;
}
}
return true;
}
private static int u16(byte[] data, int offset) {
return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8);
}
private static long u32(byte[] data, int offset) {
return (data[offset] & 0xFFL)
| ((data[offset + 1] & 0xFFL) << 8)
| ((data[offset + 2] & 0xFFL) << 16)
| ((data[offset + 3] & 0xFFL) << 24);
}
private static long u64(byte[] data, int offset) {
long value = 0;
for (int i = 7; i >= 0; i--) {
value = (value << 8) | (data[offset + i] & 0xFFL);
}
return value;
}
}
@@ -0,0 +1,279 @@
package pl.genschu.rexcatalog.media;
import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher;
import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem;
import pl.genschu.rexcatalog.db.Database;
import java.io.File;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Czyta tabele {@code .dta} i wyciąga z nich wiersze dialogów.
*
* <p>{@code dialogi.dta} to zwykły tekst CP1250 rozdzielony pionowymi kreskami:
* <pre>
* click_zawor0|Rex_Aw07|rex
* use_mlotek_on_tlok0|Kret_Aw14|rex|f__
* </pre>
* Kolumny to zdarzenie, identyfikator kwestii, rozmówca i flagi. Identyfikator
* odpowiada nazwie pliku dźwiękowego, a jego przedrostek — postaci
* ({@code Kret} to Kretes, {@code Nar} narrator).
*
* <p>Dzięki temu każda kwestia ma mówcę i kontekst zdarzenia bez transkrypcji.
* Wiersze zaczynające się od myślników są komentarzami autorów i niosą nazwy
* planszy — trzymamy je jako sekcję kolejnych wierszy.
*/
public final class DialogueIndexer {
public static final String VERSION = "dialogue-1";
private static final Charset CP1250 = Charset.forName("windows-1250");
/** Identyfikator kwestii: przedrostek postaci, podkreślenie, oznaczenie sceny. */
private static final Pattern AUDIO_ID = Pattern.compile("[A-Za-z0-9]{2,}_[A-Za-z0-9]+");
private static final Pattern SECTION = Pattern.compile("^-{3,}\\s*(.*)$");
private final Database db;
public DialogueIndexer(Database db) {
this.db = db;
}
public record Stats(int files, int lines, int failed) {
}
private record Pending(String pathInContainer, String sha1) {
}
public Stats run(boolean force) throws Exception {
Map<String, List<Pending>> byContainer = pendingWork(force);
if (byContainer.isEmpty()) {
return new Stats(0, 0, 0);
}
Connection conn = db.connection();
boolean previousAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
int files = 0;
int lines = 0;
int failed = 0;
Set<String> done = new HashSet<>();
try (PreparedStatement delete = conn.prepareStatement(
"DELETE FROM dialogue_line WHERE dta_sha1 = ?");
PreparedStatement insert = conn.prepareStatement("""
INSERT INTO dialogue_line (dta_sha1, line, section, trigger,
audio_name, speaker, extra)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(dta_sha1, line) DO UPDATE SET
section = excluded.section, trigger = excluded.trigger,
audio_name = excluded.audio_name, speaker = excluded.speaker,
extra = excluded.extra
""");
// ślad, że plik już przeszedł przez parser — inaczej tabele .dta bez
// dialogów (zapisy stanu gry) czytalibyśmy przy każdym uruchomieniu
PreparedStatement mark = conn.prepareStatement("""
INSERT INTO media (sha1, kind, note, tool_version, probed_at)
VALUES (?, 'dialogue', ?, ?, ?)
ON CONFLICT(sha1) DO UPDATE SET
kind = excluded.kind, note = excluded.note,
tool_version = excluded.tool_version, probed_at = excluded.probed_at
""")) {
for (Map.Entry<String, List<Pending>> group : byContainer.entrySet()) {
File container = new File(group.getKey());
IFileSystem fs;
try {
fs = AssetSourceDispatcher.openAssets(container);
} catch (Exception e) {
System.err.printf(" ! nie otwieram %s: %s%n",
container.getName(), e.getMessage());
failed += group.getValue().size();
continue;
}
for (Pending pending : group.getValue()) {
if (!done.add(pending.sha1())) {
continue;
}
try {
byte[] raw;
try (InputStream in = fs.open(pending.pathInContainer())) {
raw = in.readAllBytes();
}
delete.setString(1, pending.sha1());
delete.executeUpdate();
int written = parse(pending.sha1(), new String(raw, CP1250), insert);
if (written > 0) {
lines += written;
files++;
}
mark.setString(1, pending.sha1());
mark.setString(2, written + " wierszy dialogu");
mark.setString(3, VERSION);
mark.setString(4, java.time.Instant.now().toString());
mark.executeUpdate();
} catch (Exception e) {
System.err.printf(" ! %s (%s): %s%n", pending.pathInContainer(),
container.getName(), e.getMessage());
failed++;
}
}
conn.commit();
}
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(previousAutoCommit);
}
return new Stats(files, lines, failed);
}
/**
* Zapisuje tylko wiersze, w których druga kolumna wygląda na identyfikator
* kwestii. To odsiewa inne tabele {@code .dta} — zapisy stanu gry mają ten sam
* format, ale w drugiej kolumnie zwykłe wartości.
*/
private int parse(String sha1, String text, PreparedStatement insert) throws Exception {
String[] rows = text.split("\r?\n", -1);
String section = null;
int written = 0;
for (int number = 0; number < rows.length; number++) {
String row = rows[number].trim();
if (row.isEmpty()) {
continue;
}
String[] columns = row.split("\\|", -1);
String marker = sectionLabel(row, columns);
if (marker != null) {
section = marker.isEmpty() ? null : marker;
continue;
}
if (columns.length < 2) {
continue;
}
String id = columns[1].trim();
if (!AUDIO_ID.matcher(id).matches() || !isTrigger(columns[0])) {
continue;
}
insert.setString(1, sha1);
insert.setInt(2, number + 1);
insert.setString(3, section);
insert.setString(4, blankToNull(columns[0]));
insert.setString(5, audioName(id));
insert.setString(6, speaker(id));
insert.setString(7, columns.length > 2
? blankToNull(String.join("|",
java.util.Arrays.copyOfRange(columns, 2, columns.length)))
: null);
insert.executeUpdate();
written++;
}
return written;
}
/**
* Pierwsza kolumna wiersza dialogu to nazwa zdarzenia, nie plik.
*
* <p>Ten sam format {@code .dta} niosą tabele definicji planszy
* ({@code MIASTO\\DO_ALCHOMIKA.ANN|DO_ALCHOMIKA|...}), gdzie druga kolumna
* wygląda jak identyfikator kwestii, choć jest nazwą przejścia. Ścieżka
* w kolumnie zerowej rozstrzyga, że to nie dialog.
*/
private static boolean isTrigger(String column) {
String value = column.trim();
return !value.isEmpty()
&& value.indexOf('\\') < 0
&& value.indexOf('/') < 0
&& value.indexOf('.') < 0;
}
/** Komentarz autorów: myślniki na początku wiersza albo w którejś kolumnie. */
private static String sectionLabel(String row, String[] columns) {
var direct = SECTION.matcher(row);
if (direct.matches()) {
return direct.group(1).trim();
}
for (String column : columns) {
var inside = SECTION.matcher(column.trim());
if (inside.matches()) {
return inside.group(1).trim();
}
}
return null;
}
/** Identyfikatory są bez rozszerzenia, a pliki i wpisy archiwum kończą się na .wav. */
static String audioName(String id) {
String name = id.toLowerCase(Locale.ROOT);
return name.contains(".") ? name : name + ".wav";
}
static String speaker(String id) {
int underscore = id.indexOf('_');
return underscore <= 0 ? null : id.substring(0, underscore);
}
private static String blankToNull(String value) {
String trimmed = value == null ? "" : value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private Map<String, List<Pending>> pendingWork(boolean force) throws Exception {
String sql = """
SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container,
f.blob_sha1
FROM copy c
JOIN file f ON f.copy_id = c.id
JOIN blob b ON b.sha1 = f.blob_sha1
LEFT JOIN media m ON m.sha1 = b.sha1
WHERE c.status <> 'unreadable' AND b.format = 'DTA'
""" + (force ? "" : """
AND (m.sha1 IS NULL OR m.tool_version <> ?)
""") + "ORDER BY c.id";
Map<String, List<Pending>> byContainer = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
if (!force) {
st.setString(1, VERSION);
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
byContainer.computeIfAbsent(rs.getString("container"), k -> new ArrayList<>())
.add(new Pending(rs.getString("in_container"),
rs.getString("blob_sha1")));
}
}
}
return byContainer;
}
public int pendingCount() throws Exception {
Set<String> unique = new HashSet<>();
for (List<Pending> list : pendingWork(false).values()) {
list.forEach(pending -> unique.add(pending.sha1()));
}
return unique.size();
}
}
@@ -0,0 +1,182 @@
package pl.genschu.rexcatalog.media;
import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher;
import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem;
import pl.genschu.rexcatalog.db.Database;
import java.io.File;
import java.io.InputStream;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Locale;
/**
* Wyszukuje zasoby po nazwie i podaje ich bajty prosto z obrazu płyty.
*
* <p>Niczego nie wypakowujemy na dysk: kopia zostaje jednym plikiem, a katalog
* odczytuje z niej fragment na żądanie. Zasób bywa osobnym plikiem albo wpisem
* w archiwum {@code .snd} — dla odbiorcy to bez różnicy, bo jedno i drugie
* adresujemy odciskiem treści.
*/
public final class MediaIndex {
private final Database db;
public MediaIndex(Database db) {
this.db = db;
}
/**
* @param name nazwa pliku do pokazania i pobrania
* @param contentType typ MIME wynikający z rzeczywistej zawartości
* @param inArchive czy zasób leży w archiwum, a nie osobno na płycie
*/
public record Clip(String sha1, String name, String contentType, byte[] bytes,
boolean inArchive) {
}
/** Miejsce zasobu w kontenerze: {@code offset < 0} oznacza cały plik. */
private record Location(String container, String pathInContainer, String name,
String format, long offset, int size) {
}
/** Odczytuje zasób po odcisku treści; {@code null}, gdy nie ma go w katalogu. */
public Clip read(String sha1) throws Exception {
Location location = locate(sha1);
if (location == null) {
return null;
}
IFileSystem fs = AssetSourceDispatcher.openAssets(new File(location.container()));
byte[] bytes;
try (InputStream in = fs.open(location.pathInContainer())) {
if (location.offset() < 0) {
bytes = in.readAllBytes();
} else {
skipFully(in, location.offset());
bytes = in.readNBytes(location.size());
}
}
return new Clip(sha1, location.name(), contentType(location.format(), bytes), bytes,
location.offset() >= 0);
}
/**
* Znajduje odcisk zasobu o danej nazwie w obrębie kopii — najpierw wśród plików,
* potem w archiwach. Tak odwołanie ze skryptu ({@code FILENAME=BUREKTOR_M030.WAV})
* staje się czymś, co da się odsłuchać.
*/
public String resolve(long copyId, String name) throws Exception {
String needle = name.toLowerCase(Locale.ROOT);
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT blob_sha1 FROM file
WHERE copy_id = ? AND (path = ? OR path LIKE '%/' || ?)
LIMIT 1
""")) {
st.setLong(1, copyId);
st.setString(2, needle);
st.setString(3, needle);
try (ResultSet rs = st.executeQuery()) {
if (rs.next()) {
return rs.getString(1);
}
}
}
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT ae.sha1 FROM archive_entry ae
JOIN file f ON f.blob_sha1 = ae.container_sha1
WHERE f.copy_id = ? AND ae.name = ?
LIMIT 1
""")) {
st.setLong(1, copyId);
st.setString(2, needle);
try (ResultSet rs = st.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
private Location locate(String sha1) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container,
f.path AS canonical, b.format
FROM file f
JOIN copy c ON c.id = f.copy_id
JOIN blob b ON b.sha1 = f.blob_sha1
WHERE f.blob_sha1 = ? AND c.status <> 'unreadable'
LIMIT 1
""")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
if (rs.next()) {
String canonical = rs.getString("canonical");
int slash = canonical.lastIndexOf('/');
return new Location(rs.getString("container"), rs.getString("in_container"),
slash < 0 ? canonical : canonical.substring(slash + 1),
rs.getString("format"), -1, 0);
}
}
}
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container,
ae.name, ae.format, ae.offset, ae.size
FROM archive_entry ae
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
WHERE ae.sha1 = ? AND c.status <> 'unreadable'
LIMIT 1
""")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
if (rs.next()) {
return new Location(rs.getString("container"), rs.getString("in_container"),
rs.getString("name"), rs.getString("format"),
rs.getLong("offset"), rs.getInt("size"));
}
}
}
return null;
}
/**
* Rozszerzenie bywa mylące — wpisy w {@code wav.snd} nazywają się {@code *.wav},
* a niosą Vorbisa. Rozstrzygają magiczne bajty.
*/
private static String contentType(String format, byte[] bytes) {
if (bytes.length >= 4) {
if (bytes[0] == 'O' && bytes[1] == 'g' && bytes[2] == 'g' && bytes[3] == 'S') {
return "audio/ogg";
}
if (bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F') {
return "audio/wav";
}
}
if (format == null) {
return "application/octet-stream";
}
return switch (format.toUpperCase(Locale.ROOT)) {
case "WAV" -> "audio/wav";
case "OGG" -> "audio/ogg";
case "MPG" -> "video/mpeg";
default -> "application/octet-stream";
};
}
private static void skipFully(InputStream in, long count) throws java.io.IOException {
long remaining = count;
while (remaining > 0) {
long skipped = in.skip(remaining);
if (skipped <= 0) {
if (in.read() < 0) {
throw new java.io.EOFException("kontener krótszy niż pozycja wpisu");
}
remaining--;
} else {
remaining -= skipped;
}
}
}
}
@@ -0,0 +1,169 @@
package pl.genschu.rexcatalog.media;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
/**
* Archiwum {@code .snd} — płaski kontener na kwestie mówione.
*
* <p>Używa go <i>Reksio i Kapitan Nemo</i>: zamiast katalogu z tysiącami plików
* leży jeden {@code wavs/wav.snd} na 79 MB, a w środku 3274 wpisy zakodowane
* w Ogg Vorbisie (na płycie leży {@code oggenc.exe}, więc zgadza się i narzędzie).
* Nazwy wpisów kończą się na {@code .wav} mimo vorbisowej zawartości.
*
* <p>Układ wpisu, potwierdzony przejściem całego pliku — parser dochodzi
* dokładnie do jego końca, bajt w bajt:
* <pre>
* u32 znacznik (zawsze 0 w zbadanych płytach)
* u32 długość payloadu
* u32 długość nazwy razem z bajtem zerowym
* u8[] nazwa w CP1250, zakończona zerem
* u8[] payload
* </pre>
*
* <p>{@code :core} tego formatu nie zna, więc parser jest tutaj, a nie w emulatorze.
*/
public final class SndArchive {
private static final Charset CP1250 = Charset.forName("windows-1250");
/** Górny limit długości nazwy — chroni przed próbą alokacji na śmieciach. */
private static final int MAX_NAME = 512;
private SndArchive() {
}
/**
* Jeden wpis archiwum. {@code offset} wskazuje na payload, nie na nagłówek,
* więc wystarcza do odczytania zasobu bez ponownego parsowania kontenera.
*/
public record Entry(String name, String nameRaw, long offset, int size) {
}
/** Odbiorca wpisów; {@code payload} jest null, gdy przechodzimy tylko po nagłówkach. */
public interface Visitor {
void entry(Entry entry, byte[] payload) throws IOException;
}
/**
* Zwraca same wpisy, przeskakując payloady — do odnalezienia zasobu,
* gdy trzeba go tylko podać przeglądarce.
*/
public static List<Entry> index(InputStream in) throws IOException {
List<Entry> entries = new ArrayList<>();
walk(in, false, (entry, payload) -> entries.add(entry));
return entries;
}
/** Przechodzi archiwum wczytując payloady — do liczenia odcisków i nagłówków. */
public static void walk(InputStream in, Visitor visitor) throws IOException {
walk(in, true, visitor);
}
private static void walk(InputStream in, boolean withPayload, Visitor visitor)
throws IOException {
long position = 0;
byte[] header = new byte[12];
while (true) {
int read = readFully(in, header, 0, 12);
if (read == 0) {
return;
}
if (read < 12) {
throw new IOException("obcięty nagłówek wpisu na pozycji " + position);
}
position += 12;
long payloadSize = u32(header, 4);
long nameLength = u32(header, 8);
if (nameLength <= 0 || nameLength > MAX_NAME) {
throw new IOException("nieprawdopodobna długość nazwy (" + nameLength
+ ") na pozycji " + (position - 12));
}
byte[] rawName = new byte[(int) nameLength];
if (readFully(in, rawName, 0, rawName.length) < rawName.length) {
throw new IOException("obcięta nazwa wpisu na pozycji " + position);
}
position += nameLength;
String nameRaw = new String(rawName, CP1250).replace("\0", "");
Entry entry = new Entry(nameRaw.toLowerCase(java.util.Locale.ROOT), nameRaw,
position, (int) payloadSize);
if (withPayload) {
byte[] payload = new byte[(int) payloadSize];
if (readFully(in, payload, 0, payload.length) < payload.length) {
throw new IOException("obcięty payload wpisu " + nameRaw);
}
visitor.entry(entry, payload);
} else {
visitor.entry(entry, null);
skipFully(in, payloadSize);
}
position += payloadSize;
}
}
/** Czy strumień w ogóle wygląda na archiwum — po pierwszym wpisie. */
public static boolean looksLikeArchive(byte[] head) {
if (head.length < 17) {
return false;
}
long nameLength = u32(head, 8);
if (nameLength < 2 || nameLength > MAX_NAME || 12 + nameLength > head.length) {
return false;
}
// nazwa musi być zakończona zerem i mieścić się w drukowalnym ASCII
if (head[(int) (12 + nameLength - 1)] != 0) {
return false;
}
for (int i = 12; i < 12 + nameLength - 1; i++) {
if (head[i] < 0x20) {
return false;
}
}
return true;
}
private static long u32(byte[] buffer, int offset) {
return (buffer[offset] & 0xFFL)
| ((buffer[offset + 1] & 0xFFL) << 8)
| ((buffer[offset + 2] & 0xFFL) << 16)
| ((buffer[offset + 3] & 0xFFL) << 24);
}
private static int readFully(InputStream in, byte[] buffer, int offset, int length)
throws IOException {
int total = 0;
while (total < length) {
int read = in.read(buffer, offset + total, length - total);
if (read < 0) {
break;
}
total += read;
}
return total;
}
private static void skipFully(InputStream in, long count) throws IOException {
long remaining = count;
while (remaining > 0) {
long skipped = in.skip(remaining);
if (skipped <= 0) {
// skip() na niektórych strumieniach nie rusza do przodu na końcu bufora
if (in.read() < 0) {
throw new EOFException("payload krótszy niż deklarowany o " + remaining + " B");
}
remaining--;
} else {
remaining -= skipped;
}
}
}
}
@@ -0,0 +1,183 @@
package pl.genschu.rexcatalog.script;
import pl.genschu.rexcatalog.db.Database;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Wyciąga ze skryptów odwołania do zasobów i zapisuje je w {@code script_ref}.
*
* <p>Skrypty Piklib są liniowe — {@code OBIEKT:POLE=WARTOŚĆ} — również dla
* zachowań, bo dekoder zwija całe {@code CODE={...}} do jednej linii. Dzięki temu
* ten sam przebieg łapie i deklaracje ({@code SNDQUESTION:FILENAME=BUREKTOR_M030.WAV}),
* i nazwy wplecione w kod ({@code DBDIALOG^LOAD("$COMMON\DIALOGS.DTA")}), a obiekt
* przypisuje się poprawnie w obu przypadkach.
*
* <p>Odwołania zapisujemy niezależnie od tego, czy plik da się znaleźć: nazwa,
* której w wydaniu nie ma, jest sama w sobie informacją.
*/
public final class RefIndexer {
public static final String VERSION = "refs-1";
/** Rozszerzenia, które w tej kolekcji są zasobami, a nie przypadkowym tekstem. */
private static final Set<String> EXTENSIONS = Set.of(
"WAV", "OGG", "SND", "IMG", "ANN", "SEQ", "ARR", "DTA", "SEK", "FNT",
"CNV", "CLASS", "DEF", "AVI", "MPG", "TGA", "DDS", "BMP", "SCN", "LUA",
"KIMODEL", "KIANIM");
private static final Pattern ASSIGNMENT =
Pattern.compile("^([A-Za-z0-9_]+):([A-Za-z0-9_]+)=(.*)$");
/**
* Nazwa nie może być doklejona plusem: {@code "$COMMON\"+ZMIENNA+"_DEF.DTA"}
* buduje ścieżkę w czasie działania, więc {@code _DEF.DTA} nie jest nazwą pliku.
* Podkreślenie na początku samo w sobie niczego nie przesądza — {@code SNDX:FILENAME=
* _WZIECIE_JABLKA.WAV} to prawdziwy plik.
*/
private static final Pattern FILENAME = Pattern.compile(
"(?<!\\+)(?<!\\+\")"
+ "[A-Za-z0-9_$][A-Za-z0-9_$\\\\/.-]*\\.("
+ String.join("|", EXTENSIONS) + ")\\b",
Pattern.CASE_INSENSITIVE);
private final Database db;
private final Path dataDir;
public RefIndexer(Database db, Path dataDir) {
this.db = db;
this.dataDir = dataDir;
}
public record Stats(int scripts, int refs, int failed) {
}
public Stats run(boolean force) throws Exception {
Map<String, String> pending = pendingWork(force);
if (pending.isEmpty()) {
return new Stats(0, 0, 0);
}
Connection conn = db.connection();
boolean previousAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
int scripts = 0;
int refs = 0;
int failed = 0;
try (PreparedStatement delete = conn.prepareStatement(
"DELETE FROM script_ref WHERE script_sha1 = ?");
PreparedStatement insert = conn.prepareStatement("""
INSERT INTO script_ref (script_sha1, object, field, value, name, format, line)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(script_sha1, object, field, value) DO NOTHING
""")) {
for (Map.Entry<String, String> entry : pending.entrySet()) {
String sha1 = entry.getKey();
try {
String body = Files.readString(dataDir.resolve(entry.getValue()),
StandardCharsets.UTF_8);
delete.setString(1, sha1);
delete.executeUpdate();
refs += extract(sha1, body, insert);
scripts++;
} catch (Exception e) {
System.err.printf(" ! %s: %s%n", sha1, e.getMessage());
failed++;
}
}
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(previousAutoCommit);
}
return new Stats(scripts, refs, failed);
}
private int extract(String sha1, String body, PreparedStatement insert) throws Exception {
String[] lines = body.split("\n", -1);
int written = 0;
for (int number = 0; number < lines.length; number++) {
Matcher assignment = ASSIGNMENT.matcher(lines[number].trim());
if (!assignment.matches()) {
continue;
}
String object = assignment.group(1).toUpperCase(Locale.ROOT);
String field = assignment.group(2).toUpperCase(Locale.ROOT);
String rest = assignment.group(3);
Matcher filename = FILENAME.matcher(rest);
while (filename.find()) {
String value = filename.group();
insert.setString(1, sha1);
insert.setString(2, object);
insert.setString(3, field);
insert.setString(4, value);
insert.setString(5, basename(value));
insert.setString(6, filename.group(1).toUpperCase(Locale.ROOT));
insert.setInt(7, number + 1);
written += insert.executeUpdate();
}
}
return written;
}
/** Ścieżki w skryptach bywają z makrem katalogu ({@code $COMMON\...}) — liczy się nazwa. */
static String basename(String value) {
String path = value.replace('\\', '/');
int slash = path.lastIndexOf('/');
return (slash < 0 ? path : path.substring(slash + 1)).toLowerCase(Locale.ROOT);
}
/** Skrypty z cache'u, dla których nie mamy jeszcze odwołań. */
private Map<String, String> pendingWork(boolean force) throws Exception {
String sql = """
SELECT a.blob_sha1, a.path
FROM artifact a
WHERE a.kind = 'script'
""" + (force ? "" : """
AND NOT EXISTS (SELECT 1 FROM script_ref r WHERE r.script_sha1 = a.blob_sha1)
""");
Map<String, String> out = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement(sql);
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
out.put(rs.getString("blob_sha1"), rs.getString("path"));
}
}
return out;
}
public int pendingCount() throws Exception {
return pendingWork(false).size();
}
/** Czyści odwołania skryptów, których nie ma już w cache'u. */
public int prune() throws Exception {
try (Statement st = db.connection().createStatement()) {
return st.executeUpdate("""
DELETE FROM script_ref
WHERE script_sha1 NOT IN (SELECT blob_sha1 FROM artifact WHERE kind = 'script')
""");
}
}
}
@@ -0,0 +1,405 @@
package pl.genschu.rexcatalog.transcribe;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.media.MediaIndex;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Transkrypcja mowy przez whisper.cpp — uruchamiana ręcznie, nigdy sama z siebie.
*
* <p>Transkrypt jest <b>wygenerowany</b>, a nie odczytany z płyty, więc trafia do
* osobnej tabeli razem z nazwą modelu i narzędzia. Nie miesza się z metadanymi
* 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 —
* to, co już policzone, zostaje w bazie. Dzięki temu można puścić kolekcję na noc
* i zatrzymać rano bez straty.
*/
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;
/** Nazwy, pod którymi whisper.cpp bywa instalowany. */
private static final List<String> BINARIES =
List.of("whisper-cli", "whisper-cpp", "whisper", "main");
private final Database db;
private final MediaIndex media;
private final Object lock;
private final AtomicBoolean cancelled = new AtomicBoolean();
private volatile Thread worker;
private volatile Progress progress = Progress.idle();
public Transcriber(Database db, Object lock) {
this.db = db;
this.media = new MediaIndex(db);
this.lock = lock;
}
/** Gdzie leży whisper.cpp i model; {@code ready} mówi, czy da się w ogóle ruszyć. */
public record Tool(String binary, String model, boolean ready, String note) {
}
public record Progress(String state, int total, int done, int failed, String current,
long elapsedMs, String note) {
static Progress idle() {
return new Progress("bezczynny", 0, 0, 0, null, 0, null);
}
}
// ---------- wykrywanie narzędzi ----------
/**
* Szuka whisper.cpp i modelu. Ścieżki da się wskazać wprost właściwościami
* {@code catalog.whisper.bin} i {@code catalog.whisper.model} — bo model
* to kilkaset megabajtów i każdy trzyma go gdzie indziej.
*/
public static Tool detect() {
String binary = System.getProperty("catalog.whisper.bin",
System.getenv("WHISPER_BIN"));
if (binary == null) {
binary = BINARIES.stream().map(Transcriber::onPath)
.filter(java.util.Objects::nonNull).findFirst().orElse(null);
}
String model = System.getProperty("catalog.whisper.model",
System.getenv("WHISPER_MODEL"));
if (model == null) {
model = findModel();
}
if (binary == null) {
return new Tool(null, model, false,
"nie znalazłem whisper.cpp — zainstaluj (brew install whisper-cpp) "
+ "albo wskaż przez -Dcatalog.whisper.bin=");
}
if (model == null) {
return new Tool(binary, null, false,
"nie znalazłem modelu — wskaż przez -Dcatalog.whisper.model=ścieżka.bin");
}
if (!ffmpegAvailable()) {
return new Tool(binary, model, false,
"brakuje ffmpeg, a nagrania trzeba przepróbkować do 16 kHz mono");
}
return new Tool(binary, model, true, null);
}
private static String onPath(String name) {
String path = System.getenv("PATH");
if (path == null) {
return null;
}
for (String directory : path.split(java.io.File.pathSeparator)) {
Path candidate = Path.of(directory, name);
if (Files.isExecutable(candidate)) {
return candidate.toString();
}
}
return null;
}
private static String findModel() {
List<Path> places = List.of(
Path.of(System.getProperty("user.home"), ".cache", "whisper.cpp"),
Path.of(System.getProperty("user.home"), "Library", "Application Support",
"whisper.cpp"),
Path.of("/opt/homebrew/share/whisper.cpp/models"),
Path.of("/usr/local/share/whisper.cpp/models"),
Path.of("models"));
for (Path place : places) {
if (!Files.isDirectory(place)) {
continue;
}
try (var stream = Files.list(place)) {
// większy model bywa dokładniejszy, a przy polskim to widać
return stream.filter(p -> p.getFileName().toString().endsWith(".bin"))
.max(java.util.Comparator.comparingLong(Transcriber::sizeOf))
.map(Path::toString).orElse(null);
} catch (IOException ignored) {
// katalog może zniknąć między sprawdzeniem a listowaniem
}
}
return null;
}
private static long sizeOf(Path path) {
try {
return Files.size(path);
} catch (IOException e) {
return 0;
}
}
private static boolean ffmpegAvailable() {
return onPath("ffmpeg") != null;
}
// ---------- praca ----------
/** Co przetranskrybować: cała kolekcja albo jedno wydanie. */
public record Scope(Integer editionId, int limit, boolean redo) {
}
private record Job(String sha1, String name, String lang) {
}
public Progress progress() {
return progress;
}
public boolean running() {
Thread current = worker;
return current != null && current.isAlive();
}
public synchronized void start(Scope scope) throws Exception {
if (running()) {
throw new IllegalStateException("transkrypcja już trwa");
}
Tool tool = detect();
if (!tool.ready()) {
throw new IllegalStateException(tool.note());
}
List<Job> jobs;
synchronized (lock) {
jobs = pending(scope);
}
if (jobs.isEmpty()) {
progress = new Progress("bezczynny", 0, 0, 0, null, 0,
"nie ma czego transkrybować");
return;
}
cancelled.set(false);
long started = System.currentTimeMillis();
progress = new Progress("pracuje", jobs.size(), 0, 0, null, 0,
"model: " + Path.of(tool.model()).getFileName());
worker = new Thread(() -> run(tool, jobs, started), "transkrypcja");
worker.setDaemon(true);
worker.start();
}
public void stop() {
cancelled.set(true);
}
private void run(Tool tool, List<Job> jobs, long started) {
int done = 0;
int failed = 0;
Path workDir = null;
try {
workDir = Files.createTempDirectory("rex-transkrypcja");
for (Job job : 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) {
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++;
}
}
} catch (Exception e) {
progress = new Progress("błąd", jobs.size(), done, failed, null,
System.currentTimeMillis() - started, e.getMessage());
return;
} finally {
deleteQuietly(workDir);
}
progress = new Progress(cancelled.get() ? "przerwany" : "zakończony", jobs.size(),
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");
Files.deleteIfExists(wav);
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)
.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 ffmpegOutput = new String(ffmpeg.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
if (ffmpeg.waitFor() != 0) {
throw new IOException("ffmpeg: " + ffmpegOutput.trim());
}
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);
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 {
try (PreparedStatement st = db.connection().prepareStatement("""
INSERT INTO transcript (sha1, text, lang, model, tool, duration_ms, created_at)
VALUES (?, ?, ?, ?, 'whisper.cpp',
(SELECT duration_ms FROM media WHERE sha1 = ?), ?)
ON CONFLICT(sha1) DO UPDATE SET
text = excluded.text, lang = excluded.lang, model = excluded.model,
tool = excluded.tool, created_at = excluded.created_at
""")) {
st.setString(1, job.sha1());
st.setString(2, text);
st.setString(3, job.lang());
st.setString(4, Path.of(tool.model()).getFileName().toString());
st.setString(5, job.sha1());
st.setString(6, Instant.now().toString());
st.executeUpdate();
}
try (PreparedStatement delete = db.connection().prepareStatement(
"DELETE FROM transcript_fts WHERE sha1 = ?");
PreparedStatement insert = db.connection().prepareStatement(
"INSERT INTO transcript_fts (body, sha1) VALUES (?, ?)")) {
delete.setString(1, job.sha1());
delete.executeUpdate();
if (!text.isBlank()) {
insert.setString(1, text);
insert.setString(2, job.sha1());
insert.executeUpdate();
}
}
}
/**
* Wybiera mowę, pomijając efekty dźwiękowe — katalog {@code sfx/} i nazwy
* zaczynające się od {@code sfx_} to nie kwestie, a model i tak wypisałby
* dla nich przypadkowe słowa.
*/
private List<Job> pending(Scope scope) throws Exception {
List<Job> jobs = new ArrayList<>();
StringBuilder sql = new StringBuilder("""
SELECT klip.sha1 AS sha1, klip.name AS name FROM (
SELECT f.blob_sha1 AS sha1, f.path AS name, g.edition_id AS edition_id
FROM file f
JOIN blob b ON b.sha1 = f.blob_sha1
JOIN game_root g ON g.copy_id = f.copy_id
WHERE b.format = 'WAV'
UNION
SELECT ae.sha1, ae.name, g.edition_id
FROM archive_entry ae
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN game_root g ON g.copy_id = f.copy_id
) AS klip
JOIN media m ON m.sha1 = klip.sha1
LEFT JOIN transcript t ON t.sha1 = klip.sha1
WHERE m.kind = 'audio' AND COALESCE(m.duration_ms, 0) >= 300
AND klip.name NOT LIKE '%/sfx/%'
AND klip.name NOT LIKE 'sfx!_%' ESCAPE '!'
AND klip.name NOT LIKE '%/sfx!_%' ESCAPE '!'
""");
if (!scope.redo()) {
sql.append(" AND t.sha1 IS NULL\n");
}
if (scope.editionId() != null) {
sql.append(" AND klip.edition_id = ?\n");
}
sql.append("GROUP BY klip.sha1 ORDER BY klip.name LIMIT ?");
try (PreparedStatement st = db.connection().prepareStatement(sql.toString())) {
int index = 1;
if (scope.editionId() != null) {
st.setInt(index++, scope.editionId());
}
st.setInt(index, scope.limit() > 0 ? scope.limit() : 1_000_000);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
String name = rs.getString("name");
jobs.add(new Job(rs.getString("sha1"), name, languageOf(name)));
}
}
}
return jobs;
}
/**
* Wersja językowa wynika ze ścieżki: {@code wavs/cze/} to czeski dubbing.
* Kody katalogów są te same, których używa silnik.
*/
static String languageOf(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/cze/")) {
return "cs";
}
if (lower.contains("/hun/")) {
return "hu";
}
if (lower.contains("/slo/")) {
return "sk";
}
if (lower.contains("/niem/")) {
return "de";
}
return "pl";
}
private static void deleteQuietly(Path directory) {
if (directory == null) {
return;
}
try (var stream = Files.walk(directory)) {
stream.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// katalog tymczasowy, system i tak go w końcu sprzątnie
}
});
} catch (IOException ignored) {
// jw.
}
}
}
@@ -5,7 +5,9 @@ import com.google.gson.JsonObject;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.media.MediaIndex;
import pl.genschu.rexcatalog.script.ScriptSearch;
import pl.genschu.rexcatalog.transcribe.Transcriber;
import java.io.IOException;
import java.io.InputStream;
@@ -30,11 +32,15 @@ public final class WebServer {
private final Database db;
private final ScriptSearch search;
private final MediaIndex media;
private final Transcriber transcriber;
private final Object lock;
public WebServer(Database db, Path dataDir, Object lock) {
this.db = db;
this.search = new ScriptSearch(db, dataDir);
this.media = new MediaIndex(db);
this.transcriber = new Transcriber(db, lock);
this.lock = lock;
}
@@ -88,9 +94,15 @@ public final class WebServer {
String path = exchange.getRequestURI().getPath().substring("/api".length());
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
// dźwięk wychodzi bajtami, a nie JSON-em — obsługiwany przed resztą tras
if (path.startsWith("/audio/")) {
serveAudio(exchange, path.substring("/audio/".length()));
return;
}
Object payload;
synchronized (lock) {
payload = route(path, query);
payload = route(path, query, exchange.getRequestMethod());
}
if (payload == null) {
respond(exchange, 404, "application/json",
@@ -107,7 +119,8 @@ public final class WebServer {
}
}
private Object route(String path, Map<String, String> query) throws Exception {
private Object route(String path, Map<String, String> query, String method)
throws Exception {
if (path.equals("/stats")) {
return stats();
}
@@ -130,9 +143,103 @@ public final class WebServer {
if (path.startsWith("/scripts/")) {
return script(path.substring("/scripts/".length()));
}
if (path.equals("/audio-search")) {
return audioSearch(query.getOrDefault("q", ""), parseInt(query.get("limit"), 60));
}
if (path.startsWith("/media/")) {
return mediaInfo(path.substring("/media/".length()));
}
if (path.equals("/transcribe/status")) {
return transcribeStatus();
}
// start i stop zmieniają stan, więc tylko POST — GET bywa wywołany przez
// podgląd odsyłacza albo wczytanie z historii, a to godziny liczenia
if (path.equals("/transcribe/start")) {
return "POST".equals(method) ? transcribeStart(query) : methodNotAllowed();
}
if (path.equals("/transcribe/stop")) {
if (!"POST".equals(method)) {
return methodNotAllowed();
}
transcriber.stop();
return transcribeStatus();
}
return null;
}
private JsonObject methodNotAllowed() {
JsonObject out = new JsonObject();
out.addProperty("error", "ten zasób wymaga metody POST");
return out;
}
// ---------- dźwięk ----------
/**
* Podaje nagranie prosto z obrazu płyty. Nic nie ląduje na dysku — kopia
* zostaje jednym plikiem, a przeglądarka dostaje wycięty z niej fragment.
*/
private void serveAudio(HttpExchange exchange, String sha1) throws IOException {
MediaIndex.Clip clip;
try {
synchronized (lock) {
clip = media.read(sha1);
}
} catch (Exception e) {
respond(exchange, 500, "text/plain",
("Nie odczytałem nagrania: " + e.getMessage()).getBytes(UTF8));
return;
}
if (clip == null) {
respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8));
return;
}
exchange.getResponseHeaders().set("Content-Type", clip.contentType());
exchange.getResponseHeaders().set("Cache-Control", "public, max-age=86400");
exchange.sendResponseHeaders(200, clip.bytes().length);
exchange.getResponseBody().write(clip.bytes());
}
// ---------- transkrypcja ----------
private JsonObject transcribeStatus() {
Transcriber.Tool tool = Transcriber.detect();
Transcriber.Progress progress = transcriber.progress();
JsonObject out = new JsonObject();
out.addProperty("ready", tool.ready());
out.addProperty("tool_note", tool.note());
out.addProperty("binary", tool.binary());
out.addProperty("model", tool.model());
out.addProperty("running", transcriber.running());
out.addProperty("state", progress.state());
out.addProperty("total", progress.total());
out.addProperty("done", progress.done());
out.addProperty("failed", progress.failed());
out.addProperty("current", progress.current());
out.addProperty("elapsed_ms", progress.elapsedMs());
out.addProperty("note", progress.note());
return out;
}
private JsonObject transcribeStart(Map<String, String> query) throws Exception {
Integer edition = query.containsKey("edition")
? parseInt(query.get("edition"), -1) : null;
if (edition != null && edition < 0) {
edition = null;
}
try {
transcriber.start(new Transcriber.Scope(edition,
parseInt(query.get("limit"), 0),
"1".equals(query.get("redo"))));
} catch (IllegalStateException e) {
JsonObject out = transcribeStatus();
out.addProperty("error", e.getMessage());
return out;
}
return transcribeStatus();
}
private JsonObject stats() throws Exception {
JsonObject out = new JsonObject();
out.addProperty("copies", scalar("SELECT COUNT(*) FROM copy"));
@@ -142,6 +249,13 @@ public final class WebServer {
out.addProperty("blobs", scalar("SELECT COUNT(*) FROM blob"));
out.addProperty("scripts", scalar("SELECT COUNT(*) FROM script_fts"));
out.addProperty("bytes", scalar("SELECT COALESCE(SUM(size), 0) FROM blob"));
out.addProperty("audio", scalar("SELECT COUNT(*) FROM media WHERE kind = 'audio'"));
out.addProperty("audio_ms",
scalar("SELECT COALESCE(SUM(duration_ms), 0) FROM media WHERE kind = 'audio'"));
out.addProperty("archive_entries", scalar("SELECT COUNT(*) FROM archive_entry"));
out.addProperty("dialogue", scalar("SELECT COUNT(*) FROM dialogue_line"));
out.addProperty("refs", scalar("SELECT COUNT(*) FROM script_ref"));
out.addProperty("transcripts", scalar("SELECT COUNT(*) FROM transcript"));
JsonArray formats = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(
@@ -365,6 +479,283 @@ public final class WebServer {
out.addProperty("body", body);
out.add("paths", toArray(search.pathsFor(sha1)));
out.add("games", toArray(search.gamesFor(sha1)));
out.add("refs", refs(sha1));
return out;
}
/**
* Zasoby, do których odwołuje się skrypt, rozwiązane do konkretnych plików.
*
* <p>To jest sedno przeglądania: przy {@code SNDQUESTION:FILENAME=BUREKTOR_M030.WAV}
* widać od razu, ile ta kwestia trwa, kto ją wypowiada i przy jakim zdarzeniu
* pada — bez otwierania gry.
*/
private JsonArray refs(String scriptSha1) throws Exception {
JsonArray out = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
WITH kopia AS (
SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1
)
SELECT r.object, r.field, r.value, r.name, r.format, r.line,
COALESCE(
(SELECT f2.blob_sha1 FROM file f2, kopia
WHERE f2.copy_id = kopia.copy_id
AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1),
(SELECT ae.sha1 FROM archive_entry ae
JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia
WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1)
) AS target,
(SELECT d.speaker FROM dialogue_line d
WHERE d.audio_name = r.name LIMIT 1) AS speaker,
(SELECT d.trigger FROM dialogue_line d
WHERE d.audio_name = r.name LIMIT 1) AS trigger
FROM script_ref r
WHERE r.script_sha1 = ?
ORDER BY r.line, r.object
""")) {
st.setString(1, scriptSha1);
st.setString(2, scriptSha1);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
for (String column : List.of("object", "field", "value", "name", "format",
"target", "speaker", "trigger")) {
row.addProperty(column, rs.getString(column));
}
row.addProperty("line", rs.getInt("line"));
out.add(row);
}
}
}
// fakty o zasobach dokładamy jednym przebiegiem, żeby nie pytać bazy po razie na wiersz
java.util.Set<String> targets = new java.util.LinkedHashSet<>();
for (var element : out) {
String target = element.getAsJsonObject().get("target").isJsonNull()
? null : element.getAsJsonObject().get("target").getAsString();
if (target != null) {
targets.add(target);
}
}
Map<String, JsonObject> facts = mediaFacts(targets);
for (var element : out) {
JsonObject row = element.getAsJsonObject();
if (!row.get("target").isJsonNull()) {
JsonObject fact = facts.get(row.get("target").getAsString());
if (fact != null) {
row.add("media", fact);
}
}
}
return out;
}
private Map<String, JsonObject> mediaFacts(java.util.Set<String> sha1s) throws Exception {
Map<String, JsonObject> out = new LinkedHashMap<>();
if (sha1s.isEmpty()) {
return out;
}
String placeholders = String.join(",", java.util.Collections.nCopies(sha1s.size(), "?"));
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT m.sha1, m.codec, m.duration_ms, m.sample_rate, m.channels,
t.text AS transcript, t.model AS transcript_model
FROM media m
LEFT JOIN transcript t ON t.sha1 = m.sha1
WHERE m.sha1 IN (%s)
""".formatted(placeholders))) {
int index = 1;
for (String sha1 : sha1s) {
st.setString(index++, sha1);
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("codec", rs.getString("codec"));
row.addProperty("duration_ms", rs.getInt("duration_ms"));
row.addProperty("sample_rate", rs.getInt("sample_rate"));
row.addProperty("channels", rs.getInt("channels"));
row.addProperty("transcript", rs.getString("transcript"));
row.addProperty("transcript_model", rs.getString("transcript_model"));
out.put(rs.getString("sha1"), row);
}
}
}
return out;
}
/**
* Szuka nagrań po nazwie, a gdy transkrypty już są — również po tym, co słychać.
*
* <p>Nazwa i treść to dwa różne źródła, więc trafienia z transkryptu są osobno
* oznaczone: jedno jest zapisane na płycie, drugie zgadnięte przez model.
*/
private JsonObject audioSearch(String query, int limit) throws Exception {
JsonObject out = new JsonObject();
JsonArray hits = new JsonArray();
out.addProperty("query", query);
out.add("hits", hits);
if (query.isBlank()) {
return out;
}
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT klip.sha1, klip.name, klip.copy, m.duration_ms, m.codec,
(SELECT d.speaker FROM dialogue_line d
WHERE d.audio_name = klip.short LIMIT 1) AS speaker,
t.text AS transcript,
CASE WHEN instr(lower(klip.name), lower(?)) > 0 THEN 1 ELSE 0 END AS by_name
FROM (
SELECT f.blob_sha1 AS sha1, COALESCE(f.path_raw, f.path) AS name,
CASE WHEN instr(f.path, '/') > 0
THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '')
ELSE f.path END AS short,
c.display_name AS copy
FROM file f JOIN blob b ON b.sha1 = f.blob_sha1
JOIN copy c ON c.id = f.copy_id
WHERE b.format = 'WAV'
UNION
SELECT ae.sha1, ae.name_raw, ae.name, c.display_name
FROM archive_entry ae
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
) AS klip
JOIN media m ON m.sha1 = klip.sha1
LEFT JOIN transcript t ON t.sha1 = klip.sha1
WHERE instr(lower(klip.name), lower(?)) > 0
OR (t.text IS NOT NULL AND instr(lower(t.text), lower(?)) > 0)
GROUP BY klip.sha1
ORDER BY by_name DESC, klip.name
LIMIT ?
""")) {
st.setString(1, query);
st.setString(2, query);
st.setString(3, query);
st.setInt(4, Math.min(limit, 300));
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("sha1", rs.getString("sha1"));
row.addProperty("name", rs.getString("name"));
row.addProperty("copy", rs.getString("copy"));
row.addProperty("duration_ms", rs.getInt("duration_ms"));
row.addProperty("codec", rs.getString("codec"));
row.addProperty("speaker", rs.getString("speaker"));
row.addProperty("transcript", rs.getString("transcript"));
row.addProperty("by_name", rs.getInt("by_name") == 1);
hits.add(row);
}
}
}
return out;
}
/** Wszystko, co katalog wie o jednym zasobie: fakty, miejsca, dialog, transkrypt. */
private JsonObject mediaInfo(String sha1) throws Exception {
JsonObject out = new JsonObject();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.sample_rate, m.channels,
m.bits, m.note, t.text AS transcript, t.model AS transcript_model,
t.lang AS transcript_lang, t.created_at AS transcript_at
FROM media m
LEFT JOIN transcript t ON t.sha1 = m.sha1
WHERE m.sha1 = ?
""")) {
st.setString(1, sha1);
try (ResultSet rs = st.executeQuery()) {
if (!rs.next()) {
return null;
}
for (String column : List.of("kind", "codec", "note", "transcript",
"transcript_model", "transcript_lang", "transcript_at")) {
out.addProperty(column, rs.getString(column));
}
out.addProperty("sha1", sha1);
out.addProperty("duration_ms", rs.getInt("duration_ms"));
out.addProperty("sample_rate", rs.getInt("sample_rate"));
out.addProperty("channels", rs.getInt("channels"));
out.addProperty("bits", rs.getInt("bits"));
}
}
JsonArray places = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT COALESCE(f.path_raw, f.path) AS path, c.display_name, 0 AS in_archive
FROM file f JOIN copy c ON c.id = f.copy_id WHERE f.blob_sha1 = ?
UNION ALL
SELECT ae.name_raw, c.display_name, 1
FROM archive_entry ae
JOIN file f ON f.blob_sha1 = ae.container_sha1
JOIN copy c ON c.id = f.copy_id
WHERE ae.sha1 = ?
""")) {
st.setString(1, sha1);
st.setString(2, sha1);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("path", rs.getString(1));
row.addProperty("copy", rs.getString(2));
row.addProperty("in_archive", rs.getInt(3) == 1);
places.add(row);
}
}
}
out.add("places", places);
JsonArray dialogue = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT DISTINCT d.speaker, d.trigger, d.section, d.extra
FROM dialogue_line d
WHERE d.audio_name IN (
SELECT CASE WHEN instr(f.path, '/') > 0
THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '')
ELSE f.path END
FROM file f WHERE f.blob_sha1 = ?
UNION SELECT ae.name FROM archive_entry ae WHERE ae.sha1 = ?
)
""")) {
st.setString(1, sha1);
st.setString(2, sha1);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("speaker", rs.getString("speaker"));
row.addProperty("trigger", rs.getString("trigger"));
row.addProperty("section", rs.getString("section"));
row.addProperty("extra", rs.getString("extra"));
dialogue.add(row);
}
}
}
out.add("dialogue", dialogue);
JsonArray usedBy = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT DISTINCT r.script_sha1, r.object, r.field, a.path
FROM script_ref r
JOIN artifact a ON a.blob_sha1 = r.script_sha1 AND a.kind = 'script'
WHERE r.name IN (
SELECT CASE WHEN instr(f.path, '/') > 0
THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '')
ELSE f.path END
FROM file f WHERE f.blob_sha1 = ?
UNION SELECT ae.name FROM archive_entry ae WHERE ae.sha1 = ?
)
LIMIT 50
""")) {
st.setString(1, sha1);
st.setString(2, sha1);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("script_sha1", rs.getString("script_sha1"));
row.addProperty("object", rs.getString("object"));
row.addProperty("field", rs.getString("field"));
usedBy.add(row);
}
}
}
out.add("used_by", usedBy);
return out;
}
+226 -3
View File
@@ -34,6 +34,9 @@ nav button {
}
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
main { padding: 18px 20px; max-width: 1500px; }
/* własna reguła display bije [hidden] z arkusza przeglądarki, więc sekcja
z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */
[hidden] { display: none !important; }
.cols { display: grid; grid-template-columns: minmax(320px, 460px) 1fr; gap: 18px; align-items: start; }
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
.card {
@@ -81,6 +84,19 @@ h2 { font-size: 14px; margin: 0 0 10px; }
h3 { font-size: 12px; margin: 16px 0 6px; color: var(--dim);
text-transform: uppercase; letter-spacing: .05em; }
a { color: var(--accent); }
.zasob { display: grid; grid-template-columns: 1fr auto; gap: 6px 12px;
padding: 7px 8px; border-bottom: 1px solid var(--line); align-items: center; }
.zasob:last-child { border-bottom: 0; }
.zasob audio { height: 30px; width: 240px; }
.kwestia { grid-column: 1 / -1; font-size: 12.5px; padding: 2px 0 0 2px;
border-left: 2px solid var(--accent); padding-left: 8px; }
.maszyna { color: var(--dim); font-size: 11px; font-style: italic; }
.postep { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
.postep .bar { flex: 1; height: 10px; }
button.akcja { border: 1px solid var(--line); background: var(--panel); color: var(--ink);
padding: 6px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12.5px; }
button.akcja:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button.akcja:disabled { opacity: .45; cursor: default; }
</style>
</head>
<body>
@@ -91,6 +107,7 @@ a { color: var(--accent); }
<nav>
<button data-view="katalog" class="on">Katalog</button>
<button data-view="skrypty">Skrypty</button>
<button data-view="dzwiek">Dźwięk</button>
<button data-view="formaty">Formaty</button>
</nav>
</header>
@@ -124,6 +141,28 @@ a { color: var(--accent); }
<div id="podglad"><div class="card empty">Wybierz trafienie, żeby zobaczyć skrypt.</div></div>
</section>
<section id="dzwiek" hidden class="cols">
<div>
<div class="card">
<h2>Transkrypcja mowy</h2>
<div class="dim" style="font-size:12.5px;margin:6px 0 10px">
Rozpoznawanie mowy przez whisper.cpp. Wynik jest <b>wygenerowany maszynowo</b>,
a nie odczytany z płyty — trzymamy go osobno od metadanych wydania.
</div>
<div id="whisper"></div>
</div>
<div class="card">
<input type="search" id="szukajdzwiek"
placeholder="Szukaj nagrania po nazwie, np. kret_e5" autocomplete="off">
<div class="dim" style="margin-top:8px;font-size:12px">
Można też szukać po treści transkryptu, jeśli już jest.
</div>
</div>
<div class="card" id="wynikidzwiek"><div class="empty">Wpisz zapytanie.</div></div>
</div>
<div id="podgladdzwiek"><div class="card empty">Wybierz nagranie.</div></div>
</section>
<section id="formaty" hidden>
<div class="card" style="max-width:620px">
<h2>Rozkład formatów</h2>
@@ -141,13 +180,15 @@ const mb = b => b >= 1048576 ? (b/1048576).toFixed(1)+' MB'
: b >= 1024 ? (b/1024).toFixed(1)+' kB' : b+' B';
const get = async u => { const r = await fetch(u); if (!r.ok) throw new Error(await r.text());
return r.json(); };
const post = async u => { const r = await fetch(u, {method: 'POST'});
if (!r.ok) throw new Error(await r.text()); return r.json(); };
let tytuly = [];
// ---- nawigacja ----
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
['katalog','skrypty','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view);
['katalog','skrypty','dzwiek','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view);
});
// ---- katalog ----
@@ -316,16 +357,195 @@ async function otworzSkrypt(sha1) {
Ten sam plik pod ${s.paths.length} ścieżkami:<br>
<span class="mono">${s.paths.map(esc).join('<br>')}</span></div>` : ''}
</div>
${rysujZasoby(s.refs || [])}
<div class="card"><pre>${esc(s.body)}</pre></div>`;
}
const czas = ms => ms == null ? '' :
ms >= 60000 ? Math.floor(ms/60000) + ' min ' + Math.round(ms%60000/1000) + ' s'
: (ms/1000).toFixed(1) + ' s';
/**
* Zasoby, do których odwołuje się skrypt. Dźwięk dostaje odtwarzacz, bo o to
* chodzi: widząc SNDQUESTION:FILENAME=KRET_E511.WAV chce się od razu wiedzieć,
* co w tym pliku jest — kto mówi, jak długo i co konkretnie.
*/
function rysujZasoby(refs) {
if (!refs.length) return '';
const audio = refs.filter(r => r.format === 'WAV' || r.format === 'OGG');
const reszta = refs.filter(r => !(r.format === 'WAV' || r.format === 'OGG'));
const brak = refs.filter(r => !r.target).length;
const wiersz = r => {
const m = r.media || {};
const opis = [r.speaker, r.trigger].filter(Boolean).map(esc).join(' · ');
return `<div class="zasob">
<div>
<span class="mono">${esc(r.value)}</span>
<span class="dim" style="font-size:11.5px"> ${esc(r.object)}:${esc(r.field)}</span>
${opis ? `<div class="dim" style="font-size:12px">${opis}</div>` : ''}
</div>
<div>${r.target
? `<audio controls preload="none" src="/api/audio/${r.target}"></audio>
<div class="dim" style="font-size:11px;text-align:right">
${czas(m.duration_ms)} ${m.codec ? '· ' + esc(m.codec) : ''}</div>`
: '<span class="dim" style="font-size:12px">brak na płycie</span>'}</div>
${m.transcript ? `<div class="kwestia">${esc(m.transcript)}
<div class="maszyna">rozpoznane maszynowo — ${esc(m.transcript_model)}</div></div>` : ''}
</div>`;
};
return `<div class="card">
<h2>Zasoby ${refs.length}
${brak ? `<span class="dim" style="font-weight:400;font-size:12px">
· ${brak} bez pliku na płycie</span>` : ''}</h2>
${audio.length ? `<h3>Dźwięk (${audio.length})</h3>${audio.map(wiersz).join('')}` : ''}
${reszta.length ? `<h3>Pozostałe (${reszta.length})</h3>
<div style="columns:2;column-gap:20px;font-size:12.5px">
${reszta.map(r => `<div class="mono" style="break-inside:avoid;padding:1px 0">
${r.target ? '' : '<span class="dim">·</span> '}${esc(r.value)}
<span class="dim" style="font-size:11px">${esc(r.format)}</span></div>`).join('')}
</div>` : ''}
</div>`;
}
// ---- dźwięk ----
let czekajD;
$('#szukajdzwiek').oninput = e => {
clearTimeout(czekajD);
const q = e.target.value.trim();
czekajD = setTimeout(() => szukajDzwieku(q), 250);
};
async function szukajDzwieku(q) {
if (!q) { $('#wynikidzwiek').innerHTML = '<div class="empty">Wpisz zapytanie.</div>'; return; }
$('#wynikidzwiek').innerHTML = '<div class="empty">Szukam…</div>';
const r = await get('/api/audio-search?q=' + encodeURIComponent(q));
if (!r.hits.length) {
$('#wynikidzwiek').innerHTML = '<div class="empty">Nic nie pasuje.</div>';
return;
}
$('#wynikidzwiek').innerHTML =
`<div class="dim" style="margin-bottom:8px">${r.hits.length} nagrań</div>` +
r.hits.map(h => `
<div class="wynik" data-sha1="${h.sha1}" style="padding:7px;border-radius:6px;cursor:pointer">
<div class="mono">${esc(h.name)}
<span class="dim" style="font-size:11px">${czas(h.duration_ms)}</span></div>
<div class="dim" style="font-size:12px">${esc(h.copy)}
${h.speaker ? '· ' + esc(h.speaker) : ''}
${h.by_name ? '' : '· trafienie w transkrypcie'}</div>
${h.transcript ? `<div class="snip">${esc(h.transcript)}</div>` : ''}
</div>`).join('');
$('#wynikidzwiek').querySelectorAll('.wynik').forEach(d => {
d.onmouseenter = () => d.style.background = 'var(--hl)';
d.onmouseleave = () => d.style.background = '';
d.onclick = () => otworzNagranie(d.dataset.sha1);
});
}
async function otworzNagranie(sha1) {
$('#podgladdzwiek').innerHTML = '<div class="card empty">Wczytuję…</div>';
const m = await get('/api/media/' + sha1);
const d = m.dialogue[0] || {};
$('#podgladdzwiek').innerHTML = `
<div class="card">
<h2 class="mono" style="font-size:13px">${esc(m.places[0]?.path || sha1)}</h2>
<div class="dim" style="font-size:12px">${esc(m.places[0]?.copy || '')}
${m.places[0]?.in_archive ? '· wewnątrz archiwum wav.snd' : ''}</div>
<div style="margin:12px 0"><audio controls preload="metadata"
src="/api/audio/${sha1}" style="width:100%"></audio></div>
<dl>
<dt>Długość</dt><dd>${czas(m.duration_ms)}</dd>
<dt>Format</dt><dd>${esc(m.codec)} · ${m.sample_rate} Hz ·
${m.channels === 1 ? 'mono' : m.channels + ' kanały'}</dd>
${d.speaker ? `<dt>Mówi</dt><dd>${esc(d.speaker)}</dd>` : ''}
${d.trigger ? `<dt>Zdarzenie</dt><dd class="mono">${esc(d.trigger)}</dd>` : ''}
${d.section ? `<dt>Scena</dt><dd>${esc(d.section)}</dd>` : ''}
<dt>sha1</dt><dd class="mono" style="font-size:11px">${esc(sha1)}</dd>
</dl>
</div>
${m.transcript ? `<div class="card">
<h2>Co słychać</h2>
<div style="margin-top:8px">${esc(m.transcript)}</div>
<div class="maszyna" style="margin-top:8px">rozpoznane maszynowo przez
${esc(m.transcript_model)}, język ${esc(m.transcript_lang)}
to hipoteza modelu, nie zapis z płyty</div>
</div>` : ''}
${m.used_by.length ? `<div class="card">
<h3>Używane przez ${m.used_by.length} obiektów w skryptach</h3>
${m.used_by.slice(0, 12).map(u => `<div style="font-size:12.5px;padding:2px 0">
<a href="#" data-skrypt="${u.script_sha1}" class="mono">${esc(u.object)}</a>
<span class="dim">:${esc(u.field)}</span></div>`).join('')}
</div>` : ''}`;
$('#podgladdzwiek').querySelectorAll('[data-skrypt]').forEach(a =>
a.onclick = e => { e.preventDefault(); otworzSkrypt(a.dataset.skrypt); });
}
// ---- transkrypcja ----
let pytajOStan = null;
function rysujWhisper(st) {
const pasek = st.total
? `<div class="postep">
<div class="bar"><i style="width:${(st.done + st.failed) / st.total * 100}%"></i></div>
<span class="mono" style="font-size:12px">${st.done + st.failed} / ${st.total}</span>
</div>
<div class="dim" style="font-size:12px">
${st.current ? 'teraz: <span class="mono">' + esc(st.current) + '</span>' : ''}
${st.failed ? ' · nieudanych: ' + st.failed : ''}
${st.elapsed_ms ? ' · ' + czas(st.elapsed_ms) : ''}
</div>` : '';
$('#whisper').innerHTML = st.ready
? `<div class="dim" style="font-size:12px">
model: <span class="mono">${esc((st.model || '').split('/').pop())}</span>
</div>
${pasek}
<div style="margin-top:10px;display:flex;gap:8px">
<button class="akcja" id="start" ${st.running ? 'disabled' : ''}>
${st.running ? 'Pracuje…' : 'Transkrybuj kolekcję'}</button>
<button class="akcja" id="stop" ${st.running ? '' : 'disabled'}>Zatrzymaj</button>
</div>
${st.state !== 'bezczynny' && !st.running
? `<div class="dim" style="font-size:12px;margin-top:8px">stan: ${esc(st.state)}</div>`
: ''}`
: `<div class="dim" style="font-size:12.5px">${esc(st.tool_note)}</div>
<div class="dim" style="font-size:12px;margin-top:8px">
Katalog działa bez tego — mówcę i kontekst kwestii bierzemy z
<span class="mono">dialogi.dta</span>, a nie z rozpoznawania mowy.
</div>`;
const start = $('#start'), stop = $('#stop');
if (start) start.onclick = async () => {
start.disabled = true;
rysujWhisper(await post('/api/transcribe/start'));
sledzPostep();
};
if (stop) stop.onclick = async () => {
stop.disabled = true;
rysujWhisper(await post('/api/transcribe/stop'));
};
}
function sledzPostep() {
clearInterval(pytajOStan);
pytajOStan = setInterval(async () => {
const st = await get('/api/transcribe/status');
rysujWhisper(st);
if (!st.running) clearInterval(pytajOStan);
}, 1000);
}
// ---- start ----
(async () => {
const s = await get('/api/stats');
$('#stats').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów w indeksie `
+ `· ${mb(s.bytes)}`;
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów `
+ `· ${s.audio.toLocaleString('pl')} nagrań (${(s.audio_ms/3600000).toFixed(1)} h) `
+ `· ${s.refs.toLocaleString('pl')} powiązań · ${mb(s.bytes)}`;
const max = Math.max(...s.formats.map(f => f.n));
$('#tabformaty').innerHTML = s.formats.map(f => `
@@ -335,6 +555,9 @@ async function otworzSkrypt(sha1) {
<td style="width:170px"><div class="bar"><i style="width:${f.n/max*100}%"></i></div></td>
</tr>`).join('');
rysujWhisper(await get('/api/transcribe/status'));
if ((await get('/api/transcribe/status')).running) sledzPostep();
tytuly = await get('/api/titles');
rysujTytuly();
$('#filtr').oninput = e => rysujTytuly(e.target.value);