JJ_MKVMaker/lib/core/ai_subtitle.dart

107 lines
4.3 KiB
Dart

import 'languages.dart';
import 'srt.dart';
/// 음성인식에 쓰는 Whisper 언어 코드 (zh-Hans → zh)
String whisperCode(Language l) => l.code.split('-').first;
/// 영상 → 16kHz 모노 WAV (음성인식 입력)
List<String> buildExtractAudioArgs(String video, String wav) => [
'-hide_banner', '-nostdin', '-y', '-i', video,
'-map', '0:a:0', '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le',
'-progress', 'pipe:1', '-nostats', wav,
];
/// 인식한 글자의 문자 체계로 원어 추정.
///
/// 한글 → 한국어, 가나 → 일본어, 한자만 → 중국어, 키릴 → 러시아어, 태국·아랍·힌디 문자,
/// 라틴 문자는 자주 쓰는 단어로 영어·스페인어·프랑스어·독일어·포르투갈어·이탈리아어·베트남어 구분.
Language detectLanguage(String text) {
var hangul = 0, kana = 0, han = 0, cyr = 0, thai = 0, arab = 0, deva = 0, latin = 0;
for (final r in text.runes) {
if (r >= 0xAC00 && r <= 0xD7A3 || r >= 0x1100 && r <= 0x11FF || r >= 0x3130 && r <= 0x318F) {
hangul++;
} else if (r >= 0x3040 && r <= 0x30FF) {
kana++;
} else if (r >= 0x4E00 && r <= 0x9FFF) {
han++;
} else if (r >= 0x0400 && r <= 0x04FF) {
cyr++;
} else if (r >= 0x0E00 && r <= 0x0E7F) {
thai++;
} else if (r >= 0x0600 && r <= 0x06FF) {
arab++;
} else if (r >= 0x0900 && r <= 0x097F) {
deva++;
} else if (r >= 0x41 && r <= 0x5A || r >= 0x61 && r <= 0x7A || r >= 0xC0 && r <= 0x24F || r >= 0x1E00 && r <= 0x1EFF) {
latin++;
}
}
final total = hangul + kana + han + cyr + thai + arab + deva + latin;
if (total == 0) return undetermined;
if (hangul >= total * 0.3) return languageOf('ko');
if (kana > 0 && kana + han >= total * 0.3) return languageOf('ja');
if (han >= total * 0.3) return languageOf('zh-Hans');
if (cyr >= total * 0.3) return languageOf('ru');
if (thai >= total * 0.3) return languageOf('th');
if (arab >= total * 0.3) return languageOf('ar');
if (deva >= total * 0.3) return languageOf('hi');
// 라틴 문자: 자주 쓰는 단어 점수
const words = {
'en': ['the', 'and', 'is', 'you', 'to', 'of', 'it', 'that', 'this', 'we', 'what', 'are'],
'es': ['el', 'la', 'que', 'de', 'y', 'es', 'los', 'por', 'una', 'pero', 'qué', 'está'],
'fr': ['le', 'la', 'les', 'et', 'est', 'je', 'vous', 'pas', 'une', 'que', 'c\'est', 'nous'],
'de': ['der', 'die', 'das', 'und', 'ist', 'ich', 'nicht', 'sie', 'ein', 'zu', 'wir', 'es'],
'pt': ['o', 'a', 'que', 'de', 'e', 'não', 'um', 'uma', 'você', 'é', 'para', 'isso'],
'it': ['il', 'di', 'che', 'e', 'la', 'non', 'è', 'un', 'per', 'sono', 'io', 'questo'],
'vi': ['và', 'là', 'của', 'không', 'có', 'tôi', 'một', 'được', 'này', 'những', 'người', 'với'],
};
final tokens = text.toLowerCase().split(RegExp(r"[^\p{L}']+", unicode: true));
var best = 'en';
var bestScore = 0;
for (final e in words.entries) {
final set = e.value.toSet();
final score = tokens.where(set.contains).length;
if (score > bestScore) {
bestScore = score;
best = e.key;
}
}
return languageOf(best);
}
final _nonSpeech = RegExp(r'^\s*[\[\((【♪*].*[\]\))】♪*]\s*$');
/// 음성인식 구간 → 자막 줄 정리
/// - 빈 줄, "[BLANK_AUDIO]" · "(음악)" 같은 비음성 표시 제거
/// - 긴 줄은 두 줄로 나눔 (한 줄 약 [maxLine] 글자)
List<Cue> cleanRecognized(List<Cue> raw, {int maxLine = 42}) {
final out = <Cue>[];
for (final c in raw) {
final t = c.text.replaceAll(RegExp(r'\s+'), ' ').trim();
if (t.isEmpty || _nonSpeech.hasMatch(t)) continue;
if (c.end <= c.start) continue;
out.add(Cue(c.start, c.end, _wrap(t, maxLine)));
}
return out;
}
String _wrap(String t, int maxLine) {
if (t.length <= maxLine) return t;
// 가운데에 가장 가까운 공백에서 나눔 (공백이 없는 언어는 가운데)
final mid = t.length ~/ 2;
var cut = -1;
for (var d = 0; d < mid; d++) {
if (mid + d < t.length && t[mid + d] == ' ') {
cut = mid + d;
break;
}
if (mid - d > 0 && t[mid - d] == ' ') {
cut = mid - d;
break;
}
}
if (cut < 0) return '${t.substring(0, mid)}\n${t.substring(mid)}';
return '${t.substring(0, cut)}\n${t.substring(cut + 1)}';
}