feat(users/Profpatsch/lyric): add vscode extension & helpers

* tap-bpm: simple CLI program that accepts key inputs and averages a
BPM value

* lyric-timing-mpv-script: If you press Ctrl+l, mpv attaches the
  current timestamp to a .lrc file named after the song.
  This is for manually timing missing songs for uploading them to
  https://lrclib.net/

* extension: vscode extension for `.lrc` files, currently with the
  following features:

    1. A “jump to LRC position” command which reads an .lrc timestamp
    from the current line and expects mpv to listen on
    `~/tmp/mpv-socket` (via `--input-ipc-server`), and will seek to
    the exact timestamp (down to the ms) in the currently playing
    song.

    2. Some initial linting warnings

      - A lint that warns if the difference to the next timestamp is
      more than 10s (which usually means there’s an instrumental and
      the previous line is stuck)

      - A lint that checks that timestamps are monotonically
      increasing

Change-Id: I32a4ac0e2c5bbe3d94e45ffcf647f81bc7c08aa0
Reviewed-on: https://cl.tvl.fyi/c/depot/+/12537
Tested-by: BuildkiteCI
Reviewed-by: Profpatsch <mail@profpatsch.de>
This commit is contained in:
Profpatsch 2024-09-28 01:30:49 +02:00
parent 970dcaa04f
commit 9bec21ea1c
17 changed files with 643 additions and 0 deletions

View file

@ -0,0 +1,5 @@
/node_modules/
/out/
# ignore for now
/package-lock.json

View file

@ -0,0 +1,8 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 90,
"arrowParens": "avoid"
}

View file

@ -0,0 +1 @@
same as toplevel

View file

@ -0,0 +1,42 @@
import tseslint from "typescript-eslint";
import tsplugin from "@typescript-eslint/eslint-plugin";
import parser from "@typescript-eslint/parser";
export default tseslint.config(tseslint.configs.eslintRecommended, {
languageOptions: {
parser: parser,
parserOptions: {
projectService: true,
},
},
ignores: ["node_modules/", "eslint.config.mjs"],
plugins: { "@typescript-eslint": tsplugin },
rules: {
"prettier/prettier": "off",
"prefer-const": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"no-array-constructor": "off",
"@typescript-eslint/no-array-constructor": "warn",
"@typescript-eslint/no-duplicate-enum-values": "warn",
"@typescript-eslint/no-empty-object-type": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-extra-non-null-assertion": "warn",
"@typescript-eslint/no-misused-new": "warn",
"@typescript-eslint/no-namespace": "warn",
"@typescript-eslint/no-non-null-asserted-optional-chain": "warn",
"@typescript-eslint/no-require-imports": "warn",
"@typescript-eslint/no-this-alias": "warn",
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
"@typescript-eslint/no-unsafe-declaration-merging": "warn",
"@typescript-eslint/no-unsafe-function-type": "warn",
"@typescript-eslint/strict-boolean-expressions": ["warn"],
"no-unused-expressions": "off",
"@typescript-eslint/no-unused-expressions": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-wrapper-object-types": "warn",
"@typescript-eslint/prefer-as-const": "warn",
"@typescript-eslint/prefer-namespace-keyword": "warn",
"@typescript-eslint/triple-slash-reference": "warn",
},
});

View file

@ -0,0 +1,52 @@
{
"name": "profpatsch-jump-to-lrc-position",
"displayName": "Jump to .lrc Position in mpv",
"description": "Reads a timestamp from the current file and pipes it to a mpv socket",
"version": "0.0.1",
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Other"
],
"main": "./out/extension.js",
"activationEvents": [
"onLanguage:lrc"
],
"contributes": {
"commands": [
{
"command": "extension.jumpToLrcPosition",
"title": "Jump to .lrc Position",
"category": "LRC"
}
],
"languages": [
{
"id": "lrc",
"extensions": [
".lrc"
],
"aliases": [
"Lyric file"
]
}
]
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc",
"install-extension": "vsce package --allow-missing-repository --out ./jump-to-lrc-position.vsix && code --install-extension ./jump-to-lrc-position.vsix"
},
"devDependencies": {
"vscode": "^1.1.37",
"@eslint/js": "^9.10.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.5.5",
"@typescript-eslint/parser": "^8.7.0",
"eslint": "^9.10.0",
"globals": "^15.9.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.6.0"
}
}

View file

@ -0,0 +1,236 @@
import * as vscode from 'vscode';
import * as net from 'net';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(...registerCheckLineTimestamp(context));
context.subscriptions.push(
vscode.commands.registerCommand('extension.jumpToLrcPosition', () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showInformationMessage('No active editor found.');
return;
}
const ext = new Ext(editor);
const position = editor.selection.active;
const res = ext.getTimestampFromLine(position);
if (!res) {
return;
}
const { milliseconds, seconds } = res;
// Prepare JSON command to send to the socket
const jsonCommand = {
command: ['seek', seconds, 'absolute'],
};
const socket = new net.Socket();
const socketPath = process.env.HOME + '/tmp/mpv-socket';
socket.connect(socketPath, () => {
socket.write(JSON.stringify(jsonCommand));
socket.write('\n');
vscode.window.showInformationMessage(
`Sent command to jump to [${formatTimestamp(milliseconds)}].`,
);
socket.end();
});
socket.on('error', err => {
vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
});
}),
);
}
// 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();
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, everSeen, changesToCheck);
}
}),
vscode.workspace.onDidOpenTextDocument(e => {
changesToCheck.add(e);
everSeen.add(e);
if (vscode.window.activeTextEditor?.document === e) {
doEditorChecks(vscode.window.activeTextEditor, everSeen, changesToCheck);
}
}),
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
doEditorChecks(editor, everSeen, changesToCheck);
}
}),
vscode.window.onDidChangeVisibleTextEditors(editors => {
for (const editor of editors) {
doEditorChecks(editor, everSeen, changesToCheck);
}
}),
];
}
function doEditorChecks(
editor: vscode.TextEditor,
everSeen: Set<vscode.TextDocument>,
changesToCheck: Set<vscode.TextDocument>,
) {
const ext = new Ext(editor);
const document = editor.document;
if (!everSeen.has(editor.document)) {
changesToCheck.add(editor.document);
everSeen.add(editor.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 doesnt 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 */
function timeDifferenceTooLarge(ext: Ext, line: number): string | undefined {
const timeDifference = ext.getTimeDifferenceToNextLineTimestamp(
new vscode.Position(line, 0),
);
if (
!timeDifference ||
timeDifference.thisLineIsEmpty ||
timeDifference.difference <= 10000
) {
return;
}
return `Time difference to next line is ${formatTimestamp(timeDifference.difference)}`;
}
/** 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 editor: vscode.TextEditor) {}
getTimeDifferenceToNextLineTimestamp(position: vscode.Position) {
const thisLineTimestamp = this.getTimestampFromLine(position);
const nextLineTimestamp = this.getTimestampFromLine(
position.with({ line: position.line + 1 }),
);
if (!thisLineTimestamp || !nextLineTimestamp) {
return;
}
return {
difference: nextLineTimestamp.milliseconds - thisLineTimestamp.milliseconds,
thisLineIsEmpty: thisLineTimestamp.text.trim() === '',
};
}
getTimestampFromLine(position: vscode.Position) {
const document = this.editor.document;
const lineText = document.lineAt(position.line).text;
// 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 };
}
}
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}`;
}
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();

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"lib": [
"ES6"
],
"outDir": "./out",
"rootDir": "./src",
"strict": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src"
],
"exclude": [
"node_modules",
".vscode-test"
]
}