about summary refs log tree commit diff
diff options
context:
space:
mode:
authorProfpatsch <mail@profpatsch.de>2024-10-01T00·06+0200
committerProfpatsch <mail@profpatsch.de>2024-10-05T13·49+0000
commit686b141767ebd7d4dcb817766b058e5ba01cb7e1 (patch)
tree097a4945850ce36a50401fac0cccee4a98cec413
parent48d021de1559db2a41bcc3a971afb448796e9371 (diff)
feat(users/Profpatsch/lyric/ext): add bpm quantization r/8763
It’s a bit crappy and really depends on the input field opening
quickly again (which it often doesn’t really do…), but it was the
easiest way I figured how to do it haha.

Aligning to eigth notes is pretty much the easiest way to sync
everything up after tapping in the timestamps (for most songs).

Change-Id: Ibbb072f62b6ee17d983e81b6c1554bc3516fa636
Reviewed-on: https://cl.tvl.fyi/c/depot/+/12551
Reviewed-by: Profpatsch <mail@profpatsch.de>
Tested-by: BuildkiteCI
-rw-r--r--users/Profpatsch/lyric/extension/package.json5
-rw-r--r--users/Profpatsch/lyric/extension/src/extension.ts100
-rw-r--r--users/Profpatsch/lyric/extension/src/quantize-lrc.ts82
3 files changed, 186 insertions, 1 deletions
diff --git a/users/Profpatsch/lyric/extension/package.json b/users/Profpatsch/lyric/extension/package.json
index 389df271cf5a..76fd8d63e73d 100644
--- a/users/Profpatsch/lyric/extension/package.json
+++ b/users/Profpatsch/lyric/extension/package.json
@@ -29,6 +29,11 @@
         "command": "extension.shiftLyricsUp",
         "title": "Shift Lyrics Up from Current Line",
         "category": "LRC"
+      },
+      {
+        "command": "extension.quantizeToEigthNote",
+        "title": "Quantize timestamps to nearest eighth note",
+        "category": "LRC"
       }
     ],
     "languages": [
diff --git a/users/Profpatsch/lyric/extension/src/extension.ts b/users/Profpatsch/lyric/extension/src/extension.ts
index 83b4ab093eda..f09be13d04bd 100644
--- a/users/Profpatsch/lyric/extension/src/extension.ts
+++ b/users/Profpatsch/lyric/extension/src/extension.ts
@@ -1,5 +1,6 @@
 import * as vscode from 'vscode';
 import * as net from 'net';
+import { adjustTimestampToEighthNote, bpmToEighthNoteDuration } from './quantize-lrc';
 
 export function activate(context: vscode.ExtensionContext) {
   context.subscriptions.push(...registerCheckLineTimestamp(context));
@@ -7,6 +8,7 @@ export function activate(context: vscode.ExtensionContext) {
     vscode.commands.registerCommand('extension.jumpToLrcPosition', jumpToLrcPosition),
     vscode.commands.registerCommand('extension.shiftLyricsDown', shiftLyricsDown),
     vscode.commands.registerCommand('extension.shiftLyricsUp', shiftLyricsUp),
+    vscode.commands.registerCommand('extension.quantizeToEigthNote', quantizeLrc),
   );
 }
 
@@ -160,6 +162,102 @@ async function shiftLyricsUp() {
   });
 }
 
