Browse Source

implement note translation

imwald
Silberengel 2 weeks ago
parent
commit
d02b642511
  1. 64
      src/components/Note/index.tsx
  2. 85
      src/components/NoteOptions/useMenuActions.tsx
  3. 13
      src/components/ReadAloudPlayerModal.tsx
  4. 11
      src/components/Settings/SettingsMenuBody.tsx
  5. 10
      src/i18n/index.ts
  6. 9
      src/i18n/locales/en.ts
  7. 1
      src/lib/link.ts
  8. 59
      src/lib/note-translation-display.ts
  9. 9
      src/lib/piper-tts-cache-policy.ts
  10. 32
      src/lib/piper-voice-for-app-language.ts
  11. 55
      src/lib/read-aloud.ts
  12. 93
      src/lib/translate-note-for-menu.ts
  13. 42
      src/pages/secondary/TranslationPage/index.tsx
  14. 2
      src/routes.tsx
  15. 5
      src/services/navigation.service.ts

64
src/components/Note/index.tsx

@ -27,6 +27,7 @@ import { muteSetHas } from '@/lib/mute-set' @@ -27,6 +27,7 @@ import { muteSetHas } from '@/lib/mute-set'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools'
import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
@ -139,6 +140,9 @@ export default function Note({ @@ -139,6 +140,9 @@ export default function Note({
const [postEditorOpen, setPostEditorOpen] = useState(false)
const [publicMessageTo, setPublicMessageTo] = useState<string | null>(null)
const [callInviteContent, setCallInviteContent] = useState<string | null>(null)
const noteTranslation = useNoteTranslation(event.id)
const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation])
const reactionDisplay = useNotificationReactionDisplay(event)
const webReactionParentUrl = useMemo(
() =>
@ -188,7 +192,7 @@ export default function Note({ @@ -188,7 +192,7 @@ export default function Note({
hideMetadata?: boolean
className?: string
} = {}) => {
if (isStringifiedJsonContent(event.content)) {
if (isStringifiedJsonContent(displayEvent.content)) {
return (
<pre
className={cn(
@ -196,15 +200,15 @@ export default function Note({ @@ -196,15 +200,15 @@ export default function Note({
className
)}
>
{event.content}
{displayEvent.content}
</pre>
)
}
if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) {
if (ASCIIDOC_CONTENT_KINDS.has(displayEvent.kind)) {
return (
<AsciidocArticle
className={className}
event={event}
event={displayEvent}
hideImagesAndInfo={hideMetadata}
/>
)
@ -212,21 +216,21 @@ export default function Note({ @@ -212,21 +216,21 @@ export default function Note({
return (
<MarkdownArticle
className={className}
event={event}
event={displayEvent}
hideMetadata={hideMetadata}
lazyMedia={!autoLoadMedia}
fullCalendarInvite={fullCalendarInvite}
/>
)
},
[event, fullCalendarInvite, autoLoadMedia]
[displayEvent, fullCalendarInvite, autoLoadMedia]
)
let content: React.ReactNode
if (!isRenderableNoteKind(event.kind)) {
logger.debug('Note component - rendering UnknownNote for unsupported kind:', event.kind)
content = <UnknownNote className="mt-2" event={event} omitKindLabel />
content = <UnknownNote className="mt-2" event={displayEvent} omitKindLabel />
} else if (muteSetHas(mutePubkeySet, event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
@ -234,11 +238,11 @@ export default function Note({ @@ -234,11 +238,11 @@ export default function Note({
} else if (isNip25ReactionKind(event.kind)) {
content = null
} else if (isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) {
content = <NotificationEventCard className="mt-2" event={event} />
content = <NotificationEventCard className="mt-2" event={displayEvent} />
} else if (event.kind === kinds.Highlights) {
// Try to render the Highlight component with error boundary
try {
content = <Highlight className="mt-2" event={event} />
content = <Highlight className="mt-2" event={displayEvent} />
} catch (error) {
logger.error('Note component - Error rendering Highlight component:', error)
content = <div className="mt-2 p-4 bg-red-100 border border-red-500 rounded">
@ -249,8 +253,8 @@ export default function Note({ @@ -249,8 +253,8 @@ export default function Note({
</div>
}
} else if (event.kind === ExtendedKind.WEB_BOOKMARK) {
const href = getWebBookmarkArticleUrl(event)
const title = event.tags.find((tag) => tag[0] === 'title')?.[1]?.trim()
const href = getWebBookmarkArticleUrl(displayEvent)
const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim()
content = (
<>
{title ? (
@ -269,43 +273,43 @@ export default function Note({ @@ -269,43 +273,43 @@ export default function Note({
<WebPreview url={href} className="w-full" />
</div>
) : null}
{event.content?.trim() ? renderEventContent({ hideMetadata: true }) : null}
{displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null}
</>
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
content = showFull ? (
renderEventContent()
) : (
<WikiCard className="mt-2" event={event} />
<WikiCard className="mt-2" event={displayEvent} />
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
content = showFull ? (
renderEventContent()
) : (
<WikiCard className="mt-2" event={event} />
<WikiCard className="mt-2" event={displayEvent} />
)
} else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? (
<PublicationIndex className="mt-2" event={event} />
<PublicationIndex className="mt-2" event={displayEvent} />
) : (
<PublicationCard className="mt-2" event={event} />
<PublicationCard className="mt-2" event={displayEvent} />
)
} else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) {
content = showFull ? (
renderEventContent()
) : (
<PublicationCard className="mt-2" event={event} />
<PublicationCard className="mt-2" event={displayEvent} />
)
} else if (event.kind === kinds.LongFormArticle) {
content = renderEventContent({ hideMetadata: true })
} else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) {
content = <LiveEvent className="mt-2" event={event} />
content = <LiveEvent className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />
content = <GroupMetadata className="mt-2" event={displayEvent} originalNoteId={originalNoteId} />
} else if (event.kind === kinds.CommunityDefinition) {
content = <CommunityDefinition className="mt-2" event={event} />
content = <CommunityDefinition className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.DISCUSSION) {
const titleTag = event.tags.find(tag => tag[0] === 'title')
const titleTag = displayEvent.tags.find(tag => tag[0] === 'title')
const title = titleTag?.[1] || 'Untitled Discussion'
content = (
<>
@ -319,12 +323,12 @@ export default function Note({ @@ -319,12 +323,12 @@ export default function Note({
event.kind === ExtendedKind.CITATION_HARDCOPY ||
event.kind === ExtendedKind.CITATION_PROMPT
) {
content = <CitationCard className="mt-2" event={event} />
content = <CitationCard className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>
{renderEventContent({ hideMetadata: true })}
<Poll className="mt-2" event={event} />
<Poll className="mt-2" event={displayEvent} />
</>
)
} else if (event.kind === ExtendedKind.ZAP_POLL) {
@ -333,7 +337,7 @@ export default function Note({ @@ -333,7 +337,7 @@ export default function Note({
{renderEventContent({ hideMetadata: true })}
<ZapPoll
className="mt-2"
event={event}
event={displayEvent}
voteHighlightOptionIndex={zapPollVoteHighlightOption}
/>
</>
@ -357,21 +361,21 @@ export default function Note({ @@ -357,21 +361,21 @@ export default function Note({
} else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
content = <VideoNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={event} />
content = <RelayReview className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE) {
content = <CalendarEventContent event={event} className="mt-2" showRsvp />
content = <CalendarEventContent event={displayEvent} className="mt-2" showRsvp />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = renderEventContent({ hideMetadata: true })
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
content = <Zap className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPackPreview className="mt-2" event={event} />
content = <FollowPackPreview className="mt-2" event={displayEvent} />
} else if (
event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT ||
event.kind === ExtendedKind.GIT_ISSUE ||
event.kind === ExtendedKind.GIT_RELEASE
) {
content = <GitRepublicEventCard className="mt-2" event={event} />
content = <GitRepublicEventCard className="mt-2" event={displayEvent} />
} else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT) {
content = renderEventContent({ hideMetadata: true })
} else {
@ -381,7 +385,7 @@ export default function Note({ @@ -381,7 +385,7 @@ export default function Note({
const isSyntheticRssParent = isRssThreadSyntheticParentEvent(event)
const wrappedContent = isHighlightableKind ? (
<SelectionHighlightTrigger event={event}>{content}</SelectionHighlightTrigger>
<SelectionHighlightTrigger event={displayEvent}>{content}</SelectionHighlightTrigger>
) : (
content
)

85
src/components/NoteOptions/useMenuActions.tsx

@ -12,6 +12,12 @@ import { @@ -12,6 +12,12 @@ import {
type PublicationSectionRef
} from '@/lib/publication-section-fetch'
import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/url'
import {
clearNoteTranslation,
getNoteTranslation,
setNoteTranslation,
subscribeNoteTranslations
} from '@/lib/note-translation-display'
import { speakNoteReadAloud } from '@/lib/read-aloud'
import {
buildPinListTagsAfterToggle,
@ -46,11 +52,19 @@ import { @@ -46,11 +52,19 @@ import {
Trash2,
TriangleAlert,
Video,
Volume2
Volume2,
Languages
} from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { useMemo, useState, useEffect, useRef, useContext } from 'react'
import {
articleHasTranslatableTitle,
eventHasTranslatableTextBody,
translateNoteForDisplay
} from '@/lib/translate-note-for-menu'
import { isTranslateConfigured } from '@/lib/translate-client'
import { LocalizedLanguageNames, SUPPORTED_APP_LANGUAGE_CODES, type TLanguage } from '@/i18n'
import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import RelayIcon from '../RelayIcon'
@ -141,7 +155,13 @@ export function useMenuActions({ @@ -141,7 +155,13 @@ export function useMenuActions({
}, [])
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, event.pubkey), [mutePubkeySet, event])
const noteTranslationFromMenu = useSyncExternalStore(
subscribeNoteTranslations,
() => getNoteTranslation(event.id),
() => getNoteTranslation(event.id)
)
// Check if event is pinned
const [isPinned, setIsPinned] = useState(false)
@ -808,6 +828,50 @@ export function useMenuActions({ @@ -808,6 +828,50 @@ export function useMenuActions({
const d = encodeURIComponent(dTag)
window.open(`https://decentnewsroom.com/p/${p}/d/${d}`, '_blank', 'noopener,noreferrer')
}
const noteSupportsTranslateMenu =
isTranslateConfigured() &&
(eventHasTranslatableTextBody(event) || articleHasTranslatableTitle(event))
const translateTargetSubmenu: SubMenuAction[] = noteSupportsTranslateMenu
? [
{
label: t('Show original text'),
onClick: () => {
closeDrawer()
clearNoteTranslation(event.id)
toast.success(t('Showing original note text'))
}
},
...SUPPORTED_APP_LANGUAGE_CODES.map(
(code: TLanguage, i): SubMenuAction => ({
label: LocalizedLanguageNames[code],
separator: i === 0,
onClick: () => {
closeDrawer()
void toast.promise(
translateNoteForDisplay(event, code).then((out) => {
setNoteTranslation(event.id, {
lang: code,
content: out.content,
title: out.title
})
}),
{
loading: t('Translating note…'),
success: t('Note translated', { language: LocalizedLanguageNames[code] }),
error: (err: unknown) =>
t('Note translation failed', {
message: err instanceof Error ? err.message : String(err)
})
}
)
}
})
)
]
: []
const actions: MenuAction[] = [
{
icon: Copy,
@ -845,6 +909,18 @@ export function useMenuActions({ @@ -845,6 +909,18 @@ export function useMenuActions({
} as MenuAction
]
: []),
...(noteSupportsTranslateMenu
? [
{
icon: Languages,
label: t('Translate note'),
onClick: isSmallScreen
? () => showSubMenuActions(translateTargetSubmenu, t('Translate note'))
: undefined,
subMenu: isSmallScreen ? undefined : translateTargetSubmenu
} as MenuAction
]
: []),
...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage
? [
{
@ -1152,7 +1228,8 @@ export function useMenuActions({ @@ -1152,7 +1228,8 @@ export function useMenuActions({
onOpenCallInvite,
onOpenEditOrClone,
canSignEvents,
profile
profile,
noteTranslationFromMenu
])
return menuActions

13
src/components/ReadAloudPlayerModal.tsx

@ -115,6 +115,19 @@ export default function ReadAloudPlayerModal(): JSX.Element { @@ -115,6 +115,19 @@ export default function ReadAloudPlayerModal(): JSX.Element {
<p className="font-medium text-foreground line-clamp-2">{snap.title}</p>
) : null}
<p className="text-muted-foreground">{phaseLabel(snap, t)}</p>
{snap.piperUsedEnglishVoiceFallback && snap.piperVoiceRequestedLanguageName ? (
<div
role="status"
className="rounded-md border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-xs text-foreground"
>
<p className="font-medium">{t('Read-aloud Piper English voice fallback title')}</p>
<p className="mt-1 text-muted-foreground">
{t('Read-aloud Piper English voice fallback detail', {
language: snap.piperVoiceRequestedLanguageName
})}
</p>
</div>
) : null}
{snap.engine === 'piper' ? (
<p className="text-xs text-muted-foreground break-all">
{t('TTS endpoint')}: {snap.backend || '—'}

11
src/components/Settings/SettingsMenuBody.tsx

@ -4,7 +4,6 @@ import { @@ -4,7 +4,6 @@ import {
toPostSettings,
toRelaySettings,
toCacheSettings,
toTranslation,
toWallet,
toRssFeedSettings,
toPersonalListsSettings
@ -19,7 +18,6 @@ import { @@ -19,7 +18,6 @@ import {
Database,
Info,
KeyRound,
Languages,
PencilLine,
Rss,
Server,
@ -64,15 +62,6 @@ export default function SettingsMenuBody({ className }: { className?: string }) @@ -64,15 +62,6 @@ export default function SettingsMenuBody({ className }: { className?: string })
</div>
<ChevronRight />
</SettingItem>
{!!pubkey && (
<SettingItem className="clickable" onClick={() => navigateToSettings(toTranslation())}>
<div className="flex items-center gap-4">
<Languages />
<div>{t('Translation')}</div>
</div>
<ChevronRight />
</SettingItem>
)}
{!!pubkey && (
<SettingItem className="clickable" onClick={() => navigateToSettings(toWallet())}>
<div className="flex items-center gap-4">

10
src/i18n/index.ts

@ -30,11 +30,15 @@ export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGU @@ -30,11 +30,15 @@ export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGU
const supportedLanguages = Object.keys(LANGUAGE_META) as TLanguage[]
/** App UI languages (same set used for “Translate to …” in note menus). */
export const SUPPORTED_APP_LANGUAGE_CODES: readonly TLanguage[] = supportedLanguages
const localeModules = import.meta.glob<{ default: Resource }>('./locales/*.ts')
const localePath = (code: TLanguage): string => `./locales/${code}.ts`
function normalizeToSupported(lng: string): TLanguage {
/** Normalize a browser / i18next language tag to a supported app locale. */
export function normalizeToSupportedAppLanguage(lng: string): TLanguage {
const exact = supportedLanguages.find((s) => lng === s)
if (exact) return exact
return supportedLanguages.find((s) => lng.startsWith(s)) ?? 'en'
@ -71,7 +75,7 @@ export function initI18n(): Promise<void> { @@ -71,7 +75,7 @@ export function initI18n(): Promise<void> {
escapeValue: false
},
detection: {
convertDetectedLanguage: (lng) => normalizeToSupported(lng)
convertDetectedLanguage: (lng) => normalizeToSupportedAppLanguage(lng)
}
})
@ -102,7 +106,7 @@ export function initI18n(): Promise<void> { @@ -102,7 +106,7 @@ export function initI18n(): Promise<void> {
}
})
const target = normalizeToSupported(i18n.language)
const target = normalizeToSupportedAppLanguage(i18n.language)
if (target !== 'en') {
await ensureLocaleLoaded(target)
await i18n.changeLanguage(target)

9
src/i18n/locales/en.ts

@ -980,6 +980,15 @@ export default { @@ -980,6 +980,15 @@ export default {
'Advanced lab translation languages empty': 'Translate service returned no languages (check Docker / LibreTranslate).',
'Advanced lab translation languages error': 'Could not load languages from the translate service.',
'Advanced lab translation same source target': 'Source and target language must differ.',
'Show original text': 'Show original text',
'Showing original note text': 'Showing original text for this note.',
'Translate note': 'Translate note',
'Translating note…': 'Translating note…',
'Note translated': 'Translated to {{language}}.',
'Note translation failed': 'Translation failed: {{message}}',
'Read-aloud Piper English voice fallback title': 'English voice in use',
'Read-aloud Piper English voice fallback detail':
'Piper has no voice model for {{language}}. This session uses the English Piper voice instead; pronunciation may not match the written text.',
'Advanced lab translate not configured': 'Translation URL is not set (VITE_TRANSLATE_URL).',
'Advanced lab translate done': 'Translation inserted into the editor.',
'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)',

1
src/lib/link.ts

@ -67,7 +67,6 @@ export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => { @@ -67,7 +67,6 @@ export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
export const toWallet = () => '/settings/wallet'
export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toFollowSetsSettings = () => '/settings/follow-sets'
export const toEmojiSetsSettings = () => '/settings/emoji-sets'

59
src/lib/note-translation-display.ts

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
import type { TLanguage } from '@/i18n'
import type { Event } from 'nostr-tools'
import { useSyncExternalStore } from 'react'
export type NoteTranslationEntry = {
lang: TLanguage
content: string
/** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */
title?: string
}
const map = new Map<string, NoteTranslationEntry>()
const listeners = new Set<() => void>()
function emit(): void {
listeners.forEach((l) => l())
}
export function subscribeNoteTranslations(onStoreChange: () => void): () => void {
listeners.add(onStoreChange)
return () => listeners.delete(onStoreChange)
}
export function setNoteTranslation(eventId: string, entry: NoteTranslationEntry): void {
map.set(eventId, entry)
emit()
}
export function clearNoteTranslation(eventId: string): void {
map.delete(eventId)
emit()
}
export function getNoteTranslation(eventId: string): NoteTranslationEntry | undefined {
return map.get(eventId)
}
export function useNoteTranslation(eventId: string): NoteTranslationEntry | undefined {
return useSyncExternalStore(
subscribeNoteTranslations,
() => map.get(eventId),
() => map.get(eventId)
)
}
function patchTitleInTagsCopy(tags: string[][], title: string): string[][] {
const out = tags.map((row) => row.slice())
const i = out.findIndex((r) => r[0] === 'title')
if (i >= 0) out[i] = ['title', title]
else out.unshift(['title', title])
return out
}
/** Event with translated `content` / optional `title` tag for body renderers. */
export function mergeTranslatedNote(event: Event, tr?: NoteTranslationEntry | null): Event {
if (!tr) return event
const tags = tr.title ? patchTitleInTagsCopy(event.tags, tr.title) : event.tags
return { ...event, content: tr.content, tags }
}

9
src/lib/piper-tts-cache-policy.ts

@ -15,15 +15,18 @@ export function getPiperTtsCacheBudget(): { maxEntries: number; maxBytes: number @@ -15,15 +15,18 @@ export function getPiperTtsCacheBudget(): { maxEntries: number; maxBytes: number
}
/**
* Stable key for a Piper request: same URL + text + speed same audio.
* Stable key for a Piper request: same URL + text + speed + voice same audio.
* Server upgrades / voice changes require a new endpoint URL or speed to bust the cache.
*/
export async function buildPiperTtsCacheKey(
endpointUrl: string,
text: string,
speed: number
speed: number,
voice: string
): Promise<string> {
const payload = new TextEncoder().encode(JSON.stringify({ u: endpointUrl, t: text, s: speed }))
const payload = new TextEncoder().encode(
JSON.stringify({ u: endpointUrl, t: text, s: speed, v: voice })
)
const digest = await crypto.subtle.digest('SHA-256', payload)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))

32
src/lib/piper-voice-for-app-language.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
import type { TLanguage } from '@/i18n'
/**
* Piper voice ids aligned with the default Wyoming / `piper-tts-proxy` stock models
* (see `services/piper-tts-proxy/server.ts` `getVoiceForLanguage`).
* App locales without a dedicated model use {@link PIPER_FALLBACK_ENGLISH_VOICE}.
*/
const PIPER_VOICE_BY_APP_LANGUAGE: Partial<Record<TLanguage, string>> = {
en: 'en_US-lessac-medium',
de: 'de_DE-thorsten-medium',
fr: 'fr_FR-siwis-medium',
es: 'es_ES-davefx-medium',
ru: 'ru_RU-ruslan-medium',
zh: 'zh_CN-huayan-medium',
pl: 'pl_PL-darkman-medium'
}
export const PIPER_FALLBACK_ENGLISH_VOICE = 'en_US-lessac-medium'
export function getPiperVoiceForChosenLanguage(lang: TLanguage): {
voice: string
usedEnglishVoiceFallback: boolean
} {
const v = PIPER_VOICE_BY_APP_LANGUAGE[lang]
if (v) {
return { voice: v, usedEnglishVoiceFallback: false }
}
return {
voice: PIPER_FALLBACK_ENGLISH_VOICE,
usedEnglishVoiceFallback: lang !== 'en'
}
}

55
src/lib/read-aloud.ts

@ -1,4 +1,7 @@ @@ -1,4 +1,7 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants'
import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage, type TLanguage } from '@/i18n'
import { getNoteTranslation } from '@/lib/note-translation-display'
import { getPiperVoiceForChosenLanguage } from '@/lib/piper-voice-for-app-language'
import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import {
buildPiperTtsCacheKey,
@ -66,6 +69,10 @@ export type ReadAloudSnapshot = { @@ -66,6 +69,10 @@ export type ReadAloudSnapshot = {
readAloudPiperTryStartedAt: number | null
volume: number
backend: string
/** Piper has no model for the chosen language; the English Piper voice is used instead. */
piperUsedEnglishVoiceFallback: boolean
/** Display name of the requested language when {@link piperUsedEnglishVoiceFallback} is true. */
piperVoiceRequestedLanguageName: string
}
const initialSnapshot: ReadAloudSnapshot = {
@ -88,7 +95,9 @@ const initialSnapshot: ReadAloudSnapshot = { @@ -88,7 +95,9 @@ const initialSnapshot: ReadAloudSnapshot = {
readAloudPiperSkipped: false,
readAloudPiperTryStartedAt: null,
volume: 1,
backend: ''
backend: '',
piperUsedEnglishVoiceFallback: false,
piperVoiceRequestedLanguageName: ''
}
let snapshot: ReadAloudSnapshot = { ...initialSnapshot }
@ -295,7 +304,8 @@ async function fetchPiperTtsBlobForChunk( @@ -295,7 +304,8 @@ async function fetchPiperTtsBlobForChunk(
chunkIndex: number,
totalChunks: number,
text: string,
signal: AbortSignal
signal: AbortSignal,
voice: string
): Promise<Blob> {
const url = READ_ALOUD_TTS_URL
if (!url) {
@ -307,7 +317,7 @@ async function fetchPiperTtsBlobForChunk( @@ -307,7 +317,7 @@ async function fetchPiperTtsBlobForChunk(
const budget = getPiperTtsCacheBudget()
let cacheKey: string | undefined
try {
cacheKey = await buildPiperTtsCacheKey(url, text, speed)
cacheKey = await buildPiperTtsCacheKey(url, text, speed, voice)
const hit = await indexedDb.getPiperTtsBlobCache(cacheKey, ttlMs)
if (hit && hit.size > 0) {
return hit
@ -321,7 +331,7 @@ async function fetchPiperTtsBlobForChunk( @@ -321,7 +331,7 @@ async function fetchPiperTtsBlobForChunk(
response = await fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, speed }),
body: JSON.stringify({ text, speed, voice }),
signal,
timeoutMs: 120_000
})
@ -474,7 +484,7 @@ function playPiperBlob(blob: Blob, signal: AbortSignal): Promise<'ok' | 'error' @@ -474,7 +484,7 @@ function playPiperBlob(blob: Blob, signal: AbortSignal): Promise<'ok' | 'error'
})
}
async function speakViaPiperTtsChunks(chunks: string[]): Promise<ReadAloudResult> {
async function speakViaPiperTtsChunks(chunks: string[], piperVoice: string): Promise<ReadAloudResult> {
stopReadAloudPlayback()
readAloudAbort = new AbortController()
const signal = readAloudAbort.signal
@ -493,7 +503,7 @@ async function speakViaPiperTtsChunks(chunks: string[]): Promise<ReadAloudResult @@ -493,7 +503,7 @@ async function speakViaPiperTtsChunks(chunks: string[]): Promise<ReadAloudResult
if (text === undefined) {
p = Promise.reject(new Error(`Part ${index + 1} of ${chunks.length}: missing text`))
} else {
p = fetchPiperTtsBlobForChunk(index, chunks.length, text, signal)
p = fetchPiperTtsBlobForChunk(index, chunks.length, text, signal, piperVoice)
}
chunkBlobPromises.set(index, p)
}
@ -637,6 +647,9 @@ async function speakViaWebSpeech( @@ -637,6 +647,9 @@ async function speakViaWebSpeech(
finishedAt: null,
error: null,
...(!options?.fromPiperFallback ? { usedPiperFallback: false, piperFallbackDetail: null } : {}),
...(options?.browserOnlyNoPiper
? { piperUsedEnglishVoiceFallback: false, piperVoiceRequestedLanguageName: '' }
: {}),
...webspeechPiperFields
})
@ -673,15 +686,33 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult> @@ -673,15 +686,33 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
}
const translationOverride = takeReadAloudTranslationForEvent(event.id)
const text = translationOverride
? stripMarkupForReadAloud(translationOverride)
: buildReadAloudPlainText(event)
const persistedTranslation = getNoteTranslation(event.id)
let text: string
if (translationOverride) {
text = stripMarkupForReadAloud(translationOverride)
} else if (persistedTranslation) {
let raw = persistedTranslation.content.trim()
if (KINDS_WITH_METADATA_TITLE.has(event.kind) && persistedTranslation.title?.trim()) {
raw = `${persistedTranslation.title.trim()}. ${raw}`
}
text = stripMarkupForReadAloud(raw)
} else {
text = buildReadAloudPlainText(event)
}
if (!text) {
return 'empty'
}
const title = readAloudTitleFromEvent(event)
const chosenReadAloudLang: TLanguage =
persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en')
const { voice: piperVoice, usedEnglishVoiceFallback } =
getPiperVoiceForChosenLanguage(chosenReadAloudLang)
const piperVoiceRequestedLanguageName = usedEnglishVoiceFallback
? LocalizedLanguageNames[chosenReadAloudLang]
: ''
if (READ_ALOUD_TTS_URL) {
stopReadAloudPlayback()
readAloudUserPaused = false
@ -707,12 +738,14 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult> @@ -707,12 +738,14 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
piperFallbackDetail: null,
readAloudPiperSkipped: false,
readAloudPiperTryStartedAt: Date.now(),
backend: readAloudEndpointForLog()
backend: readAloudEndpointForLog(),
piperUsedEnglishVoiceFallback: usedEnglishVoiceFallback,
piperVoiceRequestedLanguageName
})
await yieldForReadAloudUi()
const piperResult = await speakViaPiperTtsChunks(chunks)
const piperResult = await speakViaPiperTtsChunks(chunks, piperVoice)
if (piperResult === 'ok') {
return 'ok'
}

93
src/lib/translate-note-for-menu.ts

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
import { ExtendedKind } from '@/constants'
import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds'
import {
translateAdvancedLabMarkup,
type AdvancedLabMarkupMode
} from '@/lib/advanced-lab-markup-protect'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import type { TLanguage } from '@/i18n'
import type { Event } from 'nostr-tools'
const CHUNK_MAX = 2500
/** Map app UI locale codes to LibreTranslate `target` codes where they differ. */
export function translateTargetFromAppLanguage(code: TLanguage): string {
if (code === 'pt-BR' || code === 'pt-PT') return 'pt'
return code
}
function looksLikeStringifiedJsonObject(content: string): boolean {
const trimmed = content.trim()
if (
!(trimmed.startsWith('{') && trimmed.endsWith('}')) &&
!(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
return false
}
try {
const parsed = JSON.parse(trimmed) as unknown
return parsed !== null && typeof parsed === 'object'
} catch {
return false
}
}
export function eventHasTranslatableTextBody(event: Event): boolean {
const c = event.content?.trim() ?? ''
if (!c) return false
if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) {
return false
}
if (looksLikeStringifiedJsonObject(c)) return false
return true
}
export function articleHasTranslatableTitle(event: Event): boolean {
return Boolean(getLongFormArticleMetadataFromEvent(event).title?.trim())
}
/** Same exclusions as the advanced lab (`translateAdvancedLabMarkup`). Chunk large bodies for the API. */
async function translateLongProtectedBody(
text: string,
target: string,
markupMode: AdvancedLabMarkupMode
): Promise<string> {
const t = text.trim()
if (!t) return text
if (t.length <= CHUNK_MAX) {
return translateAdvancedLabMarkup(t, target, 'auto', markupMode)
}
const blocks: string[] = []
let rest = t
while (rest.length) {
let slice = rest.slice(0, CHUNK_MAX)
const nl = slice.lastIndexOf('\n')
if (nl > 600) {
slice = rest.slice(0, nl + 1)
}
const part = slice.trimEnd()
if (part) {
blocks.push(await translateAdvancedLabMarkup(part, target, 'auto', markupMode))
}
rest = rest.slice(slice.length).trimStart()
}
return blocks.join('\n')
}
export async function translateNoteForDisplay(
event: Event,
appLang: TLanguage
): Promise<{ content: string; title?: string }> {
const target = translateTargetFromAppLanguage(appLang)
const markupMode: AdvancedLabMarkupMode = isAsciidocMarkupKind(event.kind) ? 'asciidoc' : 'markdown'
const meta = getLongFormArticleMetadataFromEvent(event)
const origTitle = meta.title?.trim()
const title = origTitle
? await translateAdvancedLabMarkup(origTitle, target, 'auto', markupMode)
: undefined
const rawContent = event.content ?? ''
const content = rawContent.trim()
? await translateLongProtectedBody(rawContent, target, markupMode)
: rawContent
return { content: content || rawContent, title }
}

42
src/pages/secondary/TranslationPage/index.tsx

@ -1,42 +0,0 @@ @@ -1,42 +0,0 @@
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const TranslationPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Translation')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="px-4 pt-3 space-y-4">
<p className="text-muted-foreground">
{t(
'To translate notes and other content, use your browser’s built-in translation. For example: right-click the page and choose “Translate to…”, or use the translate icon in the address bar.'
)}
</p>
</div>
</SecondaryPageLayout>
)
}
)
TranslationPage.displayName = 'TranslationPage'
export default TranslationPage

2
src/routes.tsx

@ -33,7 +33,6 @@ const UserEmojiListPageLazy = lazy(() => import('./pages/secondary/UserEmojiList @@ -33,7 +33,6 @@ const UserEmojiListPageLazy = lazy(() => import('./pages/secondary/UserEmojiList
const EmojiSetsSettingsPageLazy = lazy(() => import('./pages/secondary/EmojiSetsSettingsPage'))
const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage'))
const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage'))
const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage'))
const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect'))
const RssArticlePageLazy = lazy(() => import('./pages/secondary/RssArticlePage'))
@ -84,7 +83,6 @@ const ROUTES = [ @@ -84,7 +83,6 @@ const ROUTES = [
{ path: '/settings/wallet', element: SR(WalletPageLazy) },
{ path: '/settings/posts', element: SR(PostSettingsPageLazy) },
{ path: '/settings/general', element: SR(GeneralSettingsPageLazy) },
{ path: '/settings/translation', element: SR(TranslationPageLazy) },
{ path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) },
{ path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) },
{ path: '/settings/emoji-sets', element: SR(EmojiSetsSettingsPageLazy) },

5
src/services/navigation.service.ts

@ -13,7 +13,6 @@ import RelaySettingsPage from '@/pages/secondary/RelaySettingsPage' @@ -13,7 +13,6 @@ import RelaySettingsPage from '@/pages/secondary/RelaySettingsPage'
import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage'
import EmojiSetsSettingsPage from '@/pages/secondary/EmojiSetsSettingsPage'
@ -95,7 +94,6 @@ export class URLParser { @@ -95,7 +94,6 @@ export class URLParser {
'relays',
'wallet',
'posts',
'translation',
'rss-feeds',
'follow-sets',
'emoji-sets',
@ -159,8 +157,6 @@ export class ComponentFactory { @@ -159,8 +157,6 @@ export class ComponentFactory {
return React.createElement(PostSettingsPage, { index: 0, hideTitlebar: true })
case 'general':
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
case 'translation':
return React.createElement(TranslationPage, { index: 0, hideTitlebar: true })
case 'rss-feeds':
return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true })
case 'follow-sets':
@ -274,7 +270,6 @@ export class NavigationService { @@ -274,7 +270,6 @@ export class NavigationService {
if (pathname.includes('/cache')) return 'Cache & offline storage'
if (pathname.includes('/wallet')) return 'Wallet Settings'
if (pathname.includes('/posts')) return 'Post Settings'
if (pathname.includes('/translation')) return 'Translation Settings'
if (pathname.includes('/emoji-sets')) return 'Emoji sets'
return 'Settings'
}

Loading…
Cancel
Save