fix: reduce pdf flicker and harden tts

- Smooth react-pdf page turns with stable canvas staging and fade-in
- Retry transient empty page text extraction during fast page turns
- Avoid noisy TTS preload toasts, guard requests when quota is exhausted,
  and improve Howler retry/unload behavior to prevent pool exhaustion
- Enforce auth FK cascade deletes and tighten rate-limiter bucket updates
  using affected-row checks
This commit is contained in:
Richard R 2026-01-26 17:01:51 -07:00
parent c57b913cb5
commit 8189087ad9
10 changed files with 902 additions and 116 deletions

View file

@ -0,0 +1,6 @@
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,592 @@
{
"id": "62e82369-d624-4cc1-a482-7ea5fbc7275b",
"prevId": "5ac6552c-824b-4b79-8cb6-f0ec3d121031",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audiobook_chapters": {
"name": "audiobook_chapters",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"book_id": {
"name": "book_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"chapter_index": {
"name": "chapter_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"audiobook_chapters_book_id_audiobooks_id_fk": {
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "audiobooks",
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audiobooks": {
"name": "audiobooks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_path": {
"name": "cover_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"last_modified": {
"name": "last_modified",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"documents_id_user_id_pk": {
"name": "documents_id_user_id_pk",
"columns": [
"id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_anonymous": {
"name": "is_anonymous",
"type": "boolean",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_tts_chars": {
"name": "user_tts_chars",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"char_count": {
"name": "char_count",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_user_tts_chars_date": {
"name": "idx_user_tts_chars_date",
"columns": [
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_tts_chars_user_id_date_pk": {
"name": "user_tts_chars_user_id_date_pk",
"columns": [
"user_id",
"date"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1769393893675,
"tag": "0000_military_nemesis",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1769404444945,
"tag": "0001_futuristic_harpoon",
"breakpoints": true
}
]
}

View file

@ -119,6 +119,50 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/*
* PDF page turn smoothing
* - Keep a stable paint surface (prevents background flash)
* - Fade-in newly rendered canvas
*/
.pdf-page-stage {
/* Ensure we always paint something (matches app theme) while react-pdf swaps canvases */
background: var(--background);
}
.pdf-viewer .react-pdf__Page {
/* Make page paints more stable and avoid seeing the app background between renders */
background: var(--background);
isolation: isolate;
}
.pdf-viewer .react-pdf__Page canvas {
display: block;
background: var(--background);
/* GPU hint; helps reduce flicker on some browsers */
transform: translateZ(0);
backface-visibility: hidden;
}
/* Fade-in the freshly rendered canvas (react-pdf replaces the canvas element). */
.pdf-viewer .react-pdf__Page canvas {
opacity: 0;
animation: pdf-canvas-fade-in 140ms ease-out forwards;
}
@keyframes pdf-canvas-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Hide react-pdf's default tiny "Loading page..." placeholder to avoid jarring layout shifts. */
.pdf-viewer .react-pdf__message {
display: none !important;
}
.pdf-page-stage {
position: relative;
}
/* App shell utility (fullscreen, vertical layout) */
.app-shell {
--header-height: 3.25rem;

View file

@ -22,6 +22,7 @@ interface PDFOnLinkClickArgs {
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isPageRendering, setIsPageRendering] = useState(false);
const scaleRef = useRef<number>(1);
const { containerWidth, containerHeight } = usePDFResize(containerRef);
const sentenceHighlightSeqRef = useRef(0);
@ -75,6 +76,14 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
// Track page turns so we can keep the previous canvas visible until the new one paints.
const lastRenderedLayoutKeyRef = useRef<string>('');
useEffect(() => {
if (layoutKey !== lastRenderedLayoutKeyRef.current) {
setIsPageRendering(true);
}
}, [layoutKey]);
const clearSentenceHighlightTimeouts = useCallback(() => {
for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t);
sentenceHighlightTimeoutsRef.current = [];
@ -282,7 +291,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
}, [calculateScale]);
return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
<div
ref={containerRef}
className="flex flex-col items-center overflow-auto w-full px-6 h-full pdf-viewer"
>
<Document
key={currDocId || 'pdf'}
loading={<DocumentSkeleton />}
@ -302,9 +314,9 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
}
}
}}
className="flex flex-col items-center m-0 z-0"
className="flex flex-col items-center m-0 z-0"
>
<div>
<div className="pdf-page-stage" data-rendering={isPageRendering ? 'true' : 'false'}>
{viewType === 'scroll' ? (
// Scroll mode: render all pages
<div className="flex flex-col gap-4">
@ -316,6 +328,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
renderTextLayer={i + 1 === currDocPage}
className="shadow-lg"
scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
@ -334,6 +350,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
renderTextLayer={leftPage === currDocPage}
className="shadow-lg"
scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
@ -348,6 +368,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
renderTextLayer={rightPage === currDocPage}
className="shadow-lg"
scale={currentScale()}
onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey;
setIsPageRendering(false);
}}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);

View file

@ -101,6 +101,8 @@ interface PDFContextType {
const PDFContext = createContext<PDFContextType | undefined>(undefined);
const CONTINUATION_PREVIEW_CHARS = 600;
const EMPTY_TEXT_RETRY_DELAY_MS = 120;
const EMPTY_TEXT_MAX_RETRIES = 6;
/**
* PDFProvider Component
@ -150,6 +152,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
// or when react-pdf tears down and recreates its internal worker.
const pdfDocGenerationRef = useRef(0);
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
const loadSeqRef = useRef(0);
const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
useEffect(() => {
pdfDocumentRef.current = pdfDocument;
@ -183,6 +187,17 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const generation = pdfDocGenerationRef.current;
const currentPdf = pdfDocumentRef.current;
if (!currentPdf) return;
const seq = ++loadSeqRef.current;
const pageNumber = currDocPageNumber;
const existingRetry = emptyRetryRef.current;
if (existingRetry?.timer) {
clearTimeout(existingRetry.timer);
}
emptyRetryRef.current =
existingRetry && existingRetry.page === pageNumber
? { ...existingRetry, timer: null }
: null;
const margins = {
header: headerMargin,
@ -228,6 +243,35 @@ export function PDFProvider({ children }: { children: ReactNode }) {
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return;
}
if (seq !== loadSeqRef.current || pageNumber !== currDocPageNumber) {
return;
}
const trimmed = text.trim();
if (!trimmed) {
const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0;
const attempt = prevAttempt + 1;
// Avoid pushing empty text into TTS immediately; transient empty extractions can happen
// during page turns or react-pdf worker churn. Retry a few times before treating it as
// a truly blank page.
if (attempt <= EMPTY_TEXT_MAX_RETRIES) {
const timer = setTimeout(() => {
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return;
}
if (pageNumber !== currDocPageNumber) {
return;
}
void loadCurrDocText();
}, EMPTY_TEXT_RETRY_DELAY_MS);
emptyRetryRef.current = { page: pageNumber, attempt, timer };
return;
}
} else {
emptyRetryRef.current = null;
}
if (text !== currDocText || text === '') {
setCurrDocText(text);
@ -277,6 +321,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
// or fast refresh.
pdfDocGenerationRef.current += 1;
loadSeqRef.current += 1;
if (emptyRetryRef.current?.timer) {
clearTimeout(emptyRetryRef.current.timer);
}
emptyRetryRef.current = null;
pageTextCacheRef.current.clear();
setPdfDocument(undefined);
setCurrDocPages(undefined);
@ -304,6 +353,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const clearCurrDoc = useCallback(() => {
pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = undefined;
loadSeqRef.current += 1;
if (emptyRetryRef.current?.timer) {
clearTimeout(emptyRetryRef.current.timer);
}
emptyRetryRef.current = null;
setCurrDocId(undefined);
setCurrDocName(undefined);
setCurrDocData(undefined);

View file

@ -299,7 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const audioContext = useAudioContext();
const audioCache = useAudioCache(25);
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit } = useAutoRateLimit();
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAutoRateLimit();
// Add ref for location change handler
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
@ -820,6 +820,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return cachedAudio;
}
// If the user is already out of quota, avoid spamming requests.
// Cached audio above is still allowed to play.
if (isAtLimit) {
if (!preload) {
setIsPlaying(false);
}
return undefined;
}
try {
console.log('Requesting audio for sentence:', sentence);
@ -906,28 +915,34 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Handle daily quota exceeded (429 + Problem Details code)
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
toast.error('Daily TTS limit reached.', {
id: 'tts-limit-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 5000,
});
// Avoid noisy toasts from background preloading; keep the user-facing error for active playback.
if (!preload) {
toast.error('Daily TTS limit reached.', {
id: 'tts-limit-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 5000,
});
}
triggerRateLimit();
refreshRateLimit().catch(console.error);
// Do NOT re-throw, just return undefined to stop playback gracefully
return undefined;
}
toast.error('Failed to generate audio. Server not responding.', {
id: 'tts-api-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 7000,
});
// Avoid noisy toasts from background preloading.
if (!preload) {
toast.error('TTS failed. Skipped sentence and paused.', {
id: 'tts-api-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 7000,
});
}
throw error;
}
}, [
@ -947,7 +962,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
triggerRateLimit,
refreshRateLimit,
onTTSComplete,
onTTSStart
onTTSStart,
isAtLimit
]);
/**
@ -1000,9 +1016,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (preload) {
preloadRequests.current.set(sentence, processPromise);
// Clean up the map entry once the promise resolves or rejects
processPromise.finally(() => {
preloadRequests.current.delete(sentence);
});
void processPromise
.finally(() => {
preloadRequests.current.delete(sentence);
})
.catch(() => {
// Prevent unhandled rejections from the cleanup-only chained promise.
});
}
return processPromise;
@ -1024,31 +1044,33 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const INITIAL_RETRY_DELAY = 1000; // 1 second
const createHowl = async (retryCount = 0): Promise<Howl | null> => {
try {
// Get the processed audio data URI directly from processSentence
const audioDataUri = await processSentence(sentence);
if (!audioDataUri) {
// Graceful exit for rate limit or skipped sentence
console.log('Skipping playback for sentence (no audio generated)');
return null;
}
let playErrorAttempts = 0;
// Get the processed audio data URI directly from processSentence
const audioDataUri = await processSentence(sentence);
if (!audioDataUri) {
// Graceful exit for rate limit / abort / intentionally skipped sentence
console.log('Skipping playback for sentence (no audio generated)');
return null;
}
// Force unload any previous Howl instance to free up resources
if (activeHowl) {
activeHowl.unload();
}
// Force unload any previous Howl instance to free up resources
if (activeHowl) {
activeHowl.unload();
}
return new Howl({
src: [audioDataUri],
format: ['mp3', 'mpeg'],
html5: true,
preload: true,
pool: 5,
rate: audioSpeed,
onload: function (this: Howl) {
const estimate = pageTurnEstimateRef.current;
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
if (!visualPageChangeHandlerRef.current) return;
return new Howl({
src: [audioDataUri],
format: ['mp3', 'mpeg'],
html5: true,
preload: true,
// We never need overlapping playback for a single sentence. Keeping this low avoids
// Safari/HTML5 Audio pool exhaustion when retries happen.
pool: 1,
rate: audioSpeed,
onload: function (this: Howl) {
const estimate = pageTurnEstimateRef.current;
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
if (!visualPageChangeHandlerRef.current) return;
const duration = this.duration();
if (!duration || !Number.isFinite(duration)) return;
@ -1066,46 +1088,69 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
visualPageChangeHandlerRef.current?.(currentEstimate.location);
}, delayMs);
},
onplay: () => {
},
onplay: () => {
setIsProcessing(false);
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing';
}
},
onplayerror: function (this: Howl, error) {
console.warn('Howl playback error:', error);
playErrorAttempts += 1;
// Avoid looping for many seconds on Safari: if playback still fails after a single
// recovery attempt, skip the sentence and pause.
if (playErrorAttempts > 1) {
setIsProcessing(false);
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing';
}
},
onplayerror: function (this: Howl, error) {
console.warn('Howl playback error:', error);
// Try to recover by forcing HTML5 audio mode
if (this.state() === 'loaded') {
setActiveHowl(null);
this.unload();
setIsPlaying(false);
advance();
return;
}
// Try to recover by reloading once.
if (this.state() === 'loaded') {
this.unload();
this.once('load', () => this.play());
this.load();
}
},
onloaderror: async function (this: Howl, error) {
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
if (retryCount < MAX_RETRIES) {
// Calculate exponential backoff delay
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
console.log(`Retrying in ${delay}ms...`);
// Free the current Howl/audio objects before retrying to avoid pool exhaustion.
try {
this.unload();
this.once('load', () => {
this.play();
});
this.load();
} catch {
// ignore unload errors
}
},
onloaderror: async function (this: Howl, error) {
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
if (retryCount < MAX_RETRIES) {
// Calculate exponential backoff delay
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
console.log(`Retrying in ${delay}ms...`);
// Wait for the delay
await new Promise(resolve => setTimeout(resolve, delay));
// Wait for the delay
await new Promise(resolve => setTimeout(resolve, delay));
// Try to create a new Howl instance
// Try to create a new Howl instance
try {
const retryHowl = await createHowl(retryCount + 1);
if (retryHowl) {
setActiveHowl(retryHowl);
retryHowl.play();
} else {
// No audio generated (quota/abort). Stop cleanly without spamming errors.
setIsProcessing(false);
setActiveHowl(null);
setIsPlaying(false);
}
} else {
console.error('Max retries reached, moving to next sentence');
} catch (err) {
console.error('Error creating Howl instance:', err);
setIsProcessing(false);
setActiveHowl(null);
this.unload();
setIsPlaying(false);
toast.error('Audio loading failed after retries. Moving to next sentence...', {
@ -1117,6 +1162,24 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
duration: 2000,
});
advance();
}
} else {
console.error('Max retries reached, moving to next sentence');
setIsProcessing(false);
setActiveHowl(null);
this.unload();
setIsPlaying(false);
toast.error('Audio loading failed after retries. Moving to next sentence...', {
id: 'audio-load-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 2000,
});
advance();
}
},
@ -1131,43 +1194,38 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
advance();
}
},
onstop: function (this: Howl) {
setIsProcessing(false);
this.unload();
}
});
} catch (error) {
console.error('Error creating Howl instance:', error);
return null;
}
onstop: function (this: Howl) {
setIsProcessing(false);
this.unload();
}
});
};
try {
const howl = await createHowl();
if (howl) {
setActiveHowl(howl);
return howl;
if (!howl) {
// No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without
// advancing or spamming errors.
setActiveHowl(null);
setIsProcessing(false);
setIsPlaying(false);
return null;
}
throw new Error('Failed to create Howl instance');
setActiveHowl(howl);
return howl;
} catch (error) {
console.error('Error playing TTS:', error);
setActiveHowl(null);
setIsProcessing(false);
toast.error('Failed to process audio. Skipping problematic sentence.', {
id: 'tts-processing-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 3000,
});
// Skip the sentence but pause playback (user can resume manually).
abortAudio(true);
setIsPlaying(false);
advance();
return null;
}
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
}, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]);
const playAudio = useCallback(async () => {
const sentence = sentences[currentIndex];
@ -1245,6 +1303,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* Preloads the next sentence's audio
*/
const preloadNextAudio = useCallback(async () => {
if (isAtLimit) return;
try {
const nextSentence = sentences[currentIndex + 1];
if (nextSentence) {
@ -1283,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} catch (error) {
console.error('Error initiating preload:', error);
}
}, [currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
}, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
/**
* Main Playback Driver

View file

@ -54,14 +54,14 @@ export const session = pgTable("session", {
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id)
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' })
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),

View file

@ -55,14 +55,14 @@ export const session = sqliteTable("session", {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id)
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' })
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),

View file

@ -88,6 +88,14 @@ class RateLimitExceeded extends Error {
name = 'RateLimitExceeded' as const;
}
function getRowsAffected(result: unknown): number {
if (typeof result !== 'object' || result === null) return 0;
const rec = result as Record<string, unknown>;
if (typeof rec.rowCount === 'number') return rec.rowCount;
if (typeof rec.changes === 'number') return rec.changes;
return 0;
}
export class RateLimiter {
constructor() { }
@ -144,7 +152,6 @@ export class RateLimiter {
// Attempt to increment each bucket
for (const bucket of buckets) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const updateResult = await tx.update(userTtsChars)
.set({
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
@ -156,13 +163,10 @@ export class RateLimiter {
lt(userTtsChars.charCount, bucket.limit)
));
// Check if update actually happened
const row = await tx.select({ count: userTtsChars.charCount }).from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((res: any) => res[0]);
if (!row || Number(row.count) > bucket.limit) {
// If any bucket is already at/over its limit, reject the request (and roll back all increments).
// Note: we intentionally allow a request to push a bucket over its limit; we only block when the
// bucket was already exhausted before this request started.
if (getRowsAffected(updateResult) <= 0) {
throw new RateLimitExceeded();
}
}
@ -180,10 +184,6 @@ export class RateLimiter {
const effective = pickEffectiveResult(bucketResults);
if (effective.currentCount > effective.limit) {
throw new RateLimitExceeded();
}
return {
allowed: true,
currentCount: effective.currentCount,
@ -330,4 +330,4 @@ export class RateLimiter {
}
// Export singleton instance
export const rateLimiter = new RateLimiter();
export const rateLimiter = new RateLimiter();