+/** first ask the user for the BPM of the track, then quantize the timestamps in the active text editor to the closest eighth note based on the given BPM */
+async function quantizeLrc() {
+  const bpm = await timeInputBpm();
+
+  if (bpm === undefined) {
+    return;
+  }
+
+  const editor = vscode.window.activeTextEditor;
+
+  if (!editor) {
+    vscode.window.showInformationMessage('No active editor found.');
+    return;
+  }
+
+  const ext = new Ext(editor.document);
+
+  const getLine = (line: number) => ({
+    number: line,
+    range: editor.document.lineAt(line),
+  });
+
+  const documentRange = new vscode.Range(
+    getLine(0).range.range.start,
+    editor.document.lineAt(editor.document.lineCount - 1).range.end,
+  );
+
+  const eighthNoteDuration = bpmToEighthNoteDuration(bpm);
+
+  let newLines: string = '';
+  for (
+    let line = getLine(0);
+    line.number < editor.document.lineCount - 1;
+    line = getLine(line.number + 1)
+  ) {
+    const timestamp = ext.getTimestampFromLine(line.number);
+    if (timestamp === undefined) {
+      newLines += line.range.text + '\n';
+      continue;
+    }
+    const adjustedMs = adjustTimestampToEighthNote(
+      timestamp.milliseconds,
+      eighthNoteDuration,
+    );
+    newLines += `[${formatTimestamp(adjustedMs)}]${timestamp.text}\n`;
+  }
+
+  await editor.edit(editBuilder => {
+    editBuilder.replace(documentRange, newLines);
+  });
+}
+
+// Show input boxes in a loop, and record the time between each input, averaging the last 5 inputs over a sliding window, then calculate the BPM of the average
+async function timeInputBpm() {
+  const timeDifferences: number[] = [500, 500, 500, 500, 500];
+  // assign a weight to the time differences, so that the most recent time differences have more weight
+  const weights = [0.1, 0.1, 0.2, 0.3, 0.3];
+
+  const calculateBPM = () => {
+    // use a weighted average here
+    let avg = 0;
+    for (let i = 0; i < timeDifferences.length; i++) {
+      avg += timeDifferences[i] * weights[i];
+    }
+
+    return Math.floor(60000 / avg);
+  };
+
+  let lastPressTime = Date.now();
+  while (true) {
+    const res = await vscode.window.showInputBox({
+      prompt: `Press enter to record BPM (current BPM: ${calculateBPM()}), enter the final BPM once you know, or press esc to finish`,
+      placeHolder: 'BPM',
+    });
+    if (res === undefined) {
+      return undefined;
+    }
+    if (res !== '') {
+      const resBpm = parseInt(res, 10);
+      if (isNaN(resBpm)) {
+        vscode.window.showErrorMessage('Invalid BPM');
+        continue;
+      }
+      return resBpm;
+    }
+
+    const now = Date.now();
+    const timeDiff = now - lastPressTime;
+    // Add the time difference to the array (limit to last 5 key presses)
+    timeDifferences.shift(); // Remove the oldest time difference
+    timeDifferences.push(timeDiff);
+
+    lastPressTime = now;
+  }
+}
+
 // If the difference to the timestamp on the next line is larger than 10 seconds, underline the next line and show a warning message on hover
 export function registerCheckLineTimestamp(_context: vscode.ExtensionContext) {
   const changesToCheck: Set<vscode.TextDocument> = new Set();
@@ -318,7 +416,7 @@ function formatTimestamp(ms: number): string {
   ms %= 60000;
   const seconds = (ms / 1000).toFixed(2);
 
-  return `${String(minutes).padStart(2, '0')}:${seconds}`;
+  return `${String(minutes).padStart(2, '0')}:${seconds.padStart(5, '0')}`;
 }
 
 class ManageWarnings {
diff --git a/users/Profpatsch/lyric/extension/src/quantize-lrc.ts b/users/Profpatsch/lyric/extension/src/quantize-lrc.ts
new file mode 100644
index 000000000000..83c31348e26e
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/src/quantize-lrc.ts
@@ -0,0 +1,82 @@
+export function bpmToEighthNoteDuration(bpm: number): number {
+  // Convert BPM to eighth-note duration in milliseconds
+  const quarterNoteDuration = (60 / bpm) * 1000; // in ms
+  const eighthNoteDuration = quarterNoteDuration / 2;
+  return eighthNoteDuration;
+}
+
+function parseTimestamp(timestamp: string): number {
+  // Parse [mm:ss.ms] format into milliseconds
+  const [min, sec] = timestamp.split(':');
+
+  const minutes = parseInt(min, 10);
+  const seconds = parseFloat(sec);
+
+  return minutes * 60 * 1000 + seconds * 1000;
+}
+
+function formatTimestamp(ms: number): string {
+  // Format milliseconds back into [mm:ss.ms]
+  const minutes = Math.floor(ms / 60000);
+  ms %= 60000;
+  const seconds = (ms / 1000).toFixed(2);
+
+  return `${String(minutes).padStart(2, '0')}:${seconds}`;
+}
+
+export function adjustTimestampToEighthNote(
+  timestampMs: number,
+  eighthNoteDuration: number,
+): number {
+  // Find the closest multiple of the eighth-note duration
+  return Math.round(timestampMs / eighthNoteDuration) * eighthNoteDuration;
+}
+
+function adjustTimestamps(bpm: number, timestamps: string[]): string[] {
+  const eighthNoteDuration = bpmToEighthNoteDuration(bpm);
+
+  return timestamps.map(timestamp => {
+    const timestampMs = parseTimestamp(timestamp);
+    const adjustedMs = adjustTimestampToEighthNote(timestampMs, eighthNoteDuration);
+    return formatTimestamp(adjustedMs);
+  });
+}
+
+// Parse a .lrc file into an array of objects with timestamp and text
+// Then adjust the timestamps to the closest eighth note
+// Finally, format the adjusted timestamps back into [mm:ss.ms] format and put them back into the lrc object
+//
+// Example .lrc file:
+// [01:15.66] And the reviewers bewail
+// [01:18.18] There'll be no encore
+// [01:21.65] 'Cause you're not begging for more
+// [01:25.00]
+// [01:34.64] She may seem self-righteous and holier-than-thou
+// [01:39.77] She may sound like she has all the answers
+// [01:45.20] But beyond she may feel just a bit anyhow
+function parseLrc(lrc: string): { timestamp: string; text: string }[] {
+  return lrc
+    .trimEnd()
+    .split('\n')
+    .map(line => {
+      const match = line.match(/\[(\d+:\d+\.\d+)\](.*)/);
+      const [, timestamp, text] = match!;
+      return { timestamp, text };
+    });
+}
+
+function formatLrc(lrc: { timestamp: string; text: string }[]): string {
+  return lrc.map(({ timestamp, text }) => `[${timestamp}] ${text}`).join('\n');
+}
+
+function adjustLrc(lrc: string, bpm: number): string {
+  const lrcArray = parseLrc(lrc);
+  const timestamps = lrcArray.map(({ timestamp }) => timestamp);
+  const adjustedTimestamps = adjustTimestamps(bpm, timestamps);
+
+  lrcArray.forEach((line, i) => {
+    line.timestamp = adjustedTimestamps[i];
+  });
+
+  return formatLrc(lrcArray);
+}