feat(erindringer): live-afskrift mens man taler, spørgsmål+svar på ét afspillerelement, voice isolation for podcast-favoritter
Browseren streamer 16 kHz PCM direkte til ElevenLabs' realtime-STT med et engangs-token fra serveren (/api/stt-token); teksten vokser under optagelsen og sendes med kladden, så serveren slipper for at skrive af. 'Spørgsmål og svar' bruger ét audio-element (iOS blokerer ellers det andet play()) og laver spørgsmålets lyd on demand for ældre klip. Admin kan rense favorit-klip med ElevenLabs Audio Isolation (<id>-ren.mp3), aldrig som standard. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
+69
-2
@@ -392,18 +392,82 @@ def slet_klip(pid: str, kid: str) -> bool:
|
|||||||
if not k:
|
if not k:
|
||||||
return False
|
return False
|
||||||
d = _klipmappe(pid)
|
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)
|
(d / navn).unlink(missing_ok=True)
|
||||||
return 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 ""):
|
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||||
return None
|
return None
|
||||||
p = _klipmappe(pid) / f"{kid}-sp.mp3"
|
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
|
return p if p.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def isoler_stemme(pid: str, kid: str) -> bool:
|
||||||
|
"""ElevenLabs Audio Isolation: fjern baggrundsstøj fra svaret -> <id>-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 ----------------
|
# ---------------- ElevenLabs: tale ----------------
|
||||||
|
|
||||||
def transskriber(mp3: Path) -> str:
|
def transskriber(mp3: Path) -> str:
|
||||||
@@ -613,6 +677,9 @@ def eksport(pid: str) -> bytes:
|
|||||||
sp = spoergsmaal_fil(pid, x["id"])
|
sp = spoergsmaal_fil(pid, x["id"])
|
||||||
if sp:
|
if sp:
|
||||||
z.write(sp, f"{x['id']}-sp.mp3")
|
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))
|
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"
|
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"
|
f"Spørgsmål ({x.get('kilde') or 'ukendt'}{', oplæsning i ' + x['id'] + '-sp.mp3' if sp else ''}): {x.get('spoergsmaal', '')}\n\n"
|
||||||
|
|||||||
@@ -700,6 +700,11 @@ async def erindringer_tts(sha: str):
|
|||||||
def _klip_svar(pid: str, kid: str):
|
def _klip_svar(pid: str, kid: str):
|
||||||
if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: <id>-sp.mp3
|
if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: <id>-sp.mp3
|
||||||
return _spoergsmaal_svar(pid, kid[:-3])
|
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)
|
f = erindringer.klip_fil(pid, kid)
|
||||||
if not f:
|
if not f:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
@@ -825,7 +830,20 @@ async def erindringer_svar(request: Request):
|
|||||||
return _fejl(400, str(e))
|
return _fejl(400, str(e))
|
||||||
_i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"]))
|
_i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"]))
|
||||||
return {"id": meta["id"], "tekst": meta["tekst"], "status": meta["status"], "varighed": meta["varighed"],
|
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")
|
@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 ""))
|
k = erindringer.klip(p["pid"], str(data.get("id") or ""))
|
||||||
if not k:
|
if not k:
|
||||||
return _fejl(404, "Svaret findes ikke.")
|
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")
|
@app.post("/erindringer/api/bekraeft")
|
||||||
@@ -856,7 +875,8 @@ async def erindringer_bekraeft(request: Request):
|
|||||||
pr = erindringer.projekt(k.get("projekt") or "")
|
pr = erindringer.projekt(k.get("projekt") or "")
|
||||||
if pr:
|
if pr:
|
||||||
_i_baggrunden(_efter_svar(p, pr, k.get("samtale") or "", k["id"]))
|
_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")
|
@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)
|
erindringer.slet_klip(pid, kid)
|
||||||
elif handling == "ret":
|
elif handling == "ret":
|
||||||
erindringer.ret_tekst(pid, kid, str(form.get("tekst") or ""))
|
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)
|
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,19 +33,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
||||||
|
<p class="wt-muted" style="margin:.2rem 0 .8rem;max-width:48rem">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.</p>
|
||||||
{% for k in klip %}
|
{% for k in klip %}
|
||||||
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
|
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
|
||||||
<div class="wt-muted" style="font-size:.85rem">{{ 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 %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
|
<div class="wt-muted" style="font-size:.85rem">{{ 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 %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
|
||||||
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong>{% if k.kilde %} <span class="wt-tag muted">{{ k.kilde }}</span>{% endif %}</p>
|
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong>{% if k.kilde %} <span class="wt-tag muted">{{ k.kilde }}</span>{% endif %}</p>
|
||||||
{% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}<p class="wt-muted" style="margin:0 0 .3rem;font-size:.85rem">Oplæst som: {{ k.spoergsmaal_talt }}</p>{% endif %}
|
{% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}<p class="wt-muted" style="margin:0 0 .3rem;font-size:.85rem">Oplæst som: {{ k.spoergsmaal_talt }}</p>{% endif %}
|
||||||
{% if k.spoergsmaal_lyd %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Spørgsmålet:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-sp.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
|
{% if k.spoergsmaal_lyd %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Spørgsmålet:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-sp.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
|
||||||
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
|
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>
|
||||||
|
{% if k.renset %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret, renset (voice isolation):</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-ren.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}{% endif %}
|
||||||
<form method="post" action="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}" style="margin:.5rem 0 0">
|
<form method="post" action="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}" style="margin:.5rem 0 0">
|
||||||
<textarea class="wt-input" name="tekst" rows="4" style="width:100%">{{ k.tekst }}</textarea>
|
<textarea class="wt-input" name="tekst" rows="4" style="width:100%">{{ k.tekst }}</textarea>
|
||||||
<p style="margin:.5rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
|
<p style="margin:.5rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
|
||||||
<button class="wt-btn" name="handling" value="ret">Gem tekst</button>
|
<button class="wt-btn" name="handling" value="ret">Gem tekst</button>
|
||||||
<button class="wt-btn{% if k.favorit %} wt-btn--primary{% endif %}" name="handling" value="favorit" onclick="this.form.favorit.value='{{ '0' if k.favorit else '1' }}'">{{ '★ Favorit' if k.favorit else '☆ Markér som favorit' }}</button>
|
<button class="wt-btn{% if k.favorit %} wt-btn--primary{% endif %}" name="handling" value="favorit" onclick="this.form.favorit.value='{{ '0' if k.favorit else '1' }}'">{{ '★ Favorit' if k.favorit else '☆ Markér som favorit' }}</button>
|
||||||
<input type="hidden" name="favorit" value="1">
|
<input type="hidden" name="favorit" value="1">
|
||||||
|
{% if not k.skrevet and k.favorit %}<button class="wt-btn" name="handling" value="isoler" title="ElevenLabs Audio Isolation fjerner baggrundsstøj; cirka 1000 credits pr. minut" onclick="return confirm('Rens lyden med voice isolation? Det koster cirka 1000 credits pr. minut.')">{{ 'Rens igen' if k.renset else 'Rens lyd (voice isolation)' }}</button>{% endif %}
|
||||||
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
|
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+134
-14
@@ -109,6 +109,7 @@
|
|||||||
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
||||||
</button>
|
</button>
|
||||||
<p class="er-status" id="er-status" aria-live="polite"></p>
|
<p class="er-status" id="er-status" aria-live="polite"></p>
|
||||||
|
<p class="er-tekst" id="er-live" hidden style="width:100%;max-width:32rem;text-align:left;min-height:2.4rem"><span id="er-live-fast"></span><span id="er-live-delvis" class="wt-muted"></span></p>
|
||||||
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
||||||
|
|
||||||
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
||||||
@@ -158,10 +159,9 @@
|
|||||||
<p class="er-q">{{ k.spoergsmaal }}</p>
|
<p class="er-q">{{ k.spoergsmaal }}</p>
|
||||||
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
|
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
|
||||||
<div class="er-knapper" style="justify-content:flex-start;margin-top:.4rem">
|
<div class="er-knapper" style="justify-content:flex-start;margin-top:.4rem">
|
||||||
{% if k.spoergsmaal_lyd %}<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>{% endif %}
|
<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>
|
||||||
<button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button>
|
<button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button>
|
||||||
</div>
|
</div>
|
||||||
{% if k.spoergsmaal_lyd %}<audio preload="none" data-sp="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}-sp.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}"></audio>{% endif %}
|
|
||||||
<audio controls preload="none" data-svar="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.4rem"></audio>
|
<audio controls preload="none" data-svar="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.4rem"></audio>
|
||||||
<div class="er-ret"></div>
|
<div class="er-ret"></div>
|
||||||
</li>
|
</li>
|
||||||
@@ -197,7 +197,8 @@
|
|||||||
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
|
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
|
||||||
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
|
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
|
||||||
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring'),
|
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring'),
|
||||||
dag: el('er-dag'), dato: el('er-dato')
|
dag: el('er-dag'), dato: el('er-dato'),
|
||||||
|
live: el('er-live'), liveFast: el('er-live-fast'), liveDelvis: el('er-live-delvis')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tilstand for den igangværende samtale
|
// Tilstand for den igangværende samtale
|
||||||
@@ -309,20 +310,27 @@
|
|||||||
retTekst(b.dataset.ret, els, b.parentNode);
|
retTekst(b.dataset.ret, els, b.parentNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// "Spørgsmål og svar": spil spørgsmålets oplæsning, og derefter svaret
|
// "Spørgsmål og svar": ÉT afspiller-element spiller først spørgsmålet og skifter så til svaret.
|
||||||
|
// (To elementer virker ikke på iPhone: kun det element, man selv trykkede på, må spille videre.)
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var b = e.target.closest && e.target.closest('[data-begge]');
|
var b = e.target.closest && e.target.closest('[data-begge]');
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
var li = b.closest('[data-id]');
|
var li = b.closest('[data-id]');
|
||||||
var sp = li && li.querySelector('audio[data-sp]');
|
|
||||||
var sv = li && li.querySelector('audio[data-svar]');
|
var sv = li && li.querySelector('audio[data-svar]');
|
||||||
if (!sv) return;
|
if (!sv) return;
|
||||||
document.querySelectorAll('audio').forEach(function (a) { if (a !== sp && a !== sv) { try { a.pause(); } catch (x) {} } });
|
document.querySelectorAll('audio').forEach(function (a) { if (a !== sv) { try { a.pause(); } catch (x) {} } });
|
||||||
var spilSvar = function () { try { sv.currentTime = 0; var p = sv.play(); if (p && p.catch) p.catch(function () {}); } catch (x) {} };
|
var svarSrc = sv.dataset.svarSrc || sv.getAttribute('src');
|
||||||
if (sp) {
|
sv.dataset.svarSrc = svarSrc;
|
||||||
sp.onended = spilSvar;
|
var spSrc = svarSrc.replace(/\.mp3(\?|$)/, '-sp.mp3$1');
|
||||||
try { sp.currentTime = 0; var p = sp.play(); if (p && p.catch) p.catch(spilSvar); } catch (x) { spilSvar(); }
|
var tilSvar = function () {
|
||||||
} else spilSvar();
|
sv.onended = null; sv.onerror = null;
|
||||||
|
sv.src = svarSrc; sv.currentTime = 0;
|
||||||
|
var p = sv.play(); if (p && p.catch) p.catch(function () {});
|
||||||
|
};
|
||||||
|
sv.onended = tilSvar;
|
||||||
|
sv.onerror = tilSvar; // intet spørgsmål gemt (eller det kunne ikke laves): spil svaret
|
||||||
|
sv.src = spSrc; sv.currentTime = 0;
|
||||||
|
var p = sv.play(); if (p && p.catch) p.catch(tilSvar);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Datoer i "Tidligere svar" på læsbart dansk
|
// Datoer i "Tidligere svar" på læsbart dansk
|
||||||
@@ -479,11 +487,13 @@
|
|||||||
}
|
}
|
||||||
ui.optag.disabled = true;
|
ui.optag.disabled = true;
|
||||||
status('Gør mikrofonen klar…');
|
status('Gør mikrofonen klar…');
|
||||||
|
liveForbered(); // AudioContext skal skabes i klikket (iOS), og tokenet hentes imens
|
||||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||||
st.stream = stream;
|
st.stream = stream;
|
||||||
st.chunks = [];
|
st.chunks = [];
|
||||||
st.mime = mime;
|
st.mime = mime;
|
||||||
st.ext = endelse(mime);
|
st.ext = endelse(mime);
|
||||||
|
liveStart(stream);
|
||||||
try {
|
try {
|
||||||
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -537,12 +547,101 @@
|
|||||||
stopTimer();
|
stopTimer();
|
||||||
ui.optag.disabled = true;
|
ui.optag.disabled = true;
|
||||||
status('Gør optagelsen klar…');
|
status('Gør optagelsen klar…');
|
||||||
|
liveStop();
|
||||||
try {
|
try {
|
||||||
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
||||||
else optagelseFaerdig();
|
else optagelseFaerdig();
|
||||||
} catch (e) { optagelseFaerdig(); }
|
} catch (e) { optagelseFaerdig(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Live-afskrift (ElevenLabs realtime STT) ----------
|
||||||
|
Mikrofonen sendes som 16 kHz PCM direkte til ElevenLabs over en WebSocket med et
|
||||||
|
engangs-token fra serveren (API-nøglen bliver på serveren). Teksten vokser, mens man
|
||||||
|
taler; ved stop sendes den med optagelsen, så serveren ikke skal skrive af bagefter. */
|
||||||
|
var live = { ctx: null, node: null, src: null, ws: null, token: null, fast: '', delvis: '', aktiv: false, luk: null };
|
||||||
|
|
||||||
|
function liveForbered() {
|
||||||
|
live.fast = ''; live.delvis = ''; live.aktiv = false; live.token = null;
|
||||||
|
try { ui.live.hidden = true; ui.liveFast.textContent = ''; ui.liveDelvis.textContent = ''; } catch (e) {}
|
||||||
|
try {
|
||||||
|
var AC = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (AC && !live.ctx) {
|
||||||
|
try { live.ctx = new AC({ sampleRate: 16000 }); } catch (e) { live.ctx = new AC(); }
|
||||||
|
}
|
||||||
|
if (live.ctx && live.ctx.state === 'suspended') live.ctx.resume().catch(function () {});
|
||||||
|
} catch (e) { live.ctx = null; }
|
||||||
|
live.token = kald('/stt-token', {}).catch(function () { return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveStart(stream) {
|
||||||
|
if (!live.ctx || !window.WebSocket) return;
|
||||||
|
live.token.then(function (t) {
|
||||||
|
if (!t || !t.url || !st.stream) return;
|
||||||
|
var ws;
|
||||||
|
try { ws = new WebSocket(t.url); } catch (e) { return; }
|
||||||
|
live.ws = ws;
|
||||||
|
ws.onmessage = function (ev) {
|
||||||
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
|
if (m.message_type === 'partial_transcript') { live.delvis = m.text || ''; }
|
||||||
|
else if (m.message_type === 'committed_transcript') {
|
||||||
|
if (m.text) live.fast = (live.fast + ' ' + m.text).trim();
|
||||||
|
live.delvis = '';
|
||||||
|
}
|
||||||
|
liveVis();
|
||||||
|
};
|
||||||
|
ws.onclose = function () { live.aktiv = false; if (live.luk) { live.luk(); live.luk = null; } };
|
||||||
|
ws.onerror = function () { live.aktiv = false; };
|
||||||
|
ws.onopen = function () {
|
||||||
|
if (!st.stream) { try { ws.close(); } catch (e) {} return; }
|
||||||
|
live.aktiv = true;
|
||||||
|
ui.live.hidden = false;
|
||||||
|
try {
|
||||||
|
live.src = live.ctx.createMediaStreamSource(stream);
|
||||||
|
live.node = live.ctx.createScriptProcessor(4096, 1, 1);
|
||||||
|
var faktor = live.ctx.sampleRate / 16000; // Safari ignorerer af og til ønsket samplerate
|
||||||
|
live.node.onaudioprocess = function (e) {
|
||||||
|
if (!live.aktiv || ws.readyState !== 1) return;
|
||||||
|
var inp = e.inputBuffer.getChannelData(0);
|
||||||
|
var n = Math.floor(inp.length / faktor);
|
||||||
|
var pcm = new Int16Array(n);
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var v = inp[Math.floor(i * faktor)];
|
||||||
|
v = Math.max(-1, Math.min(1, v));
|
||||||
|
pcm[i] = v < 0 ? v * 32768 : v * 32767;
|
||||||
|
}
|
||||||
|
var bytes = new Uint8Array(pcm.buffer), bin = '';
|
||||||
|
for (var j = 0; j < bytes.length; j += 8192) bin += String.fromCharCode.apply(null, bytes.subarray(j, j + 8192));
|
||||||
|
ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: btoa(bin), commit: false, sample_rate: 16000 }));
|
||||||
|
};
|
||||||
|
live.src.connect(live.node);
|
||||||
|
live.node.connect(live.ctx.destination);
|
||||||
|
} catch (e) { live.aktiv = false; }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveVis() {
|
||||||
|
ui.liveFast.textContent = live.fast;
|
||||||
|
ui.liveDelvis.textContent = live.delvis ? (' ' + live.delvis) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopper lydstrømmen, beder om den sidste commit og giver teksten max 2,5 s til at lande
|
||||||
|
function liveStop() {
|
||||||
|
live.aktiv = false;
|
||||||
|
try { if (live.node) { live.node.disconnect(); live.node.onaudioprocess = null; } if (live.src) live.src.disconnect(); } catch (e) {}
|
||||||
|
live.node = null; live.src = null;
|
||||||
|
var ws = live.ws; live.ws = null;
|
||||||
|
if (ws && ws.readyState === 1) {
|
||||||
|
try { ws.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: '', commit: true, sample_rate: 16000 })); } catch (e) {}
|
||||||
|
live.aktiv = true; // modtag stadig beskeder, til vi lukker
|
||||||
|
setTimeout(function () { live.aktiv = false; try { ws.close(); } catch (e) {} }, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveTekst() {
|
||||||
|
return (live.fast + (live.delvis ? ' ' + live.delvis : '')).trim();
|
||||||
|
}
|
||||||
|
|
||||||
function optagelseFaerdig() {
|
function optagelseFaerdig() {
|
||||||
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
||||||
ui.optag.classList.remove('optager');
|
ui.optag.classList.remove('optager');
|
||||||
@@ -562,7 +661,13 @@
|
|||||||
ui.optaget.hidden = false;
|
ui.optaget.hidden = false;
|
||||||
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
||||||
ui.send.focus();
|
ui.send.focus();
|
||||||
sendKladde();
|
// Giv live-afskriften op til 2 s til at sende den sidste sætning, før kladden går op
|
||||||
|
if (live.ws || live.aktiv) {
|
||||||
|
var blob = st.blob, sendt = false;
|
||||||
|
var go = function () { if (!sendt && st.blob === blob) { sendt = true; sendKladde(); } };
|
||||||
|
live.luk = go;
|
||||||
|
setTimeout(go, 2200);
|
||||||
|
} else sendKladde();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optagelsen sendes op med det samme som kladde, så afskriften er i gang, mens man lytter.
|
// Optagelsen sendes op med det samme som kladde, så afskriften er i gang, mens man lytter.
|
||||||
@@ -572,6 +677,10 @@
|
|||||||
var form = new FormData();
|
var form = new FormData();
|
||||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||||
form.append('kladde', '1');
|
form.append('kladde', '1');
|
||||||
|
// Live-afskriften (hvis den var med) sendes med, så serveren slipper for at skrive af bagefter.
|
||||||
|
// Vent et øjeblik på den sidste committed-besked, så kladden får hele teksten med.
|
||||||
|
var t = liveTekst();
|
||||||
|
if (t) form.append('tekst', t);
|
||||||
form.append('projekt', st.projekt);
|
form.append('projekt', st.projekt);
|
||||||
form.append('samtale', st.samtale || '');
|
form.append('samtale', st.samtale || '');
|
||||||
form.append('spoergsmaal', st.spoergsmaal);
|
form.append('spoergsmaal', st.spoergsmaal);
|
||||||
@@ -722,10 +831,21 @@
|
|||||||
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
||||||
li.appendChild(m);
|
li.appendChild(m);
|
||||||
}
|
}
|
||||||
var ret = document.createElement('div'); ret.className = 'er-ret';
|
var knapper = document.createElement('div'); knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
||||||
|
if (!j.skrevet) {
|
||||||
|
var begge = document.createElement('button');
|
||||||
|
begge.type = 'button'; begge.className = 'wt-btn'; begge.textContent = '▶ Spørgsmål og svar'; begge.setAttribute('data-begge', j.id);
|
||||||
|
knapper.appendChild(begge);
|
||||||
|
}
|
||||||
var b = document.createElement('button');
|
var b = document.createElement('button');
|
||||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
||||||
ret.appendChild(b); li.appendChild(ret);
|
knapper.appendChild(b); li.appendChild(knapper);
|
||||||
|
if (!j.skrevet) {
|
||||||
|
var a = document.createElement('audio'); a.controls = true; a.preload = 'none'; a.setAttribute('data-svar', j.id);
|
||||||
|
a.src = ROD + '/klip/' + j.id + '.mp3' + (NOEGLE ? ('?noegle=' + encodeURIComponent(NOEGLE)) : '');
|
||||||
|
a.style.marginTop = '.4rem'; li.appendChild(a);
|
||||||
|
}
|
||||||
|
var ret = document.createElement('div'); ret.className = 'er-ret'; li.appendChild(ret);
|
||||||
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
||||||
ui.dagensTom.hidden = true;
|
ui.dagensTom.hidden = true;
|
||||||
return t;
|
return t;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Integrationstest under en RIGTIG uvicorn (Starlette TestClient afvikler ikke baggrundsjobs færdigt).
|
||||||
|
# Kør i en kopi af appen: export env fra /etc/portal.env, PORTAL_DATA=/tmp/x, start uvicorn på :8001, kør denne.
|
||||||
|
import time, httpx, sys, os, pathlib
|
||||||
|
sys.path.insert(0, "/tmp/ef-test"); import erindringer
|
||||||
|
os.system('ffmpeg -y -v error -f lavfi -i "sine=frequency=440:duration=2" -c:a libmp3lame /tmp/ef-test/tone.mp3')
|
||||||
|
c = httpx.Client(base_url="http://127.0.0.1:8001", timeout=120)
|
||||||
|
erindringer.gem_projekt("test-ep", "Testemne", "b", "Personen er Asger.", ["Hvad husker du fra Rørbæk?", "Hvem var din morfar?"], 2)
|
||||||
|
p = erindringer.ny_gaest("Tina Test", ["test-ep"]); noegle = p["noegle"]; pid = p["pid"]
|
||||||
|
t = c.post("/erindringer/api/stt-token", json={"noegle": noegle}).json(); print("stt-token:", "token" in t and t["url"].startswith("wss://"))
|
||||||
|
sam = erindringer.ny_samtale()
|
||||||
|
q = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": sam, "noegle": noegle}).json(); print("Q", q["spoergsmaal"][:50])
|
||||||
|
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": sam, "spoergsmaal": q["spoergsmaal"], "kladde": "1", "noegle": noegle, "lyd_sha": q["lyd_sha"], "tekst": "Jeg husker min morfar Gustav og hans motorcykel i Hannerup."}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")}).json()
|
||||||
|
print("kladde m. live-tekst:", r["status"], "skrevet:", r["skrevet"])
|
||||||
|
b = c.post("/erindringer/api/bekraeft", json={"id": r["id"], "noegle": noegle}).json(); print("bekraeft", b["status"])
|
||||||
|
time.sleep(9)
|
||||||
|
t0 = time.time(); n = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": sam, "noegle": noegle}).json(); print("naeste %.1fs" % (time.time() - t0), n["spoergsmaal"][:70])
|
||||||
|
d = pathlib.Path("/tmp/ef-test/data/erindringer/klip/" + pid.replace(":", "_")); sp = d / (r["id"] + "-sp.mp3"); sp.unlink(missing_ok=True)
|
||||||
|
x = c.get("/erindringer/g/%s/klip/%s-sp.mp3" % (noegle, r["id"])); print("sp on demand:", x.status_code, sp.exists())
|
||||||
|
print("ren (ikke lavet):", c.get("/erindringer/g/%s/klip/%s-ren.mp3" % (noegle, r["id"])).status_code)
|
||||||
|
print("ALT OK")
|
||||||
Reference in New Issue
Block a user