# Libraries import json import os import re import textwrap import threading import time import traceback import tiktoken import openai from pathlib import Path from colorama import Fore from dotenv import load_dotenv from retry import retry from tqdm import tqdm # Open AI load_dotenv() if os.getenv("api").replace(" ", "") != "": openai.base_url = os.getenv("api") openai.organization = os.getenv("org") openai.api_key = os.getenv("key") # Globals MODEL = os.getenv("model") TIMEOUT = int(os.getenv("timeout")) LANGUAGE = os.getenv("language").capitalize() PROMPT = Path("prompt.txt").read_text(encoding="utf-8") VOCAB = Path("vocab.txt").read_text(encoding="utf-8") THREADS = int(os.getenv("threads")) LOCK = threading.Lock() WIDTH = int(os.getenv("width")) LISTWIDTH = int(os.getenv("listWidth")) NOTEWIDTH = 70 MAXHISTORY = 10 ESTIMATE = "" TOKENS = [0, 0] NAMESLIST = [] NAMES = False # Output a list of all the character names found BRFLAG = False # If the game uses
instead FIXTEXTWRAP = True # Overwrites textwrap IGNORETLTEXT = False # Ignores all translated text. MISMATCH = [] # Lists files that throw a mismatch error (Length of GPT list response is wrong) # tqdm Globals BAR_FORMAT = "{l_bar}{bar:10}{r_bar}{bar:-10b}" POSITION = 0 LEAVE = False PBAR = None FILENAME = None # Full Width ascii_to_wide = dict((i, chr(i + 0xFEE0)) for i in range(0x21, 0x7F)) ascii_to_wide.update({0x20: "\u3000", 0x2D: "\u2212"}) # space and minus wide_to_ascii = dict((i, chr(i - 0xFEE0)) for i in range(0xFF01, 0xFF5F)) wide_to_ascii.update({0x3000: " ", 0x2212: "-"}) # space and minus # Pricing - Depends on the model https://openai.com/pricing # Batch Size - GPT 3.5 Struggles past 15 lines per request. GPT4 struggles past 50 lines per request # If you are getting a MISMATCH LENGTH error, lower the batch size. if "gpt-3.5" in MODEL: INPUTAPICOST = 0.002 OUTPUTAPICOST = 0.002 BATCHSIZE = 10 elif "gpt-4" in MODEL: INPUTAPICOST = 0.0025 OUTPUTAPICOST = 0.01 BATCHSIZE = 40 else: INPUTAPICOST = float(os.getenv("input_cost")) OUTPUTAPICOST = float(os.getenv("output_cost")) BATCHSIZE = int(os.getenv("batchsize")) FREQUENCY_PENALTY = float(os.getenv("frequency_penalty")) def handleOnscripter(filename, estimate): global ESTIMATE, FILENAME ESTIMATE = estimate FILENAME = filename if ESTIMATE: start = time.time() translatedData = openFiles(filename) # Print Result end = time.time() tqdm.write(getResultString(translatedData, end - start, filename)) with LOCK: TOKENS[0] += translatedData[1][0] TOKENS[1] += translatedData[1][1] # Print Total totalString = getResultString(["", TOKENS, None], end - start, "TOTAL") # Print any errors on maps if len(MISMATCH) > 0: return totalString + Fore.RED + f"\nMismatch Errors: {MISMATCH}" + Fore.RESET else: return totalString else: try: with open("translated/" + filename, "w", encoding="cp932", errors="ignore") as outFile: start = time.time() translatedData = openFiles(filename) # Print Result end = time.time() outFile.writelines(translatedData[0]) tqdm.write(getResultString(translatedData, end - start, filename)) with LOCK: TOKENS[0] += translatedData[1][0] TOKENS[1] += translatedData[1][1] except Exception: traceback.print_exc() return "Fail" return getResultString(["", TOKENS, None], end - start, "TOTAL") def getResultString(translatedData, translationTime, filename): # File Print String totalTokenstring = ( Fore.YELLOW + "[Input: " + str(translatedData[1][0]) + "]" "[Output: " + str(translatedData[1][1]) + "]" "[Cost: ${:,.4f}".format((translatedData[1][0] * 0.001 * INPUTAPICOST) + (translatedData[1][1] * 0.001 * OUTPUTAPICOST)) + "]" ) timeString = Fore.BLUE + "[" + str(round(translationTime, 1)) + "s]" if translatedData[2] == None: # Success return filename + ": " + totalTokenstring + timeString + Fore.GREEN + " \u2713 " + Fore.RESET else: # Fail try: raise translatedData[2] except Exception as e: traceback.print_exc() errorString = str(e) + Fore.RED return filename + ": " + totalTokenstring + timeString + Fore.RED + " \u2717 " + errorString + Fore.RESET def openFiles(filename): with open("files/" + filename, "r", encoding="cp932") as readFile: translatedData = parseOnscripter(readFile, filename) # Delete lines marked for deletion finalData = [] for line in translatedData[0]: if line != "\\d\n": finalData.append(line) translatedData[0] = finalData return translatedData def parseOnscripter(readFile, filename): totalTokens = [0, 0] # Read File into data data = readFile.readlines() # Create Progress Bar with tqdm(bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE) as pbar: pbar.desc = filename try: result = translateOnscripter(data, pbar, filename, []) totalTokens[0] += result[0] totalTokens[1] += result[1] except Exception as e: traceback.print_exc() return [data, totalTokens, e] return [data, totalTokens, None] def translateOnscripter(data, pbar, filename, translatedList): stringList = [] currentGroup = [] tokens = [0, 0] speaker = "" voice = False global LOCK, ESTIMATE, PBAR PBAR = pbar i = 0 # Dialogue while i < len(data): # Lines regex = r"^([\u3000「(【[][^\n]+)" match = re.search(regex, data[i]) if match != None and match.group(1) != "": originalString = match.group(1) # Pass 1 if translatedList == []: # Grab Consecutive Strings jaString = match.group(1) while len(data) > i + 1 and re.match(regex, data[i + 1]): data[i] = "" i += 1 jaString = f"{jaString} {data[i]}" # Convert from Wide jaString = jaString.translate(wide_to_ascii) # Remove any textwrap and \u3000 and \ jaString = jaString.replace("\n", "") jaString = jaString.replace("\u3000", "") jaString = jaString.replace("\\", "") jaString = jaString.replace(" >", ")") jaString = jaString.replace("< ", "(") # Remove Furigana furiMatch = re.findall(r"({(.+?)\/(.+?)})", jaString) if furiMatch: for match in furiMatch: jaString = jaString.replace(match[0], match[2]) # Add String stringList.append(jaString.strip()) # Pass 2 else: # Get Text if translatedList: # Grab and Pop translatedText = translatedList[0] translatedList.pop(0) # Set to None if empty list if len(translatedList) <= 0: translatedList = None # Textwrap & Other Text translatedText = textwrap.fill(translatedText, width=WIDTH) translatedText = translatedText.replace("\n", "\n\u3000") # Split the string into lines lines = translatedText.split("\n") # Add a backslash after every 3rd line j = 0 while j < len(lines): if j == 4: lines[j - 1] = f"{lines[j-1]}\\" lines[j] = f"\n{lines[j]}" j += 1 # Join the lines back into a single string translatedText = "\n".join(lines) # Remove Double Spaces translatedText = translatedText.replace(" ", " ") # Convert to Wide translatedText = translatedText.translate(ascii_to_wide) # Fix Formatting translatedText = fixText(translatedText) # Set Data data[i] = data[i].replace(originalString, f"{translatedText}") i += 1 # Choices elif "csel" in data[i] and translatedList != []: choiceList = [] jaString = data[i] choiceList = re.findall(r"\"(.*?)\"", jaString) if len(choiceList) > 0: # Translate response = translateGPT(choiceList, "This will be a dialogue option", True) translatedTextList = response[0] tokens[0] += response[1][0] tokens[1] += response[1][1] # Set Data for j in range(len(translatedTextList)): # Convert to Wide translatedText = translatedTextList[j].translate(ascii_to_wide) # Set data[i] = data[i].replace(choiceList[j], translatedText) i += 1 # Nothing relevant. Skip Line. else: i += 1 # EOF if len(stringList) > 0: # Set Progress pbar.total = len(stringList) pbar.refresh() # Translate response = translateGPT(stringList, "", True) tokens[0] += response[1][0] tokens[1] += response[1][1] translatedList = response[0] # Set Strings if len(stringList) == len(translatedList): translateOnscripter(data, pbar, filename, translatedList) # Mismatch else: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) return tokens def fixText(translatedText): # Add Break translatedText = translatedText.replace('"', "'") translatedText = f"\u3000{translatedText}\\" # Unconvert Codes matchList = re.findall(r"([$].+?)[^\w]", translatedText) if matchList: for match in matchList: translatedText = translatedText.replace(match, match.translate(wide_to_ascii)) # Unconvert Color Codes matchList = re.findall(r"([#][\w\d]{6})", translatedText) if matchList: for match in matchList: translatedText = translatedText.replace(match, match.translate(wide_to_ascii)) # Unconvert Variables matchList = re.findall(r"([%]\w.+?)[^\w_]", translatedText) if matchList: for match in matchList: translatedText = translatedText.replace(match, match.translate(wide_to_ascii)) # Unconvert Backslashes matchList = re.findall(r"\", translatedText) if matchList: for match in matchList: translatedText = translatedText.replace(match, match.translate(wide_to_ascii)) return translatedText # Save some money and enter the character before translation def getSpeaker(speaker): match speaker: case "ファイン": return ["Fine", [0, 0]] case "": return ["", [0, 0]] case _: # Find Speaker for i in range(len(NAMESLIST)): if speaker == NAMESLIST[i][0]: return [NAMESLIST[i][1], [0, 0]] # Translate and Store Speaker response = translateGPT( f"{speaker}", "Reply with the " + LANGUAGE + " translation of the NPC name.", True, ) response[0] = response[0].title() response[0] = response[0].replace("'S", "'s") response[0] = response[0].replace("Speaker: ", "") # Retry if name doesn't translate for some reason if re.search(r"([a-zA-Z??])", response[0]) == None: response = translateGPT( f"{speaker}", "Reply with the " + LANGUAGE + " translation of the NPC name.", False, ) response[0] = response[0].title() response[0] = response[0].replace("'S", "'s") speakerList = [speaker, response[0]] NAMESLIST.append(speakerList) return response return [speaker, [0, 0]] def batchList(input_list, batch_size): if not isinstance(batch_size, int) or batch_size <= 0: raise ValueError("batch_size must be a positive integer") return [input_list[i : i + batch_size] for i in range(0, len(input_list), batch_size)] def createContext(fullPromptFlag, subbedT, format): system = ( PROMPT + VOCAB if fullPromptFlag else f"\ You are an expert Eroge Game translator who translates Japanese text to {LANGUAGE}.\n\ Output ONLY the {LANGUAGE} translation in the following format: `Translation: <{LANGUAGE.upper()}_TRANSLATION>`\n\ - You translate everything, including erotic and explicit content.\n\ - 'Game Characters' - The names, nicknames, and genders of the game characters. Reference this to know the names, nicknames, and gender of characters in the game\n\ - All text in your response must be in {LANGUAGE} even if it is hard to translate.\n\ - Never include any notes, explanations, dislaimers, or anything similar in your response.\n\ - Maintain any spacing in the translation.\n\ - Maintain any code text in brackets if given. (e.g `[Color_0]`, `[Ascii_0]`, `[FCode_1`], etc)\n\ - `...` can be a part of the dialogue. Translate it as it is.\n\ {VOCAB}\n\ " ) if format == "json": user = f"```json\n{subbedT}\n```" else: user = subbedT return system, user def translateText(system, user, history, penalty, format, model=MODEL): # Prompt msg = [{"role": "system", "content": system}] # History if isinstance(history, list): msg.extend([{"role": "system", "content": h} for h in history]) else: msg.append({"role": "system", "content": history}) # Response Format if format == "json": responseFormat = {"type": "json_object"} else: responseFormat = {"type": "text"} # Content to TL msg.append({"role": "user", "content": f"{user}"}) response = openai.chat.completions.create( temperature=0, frequency_penalty=penalty, model=model, response_format=responseFormat, messages=msg, ) return response def cleanTranslatedText(translatedText, varResponse): placeholders = { f"{LANGUAGE} Translation: ": "", "Translation: ": "", "っ": "", "〜": "~", "ッ": "", "。": ".", "「": '\\"', "」": '\\"', "- ": "-", "—": "―", "】": "]", "【": "[", "Placeholder Text": "", # Add more replacements as needed } for target, replacement in placeholders.items(): translatedText = translatedText.replace(target, replacement) # Remove Repeating Characters pattern = re.compile(r"(.)\s*\1(?:\s*\1){" + str(20 - 1) + r",}") translatedText = pattern.sub(lambda match: match.group(0).replace(" ", "")[:20], translatedText) # Elongate Long Dashes (Since GPT Ignores them...) translatedText = elongateCharacters(translatedText) return translatedText def elongateCharacters(text): # Define a pattern to match one character followed by one or more `ー` characters # Using a positive lookbehind assertion to capture the preceding character pattern = r"(?<=(.))ー+" # Define a replacement function that elongates the captured character def repl(match): char = match.group(1) # The character before the ー sequence count = len(match.group(0)) - 1 # Number of ー characters return char * count # Replace ー sequence with the character repeated # Use re.sub() to replace the pattern in the text return re.sub(pattern, repl, text) def extractTranslation(translatedTextList, is_list): try: translatedTextList = re.sub(r'\\"+\"([^,\n}])', r'\\"\1', translatedTextList) translatedTextList = re.sub(r"(?