diff --git a/erindringer.py b/erindringer.py index 53816bb..c4881d5 100644 --- a/erindringer.py +++ b/erindringer.py @@ -392,18 +392,82 @@ def slet_klip(pid: str, kid: str) -> bool: if not k: return False d = _klipmappe(pid) - for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3"): + for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3", f"{kid}-ren.mp3"): (d / navn).unlink(missing_ok=True) return True -def spoergsmaal_fil(pid: str, kid: str) -> Path | None: +def spoergsmaal_fil(pid: str, kid: str, lav: bool = True) -> Path | None: + """Spørgsmålets oplæsning til et svar. Mangler den (ældre klip), laves den fra den talte tekst; + teksten er den samme, så tts-cachen rammer oftest.""" if not PID.match(pid or "") or not KLIPID.match(kid or ""): return None p = _klipmappe(pid) / f"{kid}-sp.mp3" + if p.exists(): + return p + if not lav: + return None + k = klip(pid, kid) + if not k: + return None + sp = tts_sti(k.get("spoergsmaal_talt") or k.get("spoergsmaal") or "") + if not sp: + return None + p.write_bytes(sp.read_bytes()) + k["spoergsmaal_lyd"] = True + _skriv(_klipmappe(pid) / f"{kid}.json", k) + return p + + +def renset_fil(pid: str, kid: str) -> Path | None: + if not PID.match(pid or "") or not KLIPID.match(kid or ""): + return None + p = _klipmappe(pid) / f"{kid}-ren.mp3" return p if p.exists() else None +def isoler_stemme(pid: str, kid: str) -> bool: + """ElevenLabs Audio Isolation: fjern baggrundsstøj fra svaret -> -ren.mp3 (valgfrit, pr. klip; + koster ca. 1000 credits pr. minut, så det er en admin-knap, ikke standard).""" + f = klip_fil(pid, kid) + if not f or not ELEVEN_KEY: + return False + try: + with open(f, "rb") as fh: + r = httpx.post("https://api.elevenlabs.io/v1/audio-isolation", headers={"xi-api-key": ELEVEN_KEY}, + files={"audio": (f.name, fh, "audio/mpeg")}, timeout=600) + if r.status_code != 200 or len(r.content) < 1000: + return False + except httpx.HTTPError: + return False + ud = _klipmappe(pid) / f"{kid}-ren.mp3" + tmp = ud.with_name("." + ud.name + ".tmp") + tmp.write_bytes(r.content) + os.replace(tmp, ud) + k = klip(pid, kid) + if k: + k["renset"] = True + _skriv(_klipmappe(pid) / f"{kid}.json", k) + return True + + +def stt_token() -> dict | None: + """Engangs-token til browserens direkte forbindelse til ElevenLabs' realtime-STT (15 min, kun STT), + så API-nøglen aldrig forlader serveren.""" + if not ELEVEN_KEY: + return None + try: + r = httpx.post("https://api.elevenlabs.io/v1/single-use-token/realtime_scribe", + headers={"xi-api-key": ELEVEN_KEY}, timeout=30) + if r.status_code == 200 and r.json().get("token"): + t = r.json()["token"] + return {"token": t, "url": "wss://api.elevenlabs.io/v1/speech-to-text/realtime?model_id=scribe_v2_realtime" + f"&token={t}&audio_format=pcm_16000&sample_rate=16000&commit_strategy=vad&language_code=da"} + except (httpx.HTTPError, ValueError): + pass + return None + + # ---------------- ElevenLabs: tale ---------------- def transskriber(mp3: Path) -> str: @@ -613,6 +677,9 @@ def eksport(pid: str) -> bytes: sp = spoergsmaal_fil(pid, x["id"]) if sp: z.write(sp, f"{x['id']}-sp.mp3") + ren = renset_fil(pid, x["id"]) + if ren: + z.write(ren, f"{x['id']}-ren.mp3") z.writestr(f"{x['id']}.json", json.dumps(x, ensure_ascii=False, indent=1)) linjer.append(f"## {x['id']} · {x.get('tid', '')[:16]} · {x.get('projekt')}{' · favorit' if x.get('favorit') else ''}\n" f"Spørgsmål ({x.get('kilde') or 'ukendt'}{', oplæsning i ' + x['id'] + '-sp.mp3' if sp else ''}): {x.get('spoergsmaal', '')}\n\n" diff --git a/main.py b/main.py index c2fe676..af83593 100644 --- a/main.py +++ b/main.py @@ -700,6 +700,11 @@ async def erindringer_tts(sha: str): def _klip_svar(pid: str, kid: str): if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: -sp.mp3 return _spoergsmaal_svar(pid, kid[:-3]) + if kid.endswith("-ren"): # svaret med baggrundsstøj fjernet (valgfrit) + f = erindringer.renset_fil(pid, kid[:-4]) + if not f: + raise HTTPException(404) + return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"}) f = erindringer.klip_fil(pid, kid) if not f: raise HTTPException(404) @@ -825,7 +830,20 @@ async def erindringer_svar(request: Request): return _fejl(400, str(e)) _i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"])) return {"id": meta["id"], "tekst": meta["tekst"], "status": meta["status"], "varighed": meta["varighed"], - "samtale": samtale, "kladde": kladde} + "samtale": samtale, "kladde": kladde, "skrevet": meta.get("skrevet", False)} + + +@app.post("/erindringer/api/stt-token") +async def erindringer_stt_token(request: Request): + try: + data = await request.json() + except ValueError: + data = {} + _erindringer_person(request, str(data.get("noegle") or "")) + t = await asyncio.to_thread(erindringer.stt_token) + if not t: + return _fejl(503, "Live-afskrift er ikke tilgængelig lige nu.") + return t @app.post("/erindringer/api/status") @@ -838,7 +856,8 @@ async def erindringer_status(request: Request): k = erindringer.klip(p["pid"], str(data.get("id") or "")) if not k: return _fejl(404, "Svaret findes ikke.") - return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)} + return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0), + "skrevet": k.get("skrevet", False)} @app.post("/erindringer/api/bekraeft") @@ -856,7 +875,8 @@ async def erindringer_bekraeft(request: Request): pr = erindringer.projekt(k.get("projekt") or "") if pr: _i_baggrunden(_efter_svar(p, pr, k.get("samtale") or "", k["id"])) - return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)} + return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0), + "skrevet": k.get("skrevet", False)} @app.post("/erindringer/api/slet") @@ -977,6 +997,10 @@ async def admin_erindringer_klip_post(request: Request, pid: str, kid: str): erindringer.slet_klip(pid, kid) elif handling == "ret": erindringer.ret_tekst(pid, kid, str(form.get("tekst") or "")) + elif handling == "isoler": + ok = await asyncio.to_thread(erindringer.isoler_stemme, pid, kid) + if not ok: + return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved=Rensningen+lykkedes+ikke#k-{kid}", status_code=303) return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303) diff --git a/templates/admin_erindringer_person.html b/templates/admin_erindringer_person.html index e25bcd8..1a3d05f 100644 --- a/templates/admin_erindringer_person.html +++ b/templates/admin_erindringer_person.html @@ -33,19 +33,22 @@

{{ klip|length }} svar

+

Markér de svar, der skal med i podcasten, som favorit. Kun favoritter kan renses med voice isolation (ElevenLabs Audio Isolation, cirka 1000 credits pr. minut); det laver en ekstra fil ved siden af originalen og bruges ikke til dagbog.

{% for k in klip %}
{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · {{ k.id }}

{{ k.spoergsmaal }}{% if k.kilde %} {{ k.kilde }}{% endif %}

{% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}

Oplæst som: {{ k.spoergsmaal_talt }}

{% endif %} {% if k.spoergsmaal_lyd %}

Spørgsmålet:

{% endif %} - {% if not k.skrevet %}

Svaret:

{% endif %} + {% if not k.skrevet %}

Svaret:

+ {% if k.renset %}

Svaret, renset (voice isolation):

{% endif %}{% endif %}

+ {% if not k.skrevet and k.favorit %}{% endif %}

diff --git a/templates/erindringer.html b/templates/erindringer.html index caf7c2b..bead650 100644 --- a/templates/erindringer.html +++ b/templates/erindringer.html @@ -109,6 +109,7 @@

+

Tal bare, som du ville fortælle det til et barnebarn.