diff --git a/lyrics/static/lyrics/input.js b/lyrics/static/lyrics/input.js index c2bc87b..aa9908f 100644 --- a/lyrics/static/lyrics/input.js +++ b/lyrics/static/lyrics/input.js @@ -16,6 +16,7 @@ const btn_download_subtitles = document.getElementById('btn_download_subtitles') const req_tokenization_url = './tokenize'; // let has_lyrics_changed = false let tokenized_lyric_lines = []; +let arrangement_line_numbers = []; let lyric_section_ranges = {}; // [start, end) line indices const re_lyric_section = /\[(.+)\]/; @@ -84,26 +85,24 @@ function on_receive_tokenized_lyrics(data) { function update_arrangement_output() { console.log('Updating arrangement output'); + arrangement_line_numbers = [] + arrangement_input_element.value + .split(',') + .map(s=>lyric_section_ranges[s.trim()]) + .filter(x=>x) + .forEach(([i0, i1])=>{ + for (let i=i0; i { - const s = section.trim(); - if (s in lyric_section_ranges) { - console.log(`Adding section [${s}]`); - - // html += `[${s}]
`; // Section name is already in the line range :) - const [i0, i1] = lyric_section_ranges[s]; - for (let i = i0; i < i1; i++) { - if ((typeof tokenized_lyric_lines[i]) != "string") { - if (i < tl_lines.length) html += `${tl_lines[i]}
`; - } - html += format_parsed_line(tokenized_lyric_lines[i]); - } - } else { - console.log(`Cannot add section [${s}]`); + for (const i of arrangement_line_numbers) { + const tokenized_line = tokenized_lyric_lines[i]; + if ((typeof tokenized_line) != "string" && i < tl_lines.length) { + html += `${tl_lines[i]}
`; } - }) + html += format_parsed_line(tokenized_line); + } arrangement_output_element.innerHTML = html; } @@ -231,7 +230,24 @@ btn_download_subtitles.addEventListener('click', () => { import generate_subtitles_from_data from './subtitle_generator.js' function generate_subtitles() { console.log('Attempting to generate subtitles'); - // TODO: Do something to generate subtitles + + let lines = []; + let t0 = 72.0; // TODO: add line timing data + const seconds_per_line = 10.0; + + const tl_lines = lyrics_tl_input_element.value.split('\n'); + for (const i of arrangement_line_numbers) { + const tokenized_line = tokenized_lyric_lines[i]; + if ((typeof tokenized_line) == "string") continue; + + lines.push(Object.assign({ + t0: t0, + t1: t0+seconds_per_line, + translated_line: tl_lines?.[i] ?? '', + }, tokenized_line)); + t0 += seconds_per_line; + } + const subtitles = generate_subtitles_from_data({ song_title: document.getElementById('song_title').value, song_title_en: document.getElementById('song_title_en').value, @@ -240,6 +256,7 @@ function generate_subtitles() { song_composer: document.getElementById('song_composer').value, song_composer_en: document.getElementById('song_composer_en').value, title_fade_duration_ms: 2500, + lines: lines, }); subtitle_editor_textarea.value = subtitles; subtitle_editor_textarea.dispatchEvent(new Event('change', {})); // This triggers a reload in the player diff --git a/lyrics/static/lyrics/subtitle_generator.js b/lyrics/static/lyrics/subtitle_generator.js index 8eafbf9..aa23d5d 100644 --- a/lyrics/static/lyrics/subtitle_generator.js +++ b/lyrics/static/lyrics/subtitle_generator.js @@ -3,6 +3,26 @@ function timecode(s) { return new Date(s*1000).toISOString().substring(11, 22); // This can overflow at 24 hours. Who cares? } +const kana_merge_previous_syllable = new Set('ゃゅょぁぃぇぉぅゎんャュョァィェォゥヮン'.split('')); +const kana_merge_next_syllable = new Set('っッ'.split('')); +function kana_to_syllable_list(kana) { + let a = []; + const len = kana.length; + for (let i = 0; i < len; i++) { + const k = kana[i]; + if (kana_merge_next_syllable.has(k)) { + i++; + a.push(k + kana[i]); + } else if (kana_merge_previous_syllable.has(k)) { + a.push(a.pop() + k); + } else { + a.push(k); + } + } + console.log(a); + return a; +} + export default function generate_subtitles_from_data(data) { const format_defaults = { 'PlayResX': 1280, @@ -39,10 +59,11 @@ Style: Title,Droid Sans,72,&HFFFFFF,&H0019FF,&H000000,&H000000,0,0,0,0,100,100,0 Style: TitleJP,Droid Sans Japanese,72,&HFFFFFF,&H0019FF,&H000000,&H000000,0,0,0,0,100,100,0,0,1,2.5,0,1,30,30,25,1 [Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text`; +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +`; // Title card - const t0 = 0.0; - const t1 = 80.0; // TODO: tie this to the first line time + const t0 = 1.0; + const t1 = (data.lines?.[0]?.t0 ?? 11.0) - 1.0; // tie this to the first line time const t0s = timecode(t0); const t1s = timecode(t1); const duration_seconds = t1-t0; @@ -50,13 +71,100 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text` const fade_duration_ms = data.title_fade_duration_ms ?? 1000; const fade = `{\\fade(255,0,255,0,${fade_duration_ms},${duration_ms-fade_duration_ms},${duration_ms})}`; s += ` -Comment: 0,00:00:00.00,00:01:20.00,,,,,,,Title Card +Comment: 0,${t0s},${t1s},,,,,,,Title Card Dialogue: 0,${t0s},${t1s},TitleJP,,,,,,${fade}作曲 ${data.song_composer} Dialogue: 0,${t0s},${t1s},TitleJP,,,,,,${fade}作詞 ${data.song_lyricist} Dialogue: 0,${t0s},${t1s},TitleJP,,,,,,${fade}曲名 ${data.song_title} Dialogue: 0,${t0s},${t1s},Title,,,,,,${fade}Music: ${data.song_composer_en} Dialogue: 0,${t0s},${t1s},Title,,,,,,${fade}Lyrics: ${data.song_lyricist_en} -Dialogue: 0,${t0s},${t1s},Title,,,,,,${fade}${data.song_title_en}`; - // TODO: add the lines +Dialogue: 0,${t0s},${t1s},Title,,,,,,${fade}${data.song_title_en} +`; + + // Kanji Furigana layout stuff + const pt_to_fullwidth = 55.0/72.0; // This seems right for DroidSansJapanese, probably different for each font + const size_kanji_x = format_defaults.KanjiSize * pt_to_fullwidth; + const size_furi_x = format_defaults.FuriSize * pt_to_fullwidth; + const res_x = format_defaults.PlayResX; + const res_xh = res_x/2; + + // Add the lines + data.lines?.forEach(line => { + const t0 = line.t0; + const t1 = line.t1; + const t0s = timecode(t0); + const t1s = timecode(t1); + const duration_seconds = t1-t0; + const num_syllables = line.romaji_syllables.filter(x=>x.trim()).length; + const duration_cs = Math.floor(duration_seconds * 100); // For karaoke syllable fallback + const fallback_syl_duration_cs = duration_cs/num_syllables; + const centiseconds = line.arr_syllables_cs ?? Array(num_syllables+1).fill().map((_,i) => Math.floor(i*fallback_syl_duration_cs)); + + const sub_preamble = `Dialogue: 0,${t0s},${t1s}`; + const furi_preamble = `Dialogue: 1,${t0s},${t1s}`; // Different layer to avoid kanji collision detection + + // Translation line is easy and static + s += `${sub_preamble},Translation,,,,,,${line.translated_line}\n`; + + // Romaji line is also easy, just intersperse durations + let romaji_line = `{\\k${centiseconds[0]}}`; + let i = 0; // syllable counter + for (const syl of line.romaji_syllables) { + if (syl.trim()){ // not whtespace + romaji_line += `{\\K${centiseconds[i+1]-centiseconds[i]}}${syl}`; + i++; + } else { // whitespace + romaji_line += `{\\k0}${syl}`; + } + } + s += `${sub_preamble},Romaji,,,,,,${romaji_line}\n`; + + // Now for the kanji and furi lines... + const kanji_plain_str = line.furi_blocks.map(b => b[0]).join(''); + const full_kanji_width = kanji_plain_str.length * size_kanji_x; + let kanji_line = `{\\k${centiseconds[0]}}`; + let kanji_line_progress = 0; // increment as we go, to track furi position + let furi_lines = []; + i = 0; // syllable counter + for (const furi_block of line.furi_blocks) { + if (furi_block[1].length == 0) { // kana or punctuation, nice and simple! + let syls = kana_to_syllable_list(furi_block[0]); + for (const syl of syls) { + if (syl.trim().length == 0) { // don't time spaces + kanji_line += `{\\k0}${syl}`; + kanji_line_progress += syl.length; + } else { + kanji_line += `{\\K${centiseconds[i+1]-centiseconds[i]}}${syl}`; + kanji_line_progress += syl.length; + i++; + } + } + } else { // Kanji block + const i0 = i; // Store this to later calculate block time for the kanji + let syls = kana_to_syllable_list(furi_block[1]); + let furi_line = `{\\k${centiseconds[i]}}`; + let furi_chars = 0; + for (const syl of syls) { + furi_line += `{\\K${centiseconds[i+1]-centiseconds[i]}}${syl}`; + furi_chars += syl.length; + i++; + } + // Need to calculate kanji block position and span to typeset the furigana above it + const k = furi_block[0]; + const k_start = kanji_line_progress; + kanji_line_progress += k.length; + const k_end = kanji_line_progress; + const target_middle_x = (size_kanji_x * (k_end+k_start)/2) - (full_kanji_width/2); // x=0 at center + const furi_width = furi_chars * size_furi_x; + const margin_l = Math.floor(res_xh+target_middle_x); + const margin_r = Math.floor(res_xh-target_middle_x); + furi_lines.push(`${furi_preamble},Furigana,,${margin_l},${margin_r},,,${furi_line}\n`); + kanji_line += `{\\K${centiseconds[i]-centiseconds[i0]}}${k}`; + } + }; + s += `${sub_preamble},Kanji,,,,,,${kanji_line}\n`; + for (line of furi_lines) { + s += line; + } + }); return s; }