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));
context.subscriptions.push(
vscode.commands.registerCommand('extension.jumpToLrcPosition', jumpToLrcPosition),
vscode.commands.registerCommand('extension.shiftLyricsDown', shiftLyricsDown),
vscode.commands.registerCommand('extension.shiftLyricsUp', shiftLyricsUp),
vscode.commands.registerCommand('extension.tapBpm', tapBpm),
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,
),
);
}
/**
* Jumps to the position in the lyric file corresponding to the current cursor position in the active text editor.
* Sends a command to a socket to seek to the specified position in mpv at the socket path `~/tmp/mpv-socket`.
* @remarks
* This function requires the following dependencies:
* - `vscode` module for accessing the active text editor and displaying messages.
* - `net` module for creating a socket connection.
* @throws {Error} If there is an error sending the command to the socket.
*/
function jumpToLrcPosition() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('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, seconds } = res;
// Prepare JSON command to send to the socket
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(seekCommand));
socket.write('\n');
socket.write(JSON.stringify(reloadSubtitlesCommand));
socket.write('\n');
vscode.window.showInformationMessage(
`Sent command to jump to [${formatTimestamp(milliseconds)}] and sync subtitles.`,
);
socket.end();
});
socket.on('error', err => {
vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
});
}
/**
* Shifts the lyrics down by one line starting from the current cursor position in the active text editor.
* @remarks
* This function requires the following dependencies:
* - `vscode` module for accessing the active text editor and displaying messages.
*/
async function shiftLyricsDown() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor found.');
return;
}
const ext = new Ext(editor.document);
const getLine = (line: number) => ({
number: line,
range: editor.document.lineAt(line),
});
// get the document range from the beginning of the current line to the end of the file
const documentRange = new vscode.Range(
getLine(editor.selection.active.line).range.range.start,
editor.document.lineAt(editor.document.lineCount - 1).range.end,
);
let newLines: string = '';
// iterate through all lines under the current line, save the lyric text from the current line, and replace it with the lyric text from the previous line
let previousLineText = '';
for (
// get the current line range
let line = getLine(editor.selection.active.line);
line.number < editor.document.lineCount - 1;
// next line as position from line number
line = getLine(line.number + 1)
) {
const timestamp = ext.getTimestampFromLine(line.number);
if (timestamp === undefined) {
newLines += line.range.text + '\n';
continue;
}
newLines += `[${formatTimestamp(timestamp.milliseconds)}]` + previousLineText + '\n';
previousLineText = timestamp.text;
}
// replace documentRange with newLines
await editor.edit(editBuilder => {
editBuilder.replace(documentRange, newLines);
});
}
/**
* Shifts the lyrics up by one line starting from the current cursor position in the active text editor.
* @remarks
* This function requires the following dependencies:
* - `vscode` module for accessing the active text editor and displaying messages.
*/
async function shiftLyricsUp() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor found.');
return;
}
const ext = new Ext(editor.document);
const getLine = (line: number) => ({
number: line,
range: editor.document.lineAt(line),
});
// get the document range from the beginning of the current line to the end of the file
const documentRange = new vscode.Range(
getLine(editor.selection.active.line).range.range.start,
editor.document.lineAt(editor.document.lineCount - 1).range.end,
);
let newLines: string = '';
// iterate through all lines under the current line, save the lyric text from the current line, and replace it with the lyric text from the next line
for (
// get the current line range
let line = getLine(editor.selection.active.line);
line.number < editor.document.lineCount - 2;
// next line as position from line number
line = getLine(line.number + 1)
) {
const nextLineText =
ext.getTimestampFromLine(line.number + 1)?.text ??
ext.document.lineAt(line.number + 1).text;
const timestamp = ext.getTimestampFromLine(line.number);
if (timestamp === undefined) {
continue;
}
newLines += `[${formatTimestamp(timestamp.milliseconds)}]` + nextLineText + '\n';
}
// replace documentRange with newLines
await editor.edit(editBuilder => {
editBuilder.replace(documentRange, newLines);
});
}
/**
* Tap the BPM of the track and write it to the header of the active text editor.
* @remarks
* This function requires the following dependencies:
* - `vscode` module for accessing the active text editor and displaying messages.
*/
async function tapBpm() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor found.');
return;
}
const ext = new Ext(editor.document);
const startBpm = ext.findBpmHeader();
const bpm = await timeInputBpm(startBpm);
if (bpm === undefined) {
return;
}
await ext.writeHeader('bpm', bpm.toString());
}
/** 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 quantizeToEigthNote() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor found.');
return;
}
const ext = new Ext(editor.document);
const startBpm = ext.findBpmHeader();
const bpm = await timeInputBpm(startBpm);
if (bpm === undefined) {
return;
}
await ext.writeHeader('bpm', bpm.toString());
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);
});
}
/** 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.showErrorMessage('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);
}
// 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(startBpm?: number) {
const startBpmMs = bpmToMs(startBpm ?? 120);
const timeDifferences: number[] = [
startBpmMs,
startBpmMs,
startBpmMs,
startBpmMs,
startBpmMs,
];
// 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();
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 && firstLoop ? startBpm.toString() : undefined,
});
firstLoop = false;
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;
}
}
/**
* 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.showErrorMessage('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.');
channel_global.appendLine('Lyrics successfully uploaded.');
} else {
vscode.window.showErrorMessage('Failed to upload lyrics.');
channel_global.appendLine('Failed to upload lyrics.');
}
}
// If the difference to the timestamp on the next line is larger than 10 seconds (for 120 BPM), underline the next line and show a warning message on hover
export function registerCheckLineTimestamp(_context: vscode.ExtensionContext) {
const changesToCheck: Set<vscode.TextDocument> = new Set();
const everSeen = new Set<vscode.TextDocument>();
return [
vscode.workspace.onDidChangeTextDocument(e => {
changesToCheck.add(e.document);
if (vscode.window.activeTextEditor?.document === e.document) {
doEditorChecks(vscode.window.activeTextEditor.document, everSeen, changesToCheck);
}
}),
vscode.workspace.onDidOpenTextDocument(e => {
changesToCheck.add(e);
everSeen.add(e);
if (vscode.window.activeTextEditor?.document === e) {
doEditorChecks(vscode.window.activeTextEditor.document, everSeen, changesToCheck);
}
}),
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
doEditorChecks(editor.document, everSeen, changesToCheck);
}
}),
vscode.window.onDidChangeVisibleTextEditors(editors => {
for (const editor of editors) {
doEditorChecks(editor.document, everSeen, changesToCheck);
}
}),
];
}
function doEditorChecks(
document: vscode.TextDocument,
everSeen: Set<vscode.TextDocument>,
changesToCheck: Set<vscode.TextDocument>,
) {
const ext = new Ext(document);
if (!everSeen.has(document)) {
changesToCheck.add(document);
everSeen.add(document);
}
if (!changesToCheck.has(document)) {
return;
}
changesToCheck.delete(document);
const from = 0;
const to = document.lineCount - 1;
for (let line = from; line <= to; line++) {
const warnings: string[] = [];
const timeDiff = timeDifferenceTooLarge(ext, line);
if (timeDiff !== undefined) {
warnings.push(timeDiff);
}
const nextTimestampSmaller = nextLineTimestampSmallerThanCurrent(ext, line);
if (nextTimestampSmaller !== undefined) {
warnings.push(nextTimestampSmaller);
}
for (const warning of warnings) {
global_manageWarnings.setWarning(document, line, warning);
}
// unset any warnings if this doesn’t apply anymore
if (warnings.length === 0) {
global_manageWarnings.setWarning(document, line);
}
}
}
/** Warn if the difference to the timestamp on the next line is larger than
* * 10 seconds at 120 BPM
* * 5 seconds at 240 BPM
* * 20 seconds at 60 BPM
* * etc
*/
function timeDifferenceTooLarge(ext: Ext, line: number): string | undefined {
const bpm = ext.findBpmHeader() ?? 120;
const maxTimeDifference = 10000 * (120 / bpm);
const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
new vscode.Position(line, 0),
);
if (
!timeDifference ||
timeDifference.thisLineIsEmpty ||
timeDifference.difference <= maxTimeDifference
) {
return;
}
return `Time difference to next line is ${formatTimestamp(
timeDifference.difference,
)}, should there be silence here? At ${bpm} BPM, we assume anything more than ${(
maxTimeDifference / 1000
).toFixed(2)} seconds is a mistake.`;
}
/** Warn if the timestamp on the next line is smaller or equal to the current timestamp */
function nextLineTimestampSmallerThanCurrent(ext: Ext, line: number): string | undefined {
const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
new vscode.Position(line, 0),
);
if (!timeDifference) {
return;
}
if (timeDifference.difference == 0) {
return `The timestamp to the next line is not increasing`;
}
if (timeDifference.difference < 0) {
return `The timestamp to the next line is decreasing`;
}
}
class Ext {
constructor(public document: vscode.TextDocument) {}
getTimeDifferenceToNextLineTimestamp(position: vscode.Position) {
const thisLineTimestamp = this.getTimestampFromLine(position.line);
const nextLineTimestamp = this.getTimestampFromLine(position.line + 1);
if (!thisLineTimestamp || !nextLineTimestamp) {
return;
}
return {
difference: nextLineTimestamp.milliseconds - thisLineTimestamp.milliseconds,
thisLineIsEmpty: thisLineTimestamp.text.trim() === '',
};
}
/**
* Retrieves the timestamp and text from the line at the given position in the active text editor.
*
* @param position - The position of the line in the editor.
* @returns An object containing the milliseconds, seconds, and text extracted from the line.
*/
getTimestampFromLine(line: number) {
const lineText = this.document.lineAt(line).text;
return this.getTimestampFromLineText(lineText);
}
getTimestampFromLineText(lineText: string) {
// Extract timestamp [mm:ss.ms] from the current line
const match = lineText.match(/\[(\d+:\d+\.\d+)\](.*)/);
if (!match) {
return;
}
const [, timestamp, text] = match;
const milliseconds = parseTimestamp(timestamp);
const seconds = milliseconds / 1000;
return { milliseconds, seconds, text };
}
// Find a header line of the format
// [header:value]
// at the beginning of the lrc file (before the first empty line)
findHeader(headerName: string) {
for (let line = 0; line < this.document.lineCount; line++) {
const text = this.document.lineAt(line).text;
if (text.trim() === '') {
return;
}
const match = text.match(/^\[(\w+):(.*)\]$/);
if (match && match[1] === headerName) {
return { key: match[1], value: match[2], line: line };
}
}
}
/** Find the bpm header and return the bpm as number, if any */
findBpmHeader() {
const startBpmStr = this.findHeader('bpm')?.value;
let bpm;
if (startBpmStr !== undefined) {
bpm = parseInt(startBpmStr, 10);
if (isNaN(bpm)) {
bpm = undefined;
}
}
return bpm;
}
// check if the given line is a header line
isHeaderLine(line: string) {
return (
line.trim() !== '' &&
line.match(/^\[(\w+):(.*)\]$/) !== null &&
line.match(/^\[\d\d:\d\d.\d+\]/) === null
);
}
// write the given header to the lrc file, if the header already exists, update the value
async writeHeader(headerName: string, value: string) {
const header = this.findHeader(headerName);
const editor = findActiveEditor(this.document);
if (!editor) {
return;
}
if (header) {
const lineRange = this.document.lineAt(header.line).range;
await editor.edit(editBuilder => {
editBuilder.replace(lineRange, `[${headerName}: ${value}]`);
});
} else {
// insert before the first timestamp line if no header is found, or after the last header if there are multiple headers
let insertLine = 0;
let extraNewline = '';
for (let line = 0; line < this.document.lineCount; line++) {
const text = this.document.lineAt(line).text;
// check if header
if (this.isHeaderLine(text)) {
insertLine = line + 1;
} else if (text.trim() === '') {
insertLine = line;
break;
} else {
insertLine = line;
if (line == 0) {
extraNewline = '\n';
}
break;
}
}
await editor.edit(editBuilder => {
editBuilder.insert(
new vscode.Position(insertLine, 0),
`[${headerName}: ${value}]\n${extraNewline}`,
);
});
}
}
// 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
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(':');
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.padStart(5, '0')}`;
}
class ManageWarnings {
private warnings: Map<number, string> = new Map();
private diagnostics: vscode.DiagnosticCollection;
constructor() {
this.diagnostics = vscode.languages.createDiagnosticCollection();
}
/** Set a warning message on a line in a document, if null then unset */
setWarning(document: vscode.TextDocument, line: number, message?: string) {
if (message !== undefined) {
this.warnings.set(line, message);
} else {
this.warnings.delete(line);
}
this.updateDiagnostics(document);
}
private updateDiagnostics(document: vscode.TextDocument) {
const mkWarning = (line: number, message: string) => {
const lineRange = document.lineAt(line).range;
return new vscode.Diagnostic(lineRange, message, vscode.DiagnosticSeverity.Warning);
};
const diagnostics: vscode.Diagnostic[] = [];
for (const [line, message] of this.warnings) {
diagnostics.push(mkWarning(line, message));
}
this.diagnostics.delete(document.uri);
this.diagnostics.set(document.uri, diagnostics);
}
}
const global_manageWarnings = new ManageWarnings();