From f9e1eb82c42fbee5f0cc557fe7d62627a5b8649e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:18:29 +0200 Subject: [PATCH] feat: komplette Codebase vom Board ins Repo gespiegelt app/ = /opt/callrec (Node-Backend, Vite-Frontend, Python-Inferenz, Audio-Skripte, rkllm_server.cpp), system/ = systemd-Units, WirePlumber/PipeWire-Drop-ins, sudoers. Modelle, Binaries, Aufnahmen und node_modules bleiben draussen (.gitignore). Co-Authored-By: Claude Fable 5 --- .gitignore | 11 + README.md | 18 + app/aime-A.sh | 7 + app/aime-analog.sh | 19 + app/aime-cpucap.sh | 13 + app/aime-directplay.sh | 6 + app/aime-headset-keys.py | 72 ++ app/aime-iphone-route.sh | 24 + app/aime-mixer-out.sh | 11 + app/aime-route.sh | 22 + app/aime-usb-audio-setup.sh | 103 ++ app/aime-volbridge-alsa.sh | 25 + app/aime-volbridge-headset.sh | 24 + app/aime-volbridge.sh | 21 + app/bt.js | 83 ++ app/btwatch.sh | 34 + app/callrec.service | 18 + app/db.js | 188 ++++ app/diar.js | 179 ++++ app/inference/__init__.py | 0 app/inference/rknpu_embed.py | 116 +++ app/inference/rknpu_whisper_encoder.py | 131 +++ app/inference/smoke_npu.py | 23 + app/inference/transcribe_batched.py | 58 ++ app/inference/transcribe_cli.py | 66 ++ app/inference/transcribe_daemon.py | 99 ++ app/inference/transcribe_daemon_cpu.py | 87 ++ app/llm.js | 478 +++++++++ app/package-lock.json | 885 +++++++++++++++++ app/package.json | 10 + app/recorder.js | 391 ++++++++ app/rkllm_server.cpp | 104 ++ app/seedtest.js | 23 + app/server.js | 373 +++++++ app/spk.js | 59 ++ app/web/index.html | 12 + app/web/package-lock.json | 928 ++++++++++++++++++ app/web/package.json | 12 + app/web/src/main.js | 504 ++++++++++ app/web/src/style.css | 106 ++ app/web/vite.config.js | 12 + docs/08-changelog.md | 1 + system/etc/sudoers.d/callrec-cpucap | 1 + .../etc/systemd/system/aime-audio-out.service | 14 + system/etc/systemd/system/aime-gadget.service | 13 + .../systemd/system/aime-headset-keys.service | 10 + system/etc/systemd/system/aime-volume.service | 13 + .../systemd/system/callrec-btwatch.service | 12 + .../etc/systemd/system/callrec-perf.service | 12 + system/etc/systemd/system/callrec.service | 19 + .../system/callrec.service.d/gemma-model.conf | 2 + .../wireplumber.conf.d/50-headless-bluez.conf | 5 + .../53-bluez-nosuspend.conf | 6 + .../wireplumber.conf.d/54-es8323-clock.conf | 18 + .../pipewire/filter-chain.conf.d/micgate.conf | 15 + .../systemd/user/aime-iphone-route.service | 10 + .../.config/systemd/user/aime-micgate.service | 15 + .../systemd/user/aime-usb-audio.service | 10 + .../systemd/user/aime-volume-headset.service | 11 + .../.config/systemd/user/filter-chain.service | Bin 0 -> 32 bytes .../wireplumber.conf.d/51-bluez-hfp-hf.conf | 4 + 61 files changed, 5546 insertions(+) create mode 100644 .gitignore create mode 100644 app/aime-A.sh create mode 100644 app/aime-analog.sh create mode 100644 app/aime-cpucap.sh create mode 100644 app/aime-directplay.sh create mode 100644 app/aime-headset-keys.py create mode 100644 app/aime-iphone-route.sh create mode 100644 app/aime-mixer-out.sh create mode 100644 app/aime-route.sh create mode 100644 app/aime-usb-audio-setup.sh create mode 100644 app/aime-volbridge-alsa.sh create mode 100644 app/aime-volbridge-headset.sh create mode 100644 app/aime-volbridge.sh create mode 100644 app/bt.js create mode 100644 app/btwatch.sh create mode 100644 app/callrec.service create mode 100644 app/db.js create mode 100644 app/diar.js create mode 100644 app/inference/__init__.py create mode 100644 app/inference/rknpu_embed.py create mode 100644 app/inference/rknpu_whisper_encoder.py create mode 100644 app/inference/smoke_npu.py create mode 100644 app/inference/transcribe_batched.py create mode 100644 app/inference/transcribe_cli.py create mode 100644 app/inference/transcribe_daemon.py create mode 100644 app/inference/transcribe_daemon_cpu.py create mode 100644 app/llm.js create mode 100644 app/package-lock.json create mode 100644 app/package.json create mode 100644 app/recorder.js create mode 100644 app/rkllm_server.cpp create mode 100644 app/seedtest.js create mode 100644 app/server.js create mode 100644 app/spk.js create mode 100644 app/web/index.html create mode 100644 app/web/package-lock.json create mode 100644 app/web/package.json create mode 100644 app/web/src/main.js create mode 100644 app/web/src/style.css create mode 100644 app/web/vite.config.js create mode 100644 system/etc/sudoers.d/callrec-cpucap create mode 100644 system/etc/systemd/system/aime-audio-out.service create mode 100644 system/etc/systemd/system/aime-gadget.service create mode 100644 system/etc/systemd/system/aime-headset-keys.service create mode 100644 system/etc/systemd/system/aime-volume.service create mode 100644 system/etc/systemd/system/callrec-btwatch.service create mode 100644 system/etc/systemd/system/callrec-perf.service create mode 100644 system/etc/systemd/system/callrec.service create mode 100644 system/etc/systemd/system/callrec.service.d/gemma-model.conf create mode 100644 system/etc/wireplumber/wireplumber.conf.d/50-headless-bluez.conf create mode 100644 system/etc/wireplumber/wireplumber.conf.d/53-bluez-nosuspend.conf create mode 100644 system/etc/wireplumber/wireplumber.conf.d/54-es8323-clock.conf create mode 100644 system/home/callrec/.config/pipewire/filter-chain.conf.d/micgate.conf create mode 100644 system/home/callrec/.config/systemd/user/aime-iphone-route.service create mode 100644 system/home/callrec/.config/systemd/user/aime-micgate.service create mode 100644 system/home/callrec/.config/systemd/user/aime-usb-audio.service create mode 100644 system/home/callrec/.config/systemd/user/aime-volume-headset.service create mode 100644 system/home/callrec/.config/systemd/user/filter-chain.service create mode 100644 system/home/callrec/.config/wireplumber/wireplumber.conf.d/51-bluez-hfp-hf.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc22c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +__pycache__/ +*.bak* +*.tgz +recordings/ +models/ +milestones/ +*.rkllm +*.rknn +rkllm_server diff --git a/README.md b/README.md index 540be37..aeb2301 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,24 @@ flowchart LR - **Harte Anforderungen:** immer offline (kein Cloud-LLM), akustische Sprecher-ID, kein aktiver Lüfter - Details und alle Zugangsdaten: [07 – Betrieb](docs/07-betrieb.md) +## Repo-Aufbau + +| Verzeichnis | Inhalt | +|---|---| +| `app/` | Spiegel von **`/opt/callrec`** auf dem Board: Node-Backend (`server.js`, `recorder.js`, `llm.js`, `diar.js`, `spk.js`, `db.js`, `bt.js`), Vite-Frontend (`web/`), Python-Inferenz (`inference/`), Audio-/Setup-Skripte (`aime-*.sh`), `rkllm_server.cpp` | +| `system/` | System-Konfiguration, Pfade wie auf dem Board: systemd-Units (`etc/systemd/system/`, `home/callrec/.config/systemd/user/`), WirePlumber-Drop-ins, micgate-Filter-Chain, sudoers | +| `docs/` | Diese Dokumentation | + +**Nicht im Repo** (zu groß / Laufzeit-Artefakte): `node_modules`, `web/dist` (per `npx vite build` erzeugbar), +Modelle (`*.rkllm`, `*.rknn`, `models/`), `rkllm_server`-Binary (aus `rkllm_server.cpp` kompilierbar), +Aufnahmen, `milestones/`, `.bak-*`. + +**Restore-Hinweis:** `app/` → `/opt/callrec`, `system/…` → jeweilige Absolutpfade; danach `npm install` +(Root + `web/`), `npx vite build` in `web/`, User-Units mit `systemctl --user enable` aktivieren +(`filter-chain.service` maskieren!), Details in [07 – Betrieb](docs/07-betrieb.md). + +⚠️ Das Repo ist die **Kopie** — die **Wahrheit läuft auf dem Board**. Nach jeder Board-Änderung Code hierher zurückspiegeln. + ## Doku-Regeln - Diese Doku ist die **verbindliche Ablage** für alle zukünftigen Änderungen am System. diff --git a/app/aime-A.sh b/app/aime-A.sh new file mode 100644 index 0000000..3d4c9a3 --- /dev/null +++ b/app/aime-A.sh @@ -0,0 +1,7 @@ +#!/bin/bash +export XDG_RUNTIME_DIR=/run/user/1000 +pkill -f "pw-loopback.*aime" 2>/dev/null; sleep 1 +setsid pw-loopback --name aime-A \ + --capture-props="target.object=\"alsa_input.platform-fc000000.usb.stereo-fallback\" node.name=aime-A-cap" \ + --playback-props="target.object=\"bluez_output.98_34_8C_FD_24_17.1\" node.name=aime-A-play" >/dev/null 2>&1 < /dev/null & +sleep 2; echo "A up:"; pgrep -af "pw-loopback.*aime-A"|grep -oE aime-A diff --git a/app/aime-analog.sh b/app/aime-analog.sh new file mode 100644 index 0000000..d0ac29c --- /dev/null +++ b/app/aime-analog.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Aime analog bridge PC(UAC2) <-> ES8323 wired headset, high buffering (anti-xrun) +export XDG_RUNTIME_DIR=/run/user/1000 +US=alsa_input.platform-fc000000.usb.stereo-fallback +UK=alsa_output.platform-fc000000.usb.stereo-fallback +ES=alsa_output.platform-es8323-sound.stereo-fallback +EC=alsa_input.platform-es8323-sound.stereo-fallback +pkill -f "pw-loopback.*aime" 2>/dev/null; sleep 1 +# A: PC -> headphone +setsid pw-loopback --latency 200 --name aime-A \ + --capture-props="target.object=\"$US\" node.name=aime-A-cap resample.quality=4" \ + --playback-props="target.object=\"$ES\" node.name=aime-A-play" >/dev/null 2>&1 < /dev/null & +sleep 2 +# B: headset mic -> PC +setsid pw-loopback --latency 200 --name aime-B \ + --capture-props="target.object=\"$EC\" node.name=aime-B-cap resample.quality=4" \ + --playback-props="target.object=\"$UK\" node.name=aime-B-play" >/dev/null 2>&1 < /dev/null & +sleep 2 +echo "up:"; pgrep -af "pw-loopback.*aime-"|grep -oE "aime-[AB]"|sort -u diff --git a/app/aime-cpucap.sh b/app/aime-cpucap.sh new file mode 100644 index 0000000..2536b3b --- /dev/null +++ b/app/aime-cpucap.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Set the A76 (cpu4-7) scaling_max_freq. Whitelisted OPPs only. Called by the +# callrec node process via a NOPASSWD sudo rule to cap the big-core clock during +# whisper transcription — the fanless board hit CRITICAL temp + auto-shutdown on +# a long (79-min) clip at full clock. Restored to 2256000 after each run. +f="$1" +case "$f" in + 408000|600000|816000|1008000|1200000|1416000|1608000|1800000|2016000|2208000|2256000) ;; + *) echo "aime-cpucap: bad freq '$f'" >&2; exit 1 ;; +esac +for c in 4 5 6 7; do + echo "$f" > "/sys/devices/system/cpu/cpu$c/cpufreq/scaling_max_freq" 2>/dev/null +done diff --git a/app/aime-directplay.sh b/app/aime-directplay.sh new file mode 100644 index 0000000..a13d3b6 --- /dev/null +++ b/app/aime-directplay.sh @@ -0,0 +1,6 @@ +#!/bin/bash +export XDG_RUNTIME_DIR=/run/user/1000 +pkill -f "pw-loopback.*aime" 2>/dev/null; sleep 1 +W=/usr/share/sounds/alsa/Front_Center.wav +setsid bash -c "for i in \$(seq 1 8); do pw-play --target=bluez_output.98_34_8C_FD_24_17.1 $W; done" >/dev/null 2>&1 < /dev/null & +echo "direct play started" diff --git a/app/aime-headset-keys.py b/app/aime-headset-keys.py new file mode 100644 index 0000000..bb669dd --- /dev/null +++ b/app/aime-headset-keys.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# Listen to the Logitech H390 inline volume/mute buttons (they arrive as HID +# keyboard events on the BOARD, since the headset is plugged into the board) and +# apply them to the headset PipeWire sink. Headless => no desktop consumes these, +# so without this daemon the inline buttons do nothing. +import glob, os, re, struct, subprocess, time + +RUNTIME = "/run/user/1000" +USER = "callrec" +HSSINK = "alsa_output.usb-Logi_USB_Headset_Logi_USB_Headset-00.analog-stereo" +DEV_NAME_RE = re.compile(r"Logi USB Headset", re.I) + +# linux input_event (64-bit): struct timeval(2*long) + u16 type + u16 code + s32 value +EV_FMT = "llHHi" +EV_SIZE = struct.calcsize(EV_FMT) +EV_KEY = 0x01 +KEY_MUTE, KEY_VOLUMEDOWN, KEY_VOLUMEUP = 113, 114, 115 + +def pactl(*args): + subprocess.run(["sudo", "-u", USER, "env", f"XDG_RUNTIME_DIR={RUNTIME}", + "pactl", *args], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def find_event_device(): + # match the event handler of the input device whose Name contains "Logi USB + # Headset" and that reports key events (EV=... has bit 1). Parse + # /proc/bus/input/devices so we survive event-number changes across reboots. + try: + blocks = open("/proc/bus/input/devices").read().split("\n\n") + except OSError: + return None + for b in blocks: + if not DEV_NAME_RE.search(b): + continue + m = re.search(r"Handlers=.*?(event\d+)", b) + if m: + return "/dev/input/" + m.group(1) + return None + +def run(): + dev = None + while True: + path = find_event_device() + if not path or not os.path.exists(path): + time.sleep(2); continue + try: + dev = open(path, "rb", buffering=0) + except OSError: + time.sleep(2); continue + try: + while True: + data = dev.read(EV_SIZE) + if not data or len(data) < EV_SIZE: + break + _, _, etype, code, value = struct.unpack(EV_FMT, data) + if etype != EV_KEY or value == 0: # key release => ignore + continue + if code == KEY_VOLUMEUP: + pactl("set-sink-volume", HSSINK, "+4%") + elif code == KEY_VOLUMEDOWN: + pactl("set-sink-volume", HSSINK, "-4%") + elif code == KEY_MUTE and value == 1: + pactl("set-sink-mute", HSSINK, "toggle") + except OSError: + pass + finally: + try: dev.close() + except Exception: pass + time.sleep(1) # device vanished (unplug/reboot) -> re-find + +if __name__ == "__main__": + run() diff --git a/app/aime-iphone-route.sh b/app/aime-iphone-route.sh new file mode 100644 index 0000000..cf9b5d6 --- /dev/null +++ b/app/aime-iphone-route.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Auto-route iPhone HFP calls (the nodes exist only during a call): +# uplink (what far-end hears) <- micgate.out (GATED mic only -> no self-echo) +# far-end voice (bluez_input) -> callmix (recorded digitally, clean) +# and remove WirePlumber's default callrec_mix->uplink auto-link (that mix carried +# the ungated mic which picks up the far-end acoustically = echo). +export XDG_RUNTIME_DIR=/run/user/1000 +MAC=B4_96_A5_42_BD_D6 +PL(){ pw-link "$@" 2>/dev/null; } +while true; do + # uplink sink node (has input_FL) present = a call is up + UPPORT=$(pw-link -i 2>/dev/null | grep -m1 "bluez_output\.${MAC}.*:input_FL") + if [ -n "$UPPORT" ]; then + B=${UPPORT%%:*} + I=$(pw-link -o 2>/dev/null | grep -m1 "bluez_input\.${MAC}.*:output_FL"); I=${I%%:*} + if ! pw-link -l "$B" 2>/dev/null | grep -q micgate.out; then + PL -d callrec_mix:capture_FL "$B:input_FL"; PL -d callrec_mix:capture_FR "$B:input_FR" + PL micgate.out:output_FL "$B:input_FL"; PL micgate.out:output_FR "$B:input_FR" + [ -n "$I" ] && { PL "$I:output_FL" callmix:playback_FL; PL "$I:output_FR" callmix:playback_FR; } + logger -t aime-iphone-route "applied iPhone call routing ($B)" + fi + fi + sleep 2 +done diff --git a/app/aime-mixer-out.sh b/app/aime-mixer-out.sh new file mode 100644 index 0000000..60ff246 --- /dev/null +++ b/app/aime-mixer-out.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# ES8323 output/playback path setup for the wired-headset PC->headset bridge. +# es8323 S16 playback is fine (only S16 CAPTURE is buggy). Output 1/2 (0-33) are +# managed live by aime-volbridge-alsa (Windows volume); set a safe default here. +amixer -c0 sset 'Headphone' on >/dev/null 2>&1 +amixer -c0 cset numid=31 190,190 >/dev/null 2>&1 # PCM digital ~-1dB +amixer -c0 sset 'Left Mixer Left Playback' on >/dev/null 2>&1 +amixer -c0 sset 'Right Mixer Right Playback' on >/dev/null 2>&1 +amixer -c0 sset 'Output 1' 23 >/dev/null 2>&1 +amixer -c0 sset 'Output 2' 23 >/dev/null 2>&1 +exit 0 diff --git a/app/aime-route.sh b/app/aime-route.sh new file mode 100644 index 0000000..b5abc6b --- /dev/null +++ b/app/aime-route.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Aime call bridge: PC(UAC2) <-> BT headset (HFP). Staggered start (A then B) +# — firing both loopbacks simultaneously starves the single SCO clock (TX=0). +export XDG_RUNTIME_DIR=/run/user/1000 +uac_src=$(pactl list short sources | awk "/alsa_input.platform-fc000000.usb/{print \$2; exit}") +uac_sink=$(pactl list short sinks | awk "/alsa_output.platform-fc000000.usb/{print \$2; exit}") +bt_sink=$(pactl list short sinks | awk "/bluez_output/{print \$2; exit}") +bt_src=$(pactl list short sources | awk "/bluez_input/{print \$2; exit}") +echo "uac_src=$uac_src bt_sink=$bt_sink bt_src=$bt_src uac_sink=$uac_sink" +[ -z "$bt_sink" ] && { echo "!! no bluez_output (headset off)"; exit 2; } +pkill -f "pw-loopback.*aime" 2>/dev/null; sleep 1 +# A: PC far-end -> headset speaker (start FIRST, let SCO TX establish) +setsid pw-loopback --name aime-A \ + --capture-props="target.object=\"$uac_src\" node.name=aime-A-cap" \ + --playback-props="target.object=\"$bt_sink\" node.name=aime-A-play" >/dev/null 2>&1 < /dev/null & +sleep 3 +# B: headset mic -> PC (add AFTER A is pumping) +setsid pw-loopback --name aime-B \ + --capture-props="target.object=\"$bt_src\" node.name=aime-B-cap" \ + --playback-props="target.object=\"$uac_sink\" node.name=aime-B-play" >/dev/null 2>&1 < /dev/null & +sleep 2 +echo "bridges up:"; pgrep -af "pw-loopback.*aime-"|grep -oE "aime-[AB]"|sort -u diff --git a/app/aime-usb-audio-setup.sh b/app/aime-usb-audio-setup.sh new file mode 100644 index 0000000..5bec1d5 --- /dev/null +++ b/app/aime-usb-audio-setup.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# CallRec USB-headset audio topology (persistent). +# Logitech H390 = user mic + user ears. UAC2 gadget = PC bridge. +# - PC audio (gadget capture, PC->device) -> headset sink (user hears Windows) +# - PC audio -> callmix (far side of the recording) +# - headset mic -> callmix (near side of the recording) +# - headset mic -> micgate -> gadget sink (device->PC) (far end / PC hears the user) +# CallRec records callmix.monitor (both sides). +# Idempotent: if callmix already exists, does nothing. +# +# NOISE GATE (2026-07-08): the user's boom mic picks up the far-end acoustically +# (proven: muting the Logi mic silences the gadget sink completely; NOT via the +# headset earcup). Teams/Discord AGC then renormalizes it, so lowering mic gain +# alone does nothing -- the far end hears itself with the loopback round-trip +# delay. Fix: route mic->PC THROUGH a LADSPA gate (aime-micgate.service, sink +# "micgate"). In the user's speech pauses the gate closes -> the gadget sink goes +# to absolute silence -> the far end can't hear itself; AGC can't amplify silence. +# Mic HW gain is kept LOW (not the old +11 dB max) on purpose: it keeps the idle +# bleed below the gate threshold (-45 dB), so the gate stays shut when the user is +# quiet even while the far end is loud. +set -u +export XDG_RUNTIME_DIR=/run/user/1000 + +PCAUD=alsa_input.platform-fc000000.usb.stereo-fallback # PC->device (Windows playing) +PCSINK=alsa_output.platform-fc000000.usb.stereo-fallback # device->PC (what Windows records) +HSSINK=alsa_output.usb-Logi_USB_Headset_Logi_USB_Headset-00.analog-stereo +HSMIC=alsa_input.usb-Logi_USB_Headset_Logi_USB_Headset-00.mono-fallback + +# already configured? +if pactl list sinks short 2>/dev/null | grep -qw callmix; then + echo "callmix already present; nothing to do" + exit 0 +fi + +# wait for the gadget + headset PipeWire nodes to appear (headset/UAC2 enumerate +# a little after boot) +for i in $(seq 1 60); do + if pactl list sources short 2>/dev/null | grep -q "$PCAUD" \ + && pactl list sinks short 2>/dev/null | grep -q "$HSSINK" \ + && pactl list sources short 2>/dev/null | grep -q "$HSMIC"; then + break + fi + sleep 1 +done + +# wait briefly for the noise-gate sink (aime-micgate.service, a separate pipewire +# filter-chain client). Route the user's mic THROUGH it if present; fall back to +# the raw gadget sink so mic->PC still works even if the gate failed to start. +MIC_OUT="$PCSINK" +for i in $(seq 1 15); do + if pactl list sinks short 2>/dev/null | grep -qw micgate; then MIC_OUT=micgate; break; fi + sleep 1 +done + +# set the volume of a loopback's sink-input, found by its media.name tag (robust +# across reboots / stream-restore reshuffles, unlike numeric indices). +si_vol() { # $1 = media.name, $2 = volume (e.g. 60%) + local si + si=$(pactl list sink-inputs 2>/dev/null | awk -v n="$1" ' + /^Sink Input #/{id=$3; gsub("#","",id)} + index($0, "media.name = \"" n "\""){print id; exit}') + [ -n "$si" ] && pactl set-sink-input-volume "$si" "$2" >/dev/null 2>&1 +} + +# headset mic hardware gain kept LOW (see NOISE GATE note): +1.4 dB, not +11 max. +# Low analog gain -> low idle bleed -> gate stays shut in the user's pauses. +amixer -c Headset sset Mic 12 unmute >/dev/null 2>&1 # belt (overridden by the source-vol below) +pactl set-source-volume "$HSMIC" 62% >/dev/null 2>&1 # WirePlumber-owned; 62%% -> HW Mic step 12 (~+1.4 dB). 100%% would map to +11 dB max and re-open the gate on far-end bleed. + +# virtual recording mix +pactl load-module module-null-sink sink_name=callmix \ + sink_properties=device.description=CallRecMix >/dev/null 2>&1 +pactl set-sink-volume callmix 100% >/dev/null 2>&1 + +# routes — each loopback tagged so we can set per-stream gain by name +pactl load-module module-loopback source="$PCAUD" sink="$HSSINK" latency_msec=60 sink_input_properties=media.name=cr_pc2hs >/dev/null 2>&1 # PC -> headset (monitor) +pactl load-module module-loopback source="$PCAUD" sink=callmix latency_msec=60 sink_input_properties=media.name=cr_pc2mix >/dev/null 2>&1 # PC -> mix +pactl load-module module-loopback source="$HSMIC" sink=callmix latency_msec=60 sink_input_properties=media.name=cr_mic2mix >/dev/null 2>&1 # mic -> mix +pactl load-module module-loopback source="$HSMIC" sink="$MIC_OUT" latency_msec=60 sink_input_properties=media.name=cr_mic2pc >/dev/null 2>&1 # mic -> (gate ->) PC + +# GAIN STAGING (clip-safe): PC media hits ~0 dBFS, so knock it down hard in the +# mix; the mic runs near unity. Their sum must never reach 0 dBFS or clipping +# corrupts whisper + diarization (over-segments to many speakers). PC monitoring +# to the headset (cr_pc2hs) stays at unity — full volume. +sleep 1 +si_vol cr_pc2mix 60% # far side into the recording (~-12 dB) +si_vol cr_mic2mix 100% # near side (user), into the recording mix +si_vol cr_mic2pc 65% # near side to the far end (through the gate) +# NOTE: fine mic/PC balance still wants tuning on a REAL call. If the far end +# finds the user too quiet, raise cr_mic2pc / Mic gain -- but watch the gate: +# more gain -> more idle bleed -> may cross the -45 dB threshold and re-open the +# gate on the far-end bleed (echo returns). Retune the gate Threshold together. + +# expose the mix as a REAL source (not a .monitor) so the CallRec UI lists it — +# recorder.js listSources() filters out every *.monitor. +pactl load-module module-remap-source master=callmix.monitor source_name=callrec_mix \ + source_properties=device.description=CallRecMix >/dev/null 2>&1 + +# defaults: user hears via headset, CallRec records the mix +pactl set-default-sink "$HSSINK" >/dev/null 2>&1 +pactl set-default-source callrec_mix >/dev/null 2>&1 + +echo "aime usb-audio topology loaded (mic->PC via micgate=$MIC_OUT)" diff --git a/app/aime-volbridge-alsa.sh b/app/aime-volbridge-alsa.sh new file mode 100644 index 0000000..1d12169 --- /dev/null +++ b/app/aime-volbridge-alsa.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Clean poll-based volume bridge (pure ALSA, no PipeWire). +# Windows "Source/Sink" speaker volume -> gadget control numid=7 (PCM Capture +# Volume, 0-100). Applied to ES8323 analog output (Output 1/2, 0-33) only when it +# actually changes (poll every 200ms) so we don't hammer the mixer on the gadget's +# ~10Hz async rate-feedback events. +set -u +CG=UAC2Gadget +CE=0 +last=-1; lastm=-1 +while true; do + v=$(amixer -c "$CG" cget numid=7 2>/dev/null | awk -F= '/: values=/{print $2; exit}') + m=$(amixer -c "$CG" cget numid=6 2>/dev/null | awk -F= '/: values=/{print $2; exit}') + if [ -n "$v" ] && { [ "$v" != "$last" ] || [ "$m" != "$lastm" ]; }; then + o=$(( v * 25 / 100 )) + amixer -c "$CE" sset 'Output 1' "$o" >/dev/null 2>&1 + amixer -c "$CE" sset 'Output 2' "$o" >/dev/null 2>&1 + case "$m" in + off|0) amixer -c "$CE" sset 'Headphone' off >/dev/null 2>&1 ;; + *) amixer -c "$CE" sset 'Headphone' on >/dev/null 2>&1 ;; + esac + last="$v"; lastm="$m" + fi + sleep 0.2 +done diff --git a/app/aime-volbridge-headset.sh b/app/aime-volbridge-headset.sh new file mode 100644 index 0000000..008e6ff --- /dev/null +++ b/app/aime-volbridge-headset.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Windows "Source/Sink" speaker volume -> USB gadget Feature Unit (numid=7, PCM +# Capture Volume 0-100) + mute (numid=6). Mirror it onto the USB headset sink so +# the Windows volume slider actually controls what the user hears in the headset. +# Poll-based (200ms), only acts on change to avoid hammering the mixer on the +# gadget's ~10Hz async rate-feedback events. +set -u +export XDG_RUNTIME_DIR=/run/user/1000 +CG=UAC2Gadget +HSSINK=alsa_output.usb-Logi_USB_Headset_Logi_USB_Headset-00.analog-stereo +last=-1; lastm=-1 +while true; do + v=$(amixer -c "$CG" cget numid=7 2>/dev/null | awk -F= '/: values=/{print $2; exit}') + m=$(amixer -c "$CG" cget numid=6 2>/dev/null | awk -F= '/: values=/{print $2; exit}') + if [ -n "$v" ] && { [ "$v" != "$last" ] || [ "$m" != "$lastm" ]; }; then + pactl set-sink-volume "$HSSINK" "${v}%" >/dev/null 2>&1 + case "$m" in + off|0) pactl set-sink-mute "$HSSINK" 1 >/dev/null 2>&1 ;; + *) pactl set-sink-mute "$HSSINK" 0 >/dev/null 2>&1 ;; + esac + last="$v"; lastm="$m" + fi + sleep 0.2 +done diff --git a/app/aime-volbridge.sh b/app/aime-volbridge.sh new file mode 100644 index 0000000..70e35c8 --- /dev/null +++ b/app/aime-volbridge.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Bridges USB gadget host volume (Windows slider) -> ES8323 sink volume/mute. +# Host speaker volume appears on UAC2Gadget control numid=7 (PCM Capture Volume); +# f_uac2 exposes it but does not scale samples, so we apply it here. +set -u +export XDG_RUNTIME_DIR=/run/user/1000 +CARD=UAC2Gadget +SINK=alsa_output.platform-es8323-sound.stereo-fallback +apply() { + local v m + v=$(amixer -c "$CARD" cget numid=7 2>/dev/null | awk -F= '/: values=/{print $2; exit}') + m=$(amixer -c "$CARD" cget numid=6 2>/dev/null | awk -F= '/: values=/{print $2; exit}') + [ -z "$v" ] && return 0 + pactl set-sink-volume "$SINK" "${v}%" 2>/dev/null + case "$m" in + off|0) pactl set-sink-mute "$SINK" 1 2>/dev/null ;; + *) pactl set-sink-mute "$SINK" 0 2>/dev/null ;; + esac +} +apply +stdbuf -oL alsactl monitor hw:"$CARD" 2>/dev/null | while IFS= read -r _l; do apply; done diff --git a/app/bt.js b/app/bt.js new file mode 100644 index 0000000..f7689a1 --- /dev/null +++ b/app/bt.js @@ -0,0 +1,83 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +const pexec = promisify(execFile); + +// Run bluetoothctl non-interactively. Returns combined stdout even on nonzero exit. +async function btctl(args, timeoutSec = 15) { + try { + const { stdout } = await pexec('bluetoothctl', args, { timeout: timeoutSec * 1000, maxBuffer: 1 << 20 }); + return stdout; + } catch (e) { + return (e.stdout || '') + (e.stderr || ''); + } +} + +function parseDevices(out) { + const res = []; + for (const line of out.split('\n')) { + const m = line.match(/Device\s+([0-9A-F:]{17})\s+(.*)/i); + if (m) res.push({ mac: m[1].toUpperCase(), name: m[2].trim() }); + } + return res; +} + +// Scan for `seconds`, return all discovered devices. +export async function scan(seconds = 10) { + await btctl(['--timeout', '2', 'power', 'on'], 5); + await btctl(['--timeout', String(seconds), 'scan', 'on'], seconds + 6); + return enrich(parseDevices(await btctl(['devices']))); +} + +// Known devices with paired/connected status. +export async function knownDevices() { + return enrich(parseDevices(await btctl(['devices']))); +} + +async function enrich(devices) { + const pairedMacs = new Set(parseDevices(await btctl(['paired-devices'])).map(d => d.mac)); + for (const d of devices) { + d.paired = pairedMacs.has(d.mac); + const info = await btctl(['info', d.mac]); + d.connected = /Connected:\s*yes/i.test(info); + d.audio = /Icon:\s*audio|UUID:.*(Headset|Handsfree|Audio Sink)/i.test(info); + } + return devices; +} + +export async function pair(mac) { + const r1 = await btctl(['--timeout', '25', 'pair', mac], 30); + const r2 = await btctl(['trust', mac], 5); + const r3 = await btctl(['--timeout', '15', 'connect', mac], 20); + const info = await btctl(['info', mac]); + return { pair: r1.trim(), trust: r2.trim(), connect: r3.trim(), connected: /Connected:\s*yes/i.test(info) }; +} + +// Full re-pair. realme (and many cheap earbuds) DISCARD their stored bond when +// put back in pairing mode, so the board's kept bond goes stale → plain connect +// or pair fails (AuthenticationFailed). Recipe: drop stale bond, rediscover +// (scan must be its own step), then pair+trust+connect fresh. All via +// bluetoothctl, so the callrec user can run it through the polkit rule. +export async function repair(mac) { + await btctl(['disconnect', mac], 10); + await btctl(['remove', mac], 10); + await btctl(['--timeout', '2', 'power', 'on'], 5); + await btctl(['--timeout', '12', 'scan', 'on'], 18); // rediscover after removal + const found = parseDevices(await btctl(['devices'])).some(d => d.mac === mac.toUpperCase()); + if (!found) return { ok: false, step: 'scan', msg: 'Gerät nicht gefunden — im Pairing-Modus?' }; + const r1 = await btctl(['--timeout', '25', 'pair', mac], 30); + await btctl(['trust', mac], 5); + const r3 = await btctl(['--timeout', '15', 'connect', mac], 20); + const info = await btctl(['info', mac]); + const connected = /Connected:\s*yes/i.test(info); + return { ok: connected, pair: r1.trim(), connect: r3.trim(), connected }; +} + +export async function connect(mac) { + const out = await btctl(['--timeout', '15', 'connect', mac], 20); + const info = await btctl(['info', mac]); + return { out: out.trim(), connected: /Connected:\s*yes/i.test(info) }; +} + +export async function disconnect(mac) { + return { out: (await btctl(['disconnect', mac], 10)).trim() }; +} diff --git a/app/btwatch.sh b/app/btwatch.sh new file mode 100644 index 0000000..8183f3a --- /dev/null +++ b/app/btwatch.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Phone-like auto-connect for the realme Buds T110 (out-of-case = pairing mode). +# HEALTH = BT connected AND PipeWire actually has the bluez card (recording needs +# the card, not just the ACL link). Escalation ladder self-heals every state: +# healthy -> idle info-check (no RF, keeps SCO clean) +# BT-conn but no card-> desync: disconnect+reconnect, then kick wireplumber +# device present -> plain connect; else re-pair; 3x fail -> controller reset +# device not around -> quietly wait (in case / off) +MAC=98:34:8C:FD:24:17 +CE=XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus +fails=0 +bt_connected(){ bluetoothctl info "$MAC" 2>/dev/null | grep -q 'Connected: yes'; } +has_card(){ sudo -u callrec env $CE pactl list cards short 2>/dev/null | grep -qi bluez_card; } +healthy(){ bt_connected && has_card; } +present(){ timeout 10 bluetoothctl --timeout 7 scan on >/dev/null 2>&1; bluetoothctl devices 2>/dev/null | grep -qi "$MAC"; } +repair(){ bluetoothctl remove "$MAC" >/dev/null 2>&1; timeout 15 bluetoothctl --timeout 12 scan on >/dev/null 2>&1; timeout 30 bluetoothctl --timeout 25 pair "$MAC" >/dev/null 2>&1; bluetoothctl trust "$MAC" >/dev/null 2>&1; timeout 20 bluetoothctl --timeout 15 connect "$MAC" >/dev/null 2>&1; } +reset_ctrl(){ echo 'btwatch: controller reset'; bluetoothctl remove "$MAC" >/dev/null 2>&1; hciconfig $(hciconfig 2>/dev/null | grep -oE '^hci[0-9]' | head -1) reset 2>/dev/null; systemctl restart bluetooth; sleep 5; } +kick_pw(){ echo 'btwatch: kick wireplumber'; sudo -u callrec env $CE systemctl --user restart wireplumber >/dev/null 2>&1; sleep 3; } +while true; do + if healthy; then fails=0; sleep 6; continue; fi + if bt_connected && ! has_card; then + echo 'btwatch: connected but no pipewire card' + bluetoothctl disconnect "$MAC" >/dev/null 2>&1; sleep 2 + bluetoothctl connect "$MAC" >/dev/null 2>&1; sleep 3 + has_card && { fails=0; continue; } + kick_pw; has_card && { fails=0; continue; } + fi + if timeout 12 bluetoothctl connect "$MAC" >/dev/null 2>&1; then sleep 3; healthy && { echo 'btwatch: connected'; fails=0; sleep 4; continue; }; fi + if ! present; then fails=0; sleep 8; continue; fi + fails=$((fails+1)); echo "btwatch: present but not healthy (fail $fails)" + if [ "$fails" -lt 3 ]; then repair; else reset_ctrl; repair; fails=0; sleep 8; fi + healthy && fails=0 + sleep 4 +done diff --git a/app/callrec.service b/app/callrec.service new file mode 100644 index 0000000..eb82b29 --- /dev/null +++ b/app/callrec.service @@ -0,0 +1,18 @@ +[Unit] +Description=CallRec — BT recording + Whisper transcription web app +After=network-online.target mariadb.service sound.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/callrec +Environment=NODE_ENV=production +Environment=XDG_RUNTIME_DIR=/run/user/0 +Environment=PORT=3000 +ExecStart=/usr/bin/node /opt/callrec/server.js +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/app/db.js b/app/db.js new file mode 100644 index 0000000..185f2f6 --- /dev/null +++ b/app/db.js @@ -0,0 +1,188 @@ +import mysql from 'mysql2/promise'; + +export const pool = mysql.createPool({ + host: 'localhost', user: 'callrec', password: 'callrec123', + database: 'callrec', connectionLimit: 5, waitForConnections: true +}); + +// Idempotent migration: the LLM analysis columns (auto title lives in `title`). +// MariaDB supports ADD COLUMN IF NOT EXISTS. +export async function ensureSchema() { + await pool.query(`ALTER TABLE recordings + ADD COLUMN IF NOT EXISTS summary TEXT NULL, + ADD COLUMN IF NOT EXISTS todos TEXT NULL, + ADD COLUMN IF NOT EXISTS participants TEXT NULL, + ADD COLUMN IF NOT EXISTS transcript_clean TEXT NULL, + ADD COLUMN IF NOT EXISTS wav_file VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS speaker_names TEXT NULL, + ADD COLUMN IF NOT EXISTS analysis_status VARCHAR(16) NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL, + ADD COLUMN IF NOT EXISTS analysis_ms INT NULL`); + await pool.query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS speaker VARCHAR(32) NULL`); + await pool.query(`CREATE TABLE IF NOT EXISTS voiceprints ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + dim INT NOT NULL, + vec MEDIUMTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`); + // n = number of samples averaged into this voiceprint (running centroid). + await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS n INT NOT NULL DEFAULT 1`); + // Domain glossary: known proper nouns / terms the ASR mis-hears (e.g. "Aime" + // heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped + // to the correct spelling. `soundalikes` = optional comma-list of mis-hearings. + await pool.query(`CREATE TABLE IF NOT EXISTS glossary ( + id INT AUTO_INCREMENT PRIMARY KEY, + term VARCHAR(190) NOT NULL UNIQUE, + soundalikes TEXT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`); +} + +export async function listGlossary() { + const [rows] = await pool.query('SELECT id, term, soundalikes, enabled, created_at FROM glossary ORDER BY term'); + return rows.map(r => ({ ...r, enabled: !!r.enabled })); +} +export async function addGlossary(term, soundalikes) { + await pool.execute( + 'INSERT INTO glossary (term, soundalikes) VALUES (?,?) ON DUPLICATE KEY UPDATE soundalikes=VALUES(soundalikes), enabled=1', + [term, soundalikes || null]); + const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE term=?', [term]); + return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null; +} +export async function deleteGlossary(id) { + await pool.execute('DELETE FROM glossary WHERE id=?', [id]); +} + +export async function addVoiceprint(name, vec) { + const [res] = await pool.execute( + 'INSERT INTO voiceprints (name, dim, vec) VALUES (?,?,?)', [name, vec.length, JSON.stringify(vec)]); + return res.insertId; +} + +// Add a sample to a named voiceprint, keeping ONE row per name as a running +// centroid (mean of all samples). This is how the app "learns who is who": every +// time a speaker is named in a recording, their voice is folded into the centroid, +// so future recordings match them more reliably. New name -> fresh row. +export async function upsertVoiceprint(name, vec) { + const [rows] = await pool.query('SELECT id, vec, n FROM voiceprints WHERE name=? ORDER BY id LIMIT 1', [name]); + if (!rows.length) { + const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n) VALUES (?,?,?,1)', + [name, vec.length, JSON.stringify(vec)]); + return res.insertId; + } + const old = rows[0]; + const on = old.n || 1; + const ov = JSON.parse(old.vec); + const mean = vec.map((x, i) => ((ov[i] || 0) * on + x) / (on + 1)); + await pool.execute('UPDATE voiceprints SET vec=?, n=?, dim=? WHERE id=?', + [JSON.stringify(mean), on + 1, mean.length, old.id]); + return old.id; +} +export async function listVoiceprints() { + const [rows] = await pool.query('SELECT id,name,dim,vec,created_at FROM voiceprints ORDER BY name'); + return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) })); +} +export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); } + +export async function addRecording(r) { + const [res] = await pool.execute( + 'INSERT INTO recordings (title, mp3_file, wav_file, device, duration_ms, transcript, language, status, analysis_status) VALUES (?,?,?,?,?,?,?,?,?)', + [r.title, r.mp3_file, r.wav_file || null, r.device, r.duration_ms, r.transcript, r.language, r.status || 'done', 'pending']); + return res.insertId; +} + +export async function addWords(recId, words) { + if (!words || !words.length) return; + const values = words.map((w, i) => [recId, w.word, w.t_start_ms, w.t_end_ms, i, w.speaker || null]); + await pool.query('INSERT INTO words (recording_id, word, t_start_ms, t_end_ms, idx, speaker) VALUES ?', [values]); +} + +// Replace a recording's words wholesale (used when re-diarizing: the text is +// unchanged, only the speaker labels differ). Words are re-indexed by array order. +export async function replaceWords(recId, words) { + await pool.query('DELETE FROM words WHERE recording_id=?', [recId]); + await addWords(recId, words); +} + +// Store the transcript + words after async (thermal-guarded) transcription and +// flip the row out of 'transcribing'. The wav + row were saved before whisper +// ran, so this just fills them in. +export async function setTranscript(id, transcript, words) { + await pool.execute('UPDATE recordings SET transcript=?, status=? WHERE id=?', [transcript || '', 'done', id]); + await replaceWords(id, words || []); +} + +// Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The +// GUI shows a live counter from here; updateAnalysis('done') freezes the total +// into analysis_ms. Called at every trigger (record-stop, retranscribe, reanalyze). +export async function markAnalysisStart(id) { + await pool.execute('UPDATE recordings SET analysis_started_at=?, analysis_ms=NULL WHERE id=?', [Date.now(), id]); +} + +// Update just the coarse recording status ('transcribing' | 'done' | 'error'). +export async function setStatus(id, status) { + await pool.execute('UPDATE recordings SET status=? WHERE id=?', [status, id]); +} + +// Per-recording map { rawSpeaker -> displayName }. Manual edits (setSpeakerName) +// are authoritative; acoustic identification only fills blanks (mergeSpeakerNames). +async function getSpeakerNames(id) { + const [rows] = await pool.query('SELECT speaker_names FROM recordings WHERE id=?', [id]); + try { return JSON.parse((rows[0] && rows[0].speaker_names) || '{}') || {}; } catch { return {}; } +} +export async function setSpeakerName(id, speaker, name) { + const m = await getSpeakerNames(id); + if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N" + await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]); +} +// Seed names from acoustic identification WITHOUT clobbering existing (manual) ones. +export async function mergeSpeakerNames(id, map) { + if (!map || !Object.keys(map).length) return; + const m = await getSpeakerNames(id); + let changed = false; + for (const k of Object.keys(map)) if (!m[k] && map[k]) { m[k] = map[k]; changed = true; } + if (changed) await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]); +} + +// Store the LLM result. `todos` is an array → persisted as JSON text. +export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) { + const st = status || 'done'; + await pool.execute( + `UPDATE recordings SET title=COALESCE(?,title), summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?, + analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END + WHERE id=?`, + [title || null, summary || null, todos ? JSON.stringify(todos) : null, + participants ? JSON.stringify(participants) : null, transcriptClean || null, st, + st, Date.now(), id]); +} + +function parseJsonCols(row) { + for (const c of ['todos', 'participants']) { + if (row && typeof row[c] === 'string') { + try { row[c] = JSON.parse(row[c]); } catch { row[c] = []; } + } + } + return row; +} +const parseTodos = parseJsonCols; + +export async function listRecordings() { + const [rows] = await pool.query( + 'SELECT id,title,mp3_file,device,duration_ms,language,status,summary,todos,participants,analysis_status,analysis_started_at,analysis_ms,created_at FROM recordings ORDER BY id DESC'); + return rows.map(parseTodos); +} + +export async function getRecording(id) { + const [recRows] = await pool.query('SELECT * FROM recordings WHERE id=?', [id]); + const rec = recRows[0]; + if (!rec) return null; + const [words] = await pool.query( + 'SELECT word,t_start_ms,t_end_ms,idx,speaker FROM words WHERE recording_id=? ORDER BY idx', [id]); + let speaker_names = {}; + try { speaker_names = JSON.parse(rec.speaker_names || '{}') || {}; } catch {} + return { ...parseTodos(rec), speaker_names, words }; +} + +export async function deleteRecording(id) { + await pool.query('DELETE FROM recordings WHERE id=?', [id]); +} diff --git a/app/diar.js b/app/diar.js new file mode 100644 index 0000000..fbd04b2 --- /dev/null +++ b/app/diar.js @@ -0,0 +1,179 @@ +// Offline acoustic speaker diarization via sherpa-onnx (arm64 CPU, no pytorch). +// Splits a 16 kHz mono wav into speaker segments; AUTO speaker count (cluster +// threshold) because a conference phone ("Raumspinne") can put an unknown number +// of people on the single BT channel. Pinned to the A76 cores. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import * as spk from './spk.js'; +const pexec = promisify(execFile); + +// Auto-mode over-segmentation fix: sherpa's clustering splits ONE speaker into +// many clusters (e.g. 8-13 on a 2-person call, 61 on a 46-min one), and raising +// the cluster threshold barely helps. So after sherpa, we re-embed each cluster +// with the SAME TitaNet model used for voiceprint identity and MERGE clusters +// whose centroids are cosine-similar — i.e. the same voice that got split. This +// keeps the count dynamic (speakers can join/leave) without a fixed guess. +const MERGE_THRESHOLD = +(process.env.DIAR_MERGE_THRESHOLD || 0.5); +const REP_SEGMENTS = 3; // longest segments per cluster averaged into its centroid +const MIN_REP_SEC = 0.4; // skip ultra-short cuts for embedding when a longer one exists +// Stage 2: sub-second stray clusters (overlap crosstalk, noise) survive the +// centroid merge because they're too short to embed reliably. Any speaker with +// less than this much total speech is folded into the temporally-nearest real +// speaker. A genuine join/leave participant speaks well over this. +const MIN_SPEAKER_SEC = +(process.env.DIAR_MIN_SPEAKER_SEC || 3); + +// Diarization on the capped A76 cores runs at ~1x wall-clock (a 46-min wav ≈ +// 40 min). The old fixed 10-min timeout killed every recording over ~11 min and +// surfaced as an opaque "Command failed" (only sherpa's argv echo in stderr). +// Scale the timeout with the audio length: ~2.5x its duration, floor 20 min. +function diarTimeoutMs(wavPath) { + let sec = 0; + try { sec = Math.max(0, (fs.statSync(wavPath).size - 44) / 32000); } catch {} // 16kHz mono s16 + return Math.max(20 * 60 * 1000, Math.ceil(sec * 2.5) * 1000); +} + +const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu'; +const BIN = `${DIR}/bin/sherpa-onnx-offline-speaker-diarization`; +const SEG = process.env.DIAR_SEG || '/opt/sherpa/sherpa-onnx-pyannote-segmentation-3-0/model.onnx'; +const EMB = process.env.DIAR_EMB || '/opt/sherpa/nemo_titanet_small.onnx'; +// 0.7 (not 0.5): a lower threshold over-segments — a single (esp. child) voice +// split into several clusters. 0.7 merges more → fewer spurious speakers while +// still auto-detecting the count (needed for a conference phone / "Raumspinne"). +const THRESHOLD = process.env.DIAR_THRESHOLD || '0.7'; + +function env() { return { ...process.env, LD_LIBRARY_PATH: `${DIR}/lib` }; } + +// Returns [{ start, end, speaker }] (seconds, speaker = "speaker_00"…). +// numSpeakers > 0 fixes the count (--clustering.num-clusters) when the user knows +// it; otherwise auto via the cluster threshold. +export async function diarize(wavPath, numSpeakers) { + const n = parseInt(numSpeakers, 10); + const clusterArg = n > 0 ? `--clustering.num-clusters=${n}` : `--clustering.cluster-threshold=${THRESHOLD}`; + const args = [ + 'taskset', '-c', '4-7', BIN, + `--segmentation.pyannote-model=${SEG}`, + `--segmentation.num-threads=4`, + `--embedding.model=${EMB}`, + `--embedding.num-threads=4`, + clusterArg, + wavPath, + ]; + const { stdout } = await pexec(args[0], args.slice(1), { env: env(), maxBuffer: 1 << 22, timeout: diarTimeoutMs(wavPath) }); + const segs = []; + for (const line of stdout.split('\n')) { + const m = line.match(/([\d.]+)\s*--\s*([\d.]+)\s*(speaker_\d+)/); + if (m) segs.push({ start: +m[1], end: +m[2], speaker: m[3] }); + } + // Only auto mode over-segments; a user-pinned count is already trusted. + if (!(n > 0) && segs.length) { + try { return await consolidateSpeakers(wavPath, segs); } + catch (e) { console.log('diar: consolidation failed, using raw clusters:', e.message); } + } + return segs; +} + +// Average voiceprint of a cluster: embed its longest few segments and mean them +// (short cuts give noisy embeddings, so prefer the longest and average out noise). +async function clusterCentroid(wavPath, clusterSegs) { + const sorted = [...clusterSegs].sort((a, b) => (b.end - b.start) - (a.end - a.start)); + const pick = sorted.filter(s => s.end - s.start >= MIN_REP_SEC).slice(0, REP_SEGMENTS); + const use = pick.length ? pick : sorted.slice(0, 1); + const vecs = []; + for (const s of use) { + const seg = `/tmp/diarcons_${process.pid}_${Math.round(s.start * 1000)}.wav`; + try { + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(s.start), + '-to', String(Math.min(s.end, s.start + 8)), '-ar', '16000', '-ac', '1', seg]); + const v = await spk.embed(seg); + if (Array.isArray(v) && v.length) vecs.push(v); + } catch { /* skip this cut */ } + finally { try { fs.unlinkSync(seg); } catch {} } + } + if (!vecs.length) return null; + const dim = vecs[0].length, c = new Array(dim).fill(0); + for (const v of vecs) for (let i = 0; i < dim; i++) c[i] += v[i]; + for (let i = 0; i < dim; i++) c[i] /= vecs.length; + return c; +} + +// Merge sherpa's over-segmented clusters (anchor-based, see fn body), +// then renumber the surviving speakers by first appearance. Segments whose +// centroid couldn't be embedded keep their own label (safe: never lose a turn). +export async function consolidateSpeakers(wavPath, segs) { + const labels = [...new Set(segs.map(s => s.speaker))]; + if (labels.length <= 1) return segs; + + // Centroid + total speech duration per raw sherpa cluster. + const cents = {}, dur = {}; + for (const lab of labels) { + const cs = segs.filter(s => s.speaker === lab); + cents[lab] = await clusterCentroid(wavPath, cs); + dur[lab] = cs.reduce((a, s) => a + (s.end - s.start), 0); + } + + // ANCHOR-based merge (replaces the old pairwise union-find, which chained + // transitively — A~B, B~C folded A and C together — so one borderline link + // near the 0.5 cosine could cascade and the count flipped run-to-run under + // load/thermal-cap FP noise: the SAME 2-3 person call came out 2, 3, or 7). + // Only long clusters (reliable embeddings) become anchors and, processed + // LONGEST-FIRST, either join an existing speaker or open a new one — a stable, + // order-deterministic decision. Every short/stray cluster is then assigned to + // its NEAREST anchor (never opens a speaker), so noise can't fragment. + const anchors = labels + .filter(l => cents[l] && dur[l] >= MIN_SPEAKER_SEC) + .sort((a, b) => (dur[b] - dur[a]) || (a < b ? -1 : 1)); + + const speakerOf = {}; // rawLabel -> canonical speaker rep (a rawLabel) + const reps = []; // accepted speaker representatives + for (const a of anchors) { + let joined = null; + for (const r of reps) if (spk.cosine(cents[a], cents[r]) >= MERGE_THRESHOLD) { joined = r; break; } + if (joined) speakerOf[a] = joined; else { speakerOf[a] = a; reps.push(a); } + } + + const nearestRep = (lab) => { + if (!reps.length) return lab; // no anchors -> keep own label + if (!cents[lab]) return reps[0]; // unembeddable -> dominant speaker + let best = reps[0], bs = -Infinity; + for (const r of reps) { const c = spk.cosine(cents[lab], cents[r]); if (c > bs) { bs = c; best = r; } } + return best; + }; + + const merged = segs.map(s => ({ ...s, speaker: speakerOf[s.speaker] || nearestRep(s.speaker) })); + + // Renumber surviving speakers by first appearance. + const rootMap = {}; let n = 0; + for (const s of merged) if (!(s.speaker in rootMap)) rootMap[s.speaker] = `speaker_${String(n++).padStart(2, '0')}`; + if (n !== labels.length) console.log(`diar: consolidated ${labels.length} -> ${n} speakers (anchors ${reps.length}, merge>=${MERGE_THRESHOLD}, floor ${MIN_SPEAKER_SEC}s)`); + return merged.map(s => ({ ...s, speaker: rootMap[s.speaker] })); +} + +// Assign each word (t_start_ms/t_end_ms) the speaker whose segment overlaps it +// most. Words with no overlap inherit the previous word's speaker. +export function assignSpeakers(words, segs) { + if (!segs.length) return words; + let last = segs[0].speaker; + for (const w of words) { + const ws = w.t_start_ms / 1000, we = w.t_end_ms / 1000; + let best = null, bestOv = 0; + for (const s of segs) { + const ov = Math.min(we, s.end) - Math.max(ws, s.start); + if (ov > bestOv) { bestOv = ov; best = s.speaker; } + } + w.speaker = best || last; + last = w.speaker; + } + return words; +} + +// Group consecutive same-speaker words into turns [{ speaker, text }]. +export function toTurns(words) { + const turns = []; + for (const w of words) { + const sp = w.speaker || 'speaker_00'; + if (!turns.length || turns[turns.length - 1].speaker !== sp) turns.push({ speaker: sp, text: w.word }); + else turns[turns.length - 1].text += ' ' + w.word; + } + return turns; +} diff --git a/app/inference/__init__.py b/app/inference/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/inference/rknpu_embed.py b/app/inference/rknpu_embed.py new file mode 100644 index 0000000..55a1b67 --- /dev/null +++ b/app/inference/rknpu_embed.py @@ -0,0 +1,116 @@ +"""ctypes-Binding auf librknnrt.so für das wespeaker-Embedding auf der NPU (specs/007). + +Warum kein rknn-toolkit-lite2: dessen PyPI-Wheels enden bei cp312, der R1 läuft auf +Python 3.13 → direktes C-API-Binding nach dem bewährten loop_native.py-Muster (DEC-708). +Struct-Layouts verifiziert gegen rknn_api.h v2.3.2 (airockchip). Läuft NUR auf dem R1 +(braucht /usr/lib/librknnrt.so 2.3.2 + Kernel-Treiber 0.9.8) — auf Windows nicht +importier-testbar, wie alles unter device/inference/. + +Modell: /opt/aime/models/diar/wespeaker-pyannote-fp16.rknn (Fixed-Shape, +feats [1,998,80] float32 + weights [1,125] float32 → embs [1,256] float32; +Konvertierung siehe specs/007 T702/T703, Toolchain aime-rknn:2.3.2 auf dem PC). + +Nutzung: + e = RknnEmbedding("/opt/aime/models/diar/wespeaker-pyannote-fp16.rknn") + emb = e.run(feats, weights) # → np.ndarray [1, 256] + e.close() +""" +import ctypes as C + +import numpy as np + +RKNN_SUCC = 0 +RKNN_TENSOR_FLOAT32 = 0 +RKNN_TENSOR_UNDEFINED_FMT = 3 # rknn_api.h: NCHW=0, NHWC=1, NC1HWC2=2, UNDEFINED=3 +RKNN_NPU_CORE_0_1_2 = 7 # alle 3 NPU-Kerne (T704b: 1,84x vs. 1 Kern, 39 ms/Chunk) + + +class _RknnInput(C.Structure): + _fields_ = [ + ("index", C.c_uint32), + ("buf", C.c_void_p), + ("size", C.c_uint32), + ("pass_through", C.c_uint8), + ("type", C.c_int), + ("fmt", C.c_int), + ] + + +class _RknnOutput(C.Structure): + _fields_ = [ + ("want_float", C.c_uint8), + ("is_prealloc", C.c_uint8), + ("index", C.c_uint32), + ("buf", C.c_void_p), + ("size", C.c_uint32), + ] + + +class RknnEmbedding: + """Eine geladene .rknn-Instanz; run() ist NICHT thread-safe (ein Kontext).""" + + def __init__(self, model_path: str, lib_path: str = "librknnrt.so", + core_mask: int = RKNN_NPU_CORE_0_1_2): + self._lib = C.CDLL(lib_path) + self._lib.rknn_init.argtypes = [C.POINTER(C.c_uint64), C.c_void_p, + C.c_uint32, C.c_uint32, C.c_void_p] + self._lib.rknn_set_core_mask.argtypes = [C.c_uint64, C.c_int] + self._lib.rknn_inputs_set.argtypes = [C.c_uint64, C.c_uint32, + C.POINTER(_RknnInput)] + self._lib.rknn_run.argtypes = [C.c_uint64, C.c_void_p] + self._lib.rknn_outputs_get.argtypes = [C.c_uint64, C.c_uint32, + C.POINTER(_RknnOutput), C.c_void_p] + self._lib.rknn_outputs_release.argtypes = [C.c_uint64, C.c_uint32, + C.POINTER(_RknnOutput)] + self._lib.rknn_destroy.argtypes = [C.c_uint64] + + with open(model_path, "rb") as f: + blob = f.read() + self._ctx = C.c_uint64(0) + buf = C.create_string_buffer(blob, len(blob)) + rc = self._lib.rknn_init(C.byref(self._ctx), buf, len(blob), 0, None) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_init rc={rc} (librknnrt/Treiber prüfen)") + if core_mask: # best effort — Speed-Sache, kein Korrektheits-Thema + self._lib.rknn_set_core_mask(self._ctx, core_mask) + + def run(self, feats: np.ndarray, weights: np.ndarray) -> np.ndarray: + feats = np.ascontiguousarray(feats, dtype=np.float32) + weights = np.ascontiguousarray(weights, dtype=np.float32) + ins = (_RknnInput * 2)() + for i, arr in enumerate((feats, weights)): + ins[i].index = i + ins[i].buf = arr.ctypes.data_as(C.c_void_p) + ins[i].size = arr.nbytes + ins[i].pass_through = 0 + ins[i].type = RKNN_TENSOR_FLOAT32 + ins[i].fmt = RKNN_TENSOR_UNDEFINED_FMT + rc = self._lib.rknn_inputs_set(self._ctx, 2, ins) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_inputs_set rc={rc}") + rc = self._lib.rknn_run(self._ctx, None) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_run rc={rc}") + outs = (_RknnOutput * 1)() + outs[0].want_float = 1 + outs[0].is_prealloc = 0 + outs[0].index = 0 + rc = self._lib.rknn_outputs_get(self._ctx, 1, outs, None) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_outputs_get rc={rc}") + n = outs[0].size // 4 + emb = np.ctypeslib.as_array( + C.cast(outs[0].buf, C.POINTER(C.c_float)), shape=(n,)).copy() + self._lib.rknn_outputs_release(self._ctx, 1, outs) + return emb.reshape(1, -1) + + def close(self): + if getattr(self, "_ctx", None) and self._ctx.value: + self._lib.rknn_destroy(self._ctx) + self._ctx = C.c_uint64(0) + + def __del__(self): # best effort + try: + self.close() + except Exception: + pass diff --git a/app/inference/rknpu_whisper_encoder.py b/app/inference/rknpu_whisper_encoder.py new file mode 100644 index 0000000..77e2639 --- /dev/null +++ b/app/inference/rknpu_whisper_encoder.py @@ -0,0 +1,131 @@ +"""ctypes-Binding auf librknnrt.so für den Whisper-turbo-Encoder auf der NPU (specs/009 Option C). + +Muster: rknpu_embed.py (specs/007, DEC-708 — kein rknn-toolkit-lite2, cp313-Lücke). +Läuft NUR auf dem R1 (librknnrt.so 2.3.2 + Kernel-Treiber 0.9.8); auf Windows nicht +importier-testbar, wie alles unter device/inference/. + +Modell: /opt/aime/models/asr/turbo-encoder-fp16.rknn (Fixed-Shape, +mel [1, 128, 3000] float32 → enc_out [1, 1500, 1280] float32 = ein 30-s-Fenster; +Konvertierung: aime-rknn:2.3.2-Docker auf dem PC, specs/009 T903). + +Integrations-Naht: faster-whisper `WhisperModel.encode()` ist die EINZIGE +Encoder-Stelle (auch für BatchedInferencePipeline); der Transcriber ersetzt sie +bei `transcription.compute: npu` durch run() + get_ctranslate2_storage. +Batch-Eingaben (B, 128, 3000) laufen als Schleife über das statische 1er-Modell. + +Nutzung: + e = RknnWhisperEncoder("/opt/aime/models/asr/turbo-encoder-fp16.rknn") + out = e.run(mel) # (B, 128, 3000) float32 → (B, 1500, 1280) float32 + e.close() +""" +import ctypes as C + +import numpy as np + +RKNN_SUCC = 0 +RKNN_TENSOR_FLOAT32 = 0 +RKNN_TENSOR_UNDEFINED_FMT = 3 # rknn_api.h: NCHW=0, NHWC=1, NC1HWC2=2, UNDEFINED=3 +RKNN_NPU_CORE_0_1_2 = 7 # alle 3 NPU-Kerne + +MEL_SHAPE = (128, 3000) +OUT_SHAPE = (1500, 1280) + + +class _RknnInput(C.Structure): + _fields_ = [ + ("index", C.c_uint32), + ("buf", C.c_void_p), + ("size", C.c_uint32), + ("pass_through", C.c_uint8), + ("type", C.c_int), + ("fmt", C.c_int), + ] + + +class _RknnOutput(C.Structure): + _fields_ = [ + ("want_float", C.c_uint8), + ("is_prealloc", C.c_uint8), + ("index", C.c_uint32), + ("buf", C.c_void_p), + ("size", C.c_uint32), + ] + + +class RknnWhisperEncoder: + """Eine geladene .rknn-Instanz; run() ist NICHT thread-safe (ein Kontext).""" + + def __init__(self, model_path: str, lib_path: str = "librknnrt.so", + core_mask: int = RKNN_NPU_CORE_0_1_2): + self._lib = C.CDLL(lib_path) + self._lib.rknn_init.argtypes = [C.POINTER(C.c_uint64), C.c_void_p, + C.c_uint32, C.c_uint32, C.c_void_p] + self._lib.rknn_set_core_mask.argtypes = [C.c_uint64, C.c_int] + self._lib.rknn_inputs_set.argtypes = [C.c_uint64, C.c_uint32, + C.POINTER(_RknnInput)] + self._lib.rknn_run.argtypes = [C.c_uint64, C.c_void_p] + self._lib.rknn_outputs_get.argtypes = [C.c_uint64, C.c_uint32, + C.POINTER(_RknnOutput), C.c_void_p] + self._lib.rknn_outputs_release.argtypes = [C.c_uint64, C.c_uint32, + C.POINTER(_RknnOutput)] + self._lib.rknn_destroy.argtypes = [C.c_uint64] + + with open(model_path, "rb") as f: + blob = f.read() + self._ctx = C.c_uint64(0) + buf = C.create_string_buffer(blob, len(blob)) + rc = self._lib.rknn_init(C.byref(self._ctx), buf, len(blob), 0, None) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_init rc={rc} (librknnrt/Treiber prüfen)") + if core_mask: # best effort — Speed-Sache, kein Korrektheits-Thema + self._lib.rknn_set_core_mask(self._ctx, core_mask) + + def _run_one(self, mel: np.ndarray) -> np.ndarray: + ins = (_RknnInput * 1)() + ins[0].index = 0 + ins[0].buf = mel.ctypes.data_as(C.c_void_p) + ins[0].size = mel.nbytes + ins[0].pass_through = 0 + ins[0].type = RKNN_TENSOR_FLOAT32 + ins[0].fmt = RKNN_TENSOR_UNDEFINED_FMT + rc = self._lib.rknn_inputs_set(self._ctx, 1, ins) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_inputs_set rc={rc}") + rc = self._lib.rknn_run(self._ctx, None) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_run rc={rc}") + outs = (_RknnOutput * 1)() + outs[0].want_float = 1 + outs[0].is_prealloc = 0 + outs[0].index = 0 + rc = self._lib.rknn_outputs_get(self._ctx, 1, outs, None) + if rc != RKNN_SUCC: + raise RuntimeError(f"rknn_outputs_get rc={rc}") + n = outs[0].size // 4 + flat = np.ctypeslib.as_array( + C.cast(outs[0].buf, C.POINTER(C.c_float)), shape=(n,)).copy() + self._lib.rknn_outputs_release(self._ctx, 1, outs) + return flat.reshape(OUT_SHAPE) + + def run(self, mel: np.ndarray) -> np.ndarray: + """(B, 128, 3000) oder (128, 3000) float32 → (B, 1500, 1280) float32.""" + mel = np.ascontiguousarray(mel, dtype=np.float32) + if mel.ndim == 2: + mel = mel[np.newaxis] + if mel.shape[1:] != MEL_SHAPE: + raise ValueError(f"mel shape {mel.shape} != (B,)+{MEL_SHAPE}") + out = np.empty((mel.shape[0],) + OUT_SHAPE, dtype=np.float32) + for i in range(mel.shape[0]): + out[i] = self._run_one(np.ascontiguousarray(mel[i])) + return out + + def close(self): + if getattr(self, "_ctx", None) and self._ctx.value: + self._lib.rknn_destroy(self._ctx) + self._ctx = C.c_uint64(0) + + def __del__(self): # best effort + try: + self.close() + except Exception: + pass diff --git a/app/inference/smoke_npu.py b/app/inference/smoke_npu.py new file mode 100644 index 0000000..c782cd5 --- /dev/null +++ b/app/inference/smoke_npu.py @@ -0,0 +1,23 @@ +import sys, time +import numpy as np +sys.path.insert(0, "/opt/callrec") +from inference.rknpu_whisper_encoder import RknnWhisperEncoder + +M = "/opt/callrec/models/turbo-encoder-fp16.rknn" +print("== init ==", flush=True) +t0 = time.time() +e = RknnWhisperEncoder(M, lib_path="/usr/lib/librknnrt.so") +print(f"init OK in {time.time()-t0:.1f}s", flush=True) + +rng = np.random.default_rng(9) +mel = (rng.standard_normal((1,128,3000))*0.5).astype(np.float32) +print("== run ==", flush=True) +t0 = time.time() +out = e.run(mel) +dt = time.time()-t0 +print(f"run OK in {dt:.2f}s out.shape={out.shape} dtype={out.dtype}", flush=True) +print(f"out mean={out.mean():.4f} std={out.std():.4f} min={out.min():.3f} max={out.max():.3f}", flush=True) +# second run (warm) timing +t0=time.time(); e.run(mel); print(f"warm run {time.time()-t0:.2f}s", flush=True) +e.close() +print("== done ==", flush=True) diff --git a/app/inference/transcribe_batched.py b/app/inference/transcribe_batched.py new file mode 100644 index 0000000..4655153 --- /dev/null +++ b/app/inference/transcribe_batched.py @@ -0,0 +1,58 @@ +#!/opt/aime-venv/bin/python +"""Batched NPU transcriber A/B test: wav -> JSON words on stdout. +large-v3-turbo decoder CPU (int8), encoder on RK3588 NPU (rknn fp16), +faster-whisper BatchedInferencePipeline (Fabio-style batched+NPU). +Usage: transcribe_batched.py [batch_size] +""" +import sys, json, time +import numpy as np + +WAV = sys.argv[1] +BATCH = int(sys.argv[2]) if len(sys.argv) > 2 else 8 +MODEL = "large-v3-turbo" +ROOT = "/opt/callrec/models/whisper" +RKNN = "/opt/callrec/models/turbo-encoder-fp16.rknn" +sys.path.insert(0, "/opt/callrec") + +from faster_whisper import WhisperModel, BatchedInferencePipeline +from faster_whisper.transcribe import get_ctranslate2_storage +from inference.rknpu_whisper_encoder import RknnWhisperEncoder + +t0 = time.time() +model = WhisperModel(MODEL, device="cpu", compute_type="int8", + download_root=ROOT, cpu_threads=4) +npu = RknnWhisperEncoder(RKNN, lib_path="/usr/lib/librknnrt.so") +def npu_encode(features): + feats = np.asarray(features, dtype=np.float32) + out = npu.run(feats) + return get_ctranslate2_storage(np.ascontiguousarray(out)) +model.encode = npu_encode + +batched = BatchedInferencePipeline(model=model) +t_load = time.time() - t0 + +t0 = time.time() +segments, info = batched.transcribe(WAV, language="de", beam_size=1, + batch_size=BATCH, word_timestamps=True) +words = [] +texts = [] +for seg in segments: + texts.append(seg.text.strip()) + for w in (seg.words or []): + words.append({ + "word": w.word.strip(), + "t_start_ms": int(round((w.start or 0) * 1000)), + "t_end_ms": int(round((w.end or 0) * 1000)), + "probability": round(w.probability, 3), + }) +t_trans = time.time() - t0 +out = { + "text": " ".join(texts).strip(), + "n_words": len(words), + "duration": round(float(info.duration or 0), 1), + "load_s": round(t_load, 1), + "transcribe_s": round(t_trans, 1), + "rtf": round(t_trans / max(float(info.duration or 0.1), 0.1), 3), + "batch": BATCH, +} +print(json.dumps(out, ensure_ascii=False)) diff --git a/app/inference/transcribe_cli.py b/app/inference/transcribe_cli.py new file mode 100644 index 0000000..6811188 --- /dev/null +++ b/app/inference/transcribe_cli.py @@ -0,0 +1,66 @@ +#!/opt/aime-venv/bin/python +"""CallRec NPU transcriber: wav -> JSON words on stdout. +large-v3-turbo decoder on CPU (int8), encoder on RK3588 NPU (rknn fp16). +Output matches what recorder.js consumes: {"text","words":[{word,t_start_ms,t_end_ms,probability}]}. +Usage: transcribe_cli.py [--cpu] (--cpu = no NPU, plain CPU encoder for A/B) +""" +import sys, json, time +import numpy as np + +WAV = sys.argv[1] +USE_NPU = "--cpu" not in sys.argv[2:] +import os +BATCH = int(os.environ.get("WHISPER_BATCH", "8")) +MODEL = "large-v3-turbo" +ROOT = "/opt/callrec/models/whisper" +RKNN = "/opt/callrec/models/turbo-encoder-fp16.rknn" +sys.path.insert(0, "/opt/callrec") + +from faster_whisper import WhisperModel, BatchedInferencePipeline + +t0 = time.time() +model = WhisperModel(MODEL, device="cpu", compute_type="int8", + download_root=ROOT, cpu_threads=4) + +if USE_NPU: + from faster_whisper.transcribe import get_ctranslate2_storage + from inference.rknpu_whisper_encoder import RknnWhisperEncoder + npu = RknnWhisperEncoder(RKNN, lib_path="/usr/lib/librknnrt.so") + def npu_encode(features): + feats = np.asarray(features, dtype=np.float32) + out = npu.run(feats) + return get_ctranslate2_storage(np.ascontiguousarray(out)) + model.encode = npu_encode + +engine = BatchedInferencePipeline(model=model) if BATCH > 1 else model +t_load = time.time() - t0 +t0 = time.time() +if BATCH > 1: + segments, info = engine.transcribe(WAV, language="de", beam_size=1, + batch_size=BATCH, word_timestamps=True) +else: + segments, info = engine.transcribe(WAV, language="de", beam_size=1, + word_timestamps=True, vad_filter=True) +words = [] +texts = [] +for seg in segments: + texts.append(seg.text.strip()) + for w in (seg.words or []): + words.append({ + "word": w.word.strip(), + "t_start_ms": int(round((w.start or 0) * 1000)), + "t_end_ms": int(round((w.end or 0) * 1000)), + "probability": round(w.probability, 3), + }) +t_trans = time.time() - t0 +out = { + "text": " ".join(texts).strip(), + "words": words, + "duration": round(float(info.duration or 0), 1), + "load_s": round(t_load, 1), + "transcribe_s": round(t_trans, 1), + "rtf": round(t_trans / max(float(info.duration or 0.1), 0.1), 3), + "compute": "npu" if USE_NPU else "cpu", + "batch": BATCH, +} +print(json.dumps(out, ensure_ascii=False)) diff --git a/app/inference/transcribe_daemon.py b/app/inference/transcribe_daemon.py new file mode 100644 index 0000000..4fae4e2 --- /dev/null +++ b/app/inference/transcribe_daemon.py @@ -0,0 +1,99 @@ +#!/opt/aime-venv/bin/python +"""CallRec persistent transcriber daemon. + +Loads the faster-whisper large-v3-turbo CT2 model (decoder, CPU) ONCE and keeps +it warm for the whole callrec lifetime — this kills the ~22s CT2 load + ~14s +import cost that the old per-call `transcribe_cli.py` paid on every recording. + +The NPU turbo ENCODER (rknn, 1.35 GB NPU memory) is acquired per request and +RELEASED right after, because the RK3588 NPU cannot hold the encoder and the +Qwen-3B LLM (rkllm_server) at the same time (proven: mali OOM). Freeing it here +lets rkllm load for the analysis that follows. The rknn file stays in the page +cache, so re-init after the first request is a few seconds, not the cold ~17s. + +Protocol (line based, like spk_embed / rkllm_server): + stdin : one absolute wav path per line + stdout: "READY" once, then per request "OK " or "ERR " +The JSON is base64-wrapped so newlines inside it never break the line protocol. +""" +import sys +import json +import base64 +import time + +import numpy as np + +sys.path.insert(0, "/opt/callrec") + +MODEL = "large-v3-turbo" +ROOT = "/opt/callrec/models/whisper" +RKNN = "/opt/callrec/models/turbo-encoder-fp16.rknn" + +from faster_whisper import WhisperModel +from faster_whisper.transcribe import get_ctranslate2_storage +from inference.rknpu_whisper_encoder import RknnWhisperEncoder + +# CT2 decoder loaded ONCE, stays warm. +_model = WhisperModel(MODEL, device="cpu", compute_type="int8", + download_root=ROOT, cpu_threads=4) + + +def transcribe(wav): + t_load = time.time() + npu = RknnWhisperEncoder(RKNN, lib_path="/usr/lib/librknnrt.so") + load_s = time.time() - t_load + + def npu_encode(features): + feats = np.asarray(features, dtype=np.float32) + return get_ctranslate2_storage(np.ascontiguousarray(npu.run(feats))) + + _model.encode = npu_encode + try: + t0 = time.time() + segments, info = _model.transcribe( + wav, language="de", beam_size=1, word_timestamps=True, vad_filter=True) + words = [] + texts = [] + for seg in segments: + texts.append(seg.text.strip()) + for w in (seg.words or []): + words.append({ + "word": w.word.strip(), + "t_start_ms": int(round((w.start or 0) * 1000)), + "t_end_ms": int(round((w.end or 0) * 1000)), + "probability": round(w.probability, 3), + }) + dur = float(info.duration or 0) + trans_s = time.time() - t0 + return { + "text": " ".join(texts).strip(), + "words": words, + "duration": round(dur, 1), + "load_s": round(load_s, 1), + "transcribe_s": round(trans_s, 1), + "rtf": round(trans_s / max(dur, 0.1), 3), + "compute": "npu", + } + finally: + # Release the NPU encoder so rkllm can load for the analysis step. + npu.close() + + +def main(): + print("READY", flush=True) + for line in sys.stdin: + wav = line.strip() + if not wav: + continue + try: + out = transcribe(wav) + payload = base64.b64encode( + json.dumps(out, ensure_ascii=False).encode("utf-8")).decode("ascii") + print("OK " + payload, flush=True) + except Exception as e: # noqa: BLE001 + msg = base64.b64encode(str(e).encode("utf-8")).decode("ascii") + print("ERR " + msg, flush=True) + + +if __name__ == "__main__": + main() diff --git a/app/inference/transcribe_daemon_cpu.py b/app/inference/transcribe_daemon_cpu.py new file mode 100644 index 0000000..54b3326 --- /dev/null +++ b/app/inference/transcribe_daemon_cpu.py @@ -0,0 +1,87 @@ +#!/opt/aime-venv/bin/python +"""CallRec CPU transcriber daemon (SHORT clips). + +Loads faster-whisper large-v3-turbo (CT2, CPU int8) ONCE and keeps it warm for +the whole callrec lifetime. Used for SHORT recordings where per-call model load +(~28-40s) dwarfs the transcription itself. CPU-ONLY — it never touches the NPU, +so it coexists with a resident rkllm_server (Qwen-3B): short clips need NO +NPU-encoder swap AND no model reload → a 10s clip drops from ~120s to ~20s. + +(The NPU turbo-encoder path stays in transcribe_cli.py for LONG clips, where the +2.4x encoder speedup beats the swap overhead.) + +Protocol (line based, like the NPU daemon / spk_embed / rkllm_server): + stdin : one absolute wav path per line + stdout: "READY" once, then per request "OK " or "ERR " +""" +import sys +import json +import base64 +import time +import os + +sys.path.insert(0, "/opt/callrec") + +MODEL = "large-v3-turbo" +ROOT = "/opt/callrec/models/whisper" +BATCH = int(os.environ.get("WHISPER_BATCH", "8")) + +from faster_whisper import WhisperModel, BatchedInferencePipeline + +# CT2 model loaded ONCE (CPU), stays warm. No NPU encoder override -> ct2 runs +# its own encoder on the CPU, so nothing here competes with rkllm on the NPU. +_model = WhisperModel(MODEL, device="cpu", compute_type="int8", + download_root=ROOT, cpu_threads=4) +_engine = BatchedInferencePipeline(model=_model) if BATCH > 1 else _model + + +def transcribe(wav): + t0 = time.time() + if BATCH > 1: + segments, info = _engine.transcribe( + wav, language="de", beam_size=1, batch_size=BATCH, word_timestamps=True) + else: + segments, info = _engine.transcribe( + wav, language="de", beam_size=1, word_timestamps=True, vad_filter=True) + words = [] + texts = [] + for seg in segments: + texts.append(seg.text.strip()) + for w in (seg.words or []): + words.append({ + "word": w.word.strip(), + "t_start_ms": int(round((w.start or 0) * 1000)), + "t_end_ms": int(round((w.end or 0) * 1000)), + "probability": round(w.probability, 3), + }) + dur = float(info.duration or 0) + trans_s = time.time() - t0 + return { + "text": " ".join(texts).strip(), + "words": words, + "duration": round(dur, 1), + "load_s": 0.0, + "transcribe_s": round(trans_s, 1), + "rtf": round(trans_s / max(dur, 0.1), 3), + "compute": "cpu-daemon", + } + + +def main(): + print("READY", flush=True) + for line in sys.stdin: + wav = line.strip() + if not wav: + continue + try: + out = transcribe(wav) + payload = base64.b64encode( + json.dumps(out, ensure_ascii=False).encode("utf-8")).decode("ascii") + print("OK " + payload, flush=True) + except Exception as e: # noqa: BLE001 + msg = base64.b64encode(str(e).encode("utf-8")).decode("ascii") + print("ERR " + msg, flush=True) + + +if __name__ == "__main__": + main() diff --git a/app/llm.js b/app/llm.js new file mode 100644 index 0000000..ab121f8 --- /dev/null +++ b/app/llm.js @@ -0,0 +1,478 @@ +// NPU LLM integration. Spawns the persistent rkllm_server (Qwen2.5-3B on the +// RK3588 NPU) ONCE and talks to it over the base64 line protocol. Inference is +// single-flight (the NPU handles one request at a time) via a promise chain. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import readline from 'node:readline'; + +const SERVER = '/opt/callrec/rkllm_server'; +const MODEL = process.env.RKLLM_MODEL || '/opt/rkllm-models/qwen2.5-3b.rkllm'; + +let proc = null; +let ready = null; // Promise resolved when the model printed READY +let rl = null; +let pending = null; // { resolve, reject } for the in-flight request +let chain = Promise.resolve(); // serializes requests + +function b64e(s) { return Buffer.from(s, 'utf8').toString('base64'); } +function b64d(s) { return Buffer.from(s, 'base64').toString('utf8'); } + +// Spawn one rkllm_server and return a promise that resolves when it prints +// READY. CRUCIAL: if the process dies before READY (e.g. rkllm_init fails with +// the transient NPU handle-alloc error "errno 14 Bad address" that happens when +// the whisper-turbo encoder only just released the NPU), the `ready` promise is +// REJECTED immediately — the old code only nulled the variable, so the awaiter +// hung the full 120s timeout, and a stale rejected `ready` then cascaded to +// every queued request (all 7 chunks "failed" in the same second → silent empty +// analysis). startServer() below retries this a few times. +function spawnServer() { + let settleReject = () => {}; + let done = false; + proc = spawn('taskset', ['-c', '4-7', SERVER, MODEL], { stdio: ['pipe', 'pipe', 'pipe'] }); + proc.stderr.on('data', d => process.stderr.write('[rkllm] ' + d)); + ready = new Promise((resolve, reject) => { + settleReject = reject; + const to = setTimeout(() => { if (!done) { done = true; reject(new Error('rkllm READY timeout')); } }, 60000); + rl = readline.createInterface({ input: proc.stdout }); + rl.on('line', line => { + if (line === 'READY') { if (!done) { done = true; clearTimeout(to); resolve(); } return; } + if (!pending) return; + const p = pending; pending = null; + if (line.startsWith('OK ')) p.resolve(b64d(line.slice(3))); + else p.reject(new Error(line.replace(/^ERR\s*/, '') || 'rkllm error')); + }); + }); + const cleanup = () => { + proc = null; ready = null; rl = null; + if (!done) { done = true; settleReject(new Error('rkllm beendet vor READY (init fehlgeschlagen)')); } + if (pending) { pending.reject(new Error('rkllm beendet')); pending = null; } + }; + proc.on('exit', cleanup); + proc.on('error', () => cleanup()); +} + +async function startServer() { + if (proc && ready) { await ready; return; } // already up (or coming up) + if (!fs.existsSync(MODEL)) throw new Error('LLM-Modell fehlt: ' + MODEL); + // rkllm_init can transiently fail right after the NPU-whisper encoder ran + // (NPU handle not released yet). A short wait + respawn recovers it — later + // loads succeed. Up to 5 attempts, ~4s apart. + let lastErr; + for (let attempt = 1; attempt <= 5; attempt++) { + try { spawnServer(); await ready; return; } + catch (e) { + lastErr = e; + try { if (proc) proc.kill('SIGKILL'); } catch {} + proc = null; ready = null; rl = null; + console.warn(`[rkllm] start ${attempt}/5 failed: ${e.message}${attempt < 5 ? ' — retry in 4s' : ''}`); + if (attempt < 5) await new Promise(r => setTimeout(r, 4000)); + } + } + throw lastErr || new Error('rkllm start fehlgeschlagen'); +} + +// Send one prompt, resolve with the raw completion string. Each request is +// serialized on `chain`, but a FAILED request must NOT poison the chain for the +// next one (otherwise one bad chunk fails all following chunks instantly). +function ask(prompt) { + const run = chain.then(async () => { + await startServer(); + return await new Promise((resolve, reject) => { + pending = { resolve, reject }; + proc.stdin.write(b64e(prompt) + '\n'); + }); + }); + chain = run.catch(() => {}); // next request starts from a clean, resolved chain + return run; +} + +export function status() { + return { model: MODEL, modelPresent: fs.existsSync(MODEL), running: !!proc }; +} + +// Free the ~3.1GB rkllm_server (NPU LLM) from RAM. Called before the NPU whisper +// transcriber runs: turbo rknn (1.35GB) + CT2 decoder alongside a resident rkllm +// OOM-killed the 7.7GB board. rkllm auto-respawns on the next analyze() +// (startServer), and analysis always follows transcription, so this just reloads +// the model a few seconds later. No-op if not running. +export function stopServer() { + if (!proc) return Promise.resolve(); + const p = proc; + return new Promise(resolve => { + let settled = false; + const done = () => { if (!settled) { settled = true; resolve(); } }; + p.once('exit', done); + try { p.kill('SIGTERM'); } catch { done(); return; } + setTimeout(() => { try { p.kill('SIGKILL'); } catch {} setTimeout(done, 500); }, 5000); + }); +} + +// Robustly pull the first {...} JSON object out of a model completion. +function extractJson(text) { + let t = text.replace(/```(json)?/gi, '').trim(); + const s = t.indexOf('{'), e = t.lastIndexOf('}'); + if (s === -1 || e === -1 || e < s) return null; + try { return JSON.parse(t.slice(s, e + 1)); } catch { return null; } +} + +// Normalize an LLM string array: trim, drop empties and the literal "null" the +// model sometimes emits as a string inside todo/participant lists. +function cleanList(arr) { + return (arr || []).map(x => { + // Gemma sometimes emits objects ({"aufgabe": "..."}) instead of strings — + // String() would persist "[object Object]"; use the string values instead. + if (x && typeof x === 'object') x = Object.values(x).filter(v => typeof v === 'string').join(' — '); + return String(x ?? '').trim().replace(/^[?¿\s]+|[?\s]+$/g, ''); + }).filter(s => s && !/^(null|none|keine|-|\[object Object\])$/i.test(s)); +} + +// Compose the persisted summary text from the structured analysis JSON: +// short overview paragraph + bullet lists for key points and decisions. +// Rendered with white-space:pre-line in the UI, so \n and • survive. +function composeSummary(j) { + const parts = []; + const s = (j.zusammenfassung || j.summary || '').toString().trim(); + if (s) parts.push(s); + const kp = cleanList(j.kernpunkte || j.punkte).map(x => '• ' + x).join('\n'); + if (kp) parts.push('Kernpunkte:\n' + kp); + const en = cleanList(j.entscheidungen).map(x => '• ' + x).join('\n'); + if (en) parts.push('Entscheidungen:\n' + en); + return parts.join('\n\n'); +} + +// Structured analysis. The transcript is ASR output and may contain recognition +// errors — the model must infer the intended words from the WHOLE context and use +// the correct terms everywhere (e.g. "Gekopressor" → "Kompressor"). +// NOTE: contains NO concrete real-word examples on purpose. The 3B model echoes +// any concrete noun from this instruction into the title/summary (once produced a +// phantom "Kompressor/Reifendruck" summary that was just an old prompt example). +// Keep the correction PATTERN abstract; the deterministic glossary handles the +// important real-word fixes (e.g. Aime). +const CORRECTION_HINT = +`WICHTIG: Die Spracherkennung erzeugt LAUTÄHNLICHE Verwechslungen. Jedes Wort, das kein gebräuchliches deutsches Wort ist oder als erfundenes Kompositum wirkt, ist ein FEHLER — ersetze es durch das lautähnliche, im Kontext sinnvolle ECHTE Wort; nutze dazu den GESAMTEN Gesprächskontext und wähle immer ein reales, gebräuchliches deutsches Wort. Rekonstruiere auch verstümmelte Aufforderungen: endet ein Satz auf ein loses trennbares Präfix (auf/ab/ein/aus/zu) zusammen mit "bitte" oder "mal", ergänze das gemeinte Verb. STRENG: Diese Anleitung ist nur Regelwerk — übernimm KEINE Wörter, Themen oder Beispiele aus dieser Anleitung in Titel, Zusammenfassung, Aufgaben oder Transkript. Schreibe ausschließlich über das, was im Transkript unten tatsächlich gesprochen wird.`; + +// Build an extra correction hint from the user's domain glossary (known proper +// nouns / terms the ASR mis-hears, e.g. "Aime" heard as "Amy"). Applied in the +// LLM correction step — the SAFE place (feeding a term list to whisper as an +// initial-prompt makes it echo-loop; verified). `glossary` = [{term, soundalikes}]. +function glossaryHint(glossary) { + const g = (glossary || []).filter(x => x && x.term && String(x.term).trim()); + if (!g.length) return ''; + const terms = g.map(x => String(x.term).trim()); + const maps = g.filter(x => x.soundalikes && String(x.soundalikes).trim()) + .map(x => `${String(x.soundalikes).split(',').map(s => `"${s.trim()}"`).filter(s => s !== '""').join('/')} → "${String(x.term).trim()}"`); + let s = `\nBEKANNTE EIGENNAMEN/FACHBEGRIFFE (feste, korrekte Schreibweise — im Text IMMER exakt so schreiben): ${terms.join(', ')}. Wenn ein lautähnliches Wort im Transkript einem dieser Begriffe entspricht, ersetze es durch die korrekte Schreibweise. Erfinde diese Begriffe aber NICHT hinzu, wo sie nicht gemeint sind.`; + if (maps.length) s += ` Bekannte Verwechslungen: ${maps.join('; ')}.`; + return s; +} + +// Combine the base correction rule with the per-recording glossary. +const correctionHint = (glossary) => CORRECTION_HINT + glossaryHint(glossary); + +// Shared todo-extraction rule (used verbatim in the combined suffix AND the +// JSON-only fallback so the two never drift). Tuned for RECALL: the 3B model +// was dropping clear work assignments under an over-cautious "im Zweifel leer" +// rule, so this frames todos positively (imperative/assignment/commitment with a +// concrete action+object) while still excluding smalltalk and child play. +const TODO_RULE = +`"todos" = nur konkret angewiesene oder fest zugesagte Aufgaben, die WÖRTLICH IM TRANSKRIPT VORKOMMEN, jeweils als kurzer Imperativ. Eine Aufgabe liegt vor, sobald im Transkript eine konkrete Handlung mit klarem Objekt angewiesen ("du machst/sortierst/schneidest X"; "X muss gemacht werden") oder fest zugesagt wird — auch beiläufig oder höflich ("räum mir bitte mal X auf"). Nimm jeden im Transkript klar genannten Auftrag auf; lasse ihn NICHT weg. KEINE Aufgaben sind: Smalltalk, Fragen, Begrüßung/Verabschiedung, allgemeine Aussagen ohne Handlung, kindliches Spiel und leere Meta-Aussagen. WICHTIG: Wenn im Transkript keine konkrete Aufgabe angewiesen oder zugesagt wird, gib "todos": [] zurück. Erfinde NIEMALS Aufgaben und übernimm KEINE Wörter oder Beispiele aus dieser Anleitung — jede Aufgabe muss durch eine konkrete Stelle im Transkript belegt sein. Niemals Sprecher-Namen oder "Sprecher X:" in eine Aufgabe schreiben.`; + +// Anti-hallucination rule for the FREE-TEXT fields (title/summary). The 3B model +// greedily coins fake compound words when compressing (e.g. "Audio-Dualeintragung"). +// Append to every summary-producing prompt. +const NO_INVENT = +`WICHTIG (keine Halluzination): Verwende in Titel und Zusammenfassung AUSSCHLIESSLICH reale, gebräuchliche deutsche Wörter und NUR Informationen, die im Transkript tatsächlich vorkommen. Erfinde NICHTS — keine Wörter, keine zusammengesetzten Fantasiebegriffe (z.B. NIEMALS Erfindungen wie "Audio-Dualeintragung"), keine Fakten, keine Namen, keine Zahlen. Bist du dir bei einem Wort unsicher, formuliere einfacher mit gebräuchlichen Wörtern statt zu raten. Halte die Zusammenfassung schlicht und nah am Gesagten.`; + +// Runs on the ALREADY-CORRECTED transcript, so every field is consistent. +const PROMPT_JSON = (transcript) => +`Du analysierst das Transkript eines deutschen Gesprächs. + +Transkript: +""" +${transcript} +""" + +Gib AUSSCHLIESSLICH gültiges JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern: +{"titel": "kurzer prägnanter Titel, max. 6 Wörter", "zusammenfassung": "2-4 Sätze, worum es ging", "teilnehmer": ["Name oder Rolle je beteiligter Person"], "todos": ["kurze Handlungsanweisung im Imperativ"]} + +Regeln: Antworte auf Deutsch. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle wie "Sprecher"); [] wenn unklar. +${TODO_RULE} +${NO_INVENT}`; + +// Whole-conversation analysis prompt (16K-context path). The model sees the FULL +// transcript, reads through ASR errors via `hint`, and returns one rich JSON +// analysis. Richer summary than the short/chunk prompts (the whole arc is +// visible) but still analysis-only → small output. +const PROMPT_WHOLE_ANALYZE = (transcript, hint = CORRECTION_HINT) => +`Du analysierst das VOLLSTÄNDIGE Transkript eines deutschen Gesprächs aus automatischer Spracherkennung. ${hint} + +Lies das GESAMTE Gespräch und gib AUSSCHLIESSLICH gültiges JSON zurück (kein Markdown, keine Erklärung) mit genau diesen Feldern: +{"titel": "kurzer prägnanter Titel, max. 6 Wörter", "zusammenfassung": "kurzer Überblick in 2-4 Sätzen: worum es ging und was das Ergebnis war", "kernpunkte": ["wichtiger besprochener Punkt als kurzer Stichpunkt, je ein Punkt pro Thema"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer Satz"], "teilnehmer": ["Name oder Rolle je beteiligter Person"], "todos": ["kurze Handlungsanweisung im Imperativ"]} +Antworte auf Deutsch. Fülle alle Felder mit ECHTEM Inhalt aus dem Gespräch; schreibe niemals Feldbeschreibungen ab. "kernpunkte" = 3-8 Stichpunkte über ALLE wichtigen Themen; "entscheidungen" = nur wirklich getroffene Entscheidungen, [] wenn keine. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle); [] wenn unklar. +${TODO_RULE} +${NO_INVENT} + +Transkript: +""" +${transcript} +"""`; + +// Separate plain-text call for the cleaned transcript — avoids escaping a huge +// string inside JSON (fragile). Returns just the corrected text. +// Corrects ASR errors AND splits the single mixed mono transcript into a +// speaker-labelled dialogue by CONTEXT (this is text inference, not acoustic +// voice recognition). Output is plain "Name: Text" lines — robust to parse. +const PROMPT_CLEAN = (transcript, hint = CORRECTION_HINT) => +`Das folgende deutsche Transkript stammt aus automatischer Spracherkennung eines Gesprächs mit möglicherweise MEHREREN Sprechern (in einer Tonspur gemischt). ${hint} + +Aufgabe: Gib das korrigierte Gespräch als DIALOG zurück. Erkenne aus dem Kontext (Anrede, Fragen und Antworten, genannte Namen, Themen- und Perspektivwechsel) die Sprecherwechsel und ordne jede Aussage einem Sprecher zu. Verwende genannte Namen (z.B. "Michael"); wenn ein Name unbekannt ist, "Sprecher 1", "Sprecher 2". Wenn nur eine Person spricht, nutze durchgehend ein Label. + +Format: pro Redebeitrag GENAU EINE Zeile im Format "Name: Text". Keine Einleitung, keine Erklärung, kein Markdown, keine Leerzeilen. + +Transkript: +${transcript}`; + +// When acoustic diarization already labelled the turns ("Sprecher 1: …" lines), +// the LLM must NOT re-guess speakers — only fix ASR errors and keep the labels. +const PROMPT_CLEAN_LABELED = (transcript, hint = CORRECTION_HINT) => +`Das folgende deutsche Gespräch ist bereits nach Sprechern gegliedert (Zeilen im Format "Sprecher X: Text"), die Zuordnung stammt aus Stimm-Erkennung und ist korrekt. ${hint} + +Aufgabe: Gib den Text korrigiert zurück und BEHALTE die Sprecher-Labels und die Zeilenstruktur EXAKT bei (dieselben Labels, dieselbe Reihenfolge). Korrigiere nur die Wörter, ändere keine Sprecher-Zuordnung. Keine Einleitung, kein Markdown. + +Gespräch: +${transcript}`; + +// Appended to the clean prompts so correction + analysis happen in ONE NPU call +// (saves a full prompt prefill and one round trip vs. the old two-call flow). +// The corrected dialogue comes FIRST so the JSON fields are grounded in it. +const COMBINED_SUFFIX = +` + +DANACH, nach dem korrigierten Gespräch, gib eine Zeile aus, die exakt so lautet: +===ANALYSE=== +und direkt darunter AUSSCHLIESSLICH ein gültiges JSON-Objekt (beginnend mit { und endend mit }, kein Markdown, keine Erklärung) in genau dieser Form: +{"titel": "...", "zusammenfassung": "...", "teilnehmer": ["..."], "todos": ["..."]} +Bedeutung: "titel" = max. 6 Wörter; "zusammenfassung" = 2-4 Sätze über den Gesprächsinhalt; "teilnehmer" = Name oder Rolle je beteiligter Person; "todos" = kurze Handlungsanweisungen im Imperativ. +Fülle alle Felder mit ECHTEM INHALT aus dem Gespräch — schreibe niemals Feldbeschreibungen oder "..." ab. +Regeln: Antworte auf Deutsch. "teilnehmer" = im Gespräch genannte/erkennbare Personen (Namen wenn genannt, sonst Rolle wie "Sprecher"); [] wenn unklar. +${TODO_RULE} +${NO_INVENT}`; + +// Split a combined completion into the corrected dialogue and the JSON tail. +// Falls back to locating the JSON by its "titel" key when the marker is missing. +function splitCombined(out) { + const m = out.search(/^\s*=+\s*ANALYSE\s*=+\s*$/im); + if (m !== -1) { + const rest = out.slice(m); + return { clean: out.slice(0, m), json: rest.slice(rest.indexOf('\n') + 1) }; + } + const ti = out.indexOf('"titel"'); + if (ti !== -1) { + const bi = out.lastIndexOf('{', ti); + if (bi !== -1) return { clean: out.slice(0, bi), json: out.slice(bi) }; + } + return { clean: '', json: out }; +} + +// ---- Long-transcript support --------------------------------------------- +// The NPU model has a FIXED 4096-token context (set at model-conversion time, +// not raisable at runtime). The combined clean+analyze call also GENERATES +// ~as many tokens as the cleaned dialogue, so one call is only safe for short +// transcripts. Longer ones are processed in chunks (map: clean + partial +// analysis per chunk) and then reduced (final title/summary from the partial +// summaries; todos/participants merged). Works for ANY length. +const estTokens = (s) => Math.ceil((s || '').length / 3.3); // ~German chars per token +// Long path is ANALYSIS-ONLY per chunk (tiny JSON output), so the prompt can be +// large — only the prefill grows, which is cheap on the NPU. Output tokens (the +// slow part, ~5-8 tok/s) stay ~120/chunk. Big chunks → fewer chunks → faster. +const CHUNK_CHARS = 8000; // small chunks: Gemma emits valid JSON on short inputs, fails on big ones +const SINGLE_MAX_CHARS = 4800; // below this: single combined call (full cleaning) +// The 16K-converted Gemma model is RUN at an 8192 runtime context (rkllm_server +// max_context_len) to cap KV-cache RAM on the 8GB board. The WHOLE conversation +// still fits in ONE analysis-only call → coherent summary (vs the map-reduce +// chunking below, which lost cross-chunk coherence → the "Unsinn" long summary). +// Budget: 8192 ctx − 1536 gen ≈ 6656 tok prompt ≈ 21900 chars; 20000 leaves +// margin. Longer recordings still fall back to chunking. Raise together with the +// server's max_context_len if board RAM headroom allows. +const WHOLE_MAX_CHARS = 20000; + +// Split transcript into <=maxChars pieces on natural boundaries (labeled lines +// first, else sentences, else hard char cut) so no chunk splits mid-word. +function chunkText(text, maxChars) { + const units = text.includes('\n') ? text.split(/\n/) : text.split(/(?<=[.!?…])\s+/); + const chunks = []; + let cur = ''; + const flush = () => { if (cur.trim()) chunks.push(cur.trim()); cur = ''; }; + for (let u of units) { + while (u.length > maxChars) { flush(); chunks.push(u.slice(0, maxChars)); u = u.slice(maxChars); } + if (cur && (cur.length + 1 + u.length) > maxChars) flush(); + cur += (cur ? '\n' : '') + u; + } + flush(); + return chunks.length ? chunks : [text]; +} + +// Reduce the per-chunk partial summaries into ONE final title + summary. Input +// is small (a few 2-4 sentence summaries) so this call always fits the context. +const PROMPT_REDUCE = (summaries) => +`Das sind Teil-Zusammenfassungen aufeinanderfolgender Abschnitte EINES langen deutschen Gesprächs (in Reihenfolge; manche evtl. auf Englisch): +""" +${summaries} +""" + +Erstelle daraus EINE zusammenhängende deutsche Gesamt-Zusammenfassung des GANZEN Gesprächs. Decke ALLE Themen, Entscheidungen und Ergebnisse aus ALLEN Abschnitten ab — lasse kein wichtiges Thema weg, konzentriere dich nicht auf ein einzelnes. Nenne konkrete Namen, Rollen und Entscheidungen. +Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }. +{"titel": "prägnanter deutscher Titel, max. 6 Wörter, Hauptthema", "zusammenfassung": "kurzer deutscher Überblick über das GESAMTE Gespräch in 2-4 Sätzen", "kernpunkte": ["wichtiger besprochener Punkt als kurzer deutscher Stichpunkt, je ein Punkt pro Thema, ALLE Abschnitte abdecken"], "entscheidungen": ["im Gespräch getroffene Entscheidung als kurzer deutscher Satz; leere Liste wenn keine"]}`; + +const uniqStr = (arr) => [...new Set((arr || []).map(s => String(s).trim()).filter(Boolean))]; + +// Per-chunk analysis prompt for the long path. MINIMAL on purpose: Gemma-3-4B +// ignores long walls of rules on big inputs and answers with chatty English +// prose instead of JSON (4/5 chunks PARSE_FAIL'd → narrow summary). A short, +// JSON-forcing, German-forcing prompt with the format LAST (recency) makes it +// obey. No CORRECTION_HINT/TODO_RULE/NO_INVENT here — they triggered the chatter. +const PROMPT_CHUNK_ANALYZE = (transcript, hint = CORRECTION_HINT) => +`Abschnitt eines deutschen Gesprächs (aus Spracherkennung, evtl. Hörfehler — lies sinngemäß): +""" +${transcript} +""" + +Fasse NUR DIESEN Abschnitt zusammen. Nenne ALLE besprochenen Themen und Entscheidungen (nicht nur eines), mit konkreten Namen/Rollen. +Antworte auf DEUTSCH und AUSSCHLIESSLICH mit einem JSON-Objekt, ohne Text davor oder danach, ohne Markdown. Beginne mit { und ende mit }. +{"zusammenfassung": "3-6 deutsche Sätze über alle Themen dieses Abschnitts", "teilnehmer": ["Name oder Rolle"], "todos": ["konkret zugesagte Aufgabe im Imperativ; Grußformeln sind KEINE Aufgabe"]}`; + +// Analyze a transcript → { title, summary, participants[], todos[], transcriptClean }. +// `labeled` = the text already carries acoustic "Sprecher X:" turn labels. +export async function analyze(transcript, { labeled = false, glossary = [] } = {}) { + const raw = (transcript || '').trim(); + if (!raw) return { title: 'Leere Aufnahme', summary: '', participants: [], todos: [], transcriptClean: '' }; + const hint = correctionHint(glossary); + if (raw.length > WHOLE_MAX_CHARS) return analyzeLong(raw, hint); + if (raw.length > SINGLE_MAX_CHARS) return analyzeWhole(raw, hint); + return analyzeSingle(raw, labeled, hint); +} + +// Whole-transcript analysis in ONE call (16K-context model). Reads the ENTIRE +// conversation at once → summary/todos/participants are grounded in the full +// context (unlike analyzeLong's per-chunk map-reduce, which could not see across +// chunk boundaries → incoherent long summaries). Analysis-ONLY (small JSON out), +// no full-dialogue rewrite → decode stays short. transcriptClean left empty on +// purpose: the detail view renders diarized turns from `words`. +async function analyzeWhole(raw, hint = CORRECTION_HINT) { + const out = await ask(PROMPT_WHOLE_ANALYZE(raw, hint)); + let j = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`); + if (!j) throw new Error('LLM lieferte kein gültiges JSON (Gesamt-Analyse)'); + return { + title: (j.titel || j.title || '').toString().trim().slice(0, 120) || null, + summary: composeSummary(j), + participants: cleanList(j.teilnehmer || j.participants), + todos: cleanList(j.todos), + transcriptClean: '', + }; +} + +// Single-call path (short transcripts): one combined NPU call = corrected +// dialogue + JSON analysis. PROMPT_JSON alone is the fallback if it doesn't parse. +async function analyzeSingle(raw, labeled, hint = CORRECTION_HINT) { + const cleanPrompt = labeled ? PROMPT_CLEAN_LABELED : PROMPT_CLEAN; + const out = await ask(cleanPrompt(raw, hint) + COMBINED_SUFFIX); + const parts = splitCombined(out); + let transcriptClean = parts.clean + .replace(/^```.*$/gm, '') + .replace(/^\s*(Gespräch|Dialog|Transkript|Hier[^\n:]*):\s*$/gim, '') // strip echoed prompt headers + .trim(); + // Labeled input is already a usable dialogue — better than showing nothing + // when the model skipped straight to the JSON part. + if (!transcriptClean && labeled) transcriptClean = raw; + // Guard against the model echoing the field descriptions instead of filling + // them (the 3B model does this occasionally in the combined call) — then a + // dedicated JSON-only call is worth the extra ~10s. + const echoed = (o) => !o || /prägnanter Titel|worum es ging|Sätze,|^\.{3}$/.test(`${o.titel || o.title || ''} ${o.zusammenfassung || o.summary || ''}`); + // The model sometimes emits the fields without the enclosing braces — wrap and retry. + let j = extractJson(parts.json) || extractJson(`{${parts.json.replace(/^[\s,]+|[\s,]+$/g, '')}}`); + if (echoed(j)) { + console.log(`llm: combined JSON ${j ? 'echoed field descriptions' : 'unparseable'} -> separate JSON call; tail: ${JSON.stringify(out.slice(-250))}`); + j = extractJson(await ask(PROMPT_JSON(transcriptClean || raw))); + } + if (!j) throw new Error('LLM lieferte kein gültiges JSON'); + return { + title: (j.titel || j.title || '').toString().trim().slice(0, 120) || null, + summary: composeSummary(j), + participants: cleanList(j.teilnehmer || j.participants), + todos: cleanList(j.todos), + transcriptClean, + }; +} + +// Chunked map-reduce path (long transcripts). MAP: analyse each chunk into a +// small JSON (summary/participants/todos) — NO full-dialogue rewrite, so NPU +// decode stays short (~120 tok/chunk vs ~1300 for cleaning → minutes not tens of +// minutes). REDUCE: merge participants/todos and derive one final title+summary. +// transcriptClean is left empty on purpose: the detail view renders the +// diarized turns from `words`, and re-emitting the whole transcript through the +// NPU is exactly the slow step we avoid here. No fixed length limit. +async function analyzeLong(raw, hint = CORRECTION_HINT) { + const chunks = chunkText(raw, CHUNK_CHARS); + console.log(`llm: long transcript (${raw.length} chars, ~${estTokens(raw)} tok) -> ${chunks.length} chunks (analyse-only)`); + const summaries = []; + let participants = []; + let todos = []; + let okChunks = 0; + let lastErr = null; + for (let i = 0; i < chunks.length; i++) { + let j = null, rawOut = ''; + try { + rawOut = await ask(PROMPT_CHUNK_ANALYZE(chunks[i], hint)); + j = extractJson(rawOut) || extractJson(`{${rawOut.replace(/^[\s,]+|[\s,]+$/g, '')}}`); + okChunks++; + console.log(`llm: chunk ${i + 1} parsed: ${j ? 'JSON ok' : 'PARSE_FAIL(salvage prose)'}`); + } catch (e) { lastErr = e; console.log(`llm: chunk ${i + 1} failed: ${e.message}`); } + if (j) { + const s = (j.zusammenfassung || j.summary || '').toString().trim(); + if (s) summaries.push(s); + const pp = j.teilnehmer || j.participants; + if (Array.isArray(pp)) participants.push(...pp.map(String)); + if (Array.isArray(j.todos)) todos.push(...j.todos.map(String)); + } else if (rawOut) { + // JSON parse failed (Gemma answered in prose) → salvage the prose so this + // chunk's content still reaches the (German-forcing) reduce, instead of + // being dropped (which collapsed the summary to a single lucky chunk). + // Strip any malformed {...} blob first — raw JSON in a salvage leaked + // verbatim into the final summary once (#69). + const prose = rawOut.replace(/```[a-z]*|```/gi, '').replace(/\{[\s\S]*\}/g, ' ') + .replace(/^\s*(Okay|Sure|Here'?s|Certainly)[^.\n]*[.:\n]/i, '').trim(); + if (prose) summaries.push(prose.slice(0, 1200)); + } + console.log(`llm: chunk ${i + 1}/${chunks.length} done (summaries so far: ${summaries.length})`); + } + console.log(`llm: REDUCE input (${summaries.length} summaries): ${JSON.stringify(summaries.join(' || ').slice(0, 500))}`); + // Every chunk failed (e.g. rkllm never came READY) → do NOT persist a fake + // "done" with a generic "Gespräch" title; propagate so the row is marked + // 'error' and the UI shows a retry (this was the silent-failure bug). + if (okChunks === 0) throw lastErr || new Error('LLM: alle Chunks fehlgeschlagen'); + participants = uniqStr(cleanList(participants)); + todos = uniqStr(cleanList(todos)); + // Final title + summary from the (small) partial summaries. + let red = null; + if (summaries.length) { + // Gemma answers big inputs with prose instead of JSON (same failure as the + // chunk step) → cap each partial summary and retry the reduce once; the + // #69 run lost title+structure because the single reduce didn't parse. + const reduceInput = summaries.map(s => s.slice(0, 800)).join('\n\n'); + for (let attempt = 1; attempt <= 2 && !red; attempt++) { + try { + const out = await ask(PROMPT_REDUCE(reduceInput)); + red = extractJson(out) || extractJson(`{${out.replace(/^[\s,]+|[\s,]+$/g, '')}}`); + if (!red) console.log(`llm: reduce attempt ${attempt} unparseable; tail: ${JSON.stringify(out.slice(-200))}`); + } catch (e) { console.log(`llm: reduce attempt ${attempt} failed:`, e.message); } + } + } + const fallbackTitle = (summaries[0] || '').split(/[.!?\n]/)[0].trim().slice(0, 60); + return { + title: (red?.titel || red?.title || '').toString().trim().slice(0, 120) || fallbackTitle || 'Gespräch', + summary: (red ? composeSummary(red) : '') || summaries.join('\n\n').slice(0, 2500), + participants, + todos, + transcriptClean: '', + }; +} diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..e4086bb --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,885 @@ +{ + "name": "callrec", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "callrec", + "version": "1.0.0", + "dependencies": { + "express": "^4.19.2", + "mysql2": "^3.11.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/mysql2": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..147769f --- /dev/null +++ b/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "callrec", + "version": "1.0.0", + "type": "module", + "scripts": { "start": "node server.js" }, + "dependencies": { + "express": "^4.19.2", + "mysql2": "^3.11.0" + } +} diff --git a/app/recorder.js b/app/recorder.js new file mode 100644 index 0000000..68bd634 --- /dev/null +++ b/app/recorder.js @@ -0,0 +1,391 @@ +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import * as diar from './diar.js'; +import * as llm from './llm.js'; +const pexec = promisify(execFile); + +const WHISPER = '/opt/whisper.cpp/build/bin/whisper-cli'; +// medium since 2026-07-08: user reported small mis-hears words on the USB-headset +// call audio (cleaner than the old BT-mSBC, so the extra accuracy now pays off). +// ~2x slower than small + more heat on long recordings (thermal throttle >85 C) — +// acceptable trade for accuracy. Override with WHISPER_MODEL to switch back. +const MODEL = process.env.WHISPER_MODEL || '/opt/whisper.cpp/models/ggml-medium.bin'; +// NPU turbo transcriber: large-v3-turbo ENCODER on the RK3588 NPU (rknn fp16), +// decoder on CPU. Benchmarked ~2.4x faster than whisper.cpp medium on the R1 +// (RTF 0.64 vs 1.51) and the encoder runs off-CPU -> far less A76 heat. The +// Python CLI emits {text, words:[{word,t_start_ms,t_end_ms}]}. Set TRANSCRIBER=cpu +// to fall back to whisper.cpp medium (the block below). +const TRANSCRIBER = process.env.TRANSCRIBER || 'npu'; +const NPU_PY = '/opt/aime-venv/bin/python'; +const NPU_CLI = '/opt/callrec/inference/transcribe_cli.py'; +// SHORT clips (<= SHORT_SEC): a warm CPU daemon + resident rkllm instead of +// the NPU path, whose per-call model load (~28-40s) + rkllm swap (~40s) dwarf +// the actual work on a tiny clip. See the daemon client below. +const TD_DAEMON = '/opt/callrec/inference/transcribe_daemon_cpu.py'; +const SHORT_SEC = +(process.env.SHORT_TRANSCRIBE_SEC || 30); +const REC_DIR = '/opt/callrec/recordings'; +fs.mkdirSync(REC_DIR, { recursive: true }); + +// PipeWire/Pulse runs in the callrec user session; the systemd unit already +// sets XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS, so just inherit the env. +function penv() { return process.env; } + +// --- Thermal safety for whisper on the fanless board ----------------------- +// A 79-min recording transcribed with ggml-medium pinned to all 4 A76 cores at +// max clock drove the big cores to CRITICAL temp -> the kernel did a HARDWARE +// PROTECTION shutdown (auto-reboot), losing the in-flight recording. Two guards: +// 1. cap the A76 max clock while transcribing (biggest heat lever), and +// 2. a hard backstop that SIGSTOPs whisper if the big cores still cross 90 C +// and SIGCONTs it once they fall back under 78 C. +const A76_MAX = 2256000; // restore-to clock (from scaling_available_frequencies) +const A76_CAP = 1608000; // 1.6 GHz cap during whisper +const A76_CAP_NPU = 1800000; // relaxed 1.8 GHz for the NPU decoder (encoder is off-CPU; + // measured only ~2% slower at 1.6 but ~20 C cooler than full 2.25) +const TEMP_ZONES = ['/sys/class/thermal/thermal_zone1/temp', // bigcore0 + '/sys/class/thermal/thermal_zone2/temp']; // bigcore1 +const TEMP_PAUSE = 90000; // >90 C -> pause whisper +const TEMP_RESUME = 78000; // <78 C -> resume + +function readMaxBigTemp() { + let m = 0; + for (const z of TEMP_ZONES) { + try { const v = parseInt(fs.readFileSync(z, 'utf8'), 10); if (v > m) m = v; } catch {} + } + return m; +} +// callrec has a NOPASSWD sudo rule for exactly /opt/callrec/aime-cpucap.sh. +function setA76Max(freq) { + return new Promise(res => execFile('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', String(freq)], () => res())); +} + +// Spawn whisper on the A76 cores with the SIGSTOP/SIGCONT temp backstop. The A76 +// CLOCK CAP is NOT managed here — the caller (transcribeWav) holds it across the +// WHOLE heavy pipeline (whisper + diarization), because diarization also pins the +// A76 cores and crashed the board when it ran at full clock after whisper's cap +// was released. Rejects on non-zero exit. +async function runWhisperGuarded(args) { + let guard = null, paused = false; + try { + await new Promise((resolve, reject) => { + const p = spawn('taskset', ['-c', '4-7', WHISPER, ...args], { stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + p.stderr.on('data', d => { err += d; if (err.length > (1 << 20)) err = err.slice(-(1 << 19)); }); + guard = setInterval(() => { + const t = readMaxBigTemp(); + if (!paused && t >= TEMP_PAUSE) { + try { process.kill(p.pid, 'SIGSTOP'); paused = true; console.warn(`whisper PAUSED (A76 ${t / 1000} C)`); } catch {} + } else if (paused && t > 0 && t <= TEMP_RESUME) { + try { process.kill(p.pid, 'SIGCONT'); paused = false; console.warn(`whisper resumed (A76 ${t / 1000} C)`); } catch {} + } + }, 3000); + p.on('error', reject); + p.on('close', code => code === 0 ? resolve() : reject(new Error('whisper exit ' + code + ': ' + err.slice(-400)))); + }); + } finally { + if (guard) clearInterval(guard); + } +} + +// Run the NPU turbo transcriber (Python) under the same A76 temp backstop as +// whisper. taskset execs python -> p.pid IS python, so SIGSTOP/SIGCONT reach it. +// Returns the parsed CLI JSON. The A76 CLOCK CAP is held by the caller. +async function runNpuTranscribe(wavPath) { + let guard = null, paused = false, out = '', err = ''; + try { + await new Promise((resolve, reject) => { + const p = spawn('taskset', ['-c', '4-7', NPU_PY, NPU_CLI, wavPath], + { stdio: ['ignore', 'pipe', 'pipe'], cwd: '/opt/callrec' }); + p.stdout.on('data', d => { out += d; }); + p.stderr.on('data', d => { err += d; if (err.length > (1 << 20)) err = err.slice(-(1 << 19)); }); + guard = setInterval(() => { + const t = readMaxBigTemp(); + if (!paused && t >= TEMP_PAUSE) { + try { process.kill(p.pid, 'SIGSTOP'); paused = true; console.warn(`transcribe PAUSED (A76 ${t / 1000} C)`); } catch {} + } else if (paused && t > 0 && t <= TEMP_RESUME) { + try { process.kill(p.pid, 'SIGCONT'); paused = false; console.warn(`transcribe resumed (A76 ${t / 1000} C)`); } catch {} + } + }, 3000); + p.on('error', reject); + p.on('close', code => code === 0 ? resolve() : reject(new Error('npu-transcribe exit ' + code + ': ' + err.slice(-400)))); + }); + } finally { + if (guard) clearInterval(guard); + } + const line = out.trim(); + if (!line) throw new Error('npu-transcribe: leere Ausgabe. stderr: ' + err.slice(-400)); + return JSON.parse(line); +} + +// --- Warm CPU transcriber daemon (SHORT clips) ----------------------------- +// large-v3-turbo CT2 on the CPU, loaded ONCE and kept warm. CPU-only, so it +// coexists with a resident rkllm_server on the NPU (no memory-wall conflict): +// a short clip needs neither a per-call model load nor an rkllm swap. Lazy- +// spawned on the first short clip, then persistent. base64 line protocol. +let tdProc = null, tdReady = null, tdRl = null, tdPending = null, tdChain = Promise.resolve(); +function startTd() { + if (tdProc) return tdReady; + tdProc = spawn('taskset', ['-c', '4-7', NPU_PY, TD_DAEMON], + { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/opt/callrec', env: { ...process.env, WHISPER_BATCH: process.env.WHISPER_BATCH || '1' } }); + tdProc.stderr.on('data', () => {}); // onnxruntime GPU-probe warnings, ignore + tdReady = new Promise((res, rej) => { + const to = setTimeout(() => rej(new Error('transcribe-daemon READY timeout')), 120000); + tdRl = readline.createInterface({ input: tdProc.stdout }); + tdRl.on('line', line => { + if (line.startsWith('READY')) { clearTimeout(to); res(); return; } + if (!tdPending) return; + const p = tdPending; tdPending = null; + if (line.startsWith('OK ')) { try { p.resolve(JSON.parse(Buffer.from(line.slice(3), 'base64').toString('utf8'))); } catch (e) { p.reject(e); } } + else p.reject(new Error(Buffer.from(line.slice(4), 'base64').toString('utf8') || 'daemon error')); + }); + }); + const cleanup = () => { tdProc = null; tdReady = null; tdRl = null; if (tdPending) { tdPending.reject(new Error('transcribe-daemon beendet')); tdPending = null; } }; + tdProc.on('exit', cleanup); tdProc.on('error', cleanup); + return tdReady; +} +// One request at a time (single warm process), chained like spk.embed. +function runDaemonTranscribe(wavPath) { + tdChain = tdChain.then(async () => { + await startTd(); + return await new Promise((res, rej) => { tdPending = { resolve: res, reject: rej }; tdProc.stdin.write(wavPath + '\n'); }); + }); + return tdChain; +} +// Wav duration from file size (16 kHz mono s16 = 32000 B/s, 44 B header). +function wavDurationSec(wavPath) { + try { return Math.max(0, (fs.statSync(wavPath).size - 44) / 32000); } catch { return 0; } +} + +let current = null; // { proc, wavPath, startTs, source, title } + +export function status() { + return current ? { recording: true, startedAt: current.startTs, source: current.source } : { recording: false }; +} +export function isRecording() { return !!current; } + +// List real capture devices (one entry per recording device). A "source" in +// PulseAudio/PipeWire includes monitor sources (the loopback of every OUTPUT) +// and both A2DP/HFP variants of a BT card — confusing for the user. We only +// want actual microphones/inputs, labelled by their friendly device name. +export async function listSources() { + try { + const { stdout } = await pexec('pactl', ['list', 'sources'], { env: penv() }); + const out = []; + const seen = new Set(); + for (const block of stdout.split(/\nSource #/).map(b => b.trim()).filter(Boolean)) { + const name = (block.match(/^\s*Name:\s*(.+)$/m) || [])[1]; + if (!name) continue; + // Skip monitors (loopback of an output) — not a recording device. + // Every block has a "Monitor of Sink:" line; a real input reads "n/a". + if (/\.monitor$/i.test(name) || /Monitor of Sink:\s*(?!n\/a)\S/i.test(block)) continue; + const isBluetooth = /bluez/i.test(name); + // For BT the physical device is one card; both bluez_input.* names map to + // the same headset, so collapse by the MAC to a single entry. + const key = isBluetooth ? (name.match(/([0-9A-F]{2}[:_]){5}[0-9A-F]{2}/i) || [name])[0] : name; + if (seen.has(key)) continue; + seen.add(key); + const label = (block.match(/^\s*Description:\s*(.+)$/m) || [])[1] || name; + out.push({ name, label, isBluetooth }); + } + return out; + } catch (e) { return []; } +} + +// Find the bluez card name (e.g. bluez_card.98_34_8C_FD_24_17). +async function bluezCard() { + const { stdout } = await pexec('pactl', ['list', 'cards', 'short'], { env: penv() }); + return stdout.split('\n').map(l => l.split('\t')[1]).find(n => /bluez/i.test(n || '')); +} + +// List available profiles of the bluez card (for diagnostics / picking HFP). +async function cardProfiles(card) { + const { stdout } = await pexec('pactl', ['list', 'cards'], { env: penv() }); + const block = stdout.split('Card #').find(b => b.includes(card)); + if (!block) return []; + const m = block.match(/Profiles:[\s\S]*?(?=\n\tActive Profile:|\n\tPorts:|$)/); + return m ? [...m[0].matchAll(/^\t\t([\w-]+):/gm)].map(x => x[1]) : []; +} + +// Switch the connected BT headset to an HFP profile (bidirectional, mic-capable). +// A2DP has no microphone, so recording a call REQUIRES HFP (headset-head-unit). +// codec: 'msbc' (16 kHz wideband), 'cvsd' (8 kHz narrowband), or 'auto' (mSBC first). +// On this card: headset-head-unit = mSBC, headset-head-unit-cvsd = CVSD. +export async function enableHeadsetMic(codec = 'auto') { + try { + const card = await bluezCard(); + if (!card) return { ok: false, msg: 'no bluez card (headset connected?)' }; + const avail = await cardProfiles(card); + const lists = { + // mSBC = the plain "headset-head-unit" here (some stacks name it -msbc). + msbc: ['headset-head-unit-msbc', 'headset-head-unit', 'handsfree-head-unit-msbc', 'handsfree-head-unit'], + cvsd: ['headset-head-unit-cvsd', 'handsfree-head-unit-cvsd'], + auto: ['headset-head-unit-msbc', 'headset-head-unit', 'handsfree-head-unit-msbc', + 'handsfree-head-unit', 'headset-head-unit-cvsd', 'handsfree-head-unit-cvsd'], + }; + const prefer = lists[codec] || lists.auto; + const pick = prefer.find(p => avail.includes(p)); + if (!pick) return { ok: false, msg: `codec ${codec} nicht verfügbar (${avail.join(', ')})`, available: avail }; + await pexec('pactl', ['set-card-profile', card, pick], { env: penv() }); + return { ok: true, card, profile: pick, codec, available: avail }; + } catch (e) { return { ok: false, msg: String(e && e.message || e) }; } +} + +export async function startRecording({ source, title, codec, numSpeakers } = {}) { + if (current) throw new Error('already recording'); + // Bluetooth headset: force HFP so the microphone actually delivers audio + // (A2DP is playback-only). Do this right before capture; the profile holds + // while the source is open. codec = 'msbc' | 'cvsd' | 'auto' (A/B testing). + let hfp = null; + if (source && /bluez/i.test(source)) { + hfp = await enableHeadsetMic(codec || 'auto'); + if (!hfp.ok) throw new Error('HFP-Profil konnte nicht aktiviert werden: ' + hfp.msg); + // the source object is recreated under the new profile; wait, then re-resolve + // the actual bluez input source name (it may differ from the A2DP-era name). + await new Promise(r => setTimeout(r, 900)); + const srcs = await listSources(); + const bz = srcs.find(s => s.isBluetooth && !/monitor/i.test(s.name)); + if (bz) source = bz.name; + } + const ts = Date.now(); + const wavPath = path.join('/tmp', `rec_${ts}.wav`); + // 16 kHz mono s16 = whisper's native format. NOTE: parecord takes the output + // filename as a POSITIONAL arg (there is no --file= option). + const args = ['--file-format=wav', '--rate=16000', '--channels=1', '--format=s16le']; + if (source) args.push(`--device=${source}`); + args.push(wavPath); + const proc = spawn('parecord', args, { env: penv() }); + let err = ''; + proc.stderr.on('data', d => { err += d; }); + proc.on('error', e => { err += String(e); }); + // Label the recording with the actual codec used so A/B tests are obvious + // in the list (mSBC 16 kHz vs CVSD 8 kHz). + const codecTag = hfp && hfp.profile ? (/cvsd/i.test(hfp.profile) ? ' [CVSD 8k]' : ' [mSBC 16k]') : ''; + current = { + proc, wavPath, startTs: ts, source: source || 'default', numSpeakers, + title: (title || `Aufnahme ${new Date(ts).toLocaleString('de-DE')}`) + codecTag, err: () => err + }; + return { startedAt: ts }; +} + +// Stop capture and return the raw wav path only (no whisper/mp3/diarization). +// Used for voiceprint enrollment. +export async function stopRaw() { + if (!current) throw new Error('not recording'); + const c = current; current = null; + c.proc.kill('SIGINT'); + await new Promise(resolve => { + if (c.proc.exitCode !== null || c.proc.signalCode !== null) return resolve(); + let done = false; const fin = () => { if (!done) { done = true; resolve(); } }; + c.proc.on('close', fin); setTimeout(fin, 4000); + }); + if (!fs.existsSync(c.wavPath) || fs.statSync(c.wavPath).size < 1000) + throw new Error('Aufnahme leer/fehlgeschlagen. parecord: ' + c.err()); + return c.wavPath; +} + +// Stop capture and PERSIST the wav + mp3 to REC_DIR immediately — BEFORE any +// transcription. This is the crash-safety fix: whisper on a long clip can crash +// the board (thermal), so the audio must already be on rootfs (not /tmp) and the +// DB row created before whisper runs. Call transcribeWav() afterwards (async). +export async function stopAndSave() { + if (!current) throw new Error('not recording'); + const c = current; current = null; + c.proc.kill('SIGINT'); + await new Promise(resolve => { + if (c.proc.exitCode !== null || c.proc.signalCode !== null) return resolve(); + let done = false; + const fin = () => { if (!done) { done = true; resolve(); } }; + c.proc.on('close', fin); + setTimeout(fin, 4000); + }); + const dur = Date.now() - c.startTs; + if (!fs.existsSync(c.wavPath) || fs.statSync(c.wavPath).size < 1000) + throw new Error('Aufnahme leer/fehlgeschlagen. parecord: ' + c.err()); + + const base = `rec_${c.startTs}`; + const mp3Path = path.join(REC_DIR, `${base}.mp3`); + // Gentle cleanup for playback: cut low rumble + normalize loudness. Applied to + // the MP3 only; whisper still transcribes the untouched WAV. + await pexec('ffmpeg', ['-y', '-i', c.wavPath, + '-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11', + '-codec:a', 'libmp3lame', '-qscale:a', '3', mp3Path]); + + // Move the wav into REC_DIR (rootfs — survives a reboot); it is NOT left in + // /tmp, so a crash during transcription can no longer lose it. + const wavFile = `${base}.wav`; + try { fs.renameSync(c.wavPath, path.join(REC_DIR, wavFile)); } + catch { fs.copyFileSync(c.wavPath, path.join(REC_DIR, wavFile)); try { fs.unlinkSync(c.wavPath); } catch {} } + + return { mp3_file: `${base}.mp3`, wav_file: wavFile, duration_ms: dur, + language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers }; +} + +// Transcribe a saved wav (in REC_DIR) with whisper (thermal-guarded) + diarize. +// Returns { transcript, words }. Idempotent — safe to re-run for recovery of a +// recording whose transcription was interrupted (crash) or failed. +export async function transcribeWav(wavFile, numSpeakers) { + const wavPath = path.isAbsolute(wavFile) ? wavFile : path.join(REC_DIR, wavFile); + if (!fs.existsSync(wavPath)) throw new Error('wav-Datei fehlt: ' + wavFile); + const outBase = `/tmp/${path.basename(wavPath, '.wav')}`; + const words = []; + let transcript = ''; + // Diarization (sherpa-onnx over the whole recording) pins all 4 A76 cores and + // crashed the board at full clock on a 46-min call, so it ALWAYS runs under the + // A76 clock cap. Transcription is capped ONLY on the whisper.cpp (medium) path + // (CPU-hot); the NPU-turbo path runs the encoder on the NPU (CPU only decodes, + // ~59-69 C) so it needs NO cap. rkllm_server (~3.1 GB) is freed first on the NPU + // path (turbo rknn + CT2 decoder OOM-killed next to it); it respawns for analysis. + const wavSec = wavDurationSec(wavPath); + try { + if (TRANSCRIBER === 'npu' && wavSec > 0 && wavSec <= SHORT_SEC) { + // SHORT clip: warm CPU daemon + keep rkllm RESIDENT (no NPU swap), at + // full clock (a few seconds of compute won't overheat the fanless board). + // Cuts a 10s clip from ~120s (per-call model load + rkllm swap) to ~30s. + const r = await runDaemonTranscribe(wavPath); + for (const w of r.words || []) { + const t = (w.word || '').trim(); + if (!t) continue; + words.push({ word: t, t_start_ms: w.t_start_ms, t_end_ms: w.t_end_ms }); + transcript += (transcript ? ' ' : '') + t; + } + console.log(`cpu-daemon-transcribe: ${words.length} words, RTF ${r.rtf} (${r.transcribe_s}s / ${r.duration}s audio, warm, rkllm kept resident)`); + } else if (TRANSCRIBER === 'npu') { + await llm.stopServer(); + await setA76Max(A76_CAP_NPU); + // large-v3-turbo, encoder on NPU. CLI already emits word-level timestamps. + const r = await runNpuTranscribe(wavPath); + for (const w of r.words || []) { + const t = (w.word || '').trim(); + if (!t) continue; + words.push({ word: t, t_start_ms: w.t_start_ms, t_end_ms: w.t_end_ms }); + transcript += (transcript ? ' ' : '') + t; + } + console.log(`npu-transcribe: ${words.length} words, RTF ${r.rtf} (load ${r.load_s}s + transcribe ${r.transcribe_s}s / ${r.duration}s audio)`); + } else { + await setA76Max(A76_CAP); + // Fallback: whisper.cpp medium. Greedy (-bs 1 -bo 1) ~= beam-5 on German, + // ~30% faster + less heat. whisper also has a SIGSTOP temp guard. + await runWhisperGuarded(['-m', MODEL, '-f', wavPath, '-l', 'de', '-t', '4', + '-bs', '1', '-bo', '1', '-ml', '1', '-sow', '-oj', '-of', outBase]); + const json = JSON.parse(fs.readFileSync(`${outBase}.json`, 'utf8')); + for (const seg of json.transcription || []) { + const t = (seg.text || '').trim(); + if (!t) continue; + words.push({ word: t, t_start_ms: seg.offsets.from, t_end_ms: seg.offsets.to }); + transcript += (transcript ? ' ' : '') + t; + } + } + // Acoustic diarization under the A76 clock cap (both paths). Non-fatal. + await setA76Max(A76_CAP); + try { + const segs = await diar.diarize(wavPath, numSpeakers); + diar.assignSpeakers(words, segs); + } catch (e) { console.error('diarize failed:', e.message); } + try { fs.unlinkSync(`${outBase}.json`); } catch {} + } finally { + await setA76Max(A76_MAX); + } + return { transcript, words }; +} diff --git a/app/rkllm_server.cpp b/app/rkllm_server.cpp new file mode 100644 index 0000000..d81eee4 --- /dev/null +++ b/app/rkllm_server.cpp @@ -0,0 +1,104 @@ +// Persistent RKLLM inference server for the CallRec app. +// Loads a .rkllm model onto the RK3588 NPU ONCE, then answers prompts over a +// simple line protocol on stdin/stdout so Node can spawn it a single time: +// stdin : one request per line = base64(prompt) (base64 has no newlines) +// stdout: "READY" once after model load, then per request: +// "OK " on success +// "ERR " on failure +// keep_history=0 → every request is independent (Node sends full context each time). +#include // rkllm.h uses size_t without including it itself +#include "rkllm.h" +#include +#include +#include +#include + +static std::string g_out; +static bool g_error = false; + +static int cb(RKLLMResult* r, void* /*ud*/, LLMCallState st) { + if (st == RKLLM_RUN_NORMAL) { if (r && r->text) g_out += r->text; } + else if (st == RKLLM_RUN_ERROR) { g_error = true; } + return 0; +} + +static const char B64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +static std::string b64enc(const std::string& in) { + std::string out; int val = 0, bits = -6; + for (unsigned char c : in) { + val = (val << 8) + c; bits += 8; + while (bits >= 0) { out.push_back(B64[(val >> bits) & 0x3F]); bits -= 6; } + } + if (bits > -6) out.push_back(B64[((val << 8) >> (bits + 8)) & 0x3F]); + while (out.size() % 4) out.push_back('='); + return out; +} +static std::string b64dec(const std::string& in) { + int T[256]; for (int i = 0; i < 256; i++) T[i] = -1; + for (int i = 0; i < 64; i++) T[(unsigned char)B64[i]] = i; + std::string out; int val = 0, bits = -8; + for (unsigned char c : in) { + if (T[c] == -1) continue; + val = (val << 6) + T[c]; bits += 6; + if (bits >= 0) { out.push_back((char)((val >> bits) & 0xFF)); bits -= 8; } + } + return out; +} + +int main(int argc, char** argv) { + if (argc < 2) { fprintf(stderr, "usage: %s model.rkllm\n", argv[0]); return 1; } + RKLLMParam p = rkllm_createDefaultParam(); + p.model_path = argv[1]; + p.max_context_len = 8192; + p.max_new_tokens = 1536; + // Gemma-3-4B (w8a8) degenerates into repetition loops ("X, X, X…") and echoes + // the prompt under pure-greedy decode → needs real sampling + strong repeat/ + // frequency penalties. (Qwen was fine at top_k=1/1.1; Gemma is not.) + p.top_k = 20; + p.top_p = 0.9f; + p.temperature = 0.5f; + p.repeat_penalty = 1.3f; + p.frequency_penalty = 0.5f; + p.is_async = false; // rkllm_run blocks until finish → buffer complete on return + // Pin the runtime's CPU-side threads to the big A76 cores (4-7). The 1.2.1 + // default mask targets the little A55 cores, which starves de/quant and + // sampling between NPU calls. + p.extend_param.enabled_cpus_num = 4; + p.extend_param.enabled_cpus_mask = (1u<<4)|(1u<<5)|(1u<<6)|(1u<<7); + p.skip_special_token = true; + + LLMHandle h = nullptr; + RKLLMCallback cbs; memset(&cbs, 0, sizeof(cbs)); cbs.result_callback = cb; + if (rkllm_init(&h, &p, &cbs) != 0) { fprintf(stderr, "rkllm_init failed\n"); return 2; } + // Gemma-3 turn format. rkllm does NOT auto-apply the HF chat template, so + // without this the model gets the raw prompt with no turn markers and just + // rambles generic filler. prefix/postfix wrap each user prompt; is + // added by the runtime (add_bos_token=True at conversion). + rkllm_set_chat_template(h, "", "user\n", "\nmodel\n"); + printf("READY\n"); fflush(stdout); + fprintf(stderr, "rkllm model loaded\n"); fflush(stderr); + + std::string line; + while (std::getline(std::cin, line)) { + if (line.empty()) continue; + std::string prompt = b64dec(line); + g_out.clear(); g_error = false; + + RKLLMInput in; memset(&in, 0, sizeof(in)); + in.role = "user"; + in.enable_thinking = false; + in.input_type = RKLLM_INPUT_PROMPT; + in.prompt_input = prompt.c_str(); + + RKLLMInferParam ip; memset(&ip, 0, sizeof(ip)); + ip.mode = RKLLM_INFER_GENERATE; + ip.keep_history = 0; + + rkllm_clear_kv_cache(h, 0, nullptr, nullptr); // hard reset: no cross-recording KV bleed + int rc = rkllm_run(h, &in, &ip, nullptr); + if (rc != 0 || g_error) { printf("ERR run rc=%d\n", rc); fflush(stdout); continue; } + printf("OK %s\n", b64enc(g_out).c_str()); fflush(stdout); + } + rkllm_destroy(h); + return 0; +} diff --git a/app/seedtest.js b/app/seedtest.js new file mode 100644 index 0000000..3c5b041 --- /dev/null +++ b/app/seedtest.js @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import * as db from './db.js'; + +const wav = '/tmp/test.wav'; +const WH = '/opt/whisper.cpp/build/bin/whisper-cli'; +const MODEL = '/opt/whisper.cpp/models/ggml-small.bin'; + +execFileSync('ffmpeg', ['-y', '-i', wav, '-codec:a', 'libmp3lame', '-qscale:a', '4', '/opt/callrec/recordings/test.mp3']); +execFileSync(WH, ['-m', MODEL, '-f', wav, '-l', 'de', '-ml', '1', '-sow', '-oj', '-of', '/tmp/test'], { maxBuffer: 1 << 24 }); + +const j = JSON.parse(fs.readFileSync('/tmp/test.json', 'utf8')); +const words = []; let tr = ''; +for (const s of j.transcription || []) { + const t = (s.text || '').trim(); if (!t) continue; + words.push({ word: t, t_start_ms: s.offsets.from, t_end_ms: s.offsets.to }); + tr += (tr ? ' ' : '') + t; +} +const durMs = words.length ? words[words.length - 1].t_end_ms : 0; +const id = await db.addRecording({ title: 'Selbsttest (deutsche TTS)', mp3_file: 'test.mp3', device: 'test', duration_ms: durMs, transcript: tr, language: 'de', status: 'done' }); +await db.addWords(id, words); +console.log('SEEDED id=' + id + ' words=' + words.length + ' text="' + tr + '"'); +process.exit(0); diff --git a/app/server.js b/app/server.js new file mode 100644 index 0000000..5207545 --- /dev/null +++ b/app/server.js @@ -0,0 +1,373 @@ +import express from 'express'; +import path from 'node:path'; +import fs from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import * as bt from './bt.js'; +import * as rec from './recorder.js'; +import * as db from './db.js'; +import * as llm from './llm.js'; +import * as diar from './diar.js'; +import * as spk from './spk.js'; +const pexec = promisify(execFile); + +// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH +// soundalikes get a deterministic swap; term-only entries are left to the LLM hint. +function glossaryMap(glossary) { + const m = {}; + for (const g of glossary || []) { + if (!g.term || !g.soundalikes) continue; + for (const s of String(g.soundalikes).split(',')) { + const k = s.trim().toLowerCase(); + if (k) m[k] = String(g.term).trim(); + } + } + return m; +} +// Whole-word replace (Unicode-aware, case-insensitive match): swaps any token +// equal to a known soundalike for its correct term, leaving punctuation intact. +function applyGlossaryText(text, m) { + if (!text || !m || !Object.keys(m).length) return text; + return String(text).replace(/[\p{L}\p{N}]+/gu, (tok) => m[tok.toLowerCase()] || tok); +} + +// Build the LLM input from stored words. If acoustic diarization tagged speakers, +// produce a labelled dialogue ("Sprecher 1: …") so the LLM keeps real speakers +// instead of guessing; otherwise the plain transcript. +function buildBasis(rec, nameMap = {}) { + const words = rec.words || []; + const hasSpk = words.some(w => w.speaker); + if (!hasSpk) return { text: rec.transcript || '', labeled: false }; + const turns = diar.toTurns(words); + const map = {}; let n = 0; + const label = sp => nameMap[sp] || (map[sp] || (map[sp] = `Sprecher ${++n}`)); + const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n'); + return { text, labeled: true }; +} + +const REC_DIR2 = path.join(path.dirname(fileURLToPath(import.meta.url)), 'recordings'); +// Match each diarization cluster against enrolled voiceprints → { speaker: name }. +// Cuts the speaker's longest contiguous run from the kept wav, embeds it, cosine- +// matches. Returns only confident matches (others stay "Sprecher N"). +async function identifySpeakers(recData) { + const words = recData.words || []; + const speakers = [...new Set(words.filter(w => w.speaker).map(w => w.speaker))]; + if (!speakers.length || !recData.wav_file) return {}; + const wavPath = path.join(REC_DIR2, recData.wav_file); + if (!fs.existsSync(wavPath)) return {}; + const vps = await db.listVoiceprints(); + if (!vps.length) return {}; + const best = {}; let cur = null; + for (const w of words) { + if (!cur || cur.speaker !== w.speaker) cur = { speaker: w.speaker, start: w.t_start_ms, end: w.t_end_ms }; + else cur.end = w.t_end_ms; + const len = cur.end - cur.start; + if (!best[cur.speaker] || len > best[cur.speaker].len) best[cur.speaker] = { start: cur.start, end: cur.end, len }; + } + const map = {}; + for (const sp of speakers) { + const r = best[sp]; if (!r) continue; + const seg = `/tmp/idseg_${recData.id}_${sp}.wav`; + try { + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(r.start / 1000), '-to', String(r.end / 1000), '-ar', '16000', '-ac', '1', seg]); + const emb = await spk.embed(seg); + const m = spk.bestMatch(emb, vps); + if (m) map[sp] = m.name; + } catch (e) { console.error('identify', sp, e.message); } + finally { try { fs.unlinkSync(seg); } catch {} } + } + return map; +} + +// Run LLM analysis for a recording and persist title/summary/todos/participants/ +// clean dialogue. Background (NPU ~30-90s) so record/stop stays fast. +async function analyzeRecording(id) { + try { + const rec = await db.getRecording(id); + if (!rec) return; + const nameMap = await identifySpeakers(rec).catch(() => ({})); + // Seed the per-recording speaker names from acoustic identification (fills + // blanks only; manual edits stay). Manual names win for the LLM labelling. + await db.mergeSpeakerNames(id, nameMap).catch(() => {}); + const names = { ...nameMap, ...(rec.speaker_names || {}) }; + const glossary = (await db.listGlossary().catch(() => [])).filter(g => g.enabled); + // Deterministic soundalike replacement: the 3B model is unreliable at obeying + // a "map Amy->Aime" instruction (it treats "Amy" as a valid name), so entries + // WITH soundalikes are applied as a hard whole-word swap on the words FIRST — + // then the LLM sees the correct term and the turn view shows it too. Entries + // without soundalikes still rely on the (contextual) LLM glossary hint. + const gmap = glossaryMap(glossary); + if (Object.keys(gmap).length && Array.isArray(rec.words) && rec.words.length) { + let changed = false; + for (const w of rec.words) { const nt = applyGlossaryText(w.word, gmap); if (nt !== w.word) { w.word = nt; changed = true; } } + if (changed) { await db.replaceWords(id, rec.words); console.log(`glossary: applied soundalike swaps to #${id} words`); } + } + const { text, labeled } = buildBasis(rec, names); + const a = await llm.analyze(text, { labeled, glossary }); + // Participants = the ACOUSTICALLY diarized speakers (named where known), + // not the LLM's list — the model returned every name MENTIONED in the + // conversation as a "Teilnehmer" (11 names on a 3-person call, #69). + // LLM participants stay only as fallback when there is no diarization. + const spkOrder = []; + for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker); + if (spkOrder.length) a.participants = spkOrder.map((sp, i) => names[sp] || `Sprecher ${i + 1}`); + // Belt-and-suspenders: also swap in the LLM outputs (cheap, catches any the + // model reintroduced). + for (const k of ['title', 'summary', 'transcriptClean']) if (a[k]) a[k] = applyGlossaryText(a[k], gmap); + a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap)); + a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap)); + await db.updateAnalysis(id, { ...a, status: 'done' }); + console.log(`analysis done for #${id}: ${a.title}`); + } catch (e) { + console.error(`analysis failed for #${id}:`, e.message); + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); + } +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); +app.use(express.json()); + +const REC_DIR = path.join(__dirname, 'recordings'); +app.use('/media', express.static(REC_DIR)); +app.use(express.static(path.join(__dirname, 'web/dist'))); + +const wrap = fn => (req, res) => fn(req, res).catch(e => res.status(500).json({ error: String(e && e.message || e) })); + +app.get('/api/health', (req, res) => res.json({ ok: true, ...rec.status() })); + +// --- Bluetooth --- +app.post('/api/bt/scan', wrap(async (req, res) => { + const secs = Math.min(+(req.body && req.body.seconds) || 10, 30); + res.json(await bt.scan(secs)); +})); +app.get('/api/bt/devices', wrap(async (req, res) => res.json(await bt.knownDevices()))); +app.post('/api/bt/pair', wrap(async (req, res) => res.json(await bt.pair(req.body.mac)))); +app.post('/api/bt/connect', wrap(async (req, res) => res.json(await bt.connect(req.body.mac)))); +app.post('/api/bt/repair', wrap(async (req, res) => res.json(await bt.repair(req.body.mac)))); +app.post('/api/bt/disconnect', wrap(async (req, res) => res.json(await bt.disconnect(req.body.mac)))); + +// --- Audio --- +app.get('/api/audio/sources', wrap(async (req, res) => res.json(await rec.listSources()))); +app.post('/api/audio/headset-mic', wrap(async (req, res) => res.json(await rec.enableHeadsetMic((req.body && req.body.codec) || 'auto')))); + +// --- Global NPU pipeline serializer --------------------------------------- +// Transcription (whisper-turbo ENCODER on the NPU) and LLM analysis (rkllm on +// the NPU) cannot run simultaneously: while the whisper encoder holds the NPU, +// rkllm_init fails with "failed to allocate handle, ret:-1, errno:14, Bad +// address", which surfaces as "rkllm READY timeout" and a failed/empty +// analysis. This bit when two recordings finished within seconds of each other +// (both fired transcribe+analyze fire-and-forget with no coordination). Every +// heavy per-recording job (transcribe / analyze / re-diarize) now runs through +// this single FIFO queue, so exactly one touches the NPU at a time; a second +// recording simply waits its turn instead of colliding. +let npuChain = Promise.resolve(); +let npuDepth = 0; +function withNpuLock(label, fn) { + npuDepth++; + const waiting = npuDepth - 1; + const run = npuChain.then(async () => { + if (waiting > 0) console.log(`npu-queue: ${label} start (${waiting} ahead done)`); + try { return await fn(); } + finally { npuDepth--; } + }); + npuChain = run.catch(() => {}); // keep the chain alive across a job's failure + return run; +} + +// --- Recording --- +app.post('/api/record/start', wrap(async (req, res) => res.json(await rec.startRecording(req.body || {})))); +// Transcribe (thermal-guarded whisper + diarize) then analyze, in the background. +// The wav + DB row already exist, so a crash here loses nothing — the recording +// stays with status 'transcribing' and can be recovered via /retranscribe. +async function transcribeAndAnalyze(id, wavFile, numSpeakers) { + await db.markAnalysisStart(id).catch(() => {}); + try { + const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers); + await db.setTranscript(id, transcript, words); + console.log(`transcription done for #${id}: ${words.length} segments`); + } catch (e) { + console.error(`transcription failed for #${id}:`, e.message); + await db.setStatus(id, 'error').catch(() => {}); + // also mark analysis 'error' so the UI stops polling and shows a retry + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); + return; // no transcript -> skip LLM analysis + } + analyzeRecording(id); +} + +app.post('/api/record/stop', wrap(async (req, res) => { + // Persist wav + mp3 + DB row FIRST (crash-safe), respond immediately, then + // transcribe/analyze in the background. The frontend already polls while + // analysis_status === 'pending', so the transcript + analysis fill in live. + const r = await rec.stopAndSave(); + const id = await db.addRecording({ ...r, transcript: '', status: 'transcribing' }); + res.json({ id, title: r.title, mp3_file: r.mp3_file, duration_ms: r.duration_ms, + transcript: '', status: 'transcribing', analysis_status: 'pending' }); + withNpuLock(`transcribe #${id}`, () => transcribeAndAnalyze(id, r.wav_file, r.numSpeakers)); +})); + +// --- Recordings --- +app.get('/api/recordings', wrap(async (req, res) => res.json(await db.listRecordings()))); +app.get('/api/recordings/:id', wrap(async (req, res) => { + const r = await db.getRecording(+req.params.id); + r ? res.json(r) : res.status(404).json({ error: 'not found' }); +})); +app.delete('/api/recordings/:id', wrap(async (req, res) => { await db.deleteRecording(+req.params.id); res.json({ ok: true }); })); + +// --- Voiceprints / enrollment --- +app.get('/api/voiceprints', wrap(async (req, res) => { + const vps = await db.listVoiceprints(); + res.json(vps.map(v => ({ id: v.id, name: v.name, dim: v.dim, created_at: v.created_at }))); +})); +app.delete('/api/voiceprints/:id', wrap(async (req, res) => { await db.deleteVoiceprint(+req.params.id); res.json({ ok: true }); })); +app.post('/api/enroll/start', wrap(async (req, res) => res.json(await rec.startRecording({ source: req.body && req.body.source })))); +app.post('/api/enroll/stop', wrap(async (req, res) => { + const name = (req.body && req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Name fehlt' }); + const wav = await rec.stopRaw(); + try { + const emb = await spk.embed(wav); + const id = await db.addVoiceprint(name, emb); + res.json({ id, name, dim: emb.length }); + } finally { try { fs.unlinkSync(wav); } catch {} } +})); + +// --- Glossary (known proper nouns / terms; fed into LLM correction) --- +app.get('/api/glossary', wrap(async (req, res) => res.json(await db.listGlossary()))); +app.post('/api/glossary', wrap(async (req, res) => { + const term = (req.body && req.body.term || '').trim(); + const soundalikes = (req.body && req.body.soundalikes || '').trim(); + if (!term) return res.status(400).json({ error: 'Begriff fehlt' }); + res.json(await db.addGlossary(term, soundalikes)); +})); +app.delete('/api/glossary/:id', wrap(async (req, res) => { await db.deleteGlossary(+req.params.id); res.json({ ok: true }); })); + +// --- LLM --- +app.get('/api/llm/status', (req, res) => res.json(llm.status())); +app.get('/api/spk/status', (req, res) => res.json(spk.status())); +// Re-diarize a recording's words with an (optional) fixed speaker count, then +// persist the new speaker labels. Text/timestamps are untouched. Needs the wav. +async function rediarize(id, numSpeakers) { + const r = await db.getRecording(id); + if (!r || !r.wav_file) throw new Error('wav für Re-Diarisierung fehlt'); + const wav = path.join(REC_DIR, r.wav_file); + if (!fs.existsSync(wav)) throw new Error('wav-Datei nicht gefunden: ' + r.wav_file); + const words = r.words || []; + // Diarization runs on the A76 cores; the board is fanless, so cap the clock + // for the whole run (transcribeWav does this for the record path — rediarize + // is the other entry point and must do it too, or a long re-diar overheats). + await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {}); + try { + const segs = await diar.diarize(wav, numSpeakers); + diar.assignSpeakers(words, segs); + await db.replaceWords(id, words); + console.log(`re-diarized #${id} (numSpeakers=${numSpeakers || 'auto'}) → ${new Set(segs.map(s => s.speaker)).size} speakers`); + } finally { + await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '2256000']).catch(() => {}); + } +} + +// Re-run analysis (e.g. the model wasn't downloaded yet at record time). Optional +// body { numSpeakers } re-diarizes first (fixes over-segmentation on a known count). +app.post('/api/recordings/:id/reanalyze', wrap(async (req, res) => { + const id = +req.params.id; + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + const numSpeakers = +(req.body && req.body.numSpeakers) || 0; + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); + await db.markAnalysisStart(id).catch(() => {}); + res.json({ ok: true, status: 'pending', reDiarize: true }); + // Always re-diarize: numSpeakers>0 pins the count, 0 = auto (now consolidated, + // so auto no longer over-segments). This is the "fix my speakers" path. + withNpuLock(`reanalyze #${id}`, async () => { + try { await rediarize(id, numSpeakers); } + catch (e) { console.error(`re-diarize failed for #${id}:`, e.message); } + await analyzeRecording(id); + }); +})); + +// LLM-only re-analysis: re-run title/summary/todos on the EXISTING words + +// speaker labels, WITHOUT re-diarizing (fast — skips the ~40min sherpa pass when +// the speakers are already correct and only the LLM/prompts changed). +app.post('/api/recordings/:id/reanalyze-llm', wrap(async (req, res) => { + const id = +req.params.id; + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); + await db.markAnalysisStart(id).catch(() => {}); + res.json({ ok: true, status: 'pending', reDiarize: false }); + withNpuLock(`reanalyze-llm #${id}`, async () => { await analyzeRecording(id); }); +})); + +// Re-run transcription from the saved wav (recovery: a crash/thermal-shutdown +// during whisper leaves the row at status 'transcribing' with an empty +// transcript, or 'error'; the wav is on disk). Optional { numSpeakers }. +app.post('/api/recordings/:id/retranscribe', wrap(async (req, res) => { + const id = +req.params.id; + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + if (!r.wav_file) return res.status(400).json({ error: 'kein wav für Re-Transkription' }); + const numSpeakers = +(req.body && req.body.numSpeakers) || 0; + await db.setStatus(id, 'transcribing'); + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); + res.json({ ok: true, status: 'transcribing' }); + withNpuLock(`retranscribe #${id}`, () => transcribeAndAnalyze(id, r.wav_file, numSpeakers)); +})); + +// Longest contiguous run of one diarization cluster (best clean sample of a voice). +function longestRun(words, speaker) { + let best = null, cur = null; + for (const w of words || []) { + if (w.speaker !== speaker) { cur = null; continue; } + if (!cur) cur = { start: w.t_start_ms, end: w.t_end_ms }; + else cur.end = w.t_end_ms; + if (!best || (cur.end - cur.start) > (best.end - best.start)) best = { start: cur.start, end: cur.end }; + } + return best; +} + +// Name a diarized speaker in a recording and (default) LEARN their voice: fold +// the speaker's longest clean segment into the named voiceprint centroid, so +// future recordings auto-identify them. Empty name clears the label. +app.post('/api/recordings/:id/speaker', wrap(async (req, res) => { + const id = +req.params.id; + const speaker = String((req.body && req.body.speaker) || '').trim(); + const name = String((req.body && req.body.name) || '').trim(); + const enroll = !(req.body && req.body.enroll === false); + if (!speaker) return res.status(400).json({ error: 'speaker fehlt' }); + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + await db.setSpeakerName(id, speaker, name); + + let enrolled = false, learnMsg = null; + if (enroll && name && r.wav_file) { + const seg = longestRun(r.words, speaker); + const wavPath = path.join(REC_DIR, r.wav_file); + if (!seg || (seg.end - seg.start) < 1500) { + learnMsg = 'Segment zu kurz zum Lernen (<1,5s)'; + } else if (!fs.existsSync(wavPath)) { + learnMsg = 'wav fehlt – nicht gelernt'; + } else { + const cut = `/tmp/enr_${id}_${speaker}.wav`; + try { + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(seg.start / 1000), + '-to', String(seg.end / 1000), '-ar', '16000', '-ac', '1', cut]); + const emb = await spk.embed(cut); + await db.upsertVoiceprint(name, emb); + enrolled = true; + } catch (e) { console.error('enroll-from-recording failed:', e.message); learnMsg = 'Lernen fehlgeschlagen'; } + finally { try { fs.unlinkSync(cut); } catch {} } + } + } + const r2 = await db.getRecording(id); + res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg }); +})); + +app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname, 'web/dist/index.html'))); + +const PORT = process.env.PORT || 3000; +db.ensureSchema() + .catch(e => console.error('ensureSchema:', e.message)) + .finally(() => app.listen(PORT, '0.0.0.0', () => console.log(`callrec listening on :${PORT}`))); diff --git a/app/spk.js b/app/spk.js new file mode 100644 index 0000000..0b4cf63 --- /dev/null +++ b/app/spk.js @@ -0,0 +1,59 @@ +// Speaker voiceprints: talks to the persistent spk_embed process (sherpa-onnx +// NeMo TitaNet embedding on CPU). embed(wav) → 192-dim vector; cosine match a +// diarization cluster against enrolled voiceprints → a name. All offline. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import readline from 'node:readline'; + +const BIN = '/opt/callrec/spk_embed'; +const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu'; +const EMB = process.env.DIAR_EMB || '/opt/sherpa/nemo_titanet_small.onnx'; +export const THRESHOLD = +(process.env.SPK_THRESHOLD || 0.5); + +let proc = null, ready = null, rl = null, pending = null, chain = Promise.resolve(); + +function start() { + if (proc) return ready; + if (!fs.existsSync(EMB)) return Promise.reject(new Error('Embedding-Modell fehlt: ' + EMB)); + proc = spawn('taskset', ['-c', '4-7', BIN, EMB], + { env: { ...process.env, LD_LIBRARY_PATH: `${DIR}/lib` }, stdio: ['pipe', 'pipe', 'pipe'] }); + proc.stderr.on('data', d => process.stderr.write('[spk] ' + d)); + ready = new Promise((res, rej) => { + const to = setTimeout(() => rej(new Error('spk READY timeout')), 60000); + rl = readline.createInterface({ input: proc.stdout }); + rl.on('line', line => { + if (line.startsWith('READY')) { clearTimeout(to); res(); return; } + if (!pending) return; + const p = pending; pending = null; + if (line.startsWith('OK ')) p.resolve(line.slice(3).split(',').map(Number)); + else p.reject(new Error(line.replace(/^ERR\s*/, '') || 'spk error')); + }); + }); + const cleanup = () => { proc = null; ready = null; rl = null; if (pending) { pending.reject(new Error('spk beendet')); pending = null; } }; + proc.on('exit', cleanup); proc.on('error', cleanup); + return ready; +} + +// Extract the voiceprint for a 16 kHz mono wav file. +export function embed(wavPath) { + chain = chain.then(async () => { + await start(); + return await new Promise((res, rej) => { pending = { resolve: res, reject: rej }; proc.stdin.write(wavPath + '\n'); }); + }); + return chain; +} + +export function cosine(a, b) { + let d = 0, na = 0, nb = 0; + for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } + return d / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9); +} + +// Best enrolled voiceprint for an embedding, or null if below threshold. +export function bestMatch(emb, voiceprints, threshold = THRESHOLD) { + let best = null, bs = -1; + for (const v of voiceprints) { const s = cosine(emb, v.vec); if (s > bs) { bs = s; best = v; } } + return (best && bs >= threshold) ? { id: best.id, name: best.name, score: bs } : null; +} + +export function status() { return { model: EMB, present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; } diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..8123f68 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,12 @@ + + + + + + Aime + + +
+ + + diff --git a/app/web/package-lock.json b/app/web/package-lock.json new file mode 100644 index 0000000..0d976cc --- /dev/null +++ b/app/web/package-lock.json @@ -0,0 +1,928 @@ +{ + "name": "callrec-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "callrec-web", + "devDependencies": { + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/app/web/package.json b/app/web/package.json new file mode 100644 index 0000000..9ace996 --- /dev/null +++ b/app/web/package.json @@ -0,0 +1,12 @@ +{ + "name": "callrec-web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/app/web/src/main.js b/app/web/src/main.js new file mode 100644 index 0000000..494617a --- /dev/null +++ b/app/web/src/main.js @@ -0,0 +1,504 @@ +import './style.css'; + +const api = { + async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, + async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, + async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); } +}; + +const app = document.querySelector('#app'); +const state = { tab: 'record', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null }; + +function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } +function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } + +function render() { + app.classList.toggle('wide', state.tab === 'recordings'); + app.innerHTML = ` +
+

📞 Aime Anruf-Assistent · RK3588S

+ +
+
`; + app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); }); + ({ record: viewRecord, bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab](); +} + +// ---------- Bluetooth ---------- +async function viewBluetooth() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+
+ + + +
+
Lade…
+
`; + const listEl = v.querySelector('#btlist'); + const msg = v.querySelector('#btmsg'); + + async function load(devices) { + const d = devices || await api.get('/api/bt/devices'); + if (!d.length) { listEl.innerHTML = '
Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).
'; return; } + listEl.innerHTML = d.map(dev => ` +
+
+ ${dev.name || '(unbenannt)'} ${dev.audio ? '🎧' : ''} +
${dev.mac}
+
+ ${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'} + ${dev.connected + ? `` + : dev.paired + ? `` + : ``} + ${dev.audio ? `` : ''} +
`).join(''); + listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b)); + } + async function act(a, mac, btn) { + btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…'; + try { + const r = await api.post('/api/bt/' + a, { mac }); + if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok); + else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`); + if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {}); + } + catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); } + await load(); + } + v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; }; + v.querySelector('#refresh').onclick = () => load(); + load().catch(e => listEl.innerHTML = '
' + e.message + '
'); +} + +// ---------- Record ---------- +async function viewRecord() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+ +
+ + +
+ + + + +
Wenn du die Anzahl der Sprecher kennst, hier eintragen — verbessert die Trennung. Sonst leer lassen (Auto). Titel, Zusammenfassung, ToDos & Sprecher-Erkennung laufen automatisch per KI.
+
+
00:00
+ +
+
+
`; + const sel = v.querySelector('#src'); + async function loadSrc() { + try { + const s = await api.get('/api/audio/sources'); + sel.innerHTML = '' + s.map(x => ``).join(''); + } catch { sel.innerHTML = ''; } + } + await loadSrc(); + v.querySelector('#reloadsrc').onclick = loadSrc; + const timerEl = v.querySelector('#timer'); + if (state.recording) startTimerUI(timerEl); + + v.querySelector('#recbtn').onclick = async (e) => { + if (!state.recording) { + try { + await api.post('/api/record/start', { source: sel.value, codec: v.querySelector('#codec').value, numSpeakers: +v.querySelector('#nspk').value || undefined }); + state.recording = true; state.recStart = Date.now(); render(); + } catch (err) { toast(err.message, true); } + } else { + e.target.disabled = true; e.target.textContent = 'Transkribiere… (kann dauern)'; + clearInterval(state.timer); + try { + const r = await api.post('/api/record/stop', {}); + state.recording = false; + toast('Aufnahme gespeichert – wird transkribiert…'); + state.tab = 'recordings'; render(); openRecording(r.id); + } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } + } + }; +} +function startTimerUI(el) { clearInterval(state.timer); state.timer = setInterval(() => { el.textContent = fmt(Date.now() - state.recStart); }, 500); } + +// ---------- Voices (enrollment) ---------- +async function viewVoices() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+

Stimme anlernen

+
Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
+ + +
+
00:00
+ +
+
+
+

Angelernte Stimmen

Lade…
`; + const listEl = v.querySelector('#vplist'); + async function load() { + try { + const vps = await api.get('/api/voiceprints'); + listEl.innerHTML = vps.length ? vps.map(p => ` +
👤 ${escapeHtml(p.name)} +
${new Date(p.created_at).toLocaleString('de-DE')} · ${p.dim}-dim
+
`).join('') + : '
Noch keine Stimmen. Oben anlernen.
'; + listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/voiceprints/' + b.dataset.del); load(); }); + } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } + } + await load(); + const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer'); + if (state.enrolling) startTimerUI(timerEl); + btn.onclick = async () => { + if (!state.enrolling) { + const name = v.querySelector('#vname').value.trim(); + if (!name) { toast('Bitte Namen eingeben', true); return; } + state.enrollName = name; + try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); } + catch (e) { toast(e.message, true); } + } else { + btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer); + try { + const r = await api.post('/api/enroll/stop', { name: state.enrollName }); + state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render(); + } catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); } + } + }; +} + +// ---------- Glossary ---------- +async function viewGlossary() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+

