about summary refs log tree commit diff
path: root/users
diff options
context:
space:
mode:
authorProfpatsch <mail@profpatsch.de>2024-09-27T23·30+0200
committerProfpatsch <mail@profpatsch.de>2024-10-05T13·49+0000
commit9bec21ea1cb9137d8c5dae6ee2cb78aac1b5601a (patch)
treeec649db1716bc7b0a7a5d42b8adb4f0c81410659 /users
parent970dcaa04f3c4bda473674f7e7bb2d2d87ab13e8 (diff)
feat(users/Profpatsch/lyric): add vscode extension & helpers r/8759
* 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>
Diffstat (limited to 'users')
-rw-r--r--users/Profpatsch/.vscode/launch.json26
-rw-r--r--users/Profpatsch/lyric/.gitignore6
-rw-r--r--users/Profpatsch/lyric/.prettierrc8
-rw-r--r--users/Profpatsch/lyric/build.ninja17
-rw-r--r--users/Profpatsch/lyric/eslint.config.mjs42
-rw-r--r--users/Profpatsch/lyric/extension/.gitignore5
-rw-r--r--users/Profpatsch/lyric/extension/.prettierrc8
-rw-r--r--users/Profpatsch/lyric/extension/LICENSE1
-rw-r--r--users/Profpatsch/lyric/extension/eslint.config.mjs42
-rw-r--r--users/Profpatsch/lyric/extension/package.json52
-rw-r--r--users/Profpatsch/lyric/extension/src/extension.ts236
-rw-r--r--users/Profpatsch/lyric/extension/tsconfig.json22
-rw-r--r--users/Profpatsch/lyric/lyric-timing-mpv-script.lua43
-rw-r--r--users/Profpatsch/lyric/package.json23
-rw-r--r--users/Profpatsch/lyric/src/index.ts16
-rw-r--r--users/Profpatsch/lyric/src/tap-bpm.ts79
-rw-r--r--users/Profpatsch/lyric/tsconfig.json17
17 files changed, 643 insertions, 0 deletions
diff --git a/users/Profpatsch/.vscode/launch.json b/users/Profpatsch/.vscode/launch.json
index baa087d437d1..54f510e091ec 100644
--- a/users/Profpatsch/.vscode/launch.json
+++ b/users/Profpatsch/.vscode/launch.json
@@ -13,6 +13,32 @@
             "runtimeArgs": [
                 "run",
             ],
+        },
+        {
+            "type": "node",
+            "name": "Run tap-bpm",
+            "skipFiles": [
+                "<node_internals>/**"
+            ],
+            "request": "launch",
+            "program": "${workspaceFolder}/lyric/dist/index.js",
+            "preLaunchTask": "ninja build lyric",
+            "outFiles": [
+                "${workspaceFolder}/lyric/dist/**/*.js"
+            ],
+            "args": [
+                "tap-bpm"
+            ]
+        },
+        {
+            "preLaunchTask": "npm build lyric extension",
+            "name": "Launch lyric vscode Extension",
+            "type": "extensionHost",
+            "request": "launch",
+            "args": [
+                "--extensionDevelopmentPath=${workspaceFolder}/lyric/extension",
+                "${workspaceFolder}/lyric/extension"
+            ]
         }
     ]
 }
