Skip to content

Commit 24725f5

Browse files
luongndclaude
andcommitted
fix(stt+ui): Soniox auto-reconnect + transcript load-more pagination
- Soniox realtime: auto-reconnect after server-side session cap (default ~1h per plan). Refactor SonioxStreamingSTT with _open_session / _close_session helpers; wrap results() in outer reconnect loop with exponential backoff (cap 5s, 8 attempts). Track cumulative_offset_ms to keep start_ms/end_ms continuous across reconnects. Emit info heartbeat to frontend during reconnect. - Frontend: handle info heartbeat (🔄 status banner) + show real error message instead of swallowing it. - MeetingDetail: load-more pagination — render last 200 parts by default, '↑ Tải thêm' button reveals 200 more with scroll preservation. Store still holds full array so export/copy/minutes see EVERYTHING. Auto- scroll only when user is near bottom (80px tolerance) — no more yank when reading older parts. - Bump 1.2.9 → 1.2.10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cefd1b6 commit 24725f5

9 files changed

Lines changed: 464 additions & 208 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "voicescribe",
33
"private": true,
4-
"version": "1.2.9",
4+
"version": "1.2.10",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-python/stt.py

Lines changed: 340 additions & 192 deletions
Large diffs are not rendered by default.

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "voicescribe"
3-
version = "1.2.9"
3+
version = "1.2.10"
44
description = "Scribble - Meeting Minutes"
55
authors = ["you"]
66
edition = "2021"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Scribble",
4-
"version": "1.2.9",
4+
"version": "1.2.10",
55
"identifier": "com.scribble.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

src/components/MeetingDetail.tsx

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,51 @@ export function MeetingDetail() {
446446
const [editingMinutes, setEditingMinutes] = useState(false);
447447
const minutesEditRef = useRef<HTMLDivElement>(null);
448448

449+
// ── Load-more pagination ─────────────────────────────────────────────
450+
// Long sessions (4h+) can produce 500+ transcript parts. Rendering them
451+
// all at once tanks the DOM. We render only the tail by default; the
452+
// user clicks "Load older" to reveal more. The store still holds the
453+
// full array → export / copy / minutes-generation see EVERYTHING. This
454+
// is purely a render-layer optimization.
455+
const TRANSCRIPT_PAGE_SIZE = 200;
456+
const [visibleCount, setVisibleCount] = useState<number>(TRANSCRIPT_PAGE_SIZE);
457+
// When switching meetings, reset the window so we don't show 200 parts
458+
// of the previous meeting on the new one.
459+
const prevMeetingKeyRef = useRef<string | number | null>(null);
460+
useEffect(() => {
461+
const key = currentMeetingId || draftId || null;
462+
if (prevMeetingKeyRef.current !== key) {
463+
prevMeetingKeyRef.current = key;
464+
setVisibleCount(TRANSCRIPT_PAGE_SIZE);
465+
}
466+
}, [currentMeetingId, draftId]);
467+
468+
// Visible slice: always include the most recent N parts so live
469+
// transcription stays visible. Absolute index = transcriptParts.length
470+
// - visibleParts.length + visibleIdx. Used for callbacks that mutate
471+
// the full array (edit speaker, delete, edit text).
472+
const hasOlderParts = transcriptParts.length > visibleCount;
473+
const visibleParts = useMemo(
474+
() => (hasOlderParts ? transcriptParts.slice(-visibleCount) : transcriptParts),
475+
[transcriptParts, visibleCount, hasOlderParts],
476+
);
477+
const visibleStartIdx = transcriptParts.length - visibleParts.length;
478+
const handleLoadOlder = () => {
479+
// Preserve scroll position so the user stays anchored at the part
480+
// they were reading instead of jumping when older content prepends.
481+
const el = transcriptRef.current;
482+
const prevScrollHeight = el?.scrollHeight ?? 0;
483+
const prevScrollTop = el?.scrollTop ?? 0;
484+
setVisibleCount((n) => n + TRANSCRIPT_PAGE_SIZE);
485+
// After render, restore so visual position relative to previously
486+
// visible items doesn't change.
487+
requestAnimationFrame(() => {
488+
if (!el) return;
489+
const delta = el.scrollHeight - prevScrollHeight;
490+
el.scrollTop = prevScrollTop + delta;
491+
});
492+
};
493+
449494
const persistTranscriptParts = async (parts: TranscriptPart[]) => {
450495
const meetingId = currentMeetingId || draftId;
451496
if (!meetingId) return;
@@ -688,9 +733,16 @@ export function MeetingDetail() {
688733
};
689734
}, [viewingMeetingId, recording, setTranscriptParts]);
690735

