From 24ccafdaac5285c79cfb098be4f33db0583fe861 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 25 Mar 2026 06:30:55 +0100 Subject: [PATCH] add read-alouds --- docker-compose.prod.yml | 8 +- scripts/build-and-push-prod.sh | 10 +- src/components/NoteOptions/useMenuActions.tsx | 26 ++- src/constants.ts | 20 +++ src/i18n/locales/ar.ts | 5 + src/i18n/locales/de.ts | 5 + src/i18n/locales/en.ts | 5 + src/i18n/locales/es.ts | 5 + src/i18n/locales/fa.ts | 5 + src/i18n/locales/fr.ts | 5 + src/i18n/locales/hi.ts | 5 + src/i18n/locales/it.ts | 5 + src/i18n/locales/ja.ts | 5 + src/i18n/locales/ko.ts | 5 + src/i18n/locales/pl.ts | 5 + src/i18n/locales/pt-BR.ts | 5 + src/i18n/locales/pt-PT.ts | 5 + src/i18n/locales/ru.ts | 5 + src/i18n/locales/th.ts | 5 + src/i18n/locales/zh.ts | 5 + src/lib/read-aloud.ts | 159 ++++++++++++++++++ 21 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 src/lib/read-aloud.ts diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index aee1651a..2556314b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,11 @@ # Minimal compose for running the published image (e.g. on remote server). -# Usage: docker compose -f docker-compose.prod.yml up -d +# Usage: docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d +# +# Apache (unchanged on your host) should keep: +# ProxyPass /sites/ http://127.0.0.1:8090/sites/ +# ProxyPass / http://127.0.0.1:8089/ +# so the browser hits https:///sites/?url=… on Apache → OG proxy (8090); static SPA is 8089 only. +# VITE_PROXY_SERVER / VITE_READ_ALOUD_TTS_URL are baked at image build — see scripts/build-and-push-prod.sh # # NIP-66 monitor: set NIP66_MONITOR_NSEC (and optionally NIP66_MONITOR_NPUB) in the host env or .env. # - Cron (jumble-nip66-monitor) uses NIP66_MONITOR_NSEC to publish 30166/10166; nsec never goes to the client. diff --git a/scripts/build-and-push-prod.sh b/scripts/build-and-push-prod.sh index faf63769..7af5fe77 100755 --- a/scripts/build-and-push-prod.sh +++ b/scripts/build-and-push-prod.sh @@ -3,8 +3,10 @@ # Then create git tag v and push it. # Run from repo root. Requires: docker, docker login, git. # -# Optional env: JUMBLE_PROXY_SERVER_URL — passed as Docker build-arg VITE_PROXY_SERVER (default site origin). -# Must match Apache: ProxyPass /sites/ → OG backend; the app requests https:///sites/?url=… +# Optional env: +# JUMBLE_PROXY_SERVER_URL — build-arg VITE_PROXY_SERVER (default https://jumble.imwald.eu). +# Must match the public origin where Apache serves the app; Apache proxies /sites/ → :8090, not this container. +# READ_ALOUD_TTS_URL — build-arg VITE_READ_ALOUD_TTS_URL (default https://aitherboard.imwald.eu/api/piper-tts). set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -19,10 +21,12 @@ IMAGE_MONITOR="silberengel/imwald-jumble-nip66-monitor" # Use public origin only (no /proxy path): web.service builds /sites/?url=… # Override: JUMBLE_PROXY_SERVER_URL=https://other.example ./scripts/build-and-push-prod.sh JUMBLE_PROXY_SERVER_URL="${JUMBLE_PROXY_SERVER_URL:-https://jumble.imwald.eu}" +READ_ALOUD_TTS_URL="${READ_ALOUD_TTS_URL:-https://aitherboard.imwald.eu/api/piper-tts}" -echo "Building main app (version: $VERSION, VITE_PROXY_SERVER=$JUMBLE_PROXY_SERVER_URL)" +echo "Building main app (version: $VERSION, VITE_PROXY_SERVER=$JUMBLE_PROXY_SERVER_URL, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL)" docker build \ --build-arg "VITE_PROXY_SERVER=$JUMBLE_PROXY_SERVER_URL" \ + --build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \ -t "$IMAGE_APP:latest" -t "$IMAGE_APP:$VERSION" . echo "Building NIP-66 monitor (version: $VERSION)" diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index a3211195..c98a85c7 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants' import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' @@ -6,6 +6,7 @@ import { toAlexandria } from '@/lib/link' import logger from '@/lib/logger' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl, simplifyUrl } from '@/lib/url' +import { speakNoteReadAloud } from '@/lib/read-aloud' import { buildPinListTagsAfterToggle, fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { generateBech32IdFromATag } from '@/lib/tag' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' @@ -33,7 +34,8 @@ import { Send, Trash2, TriangleAlert, - Video + Video, + Volume2 } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { nip19 } from 'nostr-tools' @@ -595,6 +597,26 @@ export function useMenuActions({ closeDrawer() } }, + ...(READ_ALOUD_KINDS.includes(event.kind) + ? [ + { + icon: Volume2, + label: t('Read this note aloud'), + onClick: () => { + closeDrawer() + void speakNoteReadAloud(event).then((result) => { + if (result === 'unsupported') { + toast.error(t('Read-aloud is not supported in this browser')) + } else if (result === 'empty') { + toast.error(t('Nothing to read aloud')) + } else if (result === 'error') { + toast.error(t('Read-aloud failed')) + } + }) + } + } as MenuAction + ] + : []), ...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage ? [ { diff --git a/src/constants.ts b/src/constants.ts index 7d0cc358..5e5ae969 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,14 @@ import { kinds } from 'nostr-tools' export const JUMBLE_API_BASE_URL = (import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined) ?? 'https://api.jumble.imwald.eu' +/** + * Piper TTS proxy (same contract as aitherboard `POST /api/piper-tts`: JSON `{ text, voice?, speed? }`, body `audio/wav`). + * Set `VITE_READ_ALOUD_TTS_URL` to your deployed aitherboard URL, e.g. `https://aitherboard.example.com/api/piper-tts`. + * If empty, read-aloud uses the Web Speech API only. + */ +export const READ_ALOUD_TTS_URL = + (import.meta.env.VITE_READ_ALOUD_TTS_URL as string | undefined)?.trim() || '' + /** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */ export const HIVETALK_BASE_URL = (import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://vanilla.hivetalk.org' @@ -306,6 +314,18 @@ export const ExtendedKind = { BADGE_DEFINITION: 30009 } +/** Event kinds that show “Read this note aloud” in note options (Web Speech API). */ +export const READ_ALOUD_KINDS: readonly number[] = [ + kinds.ShortTextNote, + ExtendedKind.DISCUSSION, + ExtendedKind.COMMENT, + kinds.LongFormArticle, + ExtendedKind.PUBLICATION, + ExtendedKind.PUBLICATION_CONTENT, + ExtendedKind.WIKI_ARTICLE_MARKDOWN, + ExtendedKind.WIKI_ARTICLE +] + /** NIP-52 calendar event kinds (addressable by d-tag); use in isReplaceableEvent. */ export const CALENDAR_EVENT_KINDS = [ ExtendedKind.CALENDAR_EVENT_DATE, diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index d97c90d0..3ad467a0 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 43b65617..c356691d 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -176,6 +176,11 @@ export default { 'Copy call invite link': 'Anruf-Einladungslink kopieren', 'Start call about this': 'Anruf zu diesem Beitrag starten', 'Send call invite': 'Anruf-Einladung senden', + 'Read this note aloud': 'Diese Notiz vorlesen', + 'Read-aloud is not supported in this browser': + 'Vorlesen wird in diesem Browser nicht unterstützt', + 'Nothing to read aloud': 'Kein Text zum Vorlesen', + 'Read-aloud failed': 'Vorlesen fehlgeschlagen', 'Join the video call': 'Am Videoanruf teilnehmen', 'Schedule video call': 'Videoanruf planen', "You're invited to a scheduled video call.": diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a0a02e55..2e748d5b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -173,6 +173,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 8effc386..4a83a1ee 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index de2ca056..559c348f 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 9975010a..8320a38b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index cf8a2a25..788f7e7a 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 50569698..ebfd5ca1 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index a870ebc8..d113688d 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 5e4c6ba3..334f5e3c 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index cb2c29bb..997925c2 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 40fe205c..958c8061 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index def8c50b..be3d29ab 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 79b930c7..3d77eeb4 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 477b743a..bfb05701 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index c4eee9c4..fd80c542 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -159,6 +159,11 @@ export default { 'Copy call invite link': 'Copy call invite link', 'Start call about this': 'Start call about this', 'Send call invite': 'Send call invite', + 'Read this note aloud': 'Read this note aloud', + 'Read-aloud is not supported in this browser': + 'Read-aloud is not supported in this browser', + 'Nothing to read aloud': 'Nothing to read aloud', + 'Read-aloud failed': 'Read-aloud failed', 'Join the video call': 'Join the video call', 'Schedule video call': 'Schedule video call', "You're invited to a scheduled video call.": "You're invited to a scheduled video call.", diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts new file mode 100644 index 00000000..df8be9b0 --- /dev/null +++ b/src/lib/read-aloud.ts @@ -0,0 +1,159 @@ +import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' +import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import { Event, kinds } from 'nostr-tools' + +export type ReadAloudResult = 'ok' | 'unsupported' | 'empty' | 'error' + +const KINDS_WITH_METADATA_TITLE = new Set([ + kinds.LongFormArticle, + ExtendedKind.PUBLICATION, + ExtendedKind.PUBLICATION_CONTENT, + ExtendedKind.WIKI_ARTICLE_MARKDOWN, + ExtendedKind.WIKI_ARTICLE +]) + +let readAloudAbort: AbortController | null = null +let readAloudAudio: HTMLAudioElement | null = null + +function stopReadAloudPlayback(): void { + readAloudAbort?.abort() + readAloudAbort = null + if (readAloudAudio) { + const url = readAloudAudio.src + readAloudAudio.onended = null + readAloudAudio.onerror = null + readAloudAudio.pause() + readAloudAudio.removeAttribute('src') + readAloudAudio.load() + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url) + } + } + readAloudAudio = null + window.speechSynthesis?.cancel() +} + +/** Strip common Markdown / AsciiDoc / code so TTS reads plain text (same idea as NotePage preview). */ +function stripMarkupForReadAloud(content: string): string { + let text = content + text = text.replace(/^#{1,6}\s+/gm, '') + text = text.replace(/\*\*([^*]+)\*\*/g, '$1') + text = text.replace(/\*([^*]+)\*/g, '$1') + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + text = text.replace(/^=+\s+/gm, '') + text = text.replace(/_([^_]+)_/g, '$1') + text = text.replace(/```[\s\S]*?```/g, '') + text = text.replace(/`([^`]+)`/g, '$1') + text = text.replace(/<[^>]+>/g, '') + text = text.replace(/\n{3,}/g, '\n\n') + return text.trim() +} + +function buildReadAloudPlainText(event: Event): string { + let raw = event.content?.trim() ?? '' + if (KINDS_WITH_METADATA_TITLE.has(event.kind)) { + const meta = getLongFormArticleMetadataFromEvent(event) + const title = meta.title?.trim() + if (title) { + raw = `${title}. ${raw}` + } + } + return stripMarkupForReadAloud(raw) +} + +/** + * Piper / Wyoming proxy (aitherboard-compatible): POST JSON, receive WAV. + */ +async function speakViaPiperTts(text: string): Promise { + stopReadAloudPlayback() + readAloudAbort = new AbortController() + + try { + const response = await fetch(READ_ALOUD_TTS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, speed: 1 }), + signal: readAloudAbort.signal + }) + + if (!response.ok) { + return 'error' + } + + const blob = await response.blob() + if (!blob.size) { + return 'error' + } + + const audioUrl = URL.createObjectURL(blob) + const audio = new Audio() + readAloudAudio = audio + audio.src = audioUrl + + const cleanupBlob = () => { + if (audio.src.startsWith('blob:')) { + URL.revokeObjectURL(audioUrl) + } + } + + audio.addEventListener('ended', () => { + cleanupBlob() + if (readAloudAudio === audio) { + readAloudAudio = null + } + }) + audio.addEventListener('error', () => { + cleanupBlob() + }) + + try { + await audio.play() + return 'ok' + } catch { + cleanupBlob() + if (readAloudAudio === audio) { + readAloudAudio = null + } + return 'error' + } + } catch (e) { + const isAbort = + (e instanceof DOMException && e.name === 'AbortError') || + (e instanceof Error && e.name === 'AbortError') + if (isAbort) { + return 'ok' + } + return 'error' + } +} + +function speakViaWebSpeech(text: string): void { + stopReadAloudPlayback() + window.speechSynthesis.speak(new SpeechSynthesisUtterance(text)) +} + +export async function speakNoteReadAloud(event: Event): Promise { + if (typeof window === 'undefined') { + return 'unsupported' + } + + const text = buildReadAloudPlainText(event) + if (!text) { + return 'empty' + } + + if (READ_ALOUD_TTS_URL) { + const piperResult = await speakViaPiperTts(text) + if (piperResult === 'ok') { + return 'ok' + } + // Server failed or unreachable: fall back to Web Speech when available + } + + if (!window.speechSynthesis) { + return READ_ALOUD_TTS_URL ? 'error' : 'unsupported' + } + + speakViaWebSpeech(text) + return 'ok' +}