diff --git a/app/services/lyrics_language.py b/app/services/lyrics_language.py index 77dfdc90..d823587b 100644 --- a/app/services/lyrics_language.py +++ b/app/services/lyrics_language.py @@ -64,11 +64,16 @@ def _folded(value: str) -> str: 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], "") + exact = LANGUAGE_ALIASES.get(key) or LANGUAGE_ALIASES.get(key.split("-")[0], "") + if exact: + return exact + # Story Lab names such as "Español de España" are not exact aliases. + # Skip 2-letter tokens so "en español" does not become English. + for token in re.findall(r"[a-z]+", key): + mapped = LANGUAGE_ALIASES.get(token) + if mapped and len(token) > 2: + return mapped + return "" def _protected_texts(segments: Sequence[Mapping[str, Any]] | None) -> list[str]: diff --git a/docs/development/LYRICS_LANGUAGE.md b/docs/development/LYRICS_LANGUAGE.md index d5ec9a62..a806f373 100644 --- a/docs/development/LYRICS_LANGUAGE.md +++ b/docs/development/LYRICS_LANGUAGE.md @@ -1,7 +1,8 @@ # 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). +Status: library only (2026-09-05). MiniMax-Music3 (#135) is on `main`; wiring +the guard into write-song/generate remains a follow-up so this PR stays +outside `_launch_runtime.py`. User conversation language, authored lyric language and the provider-facing technical prompt are three different decisions: @@ -26,11 +27,21 @@ Python: `app/services/lyrics_language.py` TypeScript: `ui/src/lib/lyricsLanguageGuard.ts` (same rules for Wizard tests). +`canonical_lyrics_language` / `canonicalLyricsLanguage` resolve Story Lab +spoken-language names without prefix traps: + +1. exact folded aliases (`es`, `espanol`, `spanish`, `en`, `english`, …); +2. BCP-47 prefixes via the token before `-` (`es-MX`, `en-US`); +3. tokens longer than two letters so `Español de España` is Spanish and + `en español` is Spanish, while `en` and `English` stay English. + +Do not use `startswith("es")` / `startswith("en")`: `English` starts with +`es`, and `en español` starts with `en`. + 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. +Call the guard from Story Lab generate and `/api/v1/llm/write-song`. Do not +silently replace user-authored Spanish lines. diff --git a/tests/test_lyrics_language.py b/tests/test_lyrics_language.py index 0db1e8c5..b30bb9fe 100644 --- a/tests/test_lyrics_language.py +++ b/tests/test_lyrics_language.py @@ -6,6 +6,7 @@ from services.lyrics_language import ( assert_lyrics_language, + canonical_lyrics_language, repair_lyrics_language, validate_lyrics_language, ) @@ -45,6 +46,19 @@ def test_spanish_structured_lyric_is_ok(): assert regional["language_mismatch"] is False +def test_story_lab_spoken_language_name_is_scored(): + assert canonical_lyrics_language("Español de España") == "es" + assert canonical_lyrics_language("en español") == "es" + assert canonical_lyrics_language("en") == "en" + assert canonical_lyrics_language("en-US") == "en" + assert canonical_lyrics_language("English") == "en" + assert canonical_lyrics_language("English (US)") == "en" + report = validate_lyrics_language(ENGLISH_CHORUS, "Español de España") + assert report["ok"] is False + assert report["language_mismatch"] is True + assert any("English" in reason for reason in report["reasons"]) + + 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.", diff --git a/ui/src/lib/lyricsLanguageGuard.ts b/ui/src/lib/lyricsLanguageGuard.ts index ab0d447c..85aab7b8 100644 --- a/ui/src/lib/lyricsLanguageGuard.ts +++ b/ui/src/lib/lyricsLanguageGuard.ts @@ -41,14 +41,26 @@ export interface LyricsLanguageReport { strippedSpans: { script: string; text: string }[] } +const LANGUAGE_ALIASES: Record = { + es: 'es', espanol: 'es', castellano: 'es', + spanish: 'es', 'es-es': 'es', 'es-mx': 'es', + en: 'en', english: 'en', ingles: 'en', +} + 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' + const exact = LANGUAGE_ALIASES[key] || LANGUAGE_ALIASES[key.split('-')[0]] || '' + if (exact) return exact + // Story Lab names such as "Español de España" are not exact aliases. + // Skip 2-letter tokens so "en español" does not become English. + for (const token of key.match(/[a-z]+/g) || []) { + const mapped = LANGUAGE_ALIASES[token] + if (mapped && token.length > 2) return mapped + } return '' } diff --git a/ui/tests/lyricsLanguageGuard.test.ts b/ui/tests/lyricsLanguageGuard.test.ts index 182bc942..47ac0250 100644 --- a/ui/tests/lyricsLanguageGuard.test.ts +++ b/ui/tests/lyricsLanguageGuard.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { repairLyricsLanguage, validateLyricsLanguage } from '../src/lib/lyricsLanguageGuard' +import { canonicalLyricsLanguage, repairLyricsLanguage, validateLyricsLanguage } from '../src/lib/lyricsLanguageGuard' const SPANISH_OK = `[Verse] En la red despierta el sysadmin. @@ -30,6 +30,21 @@ test('an accidental English chorus fails a Spanish song', () => { assert.equal(report.languageMismatch, true) }) +test('Story Lab spoken-language names score as Spanish', () => { + 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 de España', + ) + assert.equal(canonicalLyricsLanguage('Español de España'), 'es') + assert.equal(canonicalLyricsLanguage('en español'), 'es') + assert.equal(canonicalLyricsLanguage('en'), 'en') + assert.equal(canonicalLyricsLanguage('en-US'), 'en') + assert.equal(canonicalLyricsLanguage('English'), 'en') + assert.equal(canonicalLyricsLanguage('English (US)'), 'en') + 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)