diff --git a/src/book-viewer.js b/src/book-viewer.js index ee406df8..6228320c 100644 --- a/src/book-viewer.js +++ b/src/book-viewer.js @@ -12,7 +12,7 @@ import { gettext as _ } from 'gettext' import * as utils from './utils.js' import * as format from './format.js' import { WebView } from './webview.js' -import * as speech from './speech.js' +import { SSIPClient } from './speech.js' import './toc.js' import './search.js' @@ -25,6 +25,8 @@ import { ImageViewer } from './image-viewer.js' import { makeBookInfoWindow } from './book-info.js' import { getURIStore, getBookList } from './library.js' +const ssip = new SSIPClient() + // for use in the WebView const uiText = { close: _('Close'), @@ -573,7 +575,9 @@ GObject.registerClass({ addAnnotation(x) { return this.#exec('reader.view.addAnnotation', x) } deleteAnnotation(x) { return this.#exec('reader.view.deleteAnnotation', x) } print() { return this.#exec('reader.print') } + initSpeech(x) { return this.#exec('reader.view.initSpeech', x) } speak(x) { return this.#exec('reader.view.speak', x) } + resumeSpeech() { return this.#exec('reader.view.resumeSpeech') } hightlightSpeechMark(x) { return this.#exec('reader.view.hightlightSpeechMark', x) } getCover() { return this.#exec('reader.getCover').then(utils.base64ToPixbuf) } init(x) { return this.#exec('reader.view.init', x) } @@ -634,6 +638,7 @@ export const BookViewer = GObject.registerClass({ #book #cover #data + #ttsPaused constructor(params) { super(params) utils.connect(this._view, { @@ -812,7 +817,7 @@ export const BookViewer = GObject.registerClass({ 'toggle-toc', 'toggle-annotations', 'toggle-bookmarks', 'preferences', 'show-info', 'bookmark', 'export-annotations', - 'tts-speak', 'tts-stop', + 'tts-speak', 'tts-pause', 'tts-stop', ], props: ['fold-sidebar'], }) @@ -991,6 +996,7 @@ export const BookViewer = GObject.registerClass({ this._search_view.doSearch() }, 'print': () => resolve('print'), + 'speak-from-here': () => resolve('speak-from-here'), })) utils.connect(popover, { 'show-popover': (_, popover) => @@ -1030,6 +1036,8 @@ export const BookViewer = GObject.registerClass({ : format.vprintf(_('ā€˜%sā€™'), [text]) utils.setClipboardText(result, this.root) } + else if (action === 'speak-from-here') + this.#speak(payload.mark).catch(e => console.error(e)) } #createOverlay({ index }) { if (!this.#data) return @@ -1133,18 +1141,36 @@ export const BookViewer = GObject.registerClass({ exportAnnotations() { exportAnnotations(this.get_root(), this.#data.storage.export()) } - async #speak() { - const ssml = await this._view.speak('word') - const iter = await speech.speak(ssml) - for await (const { mark } of iter) { + async #speak(mark) { + await this._view.initSpeech('word') + const ssml = await (!mark && this.#ttsPaused + ? this._view.resumeSpeech() + : this._view.speak(mark)) + + const iter = await ssip.speak(ssml) + this.#ttsPaused = false + + let state + for await (const { mark, message } of iter) { if (mark) await this._view.hightlightSpeechMark(mark) + else state = message + } + if (state === 'END') { + // FIXME: check if at end + await this._view.next() + return this.#speak() } } ttsSpeak() { this.#speak().catch(e => console.error(e)) } + ttsPause() { + ssip.stop() + .then(() => this.#ttsPaused = true) + .catch(e => console.error(e)) + } ttsStop() { - speech.stop().catch(e => console.error(e)) + ssip.stop().catch(e => console.error(e)) } vfunc_unroot() { this._view.viewSettings.unbindSettings() diff --git a/src/foliate-js b/src/foliate-js index 403882e6..fdec0b20 160000 --- a/src/foliate-js +++ b/src/foliate-js @@ -1 +1 @@ -Subproject commit 403882e66d6e7419b6b5d8637afe4868fabdf83a +Subproject commit fdec0b2037286abee38144b3fcf3aff38719ee4b diff --git a/src/reader/reader.js b/src/reader/reader.js index d89e53a9..b35277ea 100644 --- a/src/reader/reader.js +++ b/src/reader/reader.js @@ -444,6 +444,12 @@ class Reader { case 'print': this.printRange(range.startContainer.ownerDocument, range) break + case 'speak-from-here': + this.view.initSpeech('word').then(() => emit({ + type: 'selection', action, + mark: this.view.getSpeechMarkBefore(range), + })) + break } }) } diff --git a/src/speech.js b/src/speech.js index a467ba81..65872f74 100644 --- a/src/speech.js +++ b/src/speech.js @@ -1,19 +1,28 @@ import Gio from 'gi://Gio' import GLib from 'gi://GLib' -class SSIPClient { +class SSIPConnection { + #connection #inputStream #outputStream #onResponse #eventData = [] constructor(onEvent) { this.onEvent = onEvent + } + spawn() { + const flags = Gio.SubprocessFlags.NONE + const launcher = new Gio.SubprocessLauncher({ flags }) + const proc = launcher.spawnv(['speech-dispatcher', '--spawn']) + return new Promise(resolve => proc.wait_check_async(null, () => resolve())) + } + connect() { const path = GLib.build_filenamev( [GLib.get_user_runtime_dir(), 'speech-dispatcher/speechd.sock']) const address = Gio.UnixSocketAddress.new(path) - const connection = new Gio.SocketClient().connect(address, null) - this.#outputStream = Gio.DataOutputStream.new(connection.get_output_stream()) - this.#inputStream = Gio.DataInputStream.new(connection.get_input_stream()) + this.#connection = new Gio.SocketClient().connect(address, null) + this.#outputStream = Gio.DataOutputStream.new(this.#connection.get_output_stream()) + this.#inputStream = Gio.DataInputStream.new(this.#connection.get_input_stream()) this.#inputStream.newline_type = Gio.DataStreamNewlineType.TYPE_CR_LF this.#receive() } @@ -38,6 +47,7 @@ class SSIPClient { } send(command) { return new Promise((resolve, reject) => { + if (!this.#connection.is_connected()) reject() this.#outputStream.put_string(command + '\r\n', null) const data = [] this.#onResponse = (code, end, message) => { @@ -50,16 +60,25 @@ class SSIPClient { } } -class SpeechD { +export class SSIPClient { + #initialized #promises = new Map() - #client = new SSIPClient((msgID, result) => + #connection = new SSIPConnection((msgID, result) => this.#promises.get(msgID)?.resolve?.(result)) async init() { - const clientName = `${GLib.get_user_name()}:foliate:tts` - await this.#client.send('SET SELF CLIENT_NAME ' + clientName) - await this.#client.send('SET SELF SSML_MODE on') - await this.#client.send('SET SELF NOTIFICATION ALL on') - return this + if (this.#initialized) return + this.#initialized = true + try { + await this.#connection.spawn() + this.#connection.connect() + const clientName = `${GLib.get_user_name()}:foliate:tts` + await this.#connection.send('SET SELF CLIENT_NAME ' + clientName) + await this.#connection.send('SET SELF SSML_MODE on') + await this.#connection.send('SET SELF NOTIFICATION ALL on') + } catch (e) { + this.#initialized = false + throw e + } } #makePromise(msgID){ return new Promise((resolve, reject) => this.#promises.set(msgID, { @@ -81,10 +100,14 @@ class SpeechD { }, } } + async send(command) { + await this.init() + return this.#connection.send(command) + } async speak(str) { - await this.#client.send('SPEAK') + await this.send('SPEAK') const text = str.replace('\r\n.', '\r\n..') + '\r\n.' - const [msgID] = await this.#client.send(text) + const [msgID] = await this.send(text) const iter = this.#makeIter(msgID) let done = false const next = async () => { @@ -103,10 +126,20 @@ class SpeechD { [Symbol.asyncIterator]: () => ({ next }), } } + pause() { + return this.send('PAUSE all') + } + resume() { + return this.send('RESUME all') + } stop() { - return this.#client.send('STOP all') + return this.send('STOP all') + } + async listSynthesisVoices() { + const data = await this.send('LIST SYNTHESIS_VOICES') + return data.map(row => { + const [name, lang, variant] = row.split('\t') + return { name, lang, variant } + }) } } - -export const speak = str => new SpeechD().init().then(x => x.speak(str)) -export const stop = () => new SpeechD().init().then(x => x.stop()) diff --git a/src/ui/navbar.ui b/src/ui/navbar.ui index 6be51961..f0a477a1 100644 --- a/src/ui/navbar.ui +++ b/src/ui/navbar.ui @@ -13,12 +13,17 @@
horizontal-buttons - Start Speaking + Start viewer.tts-speak media-playback-start-symbolic - Stop Speaking + Pause + viewer.tts-pause + media-playback-pause-symbolic + + + Stop viewer.tts-stop media-playback-stop-symbolic