feat(users/Profpatsch/lyric/ext): add lrc upload & ms offset

This adds support for uploading the lyrics part of an .lrc file to
lrclib, see https://lrclib.net/docs

I pretty much only used ChatGPT to translate the rust “proof of work”
challenge to nodejs and it worked first try lol.

Before uploading the lyrics, I construct a webview with a preview of
what is going to be uploaded, and then only upload when that is
accepted. Pretty sweet.

Also adds two commands for increasing/decreasing the current timestamp
by 100ms and starting playback from 2 seconds before that, very handy
for fine-tuning lines.

Change-Id: Ia6adfe26d0c21c62554c8f8c55e97e2caec95d1e
Reviewed-on: https://cl.tvl.fyi/c/depot/+/12561
Reviewed-by: Profpatsch <mail@profpatsch.de>
Tested-by: BuildkiteCI
This commit is contained in:
Profpatsch 2024-10-01 19:10:52 +02:00
parent ad711b15a0
commit cf68a34b0d
3 changed files with 405 additions and 6 deletions

View file

@ -34,6 +34,21 @@
"command": "extension.quantizeToEigthNote",
"title": "Quantize timestamps to nearest eighth note",
"category": "LRC"
},
{
"command": "extension.fineTuneTimestampDown100MsAndPlay",
"title": "Remove 100 ms from current timestamp and play from shortly before the change",
"category": "LRC"
},
{
"command": "extension.fineTuneTimestampUp100MsAndPlay",
"title": "Add 100 ms to current timestamp and play from shortly before the change",
"category": "LRC"
},
{
"command": "extension.uploadLyricsToLrclibDotNet",
"title": "Upload Lyrics to lrclib.net",
"category": "LRC"
}
],
"languages": [

View file

@ -1,6 +1,9 @@
import * as vscode from 'vscode';
import * as net from 'net';
import { adjustTimestampToEighthNote, bpmToEighthNoteDuration } from './quantize-lrc';
import { publishLyrics, PublishRequest } from './upload-lrc';
const channel_global = vscode.window.createOutputChannel('LRC');
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(...registerCheckLineTimestamp(context));
@ -8,7 +11,19 @@ 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),
vscode.commands.registerCommand('extension.quantizeToEigthNote', quantizeToEigthNote),
vscode.commands.registerCommand(
'extension.fineTuneTimestampDown100MsAndPlay',
fineTuneTimestampAndPlay(-100),
),
vscode.commands.registerCommand(
'extension.fineTuneTimestampUp100MsAndPlay',
fineTuneTimestampAndPlay(100),
),
vscode.commands.registerCommand(
'extension.uploadLyricsToLrclibDotNet',
uploadToLrclibDotNet,
),
);
}
@ -39,18 +54,23 @@ function jumpToLrcPosition() {
const { milliseconds, seconds } = res;
// Prepare JSON command to send to the socket
const jsonCommand = {
const seekCommand = {
command: ['seek', seconds, 'absolute'],
};
const reloadSubtitlesCommand = {
command: ['sub-reload'],
};
const socket = new net.Socket();
const socketPath = process.env.HOME + '/tmp/mpv-socket';
socket.connect(socketPath, () => {
socket.write(JSON.stringify(jsonCommand));
socket.write(JSON.stringify(seekCommand));
socket.write('\n');
socket.write(JSON.stringify(reloadSubtitlesCommand));
socket.write('\n');
vscode.window.showInformationMessage(
`Sent command to jump to [${formatTimestamp(milliseconds)}].`,
`Sent command to jump to [${formatTimestamp(milliseconds)}] and sync subtitles.`,
);
socket.end();
});
@ -163,7 +183,7 @@ 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() {
async function quantizeToEigthNote() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
@ -224,6 +244,62 @@ async function quantizeLrc() {
});
}
/** fine tune the timestamp of the current line by the given amount (in milliseconds) and play the track at slightly before the new timestamp */
function fineTuneTimestampAndPlay(amountMs: number) {
return async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showInformationMessage('No active editor found.');
return;
}
const ext = new Ext(editor.document);
const position = editor.selection.active;
const res = ext.getTimestampFromLine(position.line);
if (!res) {
return;
}
const { milliseconds } = res;
const newMs = milliseconds + amountMs;
// adjust the timestamp
const documentRange = editor.document.lineAt(position.line).range;
await editor.edit(editBuilder => {
editBuilder.replace(documentRange, `[${formatTimestamp(newMs)}]${res.text}`);
});
const PLAY_BEFORE_TIMESTAMP_MS = 2000;
const seekCommand = {
command: ['seek', (newMs - PLAY_BEFORE_TIMESTAMP_MS) / 1000, 'absolute'],
};
const reloadSubtitlesCommand = {
command: ['sub-reload'],
};
const socket = new net.Socket();
const socketPath = process.env.HOME + '/tmp/mpv-socket';
socket.connect(socketPath, () => {
socket.write(JSON.stringify(seekCommand));
socket.write('\n');
socket.write(JSON.stringify(reloadSubtitlesCommand));
socket.write('\n');
vscode.window.showInformationMessage(
`Sent command to jump to [${formatTimestamp(newMs)}] and sync subtitles.`,
);
socket.end();
});
socket.on('error', err => {
vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
});
};
}
// convert the given bpm to miliseconds
function bpmToMs(bpm: number) {
return Math.floor((60 / bpm) * 1000);
@ -253,12 +329,14 @@ async function timeInputBpm(startBpm?: number) {
};
let lastPressTime = Date.now();
let firstLoop = true;
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',
value: startBpm !== undefined ? startBpm.toString() : undefined,
value: startBpm !== undefined && firstLoop ? startBpm.toString() : undefined,
});
firstLoop = false;
if (res === undefined) {
return undefined;
}
@ -281,6 +359,165 @@ async function timeInputBpm(startBpm?: number) {
}
}
/**
* Uploads the lyrics in the active text editor to the LrclibDotNet API.
* @remarks
* This function requires the following dependencies:
* - `vscode` module for accessing the active text editor and displaying messages.
* - `fetch` module for making HTTP requests.
* @throws {Error} If there is an HTTP error.
*/
async function uploadToLrclibDotNet() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showInformationMessage('No active editor found.');
return;
}
const ext = new Ext(editor.document);
const title = ext.findHeader('ti')?.value;
const artist = ext.findHeader('ar')?.value;
const album = ext.findHeader('al')?.value;
const lengthString = ext.findHeader('length')?.value;
if (
title === undefined ||
artist === undefined ||
album === undefined ||
lengthString === undefined
) {
vscode.window.showErrorMessage(
'Missing required headers: title, artist, album, length',
);
return;
}
// parse length as mm:ss
const [minutes, seconds] = lengthString?.split(':') ?? [];
if (
!minutes ||
!seconds ||
isNaN(parseInt(minutes, 10)) ||
isNaN(parseInt(seconds, 10))
) {
vscode.window.showErrorMessage('Invalid length header, expected format: mm:ss');
return;
}
const length = parseInt(minutes, 10) * 60 + parseInt(seconds, 10);
const syncedLyrics = ext.getLyricsPart();
const plainLyrics = plainLyricsFromLyrics(syncedLyrics);
// open a html preview with the lyrics saying
//
// Uploading these lyrics to lrclib.net:
// <metadata as table>
// Lyrics:
// ```<lyrics>```
// Plain lyrics:
// ```<plainLyrics>```
//
// Is this ok?
// <button to upload>
const previewTitle = 'Lyric Preview';
const metadataTable = `
<table>
<tr>
<th>Title</th>
<td>${title}</td>
</tr>
<tr>
<th>Artist</th>
<td>${artist}</td>
</tr>
<tr>
<th>Album</th>
<td>${album}</td>
</tr>
<tr>
<th>Length</th>
<td>${lengthString}</td>
</tr>
</table>
`;
const previewContent = `
<p>Uploading these lyrics to lrclib.net:</p>
${metadataTable}
<p>Lyrics:</p>
<pre>${syncedLyrics}</pre>
<p>Plain lyrics:</p>
<pre>${plainLyrics}</pre>
<p>Is this ok?</p>
<button>Upload</button>
`;
const panel = vscode.window.createWebviewPanel(
'lyricPreview',
previewTitle,
vscode.ViewColumn.One,
{ enableScripts: true },
);
panel.webview.html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Preview</title>
</head>
<body>
${previewContent}
</body>
<script>
const vscode = acquireVsCodeApi();
document.querySelector('button').addEventListener('click', () => {
vscode.postMessage({ command: 'upload' });
});
</script>
</html>
`;
let isDisposed = false;
panel.onDidDispose(() => {
isDisposed = true;
});
await new Promise((resolve, _reject) => {
panel.webview.onDidReceiveMessage((message: { command: string }) => {
if (isDisposed) {
return;
}
if (message.command === 'upload') {
panel.dispose();
resolve(true);
}
});
});
const toUpload: PublishRequest = {
trackName: title,
artistName: artist,
albumName: album,
duration: length,
plainLyrics: plainLyrics,
syncedLyrics: syncedLyrics,
};
// log the data to our extension output buffer
channel_global.appendLine('Uploading lyrics to LrclibDotNet');
const json = JSON.stringify(toUpload, null, 2);
channel_global.appendLine(json);
const res = await publishLyrics(toUpload);
if (res) {
vscode.window.showInformationMessage('Lyrics successfully uploaded.');
} else {
vscode.window.showErrorMessage('Failed to upload lyrics.');
}
}
// 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();
@ -487,6 +724,29 @@ class Ext {
});
}
}
// get the lyrics part of the lrc file (after the headers)
getLyricsPart() {
const first = this.document.lineAt(0).text;
let line = 0;
if (this.isHeaderLine(first)) {
// skip all headers (until the first empty line)
for (; line < this.document.lineCount; line++) {
const text = this.document.lineAt(line).text;
if (text.trim() === '') {
line++;
break;
}
}
}
// get the range from the current line to the end of the file
const documentRange = new vscode.Range(
new vscode.Position(line, 0),
this.document.lineAt(this.document.lineCount - 1).range.end,
);
return this.document.getText(documentRange);
}
}
// find an active editor that has the given document opened
@ -494,6 +754,16 @@ function findActiveEditor(document: vscode.TextDocument) {
return vscode.window.visibleTextEditors.find(editor => editor.document === document);
}
function plainLyricsFromLyrics(lyrics: string) {
// remove the timestamp from the beginning of every line
return (
lyrics
.replace(/\[\d\d:\d\d\.\d\d\]\s?/gm, '')
// remove empty lines
.replace(/\n{2,}/g, '\n')
);
}
function parseTimestamp(timestamp: string): number {
// Parse [mm:ss.ms] format into milliseconds
const [min, sec] = timestamp.split(':');

View file

@ -0,0 +1,114 @@
import * as crypto from 'crypto';
// Helper function to convert a hex string to a Buffer
function hexToBytes(hex: string): Buffer {
return Buffer.from(hex, 'hex');
}
// Function to verify the nonce
function verifyNonce(result: Buffer, target: Buffer): boolean {
if (result.length !== target.length) {
return false;
}
for (let i = 0; i < result.length - 1; i++) {
if (result[i] > target[i]) {
return false;
} else if (result[i] < target[i]) {
break;
}
}
return true;
}
// Function to solve the challenge
function solveChallenge(prefix: string, targetHex: string): string {
let nonce = 0;
let hashed: Buffer;
const target = hexToBytes(targetHex);
while (true) {
const input = `${prefix}${nonce}`;
hashed = crypto.createHash('sha256').update(input).digest();
if (verifyNonce(hashed, target)) {
break;
} else {
nonce += 1;
}
}
return nonce.toString();
}
async function getUploadNonce() {
try {
const response = await fetch('https://lrclib.net/api/request-challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
'Lrclib-Client': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const challengeData = (await response.json()) as { prefix: string; target: string };
return {
prefix: challengeData.prefix,
nonce: solveChallenge(challengeData.prefix, challengeData.target),
};
} catch (error) {
console.error('Error fetching the challenge:', error);
}
}
// Interface for the request body
/**
* Represents a request to publish a track with its associated information.
*/
export interface PublishRequest {
trackName: string;
artistName: string;
albumName: string;
/** In seconds? Milliseconds? mm:ss? */
duration: number;
plainLyrics: string;
syncedLyrics: string;
}
// Function to publish lyrics using the solved challenge
export async function publishLyrics(
requestBody: PublishRequest,
): Promise<true | undefined> {
const challenge = await getUploadNonce();
if (!challenge) {
return;
}
const publishToken = `${challenge.prefix}:${challenge.nonce}`;
const response = await fetch('https://lrclib.net/api/publish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
'Lrclib-Client': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
'X-Publish-Token': publishToken,
},
body: JSON.stringify(requestBody),
});
if (response.status === 201) {
console.log('Lyrics successfully published.');
return true;
} else {
const errorResponse = (await response.json()) as { [key: string]: string };
console.error('Failed to publish lyrics:', errorResponse);
return;
}
}