Begriff hinzufügen

+
Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.
+ + + + + +
+

Glossar

Lade…
`; + const listEl = v.querySelector('#glist'); + async function load() { + try { + const gs = await api.get('/api/glossary'); + listEl.innerHTML = gs.length ? gs.map(g => ` +
📖 ${escapeHtml(g.term)} + ${g.soundalikes ? `
↔ ${escapeHtml(g.soundalikes)}
` : ''}
+
`).join('') + : '
Noch keine Begriffe. Oben hinzufügen.
'; + listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); load(); }); + } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } + } + await load(); + v.querySelector('#gadd').onclick = async () => { + const term = v.querySelector('#gterm').value.trim(); + if (!term) { toast('Bitte Begriff eingeben', true); return; } + const soundalikes = v.querySelector('#gsound').value.trim(); + try { + await api.post('/api/glossary', { term, soundalikes }); + toast(`„${term}" gespeichert ✓`); + v.querySelector('#gterm').value = ''; v.querySelector('#gsound').value = ''; + load(); + } catch (e) { toast(e.message, true); } + }; +} + +// ---------- Recordings ---------- +// Analysis-duration counter. Shows the total wall time of a processing run +// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at), +// frozen to analysis_ms once done. Returns the inner only; callers add the ⏱. +function anaTimeSpan(r) { + if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`; + if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing')) + return `${fmt(Date.now() - r.analysis_started_at)}`; + return ''; +} +function ensureAnaTicker() { + if (state.anaTicker) return; + state.anaTicker = setInterval(() => { + document.querySelectorAll('.ana-time.live').forEach(el => { + const t0 = +el.dataset.anaStart; + if (t0) el.textContent = fmt(Date.now() - t0); + }); + }, 1000); +} + +// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the +// selected recording's detail (with its own audio player). Crucially, the +// while-analysing auto-refresh only rebuilds the LEFT list — it never touches +// the RIGHT detail pane, so you can keep listening to an old recording while a +// new one is being transcribed/analysed (the old bug: the whole view — detail + +// audio element — was re-rendered every 5s, stopping playback). +function viewRecordings() { + const v = document.querySelector('#view'); + v.innerHTML = ` +
+ +
← Aufnahme wählen
+
`; + refreshRecList(); + if (state.selectedId != null) openRecording(state.selectedId); +} + +// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh +// timer; safe to run while the right pane holds a playing recording. +async function refreshRecList() { + const listEl = document.querySelector('#reclist'); + if (!listEl) return; // left the tab + let recs; + try { recs = await api.get('/api/recordings'); } + catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } + // Reschedule the poll ONLY while something is still processing. + const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing'); + const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); }; + try { + if (!recs.length) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } + // Signature of everything the list RENDERS except the live ⏱ seconds (those + // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed, + // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept. + // This is the real fix for "I get pulled away from my recording every 5s": + // the 5s poll now no-ops unless a status/title/todo actually changed. + const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}`).join('~'); + if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; } + state.lastListSig = sig; + // Preserve the left pane's scroll position across the rebuild. + const scroller = listEl.closest('.rec-list-pane') || listEl; + const savedTop = scroller.scrollTop; + // Group by calendar day (recs come newest-first). Each day is a collapsible + // section; the day header shows the recording count. + const groups = []; + const byDay = {}; + for (const r of recs) { + const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); + if (!byDay[day]) { byDay[day] = []; groups.push(day); } + byDay[day].push(r); + } + listEl.innerHTML = groups.map(day => { + const collapsed = state.collapsedDates.has(day); + const items = byDay[day].map(r => { const at = anaTimeSpan(r); return ` +
+
+ ${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)} +
${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}
+ ${r.summary ? `
${escapeHtml(r.summary)}
` : ''} +
+ +
`; }).join(''); + return ` +
+
▾ ${escapeHtml(day)} ${byDay[day].length}
+
${items}
+
`; + }).join(''); + // Click a row (not the trash button) → open in the right pane. + listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => { + if (e.target.closest('[data-del]')) return; + openRecording(+it.dataset.open); + }); + listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { + e.stopPropagation(); + const rec = recs.find(r => r.id == b.dataset.del); + const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del; + if (!confirm(`${name} wirklich löschen?\nDie Aufnahme, das Transkript und die Analyse werden unwiderruflich entfernt.`)) return; + await api.del('/api/recordings/' + b.dataset.del); + if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; } + state.lastListSig = null; refreshRecList(); + }); + listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => { + const day = h.dataset.day; + if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day); + h.closest('.day-group').classList.toggle('collapsed'); + }); + scroller.scrollTop = savedTop; // keep the list where the user had it + ensureAnaTicker(); + scheduleNext(); + } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } +} + +async function openRecording(id) { + state.selectedId = id; + const detail = document.querySelector('#detail'); + if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); } + // Highlight the selected row without rebuilding the list (keeps it stable). + document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id)); + detail.innerHTML = '
Lade…
'; + const r = await api.get('/api/recordings/' + id); + const pending = r.analysis_status === 'pending'; + const errored = r.analysis_status === 'error'; + const transcribing = r.status === 'transcribing'; + const transcribeFailed = r.status === 'error'; + const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3 + const spkOrder = Object.keys(scls); // first-appearance order + const names = r.speaker_names || {}; // rawSpeaker -> name + const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1)); + detail.innerHTML = ` +
+

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