691-
// Auto-scroll
736+
// Auto-scroll: only when the user is near the bottom (so we don't yank
737+
// them away while they're reading older parts after "Load older"). 80px
738+
// tolerance = roughly one transcript row.
692739
useEffect(() => {
693-
transcriptRef.current?.scrollTo({ top: transcriptRef.current.scrollHeight, behavior: 'smooth' });
740+
const el = transcriptRef.current;
741+
if (!el) return;
742+
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
743+
if (distanceFromBottom < 80) {
744+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
745+
}
694746
}, [transcriptParts, isTranscribing]);
695747

696748

@@ -856,15 +908,31 @@ export function MeetingDetail() {
856908
</div>
857909
) : (
858910
<div className={`transcript-list ${translationEnabled ? 'with-translation' : ''}`} ref={transcriptRef}>
859-
{transcriptParts.map((part, i) => {
911+
{hasOlderParts && (
912+
<button
913+
className="transcript-load-more"
914+
onClick={handleLoadOlder}
915+
aria-label={lang === 'vi' ? 'Tải thêm transcript cũ hơn' : 'Load older transcript'}
916+
>
917+
{lang === 'vi'
918+
? `↑ Tải thêm ${Math.min(TRANSCRIPT_PAGE_SIZE, visibleStartIdx)} đoạn cũ hơn (đang ẩn ${visibleStartIdx} / tổng ${transcriptParts.length})`
919+
: `↑ Load ${Math.min(TRANSCRIPT_PAGE_SIZE, visibleStartIdx)} older parts (${visibleStartIdx} hidden of ${transcriptParts.length} total)`}
920+
</button>
921+
)}
922+
{visibleParts.map((part, visibleIdx) => {
923+
// Absolute index in the full transcriptParts array — used
924+
// for callbacks that mutate the full store (edit / delete /
925+
// speaker rename). `visibleIdx` alone would mis-target items
926+
// whenever `hasOlderParts` is true.
927+
const absoluteIdx = visibleStartIdx + visibleIdx;
860928
const speakerColor = SPEAKER_COLORS[part.speakerId % SPEAKER_COLORS.length];
861-
const isLive = i === transcriptParts.length - 1 && recording;
929+
const isLive = absoluteIdx === transcriptParts.length - 1 && recording;
862930
return (
863-
<div className={`transcript-item ${isLive ? 'live' : ''}`} key={i}>
931+
<div className={`transcript-item ${isLive ? 'live' : ''}`} key={part.chunkId || `t-${absoluteIdx}`}>
864932
<div className="transcript-actions">
865933
<button
866934
className="transcript-action-btn"
867-
onClick={() => startEditSpeaker(part.speakerId, i)}
935+
onClick={() => startEditSpeaker(part.speakerId, absoluteIdx)}
868936
title={lang === 'vi' ? 'Đổi tên speaker (áp dụng toàn bộ)' : 'Rename speaker (apply all)'}
869937
aria-label={lang === 'vi' ? 'Đổi tên speaker' : 'Rename speaker'}
870938
>
@@ -875,7 +943,7 @@ export function MeetingDetail() {
875943
</button>
876944
<button
877945
className="transcript-action-btn t-delete-btn"
878-
onClick={() => deleteTranscriptAt(i)}
946+
onClick={() => deleteTranscriptAt(absoluteIdx)}
879947
title={lang === 'vi' ? 'Xóa đoạn này' : 'Delete this item'}
880948
aria-label={lang === 'vi' ? 'Xóa đoạn này' : 'Delete this item'}
881949
>
@@ -887,7 +955,7 @@ export function MeetingDetail() {
887955
</button>
888956
</div>
889957
<div className="transcript-time">
890-
{editingSpeakerId === part.speakerId && editingSpeakerAnchorIdx === i ? (
958+
{editingSpeakerId === part.speakerId && editingSpeakerAnchorIdx === absoluteIdx ? (
891959
<div className="speaker-edit-wrap">
892960
<input
893961
className="speaker-edit-input"
@@ -916,7 +984,7 @@ export function MeetingDetail() {
916984
liveTranslationRef={liveTranslationRef}
917985
onSave={(newText) => {
918986
const next = transcriptParts.map((p, j) =>
919-
j === i ? { ...p, text: newText, translation: '' } : p
987+
j === absoluteIdx ? { ...p, text: newText, translation: '' } : p
920988
);
921989
void applyTranscriptUpdate(next);
922990
}}

src/components/recording/use-streaming-stt.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,19 @@ export function attachSocketHandlers(
6161
try {
6262
const data = JSON.parse(event.data);
6363
if (data.error) {
64-
console.error("[stt-ws]", data.error);
64+
// Surface the actual message (Soniox sends error: true + text: "..."),
65+
// not the boolean flag, so the user sees what went wrong.
66+
const msg = (typeof data.error === "string" ? data.error : "") || data.text || "STT error";
67+
console.error("[stt-ws]", msg);
6568
setIsTranscribing(false);
66-
useAppStore.getState().setInterimText("");
69+
useAppStore.getState().setInterimText(`⚠ ${msg}`);
70+
return;
71+
}
72+
// Backend reconnect heartbeat (Soniox auto-reconnect after server
73+
// closed the WS due to its ~1h session-duration cap). Show as
74+
// interim status; do NOT add to transcriptParts.
75+
if (data.info || data.type === "info") {
76+
useAppStore.getState().setInterimText(`🔄 ${data.text || "Reconnecting..."}`);
6777
return;
6878
}
6979
if (data.type === "speaker_correction" && data.chunk_id) {

src/index.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,29 @@ body.electron-app .sub-tabs {
11471147
transition: background 150ms var(--ease);
11481148
}
11491149

1150+
/* Load-more button shown above the visible transcript window when there
1151+
are older parts hidden (typical for 4h+ sessions). Keeps the DOM small
1152+
so a long recording doesn't lag the render. */
1153+
.transcript-load-more {
1154+
align-self: center;
1155+
margin: 8px 0 12px;
1156+
padding: 6px 14px;
1157+
font-size: 0.78rem;
1158+
font-weight: 500;
1159+
color: var(--text-muted);
1160+
background: rgba(99, 102, 241, 0.08);
1161+
border: 1px dashed rgba(99, 102, 241, 0.3);
1162+
border-radius: 999px;
1163+
cursor: pointer;
1164+
transition: background 150ms var(--ease), color 150ms var(--ease), border-color 150ms var(--ease);
1165+
}
1166+
1167+
.transcript-load-more:hover {
1168+
background: rgba(99, 102, 241, 0.16);
1169+
color: var(--text);
1170+
border-color: rgba(99, 102, 241, 0.55);
1171+
}
1172+
11501173
.transcript-item:hover {
11511174
background: rgba(0, 0, 0, 0.02);
11521175
}

src/types/stt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ export interface SttWsMessage {
2323
chunk_id?: string;
2424
translation?: string;
2525
append?: boolean;
26-
error?: string;
26+
/** When the backend hits a terminal STT error (billing, auth, etc.) it
27+
* may send either `error: "<message string>"` (legacy shape) or
28+
* `error: true` alongside `text: "..."` (Soniox path). Accept both. */
29+
error?: string | boolean;
30+
/** Non-terminal status heartbeat from backend (e.g. Soniox auto-reconnect
31+
* after a session-duration cap). Render as interim banner, NOT as a
32+
* transcript part. */
33+
info?: boolean;
2734
segments?: SttSegment[];
2835
/** Soniox token-level offsets (ms from stream start). Present on
2936
* real-time STT events when the upstream tokens carry timestamps. */

0 commit comments

Comments
 (0)