diff --git a/users/Profpatsch/lyric/.gitignore b/users/Profpatsch/lyric/.gitignore
new file mode 100644
index 000000000000..226235d62b9d
--- /dev/null
+++ b/users/Profpatsch/lyric/.gitignore
@@ -0,0 +1,6 @@
+/dist/
+/.ninja/
+/node_modules/
+
+# ignore for now
+/package-lock.json
diff --git a/users/Profpatsch/lyric/.prettierrc b/users/Profpatsch/lyric/.prettierrc
new file mode 100644
index 000000000000..e97f84abaf62
--- /dev/null
+++ b/users/Profpatsch/lyric/.prettierrc
@@ -0,0 +1,8 @@
+{
+  "trailingComma": "all",
+  "tabWidth": 2,
+  "semi": true,
+  "singleQuote": true,
+  "printWidth": 90,
+  "arrowParens": "avoid"
+}
diff --git a/users/Profpatsch/lyric/build.ninja b/users/Profpatsch/lyric/build.ninja
new file mode 100644
index 000000000000..a5752bef5002
--- /dev/null
+++ b/users/Profpatsch/lyric/build.ninja
@@ -0,0 +1,17 @@
+builddir = .ninja
+
+outdir = ./dist
+jsdir = $outdir/js
+
+rule tsc
+  command = node_modules/.bin/tsc
+
+build $outdir/index.js: tsc | src/index.ts tsconfig.json
+
+rule run
+  command = node $in
+
+build run: run $outdir/index.js
+
+build run-tap-bpm: run $outdir/index.js tap-bpm
+  pool = console
diff --git a/users/Profpatsch/lyric/eslint.config.mjs b/users/Profpatsch/lyric/eslint.config.mjs
new file mode 100644
index 000000000000..189efbd9be4c
--- /dev/null
+++ b/users/Profpatsch/lyric/eslint.config.mjs
@@ -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",
+    "@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",
+  },
+});
diff --git a/users/Profpatsch/lyric/extension/.gitignore b/users/Profpatsch/lyric/extension/.gitignore
new file mode 100644
index 000000000000..d2405ad640c3
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/.gitignore
@@ -0,0 +1,5 @@
+/node_modules/
+/out/
+
+# ignore for now
+/package-lock.json
diff --git a/users/Profpatsch/lyric/extension/.prettierrc b/users/Profpatsch/lyric/extension/.prettierrc
new file mode 100644
index 000000000000..e97f84abaf62
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/.prettierrc
@@ -0,0 +1,8 @@
+{
+  "trailingComma": "all",
+  "tabWidth": 2,
+  "semi": true,
+  "singleQuote": true,
+  "printWidth": 90,
+  "arrowParens": "avoid"
+}
diff --git a/users/Profpatsch/lyric/extension/LICENSE b/users/Profpatsch/lyric/extension/LICENSE
new file mode 100644
index 000000000000..4292032e6223
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/LICENSE
@@ -0,0 +1 @@
+same as toplevel
diff --git a/users/Profpatsch/lyric/extension/eslint.config.mjs b/users/Profpatsch/lyric/extension/eslint.config.mjs
new file mode 100644
index 000000000000..996dd8ca58ee
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/eslint.config.mjs
@@ -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",
+  },
+});
diff --git a/users/Profpatsch/lyric/extension/package.json b/users/Profpatsch/lyric/extension/package.json
new file mode 100644
index 000000000000..722da81b4719
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/package.json
@@ -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"
+  }
+}
diff --git a/users/Profpatsch/lyric/extension/src/extension.ts b/users/Profpatsch/lyric/extension/src/extension.ts
new file mode 100644
index 000000000000..4f46e0a1ed7c
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/src/extension.ts
@@ -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 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 */
+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();
diff --git a/users/Profpatsch/lyric/extension/tsconfig.json b/users/Profpatsch/lyric/extension/tsconfig.json
new file mode 100644
index 000000000000..44a5195795da
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/tsconfig.json
@@ -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"
+    ]
+}
diff --git a/users/Profpatsch/lyric/lyric-timing-mpv-script.lua b/users/Profpatsch/lyric/lyric-timing-mpv-script.lua
new file mode 100644
index 000000000000..6558422f1092
--- /dev/null
+++ b/users/Profpatsch/lyric/lyric-timing-mpv-script.lua
@@ -0,0 +1,43 @@
+-- This function formats the current timestamp in the [mm:ss.ms] format
+function format_timestamp(seconds)
+    local minutes = math.floor(seconds / 60)
+    local seconds = seconds % 60
+    return string.format("[%02d:%05.2f]", minutes, seconds)
+end
+
+-- Get the user’s cache directory
+local cache_dir = os.getenv("XDG_CACHE_HOME") or os.getenv("HOME") .. "/.cache"
+
+-- This function writes the timestamp to the LRC file
+function write_timestamp_to_lrc()
+    local filename = mp.get_property("path")
+    if not filename then
+        mp.msg.warn("No file currently playing.")
+        return
+    end
+
+    -- Extract metadata for artist and title
+    local artist = mp.get_property("metadata/by-key/ARTIST", "Unknown Artist")
+    local title = mp.get_property("metadata/by-key/TITLE", "Unknown Title")
+
+    -- Construct the lrc dir
+    local dir = cache_dir .. "/lyric/timed"
+    local lrc_filename = string.format("%s/%s - %s.lrc", dir, artist, title)
+
+    -- Get current playback time
+    local current_time = mp.get_property_number("time-pos", 0)
+    local formatted_time = format_timestamp(current_time)
+
+    -- Append the timestamp to the LRC file
+    local file = io.open(lrc_filename, "a")
+    if file then
+        file:write(formatted_time .. "\n")
+        file:close()
+        mp.msg.info("Timestamp " .. formatted_time .. " added to " .. lrc_filename)
+    else
+        mp.msg.error("Failed to open " .. lrc_filename)
+    end
+end
+
+-- Bind Ctrl+l to the function that writes the timestamp
+mp.add_key_binding("Ctrl+l", "insert_timestamp", write_timestamp_to_lrc)
diff --git a/users/Profpatsch/lyric/package.json b/users/Profpatsch/lyric/package.json
new file mode 100644
index 000000000000..4621d087e0fd
--- /dev/null
+++ b/users/Profpatsch/lyric/package.json
@@ -0,0 +1,23 @@
+{
+  "name": "lyric",
+  "version": "1.0.0",
+  "main": "dist/index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "author": "",
+  "type": "module",
+  "license": "GPL-3.0-or-later",
+  "description": "",
+  "dependencies": {},
+  "devDependencies": {
+    "@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"
+  }
+}
diff --git a/users/Profpatsch/lyric/src/index.ts b/users/Profpatsch/lyric/src/index.ts
new file mode 100644
index 000000000000..6bab1c4590cd
--- /dev/null
+++ b/users/Profpatsch/lyric/src/index.ts
@@ -0,0 +1,16 @@
+import { tapBpm } from "./tap-bpm.js";
+
+async function main() {
+  // subcommand for tap-bpm
+  if (process.argv[2] === "tap-bpm") {
+    await tapBpm();
+  }
+}
+
+await main();
+
+// sleep in a loop to block nodejs
+console.log("Blocking event loop...");
+while (true) {
+  await new Promise((resolve) => setTimeout(resolve, 1000));
+}
diff --git a/users/Profpatsch/lyric/src/tap-bpm.ts b/users/Profpatsch/lyric/src/tap-bpm.ts
new file mode 100644
index 000000000000..2062fb8bbca7
--- /dev/null
+++ b/users/Profpatsch/lyric/src/tap-bpm.ts
@@ -0,0 +1,79 @@
+// create a node command line listener that allows the user to press any key , and averages the distances between the key presses to determine the BPM (with a window of 4 key presses). If the user presses q, the program should exit and print the final BPM.
+
+// Import the necessary modules
+import * as readline from "readline";
+
+export function tapBpm() {
+  // Set up readline interface to listen for keypresses
+  readline.emitKeypressEvents(process.stdin);
+  process.stdin.setRawMode(true);
+  // accept SIGINT on stdin
+
+  // Array to store the time differences between the last 4 key presses
+  const timeDifferences: number[] = [];
+  let lastPressTime: number | null = null;
+
+  // Function to calculate BPM based on average time between keypresses
+  function calculateBPM() {
+    if (timeDifferences.length < 1) {
+      return 0;
+    }
+    const averageTimeDiff =
+      timeDifferences.reduce((acc, curr) => acc + curr, 0) /
+      timeDifferences.length;
+    return (60 * 1000) / averageTimeDiff;
+  }
+
+  // Handle the SIGINT (Ctrl+C) event manually
+  process.on("SIGINT", () => {
+    console.log(
+      "\nExiting via SIGINT (Ctrl+C)... Final BPM:",
+      calculateBPM().toFixed(2)
+    );
+    process.exit();
+  });
+
+  // Listen for keypress events
+  process.stdin.on("keypress", (str, key) => {
+    // Exit if 'q' is pressed
+    if (key.name === "q") {
+      console.log("Exiting... Final BPM:", calculateBPM().toFixed(2));
+      process.exit();
+    }
+
+    // Handle Ctrl+C (SIGINT)
+    if (key.sequence === "\u0003") {
+      // '\u0003' is the raw code for Ctrl+C
+      console.log(
+        "\nExiting via Ctrl+C... Final BPM:",
+        calculateBPM().toFixed(2)
+      );
+      process.exit();
+    }
+
+    // Capture the current time of the keypress
+    const currentTime = Date.now();
+
+    // If it's not the first keypress, calculate the time difference
+    if (lastPressTime !== null) {
+      const timeDiff = currentTime - lastPressTime;
+
+      // Add the time difference to the array (limit to last 10 key presses)
+      if (timeDifferences.length >= 10) {
+        timeDifferences.shift(); // Remove the oldest time difference
+      }
+      timeDifferences.push(timeDiff);
+
+      // Calculate and display the BPM
+      const bpm = calculateBPM();
+      console.log("Current BPM:", bpm.toFixed(2));
+    } else {
+      console.log("Waiting for more key presses to calculate BPM...");
+    }
+
+    // Update the lastPressTime to the current time
+    lastPressTime = currentTime;
+  });
+
+  console.log('Press any key to calculate BPM, press "q" to quit.');
+}
diff --git a/users/Profpatsch/lyric/tsconfig.json b/users/Profpatsch/lyric/tsconfig.json
new file mode 100644
index 000000000000..f3c47a381e20
--- /dev/null
+++ b/users/Profpatsch/lyric/tsconfig.json
@@ -0,0 +1,17 @@
+{
+    "compilerOptions": {
+        "target": "ES2017",
+        "module": "ESNext",
+        "outDir": "dist",
+        "strict": true,
+        "skipLibCheck": true,
+        "sourceMap": true,
+        "esModuleInterop": true
+    },
+    "include": [
+        "src/**/*.ts"
+    ],
+    "exclude": [
+        "node_modules"
+    ]
+}