+ +
+ ${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} + ${transcribing ? '
📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).
' : ''} + ${transcribeFailed ? `
Transkription fehlgeschlagen.
` : ''} + ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU, ~30-90s)
' : ''} + ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} + ${r.summary ? `

Zusammenfassung

${escapeHtml(r.summary)}

` : ''} + ${(r.participants && r.participants.length) ? `

Teilnehmer

    ${r.participants.map(p => `
  • 👤 ${escapeHtml(p)}
  • `).join('')}
` : ''} + ${(r.todos && r.todos.length) ? `

ToDos

    ${r.todos.map(t => `
  • ${escapeHtml(t)}
  • `).join('')}
` : ''} + ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''} +
+ ${spkOrder.length ? ` +

Sprecher (Namen vergeben → App lernt die Stimme)

+
${spkOrder.map((sp, i) => ` +
+ + + + +
`).join('')} +
` : ''} + ${r.transcript_clean ? `

Dialog (KI · Sprecher aus Kontext)

${renderDialog(r.transcript_clean)}
` : ''} +

Original-Transkript

+
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
+
${renderTurns(r.words, names, scls, spkOrder)}
+
`; + const reBtn = detail.querySelector('#reanalyze'); + if (reBtn) reBtn.onclick = async () => { + // Re-runs speaker separation every time: empty = automatic detection (now + // consolidated, so no more "14 speakers" for a 2-person call), a number pins + // the count. Cancel aborts. + const ans = window.prompt('Sprecher neu erkennen?\n(leer = automatisch erkennen · Zahl = feste Anzahl vorgeben)', ''); + if (ans === null) return; + const numSpeakers = parseInt(ans, 10); + const body = numSpeakers > 0 ? { numSpeakers } : {}; + reBtn.disabled = true; reBtn.textContent = 'trennt neu…'; + try { await api.post('/api/recordings/' + id + '/reanalyze', body); setTimeout(() => openRecording(id), 1500); } + catch (e) { toast(e.message, true); } + }; + const rtBtn = detail.querySelector('#retranscribe'); + if (rtBtn) rtBtn.onclick = async () => { + rtBtn.disabled = true; rtBtn.textContent = 'startet…'; + try { await api.post('/api/recordings/' + id + '/retranscribe', {}); setTimeout(() => openRecording(id), 1500); } + catch (e) { toast(e.message, true); rtBtn.disabled = false; } + }; + ensureAnaTicker(); + // Poll while transcription OR the NPU analysis is still running, then refresh. + if (pending || transcribing) setTimeout(() => { const d = document.querySelector('#detail'); if (d && d.querySelector('.analysis')) openRecording(id); }, 5000); + const audio = detail.querySelector('#player'); + const spans = [...detail.querySelectorAll('.w')]; + spans.forEach(s => s.onclick = () => { audio.currentTime = (+s.dataset.start) / 1000 + 0.001; audio.play(); }); + // Whisper (batched) word timestamps run slightly AHEAD of the audio, so a + // pure [start,end] match lit the NEXT word ("eine Zeile weiter") before it was + // spoken. Compensate with a fixed lead offset and highlight the LAST word whose + // start has passed (stays lit through inter-word gaps → no flicker). + // Tune SYNC_MS if the marker still leads (raise) or lags (lower). + const SYNC_MS = 250; + audio.ontimeupdate = () => { + const t = audio.currentTime * 1000 - SYNC_MS; + let active = null; + for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; } + spans.forEach(s => s.classList.toggle('active', s === active)); + if (active) { const r2 = active.getBoundingClientRect(); if (r2.top < 90 || r2.bottom > innerHeight - 40) active.scrollIntoView({ block: 'center', behavior: 'smooth' }); } + }; + // Rename a speaker + (optionally) learn their voice into the DB. + detail.querySelectorAll('.spk-save').forEach(btn => { + btn.onclick = async () => { + const row = btn.closest('.spk-row'); + const name = row.querySelector('.spk-name').value.trim(); + const enroll = row.querySelector('.spk-learn').checked; + btn.disabled = true; btn.textContent = '…'; + try { + const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: btn.dataset.sp, name, enroll }); + toast(res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert')); + openRecording(id); + } catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; } + }; + }); + + // Spacebar toggles play/pause of the open recording (unless typing in a field). + if (state.spaceHandler) document.removeEventListener('keydown', state.spaceHandler); + state.spaceHandler = (e) => { + if (e.code !== 'Space' && e.key !== ' ') return; + const t = e.target, tag = (t.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return; + const a = document.querySelector('#player'); + if (!a) return; + e.preventDefault(); + if (a.paused) a.play(); else a.pause(); + }; + document.addEventListener('keydown', state.spaceHandler); + + // Two-pane layout: scroll the detail pane to its top. On a narrow (stacked) + // screen also bring the detail into view since it sits below the list. + const pane = detail.closest('.rec-detail-pane'); + if (pane) pane.scrollTo({ top: 0, behavior: 'smooth' }); + if (window.matchMedia('(max-width: 800px)').matches) detail.scrollIntoView({ behavior: 'smooth' }); +} + +function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } +// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3. +function speakerClasses(words) { + const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; + for (const w of words) { if (w.speaker && !(w.speaker in m)) { m[w.speaker] = palette[i % palette.length]; i++; } } + return m; +} +// Render the raw transcript as speaker turns with a NAME header per turn. Each +// word stays its own .w span (click-to-seek + active highlight still work). +function renderTurns(words, names, scls, order) { + if (!words || !words.length) return 'Kein Transkript.'; + const idx = {}; order.forEach((s, i) => idx[s] = i); + const label = sp => (names && names[sp]) || (sp ? 'Sprecher ' + ((idx[sp] ?? 0) + 1) : ''); + let html = '', cur = '\0', buf = []; + const flush = () => { + if (!buf.length) return; + const nm = label(cur === '\0' ? null : cur); + html += `
${nm ? `${escapeHtml(nm)} ` : ''}${buf.join(' ')}
`; + buf = []; + }; + for (const w of words) { + const sp = w.speaker || null; + if (sp !== cur) { flush(); cur = sp; } + buf.push(`${escapeHtml(w.word)}`); + } + flush(); + return html; +} +// Render "Name: Text" lines as a colour-coded dialogue (speakers inferred by +// the LLM from context — not acoustic). +function renderDialog(text) { + const lines = (text || '').split(/\n+/).map(l => l.trim()).filter(Boolean); + const colors = {}; let ci = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; + return lines.map(l => { + const m = l.match(/^([^:]{1,30}):\s*(.*)$/); + if (!m) return `
${escapeHtml(l)}
`; + const spk = m[1].trim(); + if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; } + return `
${escapeHtml(spk)} ${escapeHtml(m[2])}
`; + }).join(''); +} +function analysisBadge(st) { + if (st === 'pending') return '🧠 Analyse…'; + if (st === 'error') return 'KI-Fehler'; + if (st === 'done') return 'KI ✓'; + return ''; +} + +render(); diff --git a/app/web/src/style.css b/app/web/src/style.css new file mode 100644 index 0000000..d73ed94 --- /dev/null +++ b/app/web/src/style.css @@ -0,0 +1,106 @@ +:root { + --bg: #0e1116; --card: #171c24; --line: #262d38; --fg: #e7edf5; --muted: #8a97a8; + --accent: #4f8cff; --ok: #2ecc71; --warn: #f0b429; --danger: #ff5b5b; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif; } +#app { max-width: 860px; margin: 0 auto; padding: 16px; } +#app.wide { max-width: 1180px; } +header h1 { margin: 0 0 4px; font-size: 22px; } +header .sub { font-size: 12px; color: var(--muted); font-weight: 400; } +nav { display: flex; gap: 8px; margin: 14px 0; } +.tab { background: var(--card); color: var(--muted); border: 1px solid var(--line); padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 14px; } +.tab.active { color: #fff; border-color: var(--accent); background: #1c2735; } +.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 16px; } +.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.grow { flex: 1; } +.btn { background: #222a35; color: var(--fg); border: 1px solid var(--line); border-radius: 9px; padding: 8px 14px; cursor: pointer; font-size: 14px; } +.btn:hover { border-color: var(--accent); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.danger { background: #3a2224; border-color: #5a2f31; color: var(--danger); } +.btn.big { font-size: 17px; padding: 14px 22px; border-radius: 12px; } +.btn:disabled { opacity: .6; cursor: default; } +.lbl { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; } +.input, select { width: 100%; background: #0f141b; color: var(--fg); border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; font-size: 14px; } +.list { display: flex; flex-direction: column; gap: 8px; } +.item { display: flex; gap: 10px; align-items: center; background: #0f141b; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; } +.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; background: #222a35; color: var(--muted); } +.badge.ok { background: #14351f; color: var(--ok); } +.badge.warn { background: #352c14; color: var(--warn); } +.muted { color: var(--muted); } +.mono { font-family: ui-monospace, monospace; font-size: 12px; } +.err { color: var(--danger); } +.rec-panel { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 22px 0 8px; } +.timer { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 600; } +h2 { margin: 0 0 12px; } +audio { width: 100%; margin-bottom: 10px; } +.hint { font-size: 12px; color: var(--muted); margin-bottom: 10px; } +.transcript { line-height: 2.1; font-size: 16px; } +.transcript .w { cursor: pointer; padding: 1px 2px; border-radius: 4px; transition: background .1s; } +.transcript .w:hover { background: #24303f; } +.transcript .w.active { background: var(--accent); color: #fff; } +.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #1c2735; border: 1px solid var(--accent); color: #fff; padding: 10px 18px; border-radius: 10px; z-index: 99; max-width: 90%; } +.toast.err { border-color: var(--danger); } + +/* LLM analysis */ +.summary-snip{font-size:.85rem;color:#9fb3c8;margin-top:4px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} +.analysis{margin:10px 0;padding:10px;background:#111a24;border-radius:8px} +.analysis h3{margin:8px 0 4px;font-size:.9rem;color:#cbd5e1} +.summary{margin:0 0 6px;line-height:1.5;white-space:pre-line} +.todos{margin:0;padding-left:20px} +.todos li{margin:3px 0} +.err-inline{color:#f87171;margin-bottom:6px} +.btn.small{font-size:.8rem;padding:3px 8px} +.hint{font-size:.8rem;color:#8aa} + +.parts{margin:0;padding-left:20px}.parts li{margin:3px 0} +.clean{line-height:1.55;background:#0e1620;padding:8px 10px;border-radius:8px;margin:0 0 4px} + +.dialog{display:flex;flex-direction:column;gap:6px;margin-bottom:4px} +.turn{padding:6px 10px;border-radius:8px;background:#0e1620;line-height:1.5} +.turn .spk{margin-right:6px} +.turn.sp0{border-left:3px solid #38bdf8}.turn.sp0 .spk{color:#38bdf8} +.turn.sp1{border-left:3px solid #f472b6}.turn.sp1 .spk{color:#f472b6} +.turn.sp2{border-left:3px solid #a3e635}.turn.sp2 .spk{color:#a3e635} +.turn.sp3{border-left:3px solid #fbbf24}.turn.sp3 .spk{color:#fbbf24} + +.w.sp0{color:#7dd3fc}.w.sp1{color:#f9a8d4}.w.sp2{color:#bef264}.w.sp3{color:#fcd34d} +.w.active{background:#334155;color:#fff} +/* speaker name editor (rename + learn voiceprint) */ +.spk-edit{display:flex;flex-direction:column;gap:6px;margin:4px 0 8px} +.spk-row{display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:8px;background:#0e1620;border-left:3px solid #334155} +.spk-row.sp0{border-left-color:#38bdf8}.spk-row.sp1{border-left-color:#f472b6}.spk-row.sp2{border-left-color:#a3e635}.spk-row.sp3{border-left-color:#fbbf24} +.spk-dot{width:10px;height:10px;border-radius:50%;background:#64748b;flex:none} +.spk-row.sp0 .spk-dot{background:#38bdf8}.spk-row.sp1 .spk-dot{background:#f472b6}.spk-row.sp2 .spk-dot{background:#a3e635}.spk-row.sp3 .spk-dot{background:#fbbf24} +.spk-name{flex:1;min-width:80px;padding:5px 8px;border-radius:6px;border:1px solid #334155;background:#0b1220;color:#e2e8f0} +.spk-learn-l{display:flex;align-items:center;gap:4px;font-size:12px;color:#94a3b8;white-space:nowrap} + +.ana-time{font-variant-numeric:tabular-nums;font-weight:600} +.ana-time.live{color:#4ea1ff} +.ana-line{margin:2px 0 8px} + +/* ---- Recordings split layout: list (left) + detail (right) ---- */ +.rec-layout{display:flex;gap:14px;align-items:flex-start} +.rec-list-pane{flex:0 0 340px;position:sticky;top:12px;max-height:calc(100vh - 90px);overflow-y:auto;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:10px} +.rec-detail-pane{flex:1 1 auto;min-width:0} +.rec-detail-pane .card{margin-bottom:0} +.detail-empty{padding:40px 16px;text-align:center;border:1px dashed var(--line);border-radius:14px} +/* date groups */ +.day-group{margin-bottom:6px} +.day-head{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;padding:6px 4px;user-select:none} +.day-head:hover{color:var(--fg)} +.day-caret{display:inline-block;transition:transform .15s;font-size:10px} +.day-group.collapsed .day-caret{transform:rotate(-90deg)} +.day-group.collapsed .day-items{display:none} +.day-count{margin-left:auto;background:#222a35;border-radius:20px;padding:1px 8px;font-size:11px} +.day-items{display:flex;flex-direction:column;gap:6px} +/* list rows become clickable cards; selected row highlighted */ +.item.rec{cursor:pointer;transition:border-color .1s,background .1s} +.item.rec:hover{border-color:var(--accent)} +.item.rec.sel{border-color:var(--accent);background:#1c2735} +.btn.icon{padding:6px 9px;flex:none} +@media(max-width:800px){ + #app.wide{max-width:860px} + .rec-layout{flex-direction:column} + .rec-list-pane{flex:none;width:100%;position:static;max-height:340px} +} diff --git a/app/web/vite.config.js b/app/web/vite.config.js new file mode 100644 index 0000000..be03acd --- /dev/null +++ b/app/web/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: true, + proxy: { + '/api': 'http://localhost:3000', + '/media': 'http://localhost:3000' + } + }, + build: { outDir: 'dist', emptyOutDir: true } +}); diff --git a/docs/08-changelog.md b/docs/08-changelog.md index a6cf1be..0000bde 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -4,6 +4,7 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hi Was, Warum, betroffene Dateien/Backups). ## 2026-07-16 +- **Codebase ins Repo aufgenommen:** `app/` = Spiegel von `/opt/callrec` (Backend, Frontend-Quellen, Inferenz-Python, Skripte, `rkllm_server.cpp`), `system/` = systemd-Units + WirePlumber/PipeWire-Drop-ins + sudoers. Modelle/Binaries/Aufnahmen bewusst außen vor (`.gitignore`). - **Projekt umbenannt in „Aime“** (vormals CallRec, wie der BT-Name des Boards): Gitea-Repo → `sascha/Aime`, Web-GUI-Titel/Header → „Aime“ (Bundle `index-CuWeUh1j.js`, Backups `index.html.bak-aime`/`main.js.bak-aime`), Doku rebranded. Technische Bezeichner auf dem Board bleiben `callrec*` (Stabilität). diff --git a/system/etc/sudoers.d/callrec-cpucap b/system/etc/sudoers.d/callrec-cpucap new file mode 100644 index 0000000..2563773 --- /dev/null +++ b/system/etc/sudoers.d/callrec-cpucap @@ -0,0 +1 @@ +callrec ALL=(root) NOPASSWD: /opt/callrec/aime-cpucap.sh diff --git a/system/etc/systemd/system/aime-audio-out.service b/system/etc/systemd/system/aime-audio-out.service new file mode 100644 index 0000000..6799937 --- /dev/null +++ b/system/etc/systemd/system/aime-audio-out.service @@ -0,0 +1,14 @@ +[Unit] +Description=Aime PC->headset audio bridge (USB gadget capture -> ES8323 playback) +After=aime-gadget.service sound.target +Wants=aime-gadget.service + +[Service] +Type=simple +ExecStartPre=/opt/callrec/aime-mixer-out.sh +ExecStart=/bin/sh -c 'exec arecord -D hw:2,0 -f S16_LE -r 48000 -c 2 -B 300000 -F 30000 | aplay -D plughw:0 -f S16_LE -r 48000 -c 2 -B 300000 -F 30000' +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/aime-gadget.service b/system/etc/systemd/system/aime-gadget.service new file mode 100644 index 0000000..e5fa7dd --- /dev/null +++ b/system/etc/systemd/system/aime-gadget.service @@ -0,0 +1,13 @@ +[Unit] +Description=Aime UAC2 USB audio gadget +After=sys-kernel-config.mount usb-gadget.target +Wants=sys-kernel-config.mount + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/aime-gadget.sh +ExecStop=/bin/sh -c "echo > /sys/kernel/config/usb_gadget/aime/UDC 2>/dev/null; true" + +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/aime-headset-keys.service b/system/etc/systemd/system/aime-headset-keys.service new file mode 100644 index 0000000..9467900 --- /dev/null +++ b/system/etc/systemd/system/aime-headset-keys.service @@ -0,0 +1,10 @@ +[Unit] +Description=Aime H390 inline volume/mute buttons -> headset sink +After=sound.target +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/callrec/aime-headset-keys.py +Restart=always +RestartSec=2 +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/aime-volume.service b/system/etc/systemd/system/aime-volume.service new file mode 100644 index 0000000..c3fa089 --- /dev/null +++ b/system/etc/systemd/system/aime-volume.service @@ -0,0 +1,13 @@ +[Unit] +Description=Aime Windows volume -> ES8323 output bridge +After=aime-audio-out.service +Wants=aime-audio-out.service + +[Service] +Type=simple +ExecStart=/opt/callrec/aime-volbridge-alsa.sh +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/callrec-btwatch.service b/system/etc/systemd/system/callrec-btwatch.service new file mode 100644 index 0000000..e9070a0 --- /dev/null +++ b/system/etc/systemd/system/callrec-btwatch.service @@ -0,0 +1,12 @@ +[Unit] +Description=CallRec BT headset auto-connect watcher +After=bluetooth.service +Requires=bluetooth.service + +[Service] +ExecStart=/opt/callrec/btwatch.sh +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/callrec-perf.service b/system/etc/systemd/system/callrec-perf.service new file mode 100644 index 0000000..34e4e1f --- /dev/null +++ b/system/etc/systemd/system/callrec-perf.service @@ -0,0 +1,12 @@ +[Unit] +Description=CallRec performance tuning (CPU governor = performance) +After=multi-user.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/sh -c 'for g in /sys/devices/system/cpu/cpufreq/policy*/scaling_governor; do echo performance > $g; done' +ExecStop=/bin/sh -c 'for g in /sys/devices/system/cpu/cpufreq/policy*/scaling_governor; do echo ondemand > $g; done' + +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/callrec.service b/system/etc/systemd/system/callrec.service new file mode 100644 index 0000000..8eb20e7 --- /dev/null +++ b/system/etc/systemd/system/callrec.service @@ -0,0 +1,19 @@ +[Unit] +Description=CallRec - BT recording + Whisper transcription web app +After=network-online.target mariadb.service +Wants=network-online.target + +[Service] +Type=simple +User=callrec +WorkingDirectory=/opt/callrec +Environment=NODE_ENV=production +Environment=XDG_RUNTIME_DIR=/run/user/1000 +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus +Environment=PORT=3000 +ExecStart=/usr/bin/node /opt/callrec/server.js +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/system/etc/systemd/system/callrec.service.d/gemma-model.conf b/system/etc/systemd/system/callrec.service.d/gemma-model.conf new file mode 100644 index 0000000..757275b --- /dev/null +++ b/system/etc/systemd/system/callrec.service.d/gemma-model.conf @@ -0,0 +1,2 @@ +[Service] +Environment=RKLLM_MODEL=/opt/rkllm-models/gemma3-4b-w8a8g128-16k.rkllm diff --git a/system/etc/wireplumber/wireplumber.conf.d/50-headless-bluez.conf b/system/etc/wireplumber/wireplumber.conf.d/50-headless-bluez.conf new file mode 100644 index 0000000..0442d16 --- /dev/null +++ b/system/etc/wireplumber/wireplumber.conf.d/50-headless-bluez.conf @@ -0,0 +1,5 @@ +wireplumber.profiles = { + main = { + monitor.bluez.seat-monitoring = disabled + } +} diff --git a/system/etc/wireplumber/wireplumber.conf.d/53-bluez-nosuspend.conf b/system/etc/wireplumber/wireplumber.conf.d/53-bluez-nosuspend.conf new file mode 100644 index 0000000..7cb9d4c --- /dev/null +++ b/system/etc/wireplumber/wireplumber.conf.d/53-bluez-nosuspend.conf @@ -0,0 +1,6 @@ +monitor.bluez.rules = [ + { + matches = [ { node.name = "~bluez_.*" } ] + actions = { update-props = { session.suspend-timeout-seconds = 0 node.pause-on-idle = false } } + } +] diff --git a/system/etc/wireplumber/wireplumber.conf.d/54-es8323-clock.conf b/system/etc/wireplumber/wireplumber.conf.d/54-es8323-clock.conf new file mode 100644 index 0000000..d0fd2a4 --- /dev/null +++ b/system/etc/wireplumber/wireplumber.conf.d/54-es8323-clock.conf @@ -0,0 +1,18 @@ +# es8323: S16 mode buggy -> force S24LE. Also rockchip-es8323 glitches under pw's +# MMAP/timer access (arecord RW is clean) -> force RW (disable-mmap) + big period. +monitor.alsa.rules = [ + { + matches = [ { node.name = "~alsa_(input|output)\.platform-es8323-sound.*" } ] + actions = { + update-props = { + audio.format = "S24LE" + api.alsa.disable-mmap = true + api.alsa.disable-batch = true + api.alsa.period-size = 1024 + api.alsa.headroom = 8192 + session.suspend-timeout-seconds = 0 + node.pause-on-idle = false + } + } + } +] diff --git a/system/home/callrec/.config/pipewire/filter-chain.conf.d/micgate.conf b/system/home/callrec/.config/pipewire/filter-chain.conf.d/micgate.conf new file mode 100644 index 0000000..9e0c7d0 --- /dev/null +++ b/system/home/callrec/.config/pipewire/filter-chain.conf.d/micgate.conf @@ -0,0 +1,15 @@ +context.modules = [ + { name = libpipewire-module-filter-chain + args = { + node.description = "Mic Gate" + filter.graph = { + nodes = [ + { type = ladspa name = gate plugin = "/usr/lib/ladspa/gate_1410.so" label = gate + control = { "LF key filter (Hz)" = 50.0 "HF key filter (Hz)" = 8000.0 "Threshold (dB)" = -45.0 "Attack (ms)" = 5.0 "Hold (ms)" = 250.0 "Decay (ms)" = 200.0 "Range (dB)" = -60.0 "Output select (-1 = key listen, 0 = gate, 1 = bypass)" = 0 } } + ] + } + capture.props = { node.name = "micgate" media.class = Audio/Sink audio.channels = 1 audio.position = [ MONO ] } + playback.props = { node.name = "micgate.out" node.target = "alsa_output.platform-fc000000.usb.stereo-fallback" node.passive = true audio.channels = 1 audio.position = [ MONO ] } + } + } +] diff --git a/system/home/callrec/.config/systemd/user/aime-iphone-route.service b/system/home/callrec/.config/systemd/user/aime-iphone-route.service new file mode 100644 index 0000000..c60748e --- /dev/null +++ b/system/home/callrec/.config/systemd/user/aime-iphone-route.service @@ -0,0 +1,10 @@ +[Unit] +Description=CallRec iPhone HFP call auto-routing (gated mic uplink + far-end into rec mix) +After=wireplumber.service + +[Service] +ExecStart=/opt/callrec/aime-iphone-route.sh +Restart=always + +[Install] +WantedBy=default.target diff --git a/system/home/callrec/.config/systemd/user/aime-micgate.service b/system/home/callrec/.config/systemd/user/aime-micgate.service new file mode 100644 index 0000000..ee6d1e3 --- /dev/null +++ b/system/home/callrec/.config/systemd/user/aime-micgate.service @@ -0,0 +1,15 @@ +[Unit] +Description=CallRec mic noise-gate (PipeWire filter-chain: kills far-end self-echo) +After=wireplumber.service pipewire-pulse.service +Wants=wireplumber.service + +[Service] +# Loads ~/.config/pipewire/filter-chain.conf.d/micgate.conf via the stock +# filter-chain.conf boilerplate; creates the "micgate" Audio/Sink (LADSPA +# gate_1410) whose output feeds the UAC2 gadget sink (Teams/Discord mic). +ExecStart=/usr/bin/pipewire -c filter-chain.conf +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/system/home/callrec/.config/systemd/user/aime-usb-audio.service b/system/home/callrec/.config/systemd/user/aime-usb-audio.service new file mode 100644 index 0000000..6e62740 --- /dev/null +++ b/system/home/callrec/.config/systemd/user/aime-usb-audio.service @@ -0,0 +1,10 @@ +[Unit] +Description=CallRec USB-headset audio topology (null-sink mix + loopbacks) +After=wireplumber.service pipewire-pulse.service +Wants=wireplumber.service +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/opt/callrec/aime-usb-audio-setup.sh +[Install] +WantedBy=default.target diff --git a/system/home/callrec/.config/systemd/user/aime-volume-headset.service b/system/home/callrec/.config/systemd/user/aime-volume-headset.service new file mode 100644 index 0000000..028852f --- /dev/null +++ b/system/home/callrec/.config/systemd/user/aime-volume-headset.service @@ -0,0 +1,11 @@ +[Unit] +Description=CallRec Windows volume -> USB headset bridge +After=aime-usb-audio.service +Wants=aime-usb-audio.service +[Service] +Type=simple +ExecStart=/opt/callrec/aime-volbridge-headset.sh +Restart=always +RestartSec=2 +[Install] +WantedBy=default.target diff --git a/system/home/callrec/.config/systemd/user/filter-chain.service b/system/home/callrec/.config/systemd/user/filter-chain.service new file mode 100644 index 0000000000000000000000000000000000000000..8bb671be817c25dbcdc300df060757f3b65c8a07 GIT binary patch literal 32 lcmY$iDXz@T$;`{P`~Od$A%!88p^QPFA&;SyAqNN<7yz@K2>1X1 literal 0 HcmV?d00001 diff --git a/system/home/callrec/.config/wireplumber/wireplumber.conf.d/51-bluez-hfp-hf.conf b/system/home/callrec/.config/wireplumber/wireplumber.conf.d/51-bluez-hfp-hf.conf new file mode 100644 index 0000000..9357d42 --- /dev/null +++ b/system/home/callrec/.config/wireplumber/wireplumber.conf.d/51-bluez-hfp-hf.conf @@ -0,0 +1,4 @@ +monitor.bluez.properties = { + bluez5.roles = [ a2dp_sink hfp_hf hsp_hs ] + bluez5.hfphsp-backend = "native" +}