UNPKG

tuneflow

Version:

Programmable, extensible music composition & arrangement

466 lines (418 loc) 11.8 kB
import _ from 'underscore'; import { DEFAULT_PPQ } from '../utils'; import type { Song } from './song'; import { le as lowerEqual } from 'binary-search-bounds'; export type LyricTokenizer = (input: string) => Promise<string[]>; export class Lyrics { private song: Song; private lines: LyricLine[]; constructor({ song, lines }: { song: Song; lines?: LyricLine[] }) { this.song = song; this.lines = lines ? lines : []; } getLines() { return this.lines; } /** * Gets the index of the line at a given tick. * * Returns -1 if there is no line at the tick. */ getIndexOfLineAtTick(tick: number) { if (this.lines.length <= 0) { return -1; } const startSearchIndex = lowerEqual( this.lines, { getStartTick: () => tick, } as LyricLine, (a, b) => a.getStartTick() - b.getStartTick(), ); for (let index = startSearchIndex; index >= 0; index -= 1) { const line = this.lines[index]; if (tick >= line.getStartTick() && tick <= line.getEndTick()) { return index; } } return -1; } /** Create a single line and inserts it into the lyrics. */ createLine({ startTick, resolveOrder = true }: { startTick: number; resolveOrder?: boolean }) { const lyricLine = new LyricLine({ lyrics: this, startTick }); this.lines.push(lyricLine); if (resolveOrder) { this.sortLinesInternal(); } return lyricLine; } /** * Create a single line with the given string and inserts it into the lyrics. * * The string will be splitted into words by `splitBy`. */ async createLineFromString({ input, startTick, endTick, tokenizer = LyricLine.DEFAULT_TOKENIZER, }: { input: string; startTick: number; endTick: number; /** An async function to break input into a list of string-format words. */ tokenizer?: LyricTokenizer; }) { const line = this.createLine({ startTick }); if (!input) { return line; } // @ts-ignore await LyricLine.createWordsFromString(line, input, startTick, endTick, tokenizer); return line; } /** Removes a line at the given index. */ removeLineAtIndex(index: number) { if (index < 0 || index >= this.lines.length) { return; } this.lines.splice(index, /* deleteCount= */ 1); } /** Clone a line and inserts it into the current lyrics. */ cloneLine(originalLine: LyricLine) { const line = new LyricLine({ lyrics: this, startTick: originalLine.getStartTick(), }); for (const lyricWord of originalLine.getWords()) { line.createWord({ word: lyricWord.getWord(), startTick: lyricWord.getStartTick(), endTick: lyricWord.getEndTick(), }); } this.lines.push(line); return line; } sortLinesInternal() { this.lines.sort((a, b) => a.getStartTick() - b.getStartTick()); } } /** * One single line in lyrics */ export class LyricLine { private lyrics: Lyrics; private words: LyricWord[]; constructor({ lyrics, startTick }: { lyrics: Lyrics; startTick: number }) { this.lyrics = lyrics; this.words = [ new LyricWord({ line: this, word: LyricWord.PLACEHOLDER_WORD, startTick, endTick: startTick + DEFAULT_PPQ, }), ]; } getStartTick() { if (this.words.length === 0) { throw new Error('Words cannot be empty'); } return LyricLine.getLineStartTickImpl(this.words, item => item.getStartTick()); } getEndTick() { if (this.words.length === 0) { throw new Error('Words cannot be empty'); } return LyricLine.getLineEndTickImpl(this.words, item => item.getEndTick()); } static getLineStartTickImpl<T>(words: T[], wordToStartTickFn: (word: T) => number) { if (!words || words.length === 0) { return 0; } return wordToStartTickFn(words[0]); } static getLineEndTickImpl<T>(words: T[], wordToEndTickFn: (word: T) => number) { return wordToEndTickFn(_.max(words, wordToEndTickFn) as T); } getWords() { return this.words; } moveTo(startTick: number, endTick: number) { const originalStart = this.getStartTick(); const originalEnd = this.getEndTick(); if (!_.isNumber(originalStart) || !_.isNumber(originalEnd)) { return; } startTick = Math.max(startTick, 0); const scaleFactor = (endTick - startTick) / (originalEnd - originalStart); // Maintain the duration ratio of each word. for (const lyricWord of this.getWords()) { lyricWord.setStartTick( LyricLine.relativeMovePoint( lyricWord.getStartTick(), originalStart, startTick, scaleFactor, ), /* resolveOrder= */ false, ); lyricWord.setEndTick( LyricLine.relativeMovePoint(lyricWord.getEndTick(), originalStart, startTick, scaleFactor), /* resolveOrder= */ false, ); } // Re-sort lines. this.lyrics.sortLinesInternal(); } /** Remove all existing words. */ clear() { this.words = [this.createPlaceholderWord()]; } async replaceWithString(input: string, tokenizer: LyricTokenizer = LyricLine.DEFAULT_TOKENIZER) { const startTick = this.getStartTick(); const endTick = this.getEndTick(); this.words = [this.createPlaceholderWord()]; if (input) { await LyricLine.createWordsFromString(this, input, startTick, endTick, tokenizer); } } private static async createWordsFromString( line: LyricLine, input: string, startTick: number, endTick: number, tokenizer?: LyricTokenizer, ) { if (!input) { return; } const words = tokenizer ? await tokenizer(input) : input.split(''); const tickPerWord = (endTick - startTick) / words.length; for (let index = 0; index < words.length; index += 1) { const word = words[index]; const wordStartTick = startTick + tickPerWord * index; const wordEndTick = startTick + tickPerWord * (index + 1); line.createWord({ word, startTick: wordStartTick, endTick: wordEndTick, resolveOrder: false, }); } line.sortWordsInternal(); } createWord({ word, startTick, endTick, resolveOrder = true, }: { word: string; startTick: number; endTick: number; resolveOrder?: boolean; }) { const newWord = new LyricWord({ line: this, word, startTick, endTick }); if (this.isEmpty()) { this.words = [newWord]; } else { this.words.push(newWord); } if (resolveOrder) { this.sortWordsInternal(); } return newWord; } deleteWord(word: LyricWord) { let searchIndex = lowerEqual(this.words, word, (a, b) => a.getStartTick() - b.getStartTick()); while (searchIndex >= 0 && this.words[searchIndex]) { const lyricWord = this.words[searchIndex]; if (lyricWord.equals(word)) { if (this.words.length === 1) { // Words will become empty, insert a default placeholder. this.clear(); } else { this.words.splice(searchIndex, /* deleteCount= */ 1); } return lyricWord; } searchIndex -= 1; } return null; } /** Returns The line as a single string sentence. */ getSentence() { if (this.isEmpty()) { return ''; } return LyricLine.getSentenceImpl<LyricWord>(this.words, word => word.getWord()); } isEmpty() { return LyricLine.hasOnlyPlaceholderImpl<LyricWord>(this.words, word => word.getWord()); } cloneWord(word: LyricWord) { return new LyricWord({ line: this, word: word.getWord(), startTick: word.getStartTick(), endTick: word.getEndTick(), }); } static getSentenceImpl<T>(words: T[], wordToTextFn: (word: T) => string) { if (words.length === 1 && wordToTextFn(words[0]) === LyricWord.PLACEHOLDER_WORD) { return '♪'; } return words.map(wordToTextFn).join(''); } static hasOnlyPlaceholderImpl<T>(words: T[], wordToTextFn: (word: T) => string) { if (!words) { return true; } return words.length === 1 && wordToTextFn(words[0]) === LyricWord.PLACEHOLDER_WORD; } sortWordsInternal() { const startTick = this.getStartTick(); this.words.sort((a, b) => a.getStartTick() - b.getStartTick()); if (this.getStartTick() !== startTick) { // Re-sort lines. this.lyrics.sortLinesInternal(); } } private createPlaceholderWord() { return new LyricWord({ line: this, word: LyricWord.PLACEHOLDER_WORD, startTick: this.getStartTick(), endTick: this.getStartTick() + DEFAULT_PPQ * 4 * 2, }); } /** * The default tokenizer separates on: * * Single english word * * Single chinese character, * * Single punctuation that is not associated with a word * * Space */ static async DEFAULT_TOKENIZER(input: string) { const tokens: string[] = []; let currentToken = ''; for (const char of input) { if (char === ' ') { if (currentToken) { tokens.push(currentToken); currentToken = ''; } tokens.push(char); } else if (/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]/.test(char)) { if (currentToken) { tokens.push(currentToken); currentToken = ''; } tokens.push(char); } else if (/[\p{P}]/u.test(char)) { if (currentToken) { if (/^[']+$/.test(char) && /^[A-Za-z0-9]+$/.test(currentToken)) { currentToken += char; } else { tokens.push(currentToken); tokens.push(char); currentToken = ''; } } else { tokens.push(char); } } else { currentToken += char; } } if (currentToken) { tokens.push(currentToken); } return tokens; } private static relativeMovePoint( time: number, originalReferenceStart: number, newReferenceStart: number, scaleFactor: number, ) { return newReferenceStart + (time - originalReferenceStart) * scaleFactor; } } export class LyricWord { private line: LyricLine; private startTick: number; private endTick: number; private word: string; constructor({ line, word, startTick, endTick, }: { line: LyricLine; word: string; startTick: number; endTick: number; }) { this.line = line; this.word = word; this.startTick = startTick; this.endTick = endTick; } getStartTick() { return this.startTick; } /** * Set a new start tick for the word, * note that this might delete the word if it becomes empty. */ setStartTick(startTick: number, resolveOrder = true) { if (startTick >= this.endTick && resolveOrder) { // Delete this word. this.deleteFromParent(); return; } this.startTick = startTick; if (resolveOrder) { this.line.sortWordsInternal(); } } getEndTick() { return this.endTick; } /** * Set a new emd tick for the word, * note that this might delete the word if it becomes empty. */ setEndTick(endTick: number, resolveOrder = true) { if (endTick <= this.startTick && resolveOrder) { // Delete this word. this.deleteFromParent(); return; } this.endTick = endTick; } getWord() { return this.word; } setWord(word: string) { if (!word) { throw new Error('Cannot set to empty word.'); } this.word = word; } deleteFromParent() { this.line.deleteWord(this); } equals(word: LyricWord) { return ( this.startTick === word.startTick && this.endTick === word.getEndTick() && this.word === word.getWord() ); } static PLACEHOLDER_WORD = '^%%^'; }