From 109d43f58562a56d43ecac38de5947e699980224 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Sat, 18 Jan 2025 19:30:50 -0700 Subject: [PATCH] Buggy but working --- .env.template | 7 + .gitignore | 2 +- package-lock.json | 289 +++++++++++++++++++++++++++++++++-- package.json | 6 +- src/app/layout.tsx | 15 +- src/app/providers.tsx | 9 +- src/components/PDFViewer.tsx | 185 +++++++++++++++++++++- src/context/TTSContext.tsx | 278 +++++++++++++++++++++++++++++++-- 8 files changed, 753 insertions(+), 38 deletions(-) create mode 100644 .env.template diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..84c9a61 --- /dev/null +++ b/.env.template @@ -0,0 +1,7 @@ +# OpenAI API Key for Text-to-Speech functionality +NEXT_PUBLIC_OPENAI_API_KEY=your_openai_api_key_here + +# OpenAI API Base URL (default) +NEXT_PUBLIC_OPENAI_API_BASE=https://api.openai.com/v1 + +# Add other environment variables below as needed diff --git a/.gitignore b/.gitignore index 5ef6a52..e72b4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env # vercel .vercel diff --git a/package-lock.json b/package-lock.json index 652f579..14c9ec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,17 @@ "version": "0.1.0", "dependencies": { "@headlessui/react": "^2.2.0", + "@types/string-similarity": "^4.0.2", "compromise": "^14.14.4", + "lru-cache": "^11.0.2", "next": "15.1.5", + "openai": "^4.79.1", "pdfjs-dist": "4.8.69", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", - "react-pdf": "^9.2.1" + "react-pdf": "^9.2.1", + "string-similarity": "^4.0.4" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1120,12 +1124,21 @@ "version": "20.17.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/react": { "version": "19.0.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", @@ -1146,6 +1159,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/string-similarity": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/string-similarity/-/string-similarity-4.0.2.tgz", + "integrity": "sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", @@ -1382,6 +1401,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -1405,6 +1436,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1660,6 +1703,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -2046,6 +2095,18 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2257,6 +2318,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3005,6 +3075,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3179,6 +3258,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3539,6 +3651,15 @@ "node": ">= 0.4" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4270,11 +4391,13 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", + "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/make-cancellable-promise": { "version": "1.3.2", @@ -4345,6 +4468,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", @@ -4402,7 +4546,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4551,6 +4694,45 @@ "license": "MIT", "optional": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4702,6 +4884,51 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "4.79.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.79.1.tgz", + "integrity": "sha512-M7P5/PKnT/S/B5v0D64giC9mjyxFYkqlCuQFzR5hkdzMdqUuHf8T1gHhPGPF5oAvu4+PO3TvJv/qhZoS2bqAkw==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4834,6 +5061,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path2d": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.2.tgz", @@ -5831,6 +6065,13 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-similarity": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", + "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "ISC" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6305,6 +6546,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", @@ -6485,7 +6732,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/uri-js": { @@ -6514,6 +6760,31 @@ "loose-envify": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f133f6f..ecf2827 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,17 @@ }, "dependencies": { "@headlessui/react": "^2.2.0", + "@types/string-similarity": "^4.0.2", "compromise": "^14.14.4", + "lru-cache": "^11.0.2", "next": "15.1.5", + "openai": "^4.79.1", "pdfjs-dist": "4.8.69", "react": "^19.0.0", "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", - "react-pdf": "^9.2.1" + "react-pdf": "^9.2.1", + "string-similarity": "^4.0.4" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 88612d6..3828e16 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "./providers"; -import { TTSProvider } from "@/context/TTSContext"; import TTSPlayer from "@/components/TTSPlayer"; const geistSans = Geist({ @@ -31,16 +30,14 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`} > - -
-
-
- {children} -
+
+
+
+ {children}
- - +
+ diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 934b55a..91b3742 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,8 +1,15 @@ 'use client'; import { PDFProvider } from '@/context/PDFContext'; +import { TTSProvider } from '@/context/TTSContext'; import { ReactNode } from 'react'; export function Providers({ children }: { children: ReactNode }) { - return {children}; + return ( + + + {children} + + + ); } diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index cc5654a..851d0f1 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -3,9 +3,10 @@ import { Document, Page, pdfjs } from 'react-pdf'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { PDFSkeleton } from './PDFSkeleton'; import { useTTS } from '@/context/TTSContext'; +import stringSimilarity from 'string-similarity'; // Set worker from public directory pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; @@ -14,24 +15,179 @@ interface PDFViewerProps { pdfFile: string | undefined; } +interface TextHighlight { + pageIndex: number; + content: string; + position: { + boundingRect: DOMRect; + }; +} + export function PDFViewer({ pdfFile }: PDFViewerProps) { const [numPages, setNumPages] = useState(); - const { setText } = useTTS(); + const { setText, currentSentence } = useTTS(); const [pdfText, setPdfText] = useState(''); + const [highlights, setHighlights] = useState([]); + + // Function to clear all highlights + const clearHighlights = useCallback(() => { + const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); + textNodes.forEach((node) => { + const element = node as HTMLElement; + element.style.backgroundColor = ''; + element.style.opacity = '1'; + }); + }, []); function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { setNumPages(numPages); } + const findBestMatch = useCallback((searchText: string, content: string) => { + if (!searchText?.trim() || !content?.trim()) { + return null; + } + + try { + // Split content into chunks roughly the size of the search text + const chunkSize = searchText.length + 20; // Add some padding + const chunks: string[] = []; + + // Ensure we have valid content to split + const cleanContent = content.trim(); + if (cleanContent.length < chunkSize) { + chunks.push(cleanContent); + } else { + for (let i = 0; i < cleanContent.length - chunkSize; i += chunkSize / 2) { + const chunk = cleanContent.slice(i, i + chunkSize).trim(); + if (chunk) { + chunks.push(chunk); + } + } + } + + // Ensure we have valid chunks before matching + if (chunks.length === 0) { + return null; + } + + // Find the best matching chunk + const matches = stringSimilarity.findBestMatch(searchText.trim(), chunks); + return matches.bestMatch.rating >= 0.5 ? matches.bestMatch : null; + } catch (error) { + console.error('Error in findBestMatch:', error); + return null; + } + }, []); + + const highlightPattern = useCallback((text: string, pattern: string) => { + console.log('Highlighting pattern:', pattern); + + // Always clear existing highlights first + clearHighlights(); + + if (!pattern?.trim()) { + console.log('No pattern to highlight'); + return; + } + + // Clean up the pattern + const cleanPattern = pattern.trim().replace(/\s+/g, ' '); + const patternLength = cleanPattern.length; + console.log('Clean pattern:', cleanPattern, 'Length:', patternLength); + + // Get all text nodes + const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); + const allText = Array.from(textNodes).map(node => ({ + element: node as HTMLElement, + text: (node.textContent || '').trim() + })).filter(node => node.text.length > 0); // Remove empty nodes + + // Find the best matching position + let bestMatch = { + elements: [] as HTMLElement[], + rating: 0, + text: '', + lengthDiff: Infinity + }; + + // Try different combinations of consecutive spans + for (let i = 0; i < allText.length; i++) { + let combinedText = ''; + let currentElements = []; + + // Look ahead up to 10 spans, but stop if we exceed 2x pattern length + for (let j = i; j < Math.min(i + 10, allText.length); j++) { + const node = allText[j]; + const newText = combinedText + (combinedText ? ' ' : '') + node.text; + + // Stop if we're getting too far from target length + if (newText.length > patternLength * 2) { + break; + } + + combinedText = newText; + currentElements.push(node.element); + + // Calculate similarity and length difference + const similarity = stringSimilarity.compareTwoStrings(cleanPattern, combinedText); + const lengthDiff = Math.abs(combinedText.length - patternLength); + + // Score based on both similarity and length difference + const lengthPenalty = lengthDiff / patternLength; // Normalized length difference + const adjustedRating = similarity * (1 - lengthPenalty * 0.5); // Reduce score based on length difference + + console.log('Comparing:', { + text: combinedText, + similarity, + lengthDiff, + adjustedRating + }); + + // Update best match if we have better adjusted rating + if (adjustedRating > bestMatch.rating) { + bestMatch = { + elements: [...currentElements], + rating: adjustedRating, + text: combinedText, + lengthDiff + }; + } + } + } + + // Only highlight if we found a good match + // Adjust threshold based on match quality + const similarityThreshold = bestMatch.lengthDiff < patternLength * 0.3 ? 0.4 : 0.6; + + if (bestMatch.rating >= similarityThreshold) { + console.log('Found match:', { + rating: bestMatch.rating, + text: bestMatch.text, + lengthDiff: bestMatch.lengthDiff, + threshold: similarityThreshold + }); + + bestMatch.elements.forEach(element => { + element.style.backgroundColor = 'yellow'; + element.style.opacity = '0.4'; + }); + } else { + console.log('No good match found:', { + bestRating: bestMatch.rating, + bestText: bestMatch.text, + threshold: similarityThreshold + }); + } + }, [clearHighlights]); + useEffect(() => { if (pdfFile) { - // Load PDF text when file changes const loadPdfText = async () => { try { const pdf = await pdfjs.getDocument(pdfFile).promise; let fullText = ''; - // Get text from all pages for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); @@ -41,6 +197,7 @@ export function PDFViewer({ pdfFile }: PDFViewerProps) { fullText += pageText + ' '; } + console.log('Loaded PDF text sample:', fullText.substring(0, 100)); setPdfText(fullText); setText(fullText); } catch (error) { @@ -50,7 +207,25 @@ export function PDFViewer({ pdfFile }: PDFViewerProps) { loadPdfText(); } - }, [pdfFile, setText]); + + // Clear highlights when PDF changes + return () => clearHighlights(); + }, [pdfFile, setText, clearHighlights]); + + // Update highlights when current sentence changes + useEffect(() => { + console.log('Current sentence changed:', currentSentence); + + const highlightTimeout = setTimeout(() => { + highlightPattern(pdfText, currentSentence || ''); + }, 100); // Quick response for TTS + + // Clear highlights when effect is cleaned up + return () => { + clearTimeout(highlightTimeout); + clearHighlights(); + }; + }, [pdfText, currentSentence, highlightPattern, clearHighlights]); return (
diff --git a/src/context/TTSContext.tsx b/src/context/TTSContext.tsx index d6948bb..08846a0 100644 --- a/src/context/TTSContext.tsx +++ b/src/context/TTSContext.tsx @@ -1,7 +1,27 @@ 'use client'; -import React, { createContext, useContext, useState, useCallback } from 'react'; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, +} from 'react'; import nlp from 'compromise'; +import OpenAI from 'openai'; +import { LRUCache } from 'lru-cache'; // Import LRUCache directly + +// Add type declarations +declare global { + interface Window { + webkitAudioContext: typeof AudioContext; + } +} + +type AudioContextType = typeof window extends undefined + ? never + : AudioContext | null; interface TTSContextType { isPlaying: boolean; @@ -11,6 +31,8 @@ interface TTSContextType { skipBackward: () => void; setText: (text: string) => void; currentSentence: string; + audioQueue: AudioBuffer[]; + currentAudioIndex: number; } const TTSContext = createContext(undefined); @@ -20,6 +42,40 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [currentText, setCurrentText] = useState(''); const [sentences, setSentences] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); + const [audioContext, setAudioContext] = useState(null); + const currentRequestRef = useRef(null); + const [activeSource, setActiveSource] = useState(null); + const [audioQueue, setAudioQueue] = useState([]); + const [currentAudioIndex, setCurrentAudioIndex] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const skipTriggeredRef = useRef(false); + + // Create OpenAI instance + const [openai] = useState( + () => + new OpenAI({ + apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, + baseURL: process.env.NEXT_PUBLIC_OPENAI_API_BASE, + dangerouslyAllowBrowser: true, + }) + ); + + // Audio cache using LRUCache with a maximum size of 50 entries + const audioCacheRef = useRef(new LRUCache({ max: 50 })); + + useEffect(() => { + // Initialize AudioContext on client side only + if (typeof window !== 'undefined' && !audioContext) { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (AudioContextClass) { + try { + setAudioContext(new AudioContextClass()); + } catch (error) { + console.error('Failed to initialize AudioContext:', error); + } + } + } + }, [audioContext]); const splitIntoSentences = (text: string): string[] => { const doc = nlp(text); @@ -27,37 +83,233 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { return doc.sentences().out('array') as string[]; }; + const processNextSentence = useCallback(async () => { + if (!isPlaying || currentIndex >= sentences.length - 1 || skipTriggeredRef.current) { + setIsPlaying(false); + return; + } + + setCurrentIndex((prev) => { + const nextIndex = prev + 1; + if (nextIndex < sentences.length) { + console.log('Auto-advancing to next sentence:', sentences[nextIndex]); + return nextIndex; + } + return prev; + }); + }, [isPlaying, currentIndex, sentences]); + + const processAndPlaySentence = async (sentence: string) => { + if (!audioContext || isProcessing) return; + setIsProcessing(true); + + try { + // Cancel any existing request + if (currentRequestRef.current) { + currentRequestRef.current.abort(); + } + + // Create new abort controller for this request + currentRequestRef.current = new AbortController(); + + // Stop any currently playing audio + if (activeSource) { + activeSource.stop(); + setActiveSource(null); + } + + let audioBuffer = audioCacheRef.current.get(sentence); + + if (!audioBuffer) { + console.log(' Processing TTS for sentence:', sentence.substring(0, 50) + '...'); + const startTime = Date.now(); + const response = await openai.audio.speech.create({ + model: 'tts-1', + voice: 'alloy', + input: sentence, + }); + + const duration = Date.now() - startTime; + console.log(` TTS processing completed in ${duration}ms`); + + const arrayBuffer = await response.arrayBuffer(); + audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // Store in cache + audioCacheRef.current.set(sentence, audioBuffer); + } + + // If the request was aborted, do not proceed + if (!currentRequestRef.current) return; + + const source = audioContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioContext.destination); + setActiveSource(source); + + // Set up onended handler before starting + source.onended = () => { + setActiveSource(null); + setIsProcessing(false); + if (isPlaying && !skipTriggeredRef.current) { + processNextSentence(); + } + }; + + source.start(0); + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('Request was cancelled'); + } else { + console.error('Error processing TTS:', error); + } + setActiveSource(null); + setIsProcessing(false); + } finally { + currentRequestRef.current = null; + } + }; + const togglePlay = useCallback(() => { - setIsPlaying(prev => !prev); - // TODO: Implement actual TTS play/pause logic - }, []); + setIsPlaying((prev) => { + if (!prev) { + return true; // Start playing + } else { + // Pause playback + if (activeSource) { + activeSource.stop(); // Stop the audio source + setActiveSource(null); // Clear the active source + } + return false; // Set isPlaying to false + } + }); + }, [activeSource]); const skipForward = useCallback(() => { - setCurrentIndex(prev => { + skipTriggeredRef.current = true; + // Cancel any ongoing request + if (currentRequestRef.current) { + currentRequestRef.current.abort(); + currentRequestRef.current = null; + } + + // Stop current audio + if (activeSource) { + activeSource.stop(); + setActiveSource(null); + } + + setCurrentIndex((prev) => { const nextIndex = Math.min(prev + 1, sentences.length - 1); - console.log('Current sentence:', sentences[nextIndex]); + console.log('Skipping forward to:', sentences[nextIndex]); return nextIndex; }); - }, [sentences]); + + // Reset skip flag after a short delay + setTimeout(() => { + skipTriggeredRef.current = false; + }, 100); + }, [sentences, activeSource]); const skipBackward = useCallback(() => { - setCurrentIndex(prev => { + skipTriggeredRef.current = true; + // Cancel any ongoing request + if (currentRequestRef.current) { + currentRequestRef.current.abort(); + currentRequestRef.current = null; + } + + // Stop current audio + if (activeSource) { + activeSource.stop(); + setActiveSource(null); + } + + setCurrentIndex((prev) => { const nextIndex = Math.max(prev - 1, 0); - console.log('Current sentence:', sentences[nextIndex]); + console.log('Skipping backward to:', sentences[nextIndex]); return nextIndex; }); - }, [sentences]); + + // Reset skip flag after a short delay + setTimeout(() => { + skipTriggeredRef.current = false; + }, 100); + }, [sentences, activeSource]); const setText = useCallback((text: string) => { setCurrentText(text); const newSentences = splitIntoSentences(text); setSentences(newSentences); setCurrentIndex(0); + setIsPlaying(false); + + // Clear audio cache + audioCacheRef.current.clear(); + + // Preload the first sentence immediately if (newSentences.length > 0) { - console.log('Starting sentence:', newSentences[0]); + preloadSentence(newSentences[0]).then(() => { + // Preload the second sentence after a small delay + if (newSentences[1]) { + setTimeout(() => preloadSentence(newSentences[1]), 200); + } + }); } }, []); + // Preload adjacent sentences when currentIndex changes + useEffect(() => { + const preloadAdjacentSentences = async () => { + try { + // Only preload next sentence to reduce API load + if (sentences[currentIndex + 1] && !audioCacheRef.current.has(sentences[currentIndex + 1])) { + await new Promise((resolve) => setTimeout(resolve, 200)); // Add small delay + await preloadSentence(sentences[currentIndex + 1]); + } + } catch (error) { + console.error('Error preloading adjacent sentences:', error); + } + }; + preloadAdjacentSentences(); + }, [currentIndex, sentences]); + + useEffect(() => { + if (isPlaying && sentences[currentIndex] && !isProcessing) { + processAndPlaySentence(sentences[currentIndex]); + } + }, [isPlaying, currentIndex, sentences, isProcessing]); + + const preloadSentence = async (sentence: string) => { + if (!audioContext) return; + if (audioCacheRef.current.has(sentence)) return; // Already cached + + try { + console.log(' Preloading TTS for sentence:', sentence.substring(0, 50) + '...'); + const startTime = Date.now(); + const response = await openai.audio.speech.create({ + model: 'tts-1', + voice: 'alloy', + input: sentence, + }); + + const duration = Date.now() - startTime; + console.log(` Preload TTS completed in ${duration}ms`); + + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // Store in cache + audioCacheRef.current.set(sentence, audioBuffer); + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('Request was cancelled'); + } else { + console.error('Error preloading TTS:', error); + } + } + }; + const value = { isPlaying, currentText, @@ -66,6 +318,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { skipBackward, setText, currentSentence: sentences[currentIndex] || '', + audioQueue, + currentAudioIndex, }; return {children}; @@ -77,4 +331,4 @@ export function useTTS() { throw new Error('useTTS must be used within a TTSProvider'); } return context; -} +} \ No newline at end of file