diff --git a/app/services/lyrics_language.py b/app/services/lyrics_language.py new file mode 100644 index 00000000..77dfdc90 --- /dev/null +++ b/app/services/lyrics_language.py @@ -0,0 +1,274 @@ +"""Provider-free lyric language validation and bounded repair. + +Technical prompts may be English. Sung lyrics, dialogue and quoted text must +keep the language the user asked for. Structural tags such as ``[Verse]`` +stay in English on purpose and are not contamination. + +This module does not import FastAPI, WanGP or launch. Callers in Story Lab +and the song-writer should invoke it before enqueueing generation; that +wiring is a follow-up while MiniMax-Music3 occupies those hotspots. +""" + +from __future__ import annotations + +import re +import unicodedata +from typing import Any, Mapping, Sequence + + +SECTION_TAG_RE = re.compile( + r"\[(?:intro|verse|pre[ -]?chorus|chorus|post[ -]?chorus|interlude|" + r"bridge|transition|build[ -]?up|break|hook|inst|instrumental|solo|" + r"outro|start|end)(?:[^\]]*)\]", + re.IGNORECASE, +) +PROTECTED_TOKEN_RE = re.compile(r"\{\{PROTECTED_(\d+)\}\}") +SCRIPT_RUNS = { + "han": re.compile(r"[\u3400-\u9fff\U00020000-\U0002a6df]+"), + "arabic": re.compile(r"[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff]+"), + "cyrillic": re.compile(r"[\u0400-\u04ff]+"), + "hangul": re.compile(r"[\u1100-\u11ff\u3130-\u318f\uac00-\ud7af]+"), + "kana": re.compile(r"[\u3040-\u30ff]+"), +} +ENGLISH_MARKERS = frozenset({ + "the", "and", "that", "this", "with", "from", "through", "night", + "our", "your", "you", "we", "are", "not", "for", "but", "his", "her", + "they", "their", "have", "was", "were", "will", "would", "could", + "should", "fight", "sing", "server", "software", "proprietary", +}) +SPANISH_MARKERS = frozenset({ + "el", "la", "los", "las", "que", "de", "del", "en", "y", "un", "una", + "por", "para", "con", "no", "se", "es", "mi", "tu", "yo", "somos", + "noche", "canta", "cantar", "esta", "está", "como", "pero", "porque", +}) +LANGUAGE_ALIASES = { + "es": "es", "espanol": "es", "español": "es", "castellano": "es", + "spanish": "es", "es-es": "es", "es-mx": "es", + "en": "en", "english": "en", "ingles": "en", "inglés": "en", +} + + +class LyricsLanguageReport(dict): + """JSON-safe report: ok, lyrics, repaired, reasons, language_mismatch, stripped_spans.""" + + +def _folded(value: str) -> str: + return ( + unicodedata.normalize("NFKD", value) + .encode("ascii", "ignore") + .decode("ascii") + .casefold() + .strip() + ) + + +def canonical_lyrics_language(value: str) -> str: + key = _folded(value) + if key.startswith("es") or "spanish" in key or "espanol" in key or "castellano" in key: + return "es" + if key.startswith("en") or "english" in key or "ingles" in key: + return "en" + return LANGUAGE_ALIASES.get(key) or LANGUAGE_ALIASES.get(key.split("-")[0], "") + + +def _protected_texts(segments: Sequence[Mapping[str, Any]] | None) -> list[str]: + texts: list[str] = [] + for item in segments or (): + if not isinstance(item, Mapping): + continue + kind = str(item.get("kind") or "").strip() + text = str(item.get("text") or "") + if kind in {"lyrics", "dialogue", "visible_text", "subtitle"} and text.strip(): + texts.append(text) + return texts + + +def _mask_protected(lyrics: str, protected: Sequence[str]) -> tuple[str, list[str]]: + masked = lyrics + for index, text in enumerate(protected): + if text and text in masked: + masked = masked.replace(text, f"{{{{PROTECTED_{index}}}}}") + return masked, list(protected) + + +def _restore_protected(lyrics: str, protected: Sequence[str]) -> str: + def replace(match: re.Match[str]) -> str: + index = int(match.group(1)) + if 0 <= index < len(protected): + return protected[index] + return match.group(0) + return PROTECTED_TOKEN_RE.sub(replace, lyrics) + + +def _strip_section_tags(lyrics: str) -> str: + return SECTION_TAG_RE.sub(" ", lyrics) + + +def _latin_words(sample: str) -> list[str]: + return re.findall(r"[A-Za-zÁÉÍÓÚÜÑáéíóúüñ']+", sample) + + +def _script_hits(sample: str) -> dict[str, list[str]]: + return { + name: [match.group(0) for match in pattern.finditer(sample)] + for name, pattern in SCRIPT_RUNS.items() + } + + +def _english_line_contamination(sample: str) -> bool: + for raw_line in sample.splitlines(): + line = _strip_section_tags(raw_line).strip() + if not line: + continue + words = [word.casefold() for word in _latin_words(line)] + if len(words) < 5: + continue + english = sum(1 for word in words if word in ENGLISH_MARKERS) + spanish = sum(1 for word in words if word in SPANISH_MARKERS) + if english >= 3 and english > spanish + 1 and english / max(len(words), 1) >= 0.35: + return True + return False + + +def _spanish_mismatch(sample: str) -> tuple[bool, list[str]]: + reasons: list[str] = [] + hits = _script_hits(sample) + for name, spans in hits.items(): + if spans: + reasons.append(f"Unrequested {name} script in Spanish lyrics.") + if _english_line_contamination(sample): + reasons.append("A sung line looks like English rather than Spanish.") + words = [word.casefold() for word in _latin_words(sample)] + if len(words) >= 8: + english = sum(1 for word in words if word in ENGLISH_MARKERS) + spanish = sum(1 for word in words if word in SPANISH_MARKERS) + if spanish == 0 and english >= 3: + reasons.append("The lyric does not show evidence of Spanish.") + elif english >= spanish + 4 and english >= 5: + reasons.append("English function words dominate a Spanish lyric.") + return bool(reasons), reasons + + +def _strip_foreign_scripts(sample: str) -> tuple[str, list[dict[str, str]]]: + stripped: list[dict[str, str]] = [] + + def replace(name: str, pattern: re.Pattern[str], text: str) -> str: + def keep(match: re.Match[str]) -> str: + stripped.append({"script": name, "text": match.group(0)}) + return " " + return pattern.sub(keep, text) + + cleaned = sample + for name, pattern in SCRIPT_RUNS.items(): + cleaned = replace(name, pattern, cleaned) + cleaned = re.sub(r"[ \t]{2,}", " ", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + ("\n" if sample.endswith("\n") and cleaned.strip() else ""), stripped + + +def validate_lyrics_language( + lyrics: str, + lyrics_language: str, + *, + protected_segments: Sequence[Mapping[str, Any]] | None = None, + instrumental: bool = False, +) -> LyricsLanguageReport: + """Validate sung lyrics. Style/caption fields are out of scope.""" + text = str(lyrics or "") + protected = _protected_texts(protected_segments) + if instrumental: + ok = not text.strip() or text.strip().lower() in {"[instrumental]", "instrumental"} + return LyricsLanguageReport( + ok=ok, + lyrics=text if ok else "", + repaired=False, + reasons=[] if ok else ["An instrumental song must not contain vocal lyrics."], + language_mismatch=False, + stripped_spans=[], + ) + + code = canonical_lyrics_language(lyrics_language) + masked, protected = _mask_protected(text, protected) + sample = _strip_section_tags(masked) + sample = PROTECTED_TOKEN_RE.sub(" ", sample) + reasons: list[str] = [] + mismatch = False + if code == "es": + mismatch, reasons = _spanish_mismatch(sample) + elif code and _script_hits(sample) and any(_script_hits(sample).values()): + # Non-Spanish requested languages still reject scripts that were not asked. + if code == "en": + hits = _script_hits(sample) + for name, spans in hits.items(): + if spans: + mismatch = True + reasons.append(f"Unrequested {name} script in English lyrics.") + + return LyricsLanguageReport( + ok=not reasons, + lyrics=text, + repaired=False, + reasons=reasons, + language_mismatch=mismatch, + stripped_spans=[], + ) + + +def repair_lyrics_language( + lyrics: str, + lyrics_language: str, + *, + protected_segments: Sequence[Mapping[str, Any]] | None = None, + instrumental: bool = False, +) -> LyricsLanguageReport: + """Strip unrequested foreign-script runs. Never translate English to Spanish.""" + first = validate_lyrics_language( + lyrics, lyrics_language, + protected_segments=protected_segments, instrumental=instrumental, + ) + if first["ok"] or instrumental: + return first + + protected = _protected_texts(protected_segments) + masked, protected = _mask_protected(str(lyrics or ""), protected) + cleaned, spans = _strip_foreign_scripts(masked) + restored = _restore_protected(cleaned, protected) + restored = "\n".join(line.rstrip() for line in restored.splitlines()).strip() + repaired = restored != str(lyrics or "").strip() + second = validate_lyrics_language( + restored, lyrics_language, + protected_segments=protected_segments, instrumental=instrumental, + ) + second["repaired"] = repaired + second["stripped_spans"] = spans + if repaired and not second["ok"]: + second["reasons"] = list(second["reasons"]) + [ + "Bounded repair stripped foreign scripts but did not invent a translation.", + ] + return second + + +def assert_lyrics_language( + lyrics: str, + lyrics_language: str, + *, + protected_segments: Sequence[Mapping[str, Any]] | None = None, + instrumental: bool = False, + repair: bool = True, +) -> LyricsLanguageReport: + report = ( + repair_lyrics_language( + lyrics, lyrics_language, + protected_segments=protected_segments, instrumental=instrumental, + ) + if repair + else validate_lyrics_language( + lyrics, lyrics_language, + protected_segments=protected_segments, instrumental=instrumental, + ) + ) + if not report["ok"]: + raise ValueError( + "La letra no respeta el idioma solicitado: " + " ".join(report["reasons"]) + ) + return report diff --git a/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md b/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md index 3d70689f..3dbfa2ca 100644 --- a/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md +++ b/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md @@ -70,6 +70,10 @@ Python helpers live in `app/services/generation_provenance.py`: - `resolve_generation_location` — splits the two location fields - `provenance_from_manifest` — read model over a canonical sidecar +Lyric language is a separate contract. See +[`LYRICS_LANGUAGE.md`](LYRICS_LANGUAGE.md): sung text keeps the requested +language; technical prompts may be English; quoted spans stay literal. + Legacy writers that only pass `workspace_id=` still store that string on both fields so existing readers keep working. New writers should pass `output_folder=` for the directory and `workspace_id=` only when a collection diff --git a/docs/development/LYRICS_LANGUAGE.md b/docs/development/LYRICS_LANGUAGE.md new file mode 100644 index 00000000..d5ec9a62 --- /dev/null +++ b/docs/development/LYRICS_LANGUAGE.md @@ -0,0 +1,36 @@ +# Lyrics language contract + +Status: library only (2026-09-04). Not yet wired into write-song or generate +endpoints; those files are reserved by MiniMax-Music3 (#135). + +User conversation language, authored lyric language and the provider-facing +technical prompt are three different decisions: + +- the user may speak any language; +- the technical style/caption may be generated in English when the model + guide says so; +- sung lyrics, quoted dialogue and other verbatim spans must keep the + language the user asked for. + +English structural tags such as `[Verse]` and `[Chorus]` are allowed. +Quoted segments registered on `LanguageIntent.verbatimSegments` are kept +character-for-character and are not scored as contamination. + +## API + +Python: `app/services/lyrics_language.py` + +- `validate_lyrics_language(lyrics, lyrics_language, protected_segments=..., instrumental=...)` +- `repair_lyrics_language(...)` — strips unrequested Han/Arabic/Cyrillic/Hangul/Kana runs; never translates English into Spanish +- `assert_lyrics_language(...)` — raises if the lyric still mismatches + +TypeScript: `ui/src/lib/lyricsLanguageGuard.ts` (same rules for Wizard tests). + +A valid WAV is not proof of language fidelity. CI runs these tests without +GPU. Real smoke should call the same guard after a local song is written. + +## Follow-up + +Call the guard from Story Lab generate and `/api/v1/llm/write-song` after +#135 no longer owns those hotspots. Do not silently replace user-authored +Spanish lines. diff --git a/tests/test_lyrics_language.py b/tests/test_lyrics_language.py new file mode 100644 index 00000000..0db1e8c5 --- /dev/null +++ b/tests/test_lyrics_language.py @@ -0,0 +1,107 @@ +"""Provider-free lyric language contract. No GPU, no model weights.""" + +from __future__ import annotations + +import pytest + +from services.lyrics_language import ( + assert_lyrics_language, + repair_lyrics_language, + validate_lyrics_language, +) + + +SPANISH_OK = """[Verse] +En la red despierta el sysadmin. +[Chorus] +La noche y el código cantan. +""" + +ENGLISH_CHORUS = """[Verse] +En la red despierta el sysadmin y la noche canta. +[Chorus] +The server fights through the night and we sing for our network. +""" + +CJK = """[Verse] +En la red despierta el sysadmin. +[Chorus] +夜晚在服务器里唱歌 夜は歌う +""" + +ARABIC = """[Verse] +En la red despierta el sysadmin. +[Chorus] +الليل يغني في الشبكة +""" + + +def test_spanish_structured_lyric_is_ok(): + report = validate_lyrics_language(SPANISH_OK, "Español") + assert report["ok"] is True + assert report["language_mismatch"] is False + regional = validate_lyrics_language(SPANISH_OK, "Español de España") + assert regional["ok"] is True + assert regional["language_mismatch"] is False + + +def test_section_tags_are_not_english_contamination(): + report = validate_lyrics_language( + "[Verse]\nLa noche canta en la red.\n[Chorus]\nEl código sangra.\n[Outro]\nReinicia.", + "español", + ) + assert report["ok"] is True + + +def test_accidental_english_chorus_fails(): + report = validate_lyrics_language(ENGLISH_CHORUS, "Español") + assert report["ok"] is False + assert report["language_mismatch"] is True + assert any("English" in reason for reason in report["reasons"]) + + +def test_chinese_and_arabic_fail_spanish_lyrics(): + chinese = validate_lyrics_language(CJK, "castellano") + arabic = validate_lyrics_language(ARABIC, "es") + assert chinese["ok"] is False + assert arabic["ok"] is False + assert any("han" in reason for reason in chinese["reasons"]) + assert any("arabic" in reason for reason in arabic["reasons"]) + + +def test_quoted_english_is_ok_when_protected(): + lyrics = '[Chorus]\nHello, world\nLa noche nos verá.' + report = validate_lyrics_language( + lyrics, + "Español", + protected_segments=[{"kind": "lyrics", "text": "Hello, world", "language": "en"}], + ) + assert report["ok"] is True + + +def test_technical_caption_is_not_mixed_into_lyric_validation(): + report = validate_lyrics_language(SPANISH_OK, "Español") + assert "Heavy metal" not in report["lyrics"] + assert report["ok"] is True + + +def test_repair_strips_cjk_and_keeps_spanish_lines(): + report = repair_lyrics_language(CJK, "Español") + assert "En la red despierta el sysadmin." in report["lyrics"] + assert "夜晚" not in report["lyrics"] + assert "夜は" not in report["lyrics"] + assert report["repaired"] is True + assert report["stripped_spans"] + assert report["ok"] is True + + +def test_repair_does_not_translate_an_english_chorus(): + report = repair_lyrics_language(ENGLISH_CHORUS, "Español") + assert report["ok"] is False + assert "The server fights through the night" in report["lyrics"] + assert not any("El servidor" in report["lyrics"] for _ in (0,)) + + +def test_assert_raises_on_unrepaired_mismatch(): + with pytest.raises(ValueError, match="idioma"): + assert_lyrics_language(ENGLISH_CHORUS, "Español") diff --git a/ui/src/lib/lyricsLanguageGuard.ts b/ui/src/lib/lyricsLanguageGuard.ts new file mode 100644 index 00000000..ab0d447c --- /dev/null +++ b/ui/src/lib/lyricsLanguageGuard.ts @@ -0,0 +1,181 @@ +/** + * Provider-free lyric language guard. Mirrors app/services/lyrics_language.py. + * Technical captions are out of scope; pass only sung lyrics. + */ + +const SECTION_TAG = /\[(?:intro|verse|pre[ -]?chorus|chorus|post[ -]?chorus|interlude|bridge|transition|build[ -]?up|break|hook|inst|instrumental|solo|outro|start|end)(?:[^\]]*)\]/gi + +const SCRIPT_RUNS: Record = { + han: /[\u3400-\u9fff]+/g, + arabic: /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff]+/g, + cyrillic: /[\u0400-\u04ff]+/g, + hangul: /[\u1100-\u11ff\u3130-\u318f\uac00-\ud7af]+/g, + kana: /[\u3040-\u30ff]+/g, +} + +const ENGLISH_MARKERS = new Set([ + 'the', 'and', 'that', 'this', 'with', 'from', 'through', 'night', + 'our', 'your', 'you', 'we', 'are', 'not', 'for', 'but', 'his', 'her', + 'they', 'their', 'have', 'was', 'were', 'will', 'would', 'could', + 'should', 'fight', 'sing', 'server', 'software', 'proprietary', +]) + +const SPANISH_MARKERS = new Set([ + 'el', 'la', 'los', 'las', 'que', 'de', 'del', 'en', 'y', 'un', 'una', + 'por', 'para', 'con', 'no', 'se', 'es', 'mi', 'tu', 'yo', 'somos', + 'noche', 'canta', 'cantar', 'esta', 'como', 'pero', 'porque', +]) + +export interface ProtectedLyricSegment { + kind?: string + text: string + language?: string +} + +export interface LyricsLanguageReport { + ok: boolean + lyrics: string + repaired: boolean + reasons: string[] + languageMismatch: boolean + strippedSpans: { script: string; text: string }[] +} + +function folded(value: string): string { + return value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLocaleLowerCase().trim() +} + +export function canonicalLyricsLanguage(value: string): string { + const key = folded(value) + if (key.startsWith('es') || key.includes('spanish') || key.includes('espanol') || key.includes('castellano')) return 'es' + if (key.startsWith('en') || key.includes('english') || key.includes('ingles')) return 'en' + return '' +} + +function protectedTexts(segments: readonly ProtectedLyricSegment[] | undefined): string[] { + return (segments || []) + .filter(item => item?.text?.trim() && (!item.kind || ['lyrics', 'dialogue', 'visible_text', 'subtitle'].includes(item.kind))) + .map(item => item.text) +} + +function maskProtected(lyrics: string, literals: string[]): string { + return literals.reduce((source, text, index) => ( + text && source.includes(text) ? source.split(text).join(`{{PROTECTED_${index}}}`) : source + ), lyrics) +} + +function restoreProtected(lyrics: string, literals: string[]): string { + return lyrics.replace(/\{\{PROTECTED_(\d+)\}\}/g, (_match, raw) => { + const index = Number(raw) + return literals[index] ?? `{{PROTECTED_${raw}}}` + }) +} + +function stripTags(lyrics: string): string { + return lyrics.replace(SECTION_TAG, ' ') +} + +function latinWords(sample: string): string[] { + return sample.match(/[A-Za-zÁÉÍÓÚÜÑáéíóúüñ']+/g) || [] +} + +function scriptHits(sample: string): Record { + return Object.fromEntries(Object.entries(SCRIPT_RUNS).map(([name, pattern]) => [ + name, + sample.match(new RegExp(pattern.source, 'g')) || [], + ])) +} + +function englishLineContamination(sample: string): boolean { + return sample.split('\n').some(raw => { + const words = latinWords(stripTags(raw).trim()).map(word => word.toLocaleLowerCase()) + if (words.length < 5) return false + const english = words.filter(word => ENGLISH_MARKERS.has(word)).length + const spanish = words.filter(word => SPANISH_MARKERS.has(word)).length + return english >= 3 && english > spanish + 1 && english / words.length >= 0.35 + }) +} + +function spanishMismatch(sample: string): { mismatch: boolean; reasons: string[] } { + const reasons: string[] = [] + const hits = scriptHits(sample) + for (const [name, spans] of Object.entries(hits)) { + if (spans.length) reasons.push(`Unrequested ${name} script in Spanish lyrics.`) + } + if (englishLineContamination(sample)) reasons.push('A sung line looks like English rather than Spanish.') + const words = latinWords(sample).map(word => word.toLocaleLowerCase()) + if (words.length >= 8) { + const english = words.filter(word => ENGLISH_MARKERS.has(word)).length + const spanish = words.filter(word => SPANISH_MARKERS.has(word)).length + if (spanish === 0 && english >= 3) reasons.push('The lyric does not show evidence of Spanish.') + else if (english >= spanish + 4 && english >= 5) reasons.push('English function words dominate a Spanish lyric.') + } + return { mismatch: reasons.length > 0, reasons } +} + +export function validateLyricsLanguage( + lyrics: string, + lyricsLanguage: string, + options: { protectedSegments?: readonly ProtectedLyricSegment[]; instrumental?: boolean } = {}, +): LyricsLanguageReport { + const text = lyrics || '' + if (options.instrumental) { + const ok = !text.trim() || /^\[?instrumental\]?$/i.test(text.trim()) + return { + ok, lyrics: ok ? text : '', repaired: false, + reasons: ok ? [] : ['An instrumental song must not contain vocal lyrics.'], + languageMismatch: false, strippedSpans: [], + } + } + const protectedList = protectedTexts(options.protectedSegments) + const masked = maskProtected(text, protectedList) + const sample = restoreProtected( + stripTags(masked).replace(/\{\{PROTECTED_\d+\}\}/g, ' '), + [], + ) + const code = canonicalLyricsLanguage(lyricsLanguage) + let reasons: string[] = [] + let mismatch = false + if (code === 'es') { + const result = spanishMismatch(sample) + reasons = result.reasons + mismatch = result.mismatch + } else if (code === 'en') { + for (const [name, spans] of Object.entries(scriptHits(sample))) { + if (spans.length) { + mismatch = true + reasons.push(`Unrequested ${name} script in English lyrics.`) + } + } + } + return { ok: reasons.length === 0, lyrics: text, repaired: false, reasons, languageMismatch: mismatch, strippedSpans: [] } +} + +export function repairLyricsLanguage( + lyrics: string, + lyricsLanguage: string, + options: { protectedSegments?: readonly ProtectedLyricSegment[]; instrumental?: boolean } = {}, +): LyricsLanguageReport { + const first = validateLyricsLanguage(lyrics, lyricsLanguage, options) + if (first.ok || options.instrumental) return first + const protectedList = protectedTexts(options.protectedSegments) + let masked = maskProtected(lyrics || '', protectedList) + const strippedSpans: { script: string; text: string }[] = [] + for (const [name, pattern] of Object.entries(SCRIPT_RUNS)) { + masked = masked.replace(new RegExp(pattern.source, 'g'), match => { + strippedSpans.push({ script: name, text: match }) + return ' ' + }) + } + const restored = restoreProtected(masked, protectedList) + .replace(/[ \t]{2,}/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .split('\n').map(line => line.trimEnd()).join('\n').trim() + const second = validateLyricsLanguage(restored, lyricsLanguage, options) + second.repaired = restored !== (lyrics || '').trim() + second.strippedSpans = strippedSpans + if (second.repaired && !second.ok) { + second.reasons = [...second.reasons, 'Bounded repair stripped foreign scripts but did not invent a translation.'] + } + return second +} diff --git a/ui/tests/lyricsLanguageGuard.test.ts b/ui/tests/lyricsLanguageGuard.test.ts new file mode 100644 index 00000000..182bc942 --- /dev/null +++ b/ui/tests/lyricsLanguageGuard.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { repairLyricsLanguage, validateLyricsLanguage } from '../src/lib/lyricsLanguageGuard' + +const SPANISH_OK = `[Verse] +En la red despierta el sysadmin. +[Chorus] +La noche y el código cantan. +` + +test('Spanish structured lyrics stay valid', () => { + const report = validateLyricsLanguage(SPANISH_OK, 'Español') + assert.equal(report.ok, true) + assert.equal(report.languageMismatch, false) + const regional = validateLyricsLanguage(SPANISH_OK, 'Español de España') + assert.equal(regional.ok, true) +}) + +test('English section tags are not contamination', () => { + const report = validateLyricsLanguage('[Verse]\nLa noche canta.\n[Chorus]\nEl código sangra.', 'español') + assert.equal(report.ok, true) +}) + +test('an accidental English chorus fails a Spanish song', () => { + const report = validateLyricsLanguage( + '[Verse]\nEn la red despierta el sysadmin y la noche canta.\n[Chorus]\nThe server fights through the night and we sing for our network.', + 'Español', + ) + assert.equal(report.ok, false) + assert.equal(report.languageMismatch, true) +}) + +test('Chinese or Arabic runs fail Spanish lyrics', () => { + assert.equal(validateLyricsLanguage('[Verse]\nEn la red.\n[Chorus]\n夜晚在服务器里唱歌', 'castellano').ok, false) + assert.equal(validateLyricsLanguage('[Verse]\nEn la red.\n[Chorus]\nالليل يغني', 'es').ok, false) +}) + +test('quoted English remains when protected', () => { + const report = validateLyricsLanguage('[Chorus]\nHello, world\nLa noche nos verá.', 'Español', { + protectedSegments: [{ kind: 'lyrics', text: 'Hello, world', language: 'en' }], + }) + assert.equal(report.ok, true) +}) + +test('repair strips CJK and keeps Spanish lines', () => { + const report = repairLyricsLanguage('[Verse]\nEn la red despierta el sysadmin.\n[Chorus]\n夜晚在服务器里唱歌', 'Español') + assert.match(report.lyrics, /En la red despierta el sysadmin/) + assert.equal(report.lyrics.includes('夜晚'), false) + assert.equal(report.repaired, true) + assert.equal(report.ok, true) +}) + +test('repair does not translate an English chorus', () => { + const report = repairLyricsLanguage( + '[Verse]\nEn la red despierta el sysadmin y la noche canta.\n[Chorus]\nThe server fights through the night and we sing for our network.', + 'Español', + ) + assert.equal(report.ok, false) + assert.match(report.lyrics, /The server fights through the night/) +})