Skip to content

Commit

Permalink
Improve TTS
Browse files Browse the repository at this point in the history
- Add pausing (fixes #727)
- Add "speak from here"
- Fix SSIP client
- Autospawn speech-dispatcher
  • Loading branch information
johnfactotum committed Sep 27, 2023
1 parent d2449d4 commit a6bf454
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 27 deletions.
40 changes: 33 additions & 7 deletions src/book-viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'),
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -634,6 +638,7 @@ export const BookViewer = GObject.registerClass({
#book
#cover
#data
#ttsPaused
constructor(params) {
super(params)
utils.connect(this._view, {
Expand Down Expand Up @@ -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'],
})
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/foliate-js
Submodule foliate-js updated 2 files
+5 −1 tts.js
+34 −4 view.js
6 changes: 6 additions & 0 deletions src/reader/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
}
Expand Down
67 changes: 50 additions & 17 deletions src/speech.js
Original file line number Diff line number Diff line change
@@ -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()
}
Expand All @@ -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) => {
Expand All @@ -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, {
Expand All @@ -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 () => {
Expand All @@ -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())
9 changes: 7 additions & 2 deletions src/ui/navbar.ui
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@
<section>
<attribute name="display-hint">horizontal-buttons</attribute>
<item>
<attribute name="label" translatable="yes">Start Speaking</attribute>
<attribute name="label" translatable="yes">Start</attribute>
<attribute name="action">viewer.tts-speak</attribute>
<attribute name="verb-icon">media-playback-start-symbolic</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Stop Speaking</attribute>
<attribute name="label" translatable="yes">Pause</attribute>
<attribute name="action">viewer.tts-pause</attribute>
<attribute name="verb-icon">media-playback-pause-symbolic</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Stop</attribute>
<attribute name="action">viewer.tts-stop</attribute>
<attribute name="verb-icon">media-playback-stop-symbolic</attribute>
</item>
Expand Down

0 comments on commit a6bf454

Please sign in to comment.