-
-
Notifications
You must be signed in to change notification settings - Fork 86
Implement escape characters for lyrics and lyrics code refactor #575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
achimmihca
merged 7 commits into
UltraStar-Deluxe:master
from
Tuupertunut:feature/editor-lyrics-escape
May 20, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
95c49d3
Fix space before note splitting the note when editing
Tuupertunut 72873ff
Fix replacement characters in real lyrics text while editing
Tuupertunut d46842d
Implement escape characters for separators in lyrics editor
Tuupertunut ccacdc7
Allow removing sentence separator by removing just the newline
Tuupertunut eb98813
Refactor lyrics editing code
Tuupertunut 4352dc1
Fix wrong caret position when using escape characters
Tuupertunut 514a61f
Fix not being able to add newlines in lyrics
Tuupertunut File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Text; | ||
|
|
||
| public static class LyricsUtils | ||
| { | ||
| public static readonly char syllableSeparator = ';'; | ||
| public static readonly char wordSeparator = ' '; | ||
| public static readonly char sentenceSeparator = '\n'; | ||
| public static readonly char escapeCharacter = '\\'; | ||
|
|
||
| public static string GetViewModeText(Voice voice) | ||
| { | ||
| StringBuilder stringBuilder = new(); | ||
| List<Sentence> sortedSentences = SongMetaUtils.GetSortedSentences(voice); | ||
| foreach (Sentence sentence in sortedSentences) | ||
| { | ||
| List<Note> sortedNotes = SongMetaUtils.GetSortedNotes(sentence); | ||
| foreach (Note note in sortedNotes) | ||
| { | ||
| stringBuilder.Append(note.Text); | ||
| } | ||
| stringBuilder.Append(sentenceSeparator); | ||
| } | ||
| return stringBuilder.ToString(); | ||
| } | ||
|
|
||
| public static string GetEditModeText(Voice voice) | ||
| { | ||
| List<Sentence> sortedSentences = SongMetaUtils.GetSortedSentences(voice); | ||
| return sortedSentences | ||
| .Select(sentence => GetEditModeText(sentence)) | ||
| .JoinWith(sentenceSeparator.ToString()); | ||
| } | ||
|
|
||
| public static string GetEditModeText(Sentence sentence) | ||
| { | ||
| List<Note> sortedNotes = SongMetaUtils.GetSortedNotes(sentence); | ||
| return FormatAsEditable(sortedNotes.Select(note => note.Text)); | ||
| } | ||
|
|
||
| public static string GetEditModeText(Note note) | ||
| { | ||
| return FormatAsEditable(new[] { note.Text }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Splits the text into sentences and syllables and applies it to the notes of the voice, | ||
| /// padding with empty strings if necessary. | ||
| /// </summary> | ||
| /// <param name="editModeText"></param> | ||
| /// <param name="voice"></param> | ||
| public static void MapEditModeTextToNotes(string editModeText, Voice voice) | ||
| { | ||
| List<Sentence> sortedSentences = SongMetaUtils.GetSortedSentences(voice); | ||
| string[] editModeSentences = editModeText.Split(sentenceSeparator); | ||
|
|
||
| for (int sentenceIndex = 0; sentenceIndex < sortedSentences.Count; sentenceIndex++) | ||
| { | ||
| Sentence sentence = sortedSentences[sentenceIndex]; | ||
| string editModeSentence = (sentenceIndex < editModeSentences.Length) ? editModeSentences[sentenceIndex] : ""; | ||
|
|
||
| MapEditModeTextToNotes(editModeSentence, sentence); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Splits the text into syllables and applies it to the notes of the sentence, padding with | ||
| /// empty strings if necessary. | ||
| /// </summary> | ||
| /// <param name="editModeSentence"></param> | ||
| /// <param name="sentence"></param> | ||
| public static void MapEditModeTextToNotes(string editModeSentence, Sentence sentence) | ||
| { | ||
| List<Note> sortedNotes = SongMetaUtils.GetSortedNotes(sentence); | ||
| List<string> syllables = ParseEditable(editModeSentence); | ||
|
|
||
| for (int noteIndex = 0; noteIndex < sortedNotes.Count; noteIndex++) | ||
| { | ||
| Note note = sortedNotes[noteIndex]; | ||
| string syllable = (noteIndex < syllables.Count) ? syllables[noteIndex] : ""; | ||
|
|
||
| note.SetText(syllable); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Splits the text into syllables and tries to apply them to a single note. If there are | ||
| /// multiple syllables, the note is split into multiple parts and the syllables are applied to | ||
| /// them in order. | ||
| /// </summary> | ||
| /// <param name="note"></param> | ||
| /// <param name="newText"></param> | ||
| /// <returns>The list of notes after the split. The first note is always the original.</returns> | ||
| public static List<Note> SplitNoteAndApplyEditModeText(Note note, string newText) | ||
| { | ||
| List<string> syllables = LyricsUtils.ParseEditable(newText); | ||
|
|
||
| // Change original note | ||
| note.SetText(syllables[0]); | ||
| List<Note> notesAfterSplit = new List<Note> { note }; | ||
|
|
||
| if (syllables.Count > 1) | ||
| { | ||
| // The note must be split. Splitting positions try to approximate the lengths of the | ||
| // syllables of the new notes. | ||
| int originalNoteLength = note.Length; | ||
| int syllablesTotalLength = Math.Max(1, syllables.Sum(syllable => syllable.Length)); | ||
|
|
||
| int firstEndBeat = (int)Math.Floor(note.StartBeat + originalNoteLength * ((double)syllables[0].Length / syllablesTotalLength)); | ||
| if (firstEndBeat <= note.StartBeat) | ||
| { | ||
| firstEndBeat = note.StartBeat + 1; | ||
| } | ||
| note.SetEndBeat(firstEndBeat); | ||
|
|
||
| int lastSyllablesCumulativeLength = syllables[0].Length; | ||
|
|
||
| foreach (string syllable in syllables.Skip(1)) | ||
| { | ||
| int startBeat = (int)Math.Floor(note.StartBeat + originalNoteLength * ((double)lastSyllablesCumulativeLength / syllablesTotalLength)); | ||
| int syllablesCumulativeLength = lastSyllablesCumulativeLength + syllable.Length; | ||
| int endBeat = (int)Math.Floor(note.StartBeat + originalNoteLength * ((double)syllablesCumulativeLength / syllablesTotalLength)); | ||
| if (endBeat <= startBeat) | ||
| { | ||
| endBeat = startBeat + 1; | ||
| } | ||
|
|
||
| Note newNote = new(note.Type, startBeat, endBeat - startBeat, note.TxtPitch, syllable); | ||
| newNote.SetSentence(note.Sentence); | ||
| notesAfterSplit.Add(newNote); | ||
|
|
||
| lastSyllablesCumulativeLength = syllablesCumulativeLength; | ||
| } | ||
| } | ||
|
|
||
| return notesAfterSplit; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a list of syllables into an editable lyrics string. Joins the syllables with | ||
| /// syllable separators or word separators based on context. Adds escape sequences for special | ||
| /// characters when necessary. The opposite of ParseEditable. | ||
| /// </summary> | ||
| /// <param name="syllables"></param> | ||
| /// <returns></returns> | ||
| public static string FormatAsEditable(IEnumerable<string> syllables) | ||
| { | ||
| StringBuilder output = new(); | ||
| string lastSyllable = null; | ||
| foreach (string syllable in syllables) | ||
| { | ||
| if (lastSyllable != null) | ||
| { | ||
| bool lastSyllableEndedWithWordSeparator = lastSyllable.Length > 0 && lastSyllable[^1] == wordSeparator; | ||
| bool currentSyllableStartsWithWordSeparator = syllable.Length > 0 && syllable[0] == wordSeparator; | ||
| // Neither syllable has a bordering word separator: Add a syllable separator. | ||
| if (!lastSyllableEndedWithWordSeparator && !currentSyllableStartsWithWordSeparator) | ||
| { | ||
| output.Append(syllableSeparator); | ||
| } | ||
| } | ||
| for (int i = 0; i < syllable.Length; i++) | ||
| { | ||
| char c = syllable[i]; | ||
|
|
||
| if (c == escapeCharacter | ||
| || c == syllableSeparator | ||
| // Word separators at the beginning and end of the syllable should not be escaped | ||
| || (c == wordSeparator && i > 0 && i < syllable.Length - 1)) | ||
| { | ||
| output.Append(escapeCharacter); | ||
| output.Append(c); | ||
| } | ||
| else | ||
| { | ||
| output.Append(c); | ||
| } | ||
| } | ||
| lastSyllable = syllable; | ||
| } | ||
| return output.ToString(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Parses an editable lyrics string into a list of syllables. Splits the text on syllable and | ||
| /// word separators, except when escaped with escape sequences. The opposite of | ||
| /// FormatAsEditable. This can only be used within one sentence, it does not understand sentence separators. | ||
| /// </summary> | ||
| /// <param name="text"></param> | ||
| /// <returns></returns> | ||
| public static List<string> ParseEditable(string text) | ||
| { | ||
| List<string> syllables = new(); | ||
| StringBuilder output = new(); | ||
| bool escapeInProgress = false; | ||
| for (int i = 0; i < text.Length; i++) | ||
| { | ||
| char c = text[i]; | ||
| // Unescaped escape character: Do not write the escape character, start an escape sequence. | ||
| if (c == escapeCharacter && !escapeInProgress) | ||
| { | ||
| escapeInProgress = true; | ||
| } | ||
| // Unescaped syllable separator: Do not write the syllable separator, start a new syllable. | ||
| else if (c == syllableSeparator && !escapeInProgress) | ||
| { | ||
| syllables.Add(output.ToString()); | ||
| output.Clear(); | ||
| } | ||
| // Unescaped word separator in the middle: Write the word separator, start a new syllable. | ||
| else if (c == wordSeparator && !escapeInProgress && i > 0 && i < text.Length - 1) | ||
| { | ||
| output.Append(c); | ||
|
|
||
| syllables.Add(output.ToString()); | ||
| output.Clear(); | ||
| } | ||
| // Escaped character: Write the character, end the escape sequence. | ||
| else if (escapeInProgress) | ||
| { | ||
| output.Append(c); | ||
|
|
||
| escapeInProgress = false; | ||
| } | ||
| // Other character or unescaped word separator at the ends: Write the character. | ||
| else | ||
| { | ||
| output.Append(c); | ||
| } | ||
| } | ||
| // End the last syllable | ||
| syllables.Add(output.ToString()); | ||
| return syllables; | ||
| } | ||
| } | ||
File renamed without changes.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer JavaDoc resp. JsDoc format, because I think the XML stuff is rather verbose. Especially because it repeats
<summary>always.Furthermore, empty parameter descriptions are not helpful and tend to diverge with code over time. Please remove. The empty ones.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Anyway, overall this works well and I do not want to be nitpicky with the comments.
Still in the future, prefer to use JavaDoc (I know this is not very C#-ish) and avoid empty parameter descriptions.