diff --git a/chrome/content/zotero/xpcom/sync/syncAPIClient.js b/chrome/content/zotero/xpcom/sync/syncAPIClient.js index 5931e41d90..99a0a9481c 100644 --- a/chrome/content/zotero/xpcom/sync/syncAPIClient.js +++ b/chrome/content/zotero/xpcom/sync/syncAPIClient.js @@ -617,7 +617,99 @@ Zotero.Sync.APIClient.prototype = { }, + // Hidden prefs for Read Aloud custom endpoints: + // extensions.zotero.reader.readAloud.customEndpoint + // "{mode}=[url]". Empty/invalid falls back to the normal Zotero API + // endpoint. Only "openai" is currently supported as a mode, where the + // server must implement OpenAI's /audio/speech + // (e.g. https://api.openai.com/v1). + // extensions.zotero.reader.readAloud.customEndpoint.voices + // Comma-separated voice IDs (openai mode only). Defaults to the + // OpenAI TTS voice list. + // extensions.zotero.reader.readAloud.customEndpoint.apiKey + // Sent as "Authorization: Bearer " (openai mode only). + // extensions.zotero.reader.readAloud.customEndpoint.model + // Model name sent in the request body (openai mode only). + _getReadAloudCustomEndpoint() { + let pref = Zotero.Prefs.get('reader.readAloud.customEndpoint'); + if (!pref || typeof pref !== 'string') return null; + let match = pref.match(/^(\w+)=(.+)$/); + if (!match) return null; + let [, mode, url] = match; + if (mode !== 'openai') { + Zotero.debug(`Unsupported customEndpoint mode '${mode}'`); + return null; + } + try { + // eslint-disable-next-line no-new + new URL(url); + } + catch { + Zotero.debug('Invalid customEndpoint pref value: ' + pref); + return null; + } + if (!url.endsWith('/')) { + url += '/'; + } + let voicesPref = Zotero.Prefs.get('reader.readAloud.customEndpoint.voices'); + let voices = typeof voicesPref === 'string' + ? voicesPref.split(',').map(s => s.trim()).filter(Boolean) + : null; + let apiKey = Zotero.Prefs.get('reader.readAloud.customEndpoint.apiKey'); + if (typeof apiKey !== 'string' || !apiKey) { + apiKey = null; + } + let model = Zotero.Prefs.get('reader.readAloud.customEndpoint.model'); + if (typeof model !== 'string' || !model) { + model = 'gpt-4o-mini-tts'; + } + return { mode, url, voices, apiKey, model }; + }, + + async getReadAloudVoices() { + let custom = this._getReadAloudCustomEndpoint(); + if (custom?.mode === 'openai') { + // Fall back to OpenAI's voice IDs + let voiceIDs = custom.voices ?? [ + 'alloy', + 'ash', + 'ballad', + 'coral', + 'echo', + 'fable', + 'nova', + 'onyx', + 'sage', + 'shimmer', + 'verse', + ]; + let voices = Object.fromEntries(voiceIDs.map(id => [id, { label: id }])); + // Derive a cache version from the endpoint and model so that + // changing either one invalidates previously cached audio + let cacheVersion = parseInt( + Zotero.Utilities.Internal.md5(custom.url + '|' + custom.model).slice(0, 8), + 16 + ); + // OpenAI's TTS endpoint auto-detects language from the input + // text, so use the wildcard locale. + // The reader treats '*' as matching any language. + return { + voices: { + standard: [{ + creditsPerMinute: 0, + segmentGranularity: 'sentence', + cacheVersion, + voices, + locales: { '*': voiceIDs }, + }], + }, + standardCreditsRemaining: null, + premiumCreditsRemaining: null, + devMode: false, + }; + } + let url = this.baseURL + "tts/voices"; let params = new URLSearchParams(); params.set("lang", Services.locale.appLocaleAsBCP47); @@ -669,6 +761,47 @@ Zotero.Sync.APIClient.prototype = { async getReadAloudAudio(segment, voiceID) { + let custom = this._getReadAloudCustomEndpoint(); + if (custom?.mode === 'openai') { + let text = segment === 'sample' + ? "Isn't it nice to have a computer that will talk to you?" + : segment.text; + let headers = { "Content-Type": "application/json" }; + if (custom.apiKey) { + headers.Authorization = `Bearer ${custom.apiKey}`; + } + try { + let xmlhttp = await Zotero.HTTP.request("POST", custom.url + "audio/speech", { + responseType: "blob", + headers, + body: JSON.stringify({ + model: custom.model, + voice: voiceID, + input: text, + // eslint-disable-next-line camelcase + response_format: 'mp3', + }), + errorDelayMax: 8000, + }); + return { audio: xmlhttp.response }; + } + catch (e) { + Zotero.logError(e); + + let error; + if (e instanceof Zotero.HTTP.BrowserOfflineException) { + error = 'network'; + } + else { + error = 'unknown'; + } + return { + audio: null, + error, + }; + } + } + let method; let url; let options = { @@ -741,6 +874,10 @@ Zotero.Sync.APIClient.prototype = { async getReadAloudCreditsRemaining() { + let custom = this._getReadAloudCustomEndpoint(); + if (custom?.mode === 'openai') { + return { standardCreditsRemaining: null, premiumCreditsRemaining: null }; + } let uri = this.baseURL + "tts/credits"; try { let xmlhttp = await this.makeRequest("GET", uri, { @@ -761,6 +898,10 @@ Zotero.Sync.APIClient.prototype = { async resetReadAloudCredits() { + let custom = this._getReadAloudCustomEndpoint(); + if (custom?.mode === 'openai') { + return { standardCreditsRemaining: null, premiumCreditsRemaining: null }; + } let uri = this.baseURL + "tts/reset"; try { let xmlhttp = await this.makeRequest("POST", uri, { diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index c51de7cda2..f0c527a358 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -250,6 +250,7 @@ pref("extensions.zotero.reader.autoDisableTool.image", true); pref("extensions.zotero.reader.lastSidebarTab", "annotations"); pref("extensions.zotero.reader.popupPositions", "{}"); pref("extensions.zotero.reader.readAloud.highlightGranularity", "sentence"); +pref("extensions.zotero.reader.readAloud.customEndpoint.model", "gpt-4o-mini-tts"); // Set color scheme to auto by default pref("browser.theme.toolbar-theme", 2);