You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

147 lines
5.6 KiB
JavaScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env node
/**
* Anotacoes de pronuncia para o Italiano Vivo.
*
* O italiano e fonetico, mas varios grupos de letras soam DIFERENTE do que um
* falante de PT-BR espera ler. Casos onde o som "muda" em relacao a leitura
* portuguesista:
* Voglio/griglia -> GLI = som de 'lh' ('vó-lho'), nunca 'vóglio'
* bagno/giorno -> GN = 'nh'; GG antes de E/I = 'dj' macio
* scusi/cena -> SC/C antes de I/E = 'ch' italiano; SCH mantem 'sk'
* che/chi -> CH = K seco, nunca 'tch'
* grazie/piazza -> Z/ZZ = 'ts' surdo ('grá-tsie', 'pít-tsa')
* casa/stazione -> S entre vogais = 'z'; S + surda = 'z' mudo
* piatto/palla -> consoante dupla alonga o som (muda significado!)
*
* Detecta esses padroes nas frases (chunks e dialogos) das missoes e grava o
* campo `pronuncia` (dica curta em PT-BR citando a palavra da frase).
*
* Uso:
* node tools/pronuncia.mjs # dry-run: relatorio por missao
* node tools/pronuncia.mjs --write # grava "pronuncia" em content/missions/*.json
*/
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MISSIONS_DIR = join(__dirname, "..", "content", "missions");
// Palavras funcionais que nao devem disparar regras (evita falsos positivos).
const SKIP_WORDS = new Set([
"e", "un", "una", "uno", "il", "lo", "la", "i", "le", "di", "a", "ad",
"al", "alla", "allo", "da", "dal", "dalla", "in", "con", "su", "per", "tra",
"fra", "non", "si", "mi", "ti", "ci", "vi", "ne", "ho", "hai", "ha", "hanno",
"sono", "sei", "siamo", "siete", "questo", "questa", "quello", "quella",
"qui", "qua", "gli",
]);
// Regras por palavra: [id, regexNaPalavra, dicaPT com placeholder {w}]
export const WORD_RULES = [
["gl_lh", /gl[iyeò]/i, "'{w}': GLI tem som de 'lh' — fala-se 'vó-lho', nunca 'vóglio'."],
["gn_nh", /g[nh]n?|ghn/i, "'{w}': GN tem som de 'nh' (como em 'sonho')."],
["sc_x", /sc[ièé]/i, "'{w}': SC antes de I/E tem som de 'ch' italiano (nunca 'sk')."],
["sch_sk", /sch[ièé]/i, "'{w}': SCH mantém o K — som de 'sk'."],
["ch_k", /ch[eioà]/i, "'{w}': CH é sempre K seco (nunca 'tch' do português)."],
["gh_g", /gh[eio]/i, "'{w}': GH mantém G duro mesmo antes de E/I."],
["zz_ts", /zz/i, "'{w}': ZZ duplo soa 'tss' longo e surdo (como 'pizza' = 'pít-tsa')."],
["z_ts", /(?:^|(?<=[aeiou]))z/i, "'{w}': Z costuma soar 'ts' surdo (como 'grazie' = 'grá-tsie')."],
["cc_tch", /cc[h]?[ei]/i, "'{w}': CC antes de E/I soa 'tch' duplo."],
["gg_dj", /gg[h]?[ei]/i, "'{w}': GG antes de E/I soa 'dj' macio e longo (como 'giorno')."],
["g_soft", /(?<![ghc])g[eièé]/i, "'{w}': G antes de E/I soa 'dj' macio (como 'giorno' = 'djórno')."],
["cg_k", /cch/i, "'{w}': CCH soa 'kk' seco e longo (como 'amico')."],
["s_inter_vocalica", /[aeiou]s[aeiou]/i, "'{w}': S entre vogais soa 'z' (como 'casa' = 'káza')."],
["s_sorda", /s[tcpkf]/i, "'{w}': S antes de consoante surda soa 'z' (como 'stazione')."],
];
function cleanWord(w) {
return w.replace(/[^\p{L}\p{M}'’-]/gu, "");
}
function normalizeWord(w) {
return cleanWord(w)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
/** Aplica regras a uma palavra; retorna dicas unicas com a palavra citada. */
function wordHints(word) {
const norm = normalizeWord(word);
if (!norm || SKIP_WORDS.has(norm)) return [];
const out = [];
for (const [id, re, hint] of WORD_RULES) {
const m = word.match(re);
if (!m) continue;
// SC+I/E que na verdade e SCH nao dispara a regra do 'ch' italiano
if (id === "sc_x" && /^sch/i.test(m[0])) continue;
out.push({ id, hint, word: cleanWord(word) });
}
return out;
}
export function detectPronuncia(text) {
const words = text.split(/\s+/);
const merged = [];
const seen = new Set();
for (const w of words) {
for (const h of wordHints(w)) {
if (!seen.has(h.id)) {
seen.add(h.id);
merged.push({ id: h.id, hint: h.hint.replace("{w}", `“${h.word}”`) });
}
}
}
// Sons que mais mudam para o brasileiro vao primeiro
const order = ["gl_lh", "gn_nh", "sc_x", "sch_sk", "ch_k", "gh_g", "zz_ts", "z_ts", "cc_tch", "gg_dj", "g_soft", "cg_k", "s_sorda", "s_inter_vocalica"];
merged.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
return merged.slice(0, 2); // maximo 2 dicas por frase
}
export function buildPronuncia(text) {
return detectPronuncia(text).map((h) => h.hint).join(" ");
}
function main() {
const write = process.argv.includes("--write");
const files = readdirSync(MISSIONS_DIR).filter((f) => f.endsWith(".json")).sort();
let totalTagged = 0;
let totalItems = 0;
for (const file of files) {
const path = join(MISSIONS_DIR, file);
const mission = JSON.parse(readFileSync(path, "utf-8"));
let tagged = 0;
let items = 0;
const annotate = (obj) => {
items++;
const p = buildPronuncia(obj.it);
if (p) {
obj.pronuncia = p;
tagged++;
} else if ("pronuncia" in obj) {
delete obj.pronuncia;
}
};
for (const chunk of mission.chunks ?? []) annotate(chunk);
for (const dialogue of mission.dialogues ?? []) {
for (const line of dialogue) annotate(line);
}
totalTagged += tagged;
totalItems += items;
console.log(`${mission.id}: ${tagged}/${items} frases com alternancia de som`);
if (write) {
writeFileSync(path, JSON.stringify(mission, null, 2) + "\n", "utf-8");
}
}
console.log(`\n${write ? "GRAVADO:" : "Dry-run:"} ${totalTagged}/${totalItems} frases anotadas em ${files.length} missoes.`);
}
main();