\n`; for (const word of line.text) { const wBegin = AmLyrics.formatTimestampTTML(word.timestamp); const wEnd = AmLyrics.formatTimestampTTML(word.endtime); // Escape special characters in text const text = word.text .replace(/&/g, '&') .replace(//g, '>'); ttml += ` ${text}\n`; } ttml += '
\n'; } if (this.lyrics.length > 0) { ttml += '${line.backgroundText!.map((syllable, syllableIndex) => { const startTimeMs = syllable.timestamp; const endTimeMs = syllable.endtime; const durationMs = endTimeMs - startTimeMs; const bgRomanizedText = this.showRomanization && syllable.romanizedText && syllable.romanizedText.trim() !== syllable.text.trim() ? html`${syllable.romanizedText}` : ''; return html` ${syllable.text} ${bgRomanizedText} `; })}
` : ''; // Background vocals share the same line.translation and line.romanizedText // as the main vocal, so we intentionally do NOT render a separate // translation/romanization block for background — it would just duplicate // the main line's text. const bgPlacement = hasBackground ? AmLyrics.getBackgroundTextPlacement(line) : 'after'; const lineData = this.cachedLineData?.[lineIndex]; const wordGroups = lineData?.wordGroups ?? []; const groupGrowable = lineData?.groupGrowable ?? []; const groupGlowing = lineData?.groupGlowing ?? []; const vwFullText = lineData?.vwFullText ?? []; const vwFullDuration = lineData?.vwFullDuration ?? []; const vwCharOffset = lineData?.vwCharOffset ?? []; // Create main vocals using YouLyPlus syllable structure const mainVocalElement = html`${wordGroups.map((group, groupIdx) => { const isGrowable = groupGrowable[groupIdx]; const isGlowing = groupGlowing[groupIdx]; const groupLineSynced = group.some(s => s.lineSynced); const wordText = isGrowable ? vwFullText[groupIdx] : ''; const wordDuration = isGrowable ? vwFullDuration[groupIdx] : 0; const wordNumChars = wordText.length; const groupCharOffset = isGrowable ? vwCharOffset[groupIdx] : 0; let sylCharAccumulator = 0; return html` ${group.map((syllable, sylIdx) => { const startTimeMs = syllable.timestamp; const endTimeMs = syllable.endtime; const durationMs = endTimeMs - startTimeMs; const text = syllable.text || ''; const romanizedText = this.showRomanization && syllable.romanizedText && syllable.romanizedText.trim() !== syllable.text.trim() ? html`${syllable.romanizedText}` : ''; let syllableContent: any = text; if (isGrowable) { let charIndexInsideSyllable = 0; const numCharsInSyllable = text.replace(/\s/g, '').length || 1; syllableContent = html`${text.split('').map(char => { if (char === ' ') return ' '; const charIndexInsideWord = groupCharOffset + sylCharAccumulator; const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable; sylCharAccumulator += 1; charIndexInsideSyllable += 1; const minDuration = 400; const maxDuration = 3000; const easingPower = 3; const progress = Math.min( 1, Math.max( 0, (wordDuration - minDuration) / (maxDuration - minDuration), ), ); const easedProgress = progress ** easingPower; const isLongWord = wordNumChars > 5; const isShortDuration = wordDuration < 1200; let maxDecayRate = 0; if (isLongWord || isShortDuration) { let decayStrength = 0; if (isLongWord) decayStrength += Math.min((wordNumChars - 5) / 5, 1.0) * 0.4; if (isShortDuration && wordNumChars > 3) decayStrength += Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.3; else if (isShortDuration && wordNumChars <= 3) decayStrength += Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.1; maxDecayRate = Math.min(decayStrength, 0.7); } const positionInWord = wordNumChars > 1 ? charIndexInsideWord / (wordNumChars - 1) : 0; const decayFactor = 1.0 - positionInWord * maxDecayRate; const charProgress = easedProgress * decayFactor; const baseGrowth = wordNumChars <= 3 ? 0.05 : 0.04; const charMaxScale = 1.0 + baseGrowth + charProgress * 0.08; const glowDurFactor = Math.min(1.1, wordDuration / 1500); let glowLenFactor = 1.0; if (wordNumChars <= 3) { glowLenFactor = 0.85; } else if (wordNumChars >= 6) { glowLenFactor = 1.1; } const glowIntensityScale = glowDurFactor * glowLenFactor; const charShadowIntensity = isGlowing ? (0.35 + charProgress * 0.45) * glowIntensityScale : 0; const normalizedGrowth = (charMaxScale - 1.0) / 0.1; const effectiveDuration = (wordDuration + durationMs * 2) / 3; const peakMultiplier = Math.min( 1, Math.max(0.3, effectiveDuration / 2000), ); const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak const position = (charIndexInsideWord + 0.5) / wordNumChars; const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25); return html`${char}`; })}`; } return html` ${syllableContent} ${romanizedText} `; })} `; })}
`; // Translation container (if enabled) // Hide translation if it matches the original line text const fullLineText = line.text .map(s => s.text) .join('') .trim(); const translationElement = this.showTranslation && line.translation && line.translation.trim() !== fullLineText ? html`