From 935f2805c96aa8c5d5a56cc312abb62c30935b9c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 29 Mar 2026 22:43:06 +0200 Subject: [PATCH] fix read-aloud --- package-lock.json | 4 +- package.json | 2 +- src/lib/read-aloud.ts | 123 +++++++++++++++++++++++++++++------------- 3 files changed, 88 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64d301d3..d6f86b22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "21.1.1", + "version": "21.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "21.1.1", + "version": "21.1.2", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 57d9ce39..2a41cd1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "21.1.1", + "version": "21.1.2", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts index 3415a49a..bbcf4e57 100644 --- a/src/lib/read-aloud.ts +++ b/src/lib/read-aloud.ts @@ -275,6 +275,62 @@ function buildReadAloudPlainText(event: Event): string { return stripMarkupForReadAloud(raw) } +function isAbortError(e: unknown): boolean { + return ( + (e instanceof DOMException && e.name === 'AbortError') || + (e instanceof Error && e.name === 'AbortError') + ) +} + +/** Fetch one Piper WAV blob; rethrows AbortError; throws Error with user-facing message on failure. */ +async function fetchPiperTtsBlobForChunk( + chunkIndex: number, + totalChunks: number, + text: string, + signal: AbortSignal +): Promise { + const url = READ_ALOUD_TTS_URL + if (!url) { + throw new Error(`Part ${chunkIndex + 1} of ${totalChunks}: TTS URL not configured`) + } + + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, speed: 1 }), + signal + }) + } catch (e) { + if (isAbortError(e)) { + throw e + } + const msg = e instanceof Error ? e.message : String(e) + logger.warn('[ReadAloud] Piper fetch failed (check CORS on the TTS host or use same-origin /api/piper-tts)', { + endpoint: readAloudEndpointForLog(), + error: msg + }) + throw new Error(`Part ${chunkIndex + 1} of ${totalChunks}: ${msg}`) + } + + if (!response.ok) { + logger.warn('[ReadAloud] Piper HTTP error', { + status: response.status, + endpoint: readAloudEndpointForLog() + }) + throw new Error(`Part ${chunkIndex + 1} of ${totalChunks}: HTTP ${response.status}`) + } + + const blob = await response.blob() + if (!blob.size) { + logger.warn('[ReadAloud] Piper returned empty body', { endpoint: readAloudEndpointForLog() }) + throw new Error(`Part ${chunkIndex + 1} of ${totalChunks}: empty audio response`) + } + + return blob +} + function playPiperBlob(blob: Blob, signal: AbortSignal): Promise<'ok' | 'error' | 'aborted'> { return new Promise((resolve) => { if (signal.aborted) { @@ -391,6 +447,23 @@ async function speakViaPiperTtsChunks(chunks: string[]): Promise>() + + const ensureChunkBlob = (index: number): Promise => { + let p = chunkBlobPromises.get(index) + if (!p) { + const text = chunks[index] + if (text === undefined) { + p = Promise.reject(new Error(`Part ${index + 1} of ${chunks.length}: missing text`)) + } else { + p = fetchPiperTtsBlobForChunk(index, chunks.length, text, signal) + } + chunkBlobPromises.set(index, p) + } + return p + } + try { for (let i = 0; i < chunks.length; i++) { await waitUntilUnpaused() @@ -408,51 +481,25 @@ async function speakViaPiperTtsChunks(chunks: string[]): Promise { + if (isAbortError(e)) return + /* Real errors surface when we await this index; this only avoids unhandled rejections on prefetch. */ }) + } + + let blob: Blob + try { + blob = await ensureChunkBlob(i) } catch (e) { - const isAbort = - (e instanceof DOMException && e.name === 'AbortError') || - (e instanceof Error && e.name === 'AbortError') - if (isAbort) { + if (isAbortError(e)) { return 'ok' } const msg = e instanceof Error ? e.message : String(e) - logger.warn('[ReadAloud] Piper fetch failed (check CORS on the TTS host or use same-origin /api/piper-tts)', { - endpoint: readAloudEndpointForLog(), - error: msg - }) patchSnapshot({ phase: 'error', - error: `Part ${i + 1} of ${chunks.length}: ${msg}` - }) - return 'error' - } - - if (!response.ok) { - logger.warn('[ReadAloud] Piper HTTP error', { - status: response.status, - endpoint: readAloudEndpointForLog() - }) - patchSnapshot({ - phase: 'error', - error: `Part ${i + 1} of ${chunks.length}: HTTP ${response.status}` - }) - return 'error' - } - - const blob = await response.blob() - if (!blob.size) { - logger.warn('[ReadAloud] Piper returned empty body', { endpoint: readAloudEndpointForLog() }) - patchSnapshot({ - phase: 'error', - error: `Part ${i + 1} of ${chunks.length}: empty audio response` + error: msg }) return 'error' }