feat(erindringer): live-afskrift mens man taler, spørgsmål+svar på ét afspillerelement, voice isolation for podcast-favoritter
CD / deploy (push) Successful in 14s
CI / check (push) Successful in 21s

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:
root
2026-10-06 22:43:38 +02:00
parent 49a33b25a6
commit 1c1962e1d8
5 changed files with 254 additions and 20 deletions
+69 -2
View File
@@ -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"
+27 -3
View File
@@ -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)
+4 -1
View File
@@ -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
View File
@@ -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;
+20
View File
@@ -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")