Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions app/services/lyrics_language.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
21 changes: 16 additions & 5 deletions docs/development/LYRICS_LANGUAGE.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.
14 changes: 14 additions & 0 deletions tests/test_lyrics_language.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from services.lyrics_language import (
assert_lyrics_language,
canonical_lyrics_language,
repair_lyrics_language,
validate_lyrics_language,
)
Expand Down Expand Up @@ -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.",
Expand Down
16 changes: 14 additions & 2 deletions ui/src/lib/lyricsLanguageGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,26 @@ export interface LyricsLanguageReport {
strippedSpans: { script: string; text: string }[]
}

const LANGUAGE_ALIASES: Record<string, string> = {
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 ''
}

Expand Down
17 changes: 16 additions & 1 deletion ui/tests/lyricsLanguageGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
Expand Down