pimatic-tts
Version:
Pimatic plugin providing Text-to-Speech capability
245 lines (197 loc) • 9.04 kB
text/coffeescript
module.exports = (env) ->
Promise = env.require 'bluebird'
_ = env.require 'lodash'
t = env.require('decl-api').types
commons = require('pimatic-plugin-commons')(env)
fs = require('fs')
Crypto = require('crypto')
Volume = require('pcm-volume')
Speaker = require('speaker')
class TTSDevice extends env.devices.Device
attributes:
language:
description: "Voice synthesis language"
type: t.string
acronym: 'Language:'
discrete: true
volume:
description: "Voice volume"
type: t.number
acronym: 'Volume:'
discrete: true
repeat:
description: "Repeats of same message"
type: t.number
acronym: 'Repeat:'
discrete: true
interval:
description: "Time between two repeats"
type: t.number
acronym: 'Interval:'
discrete: true
actions:
getLanguage:
description: "Returns the Voice synthesis language"
returns:
language:
type: t.string
getVolume:
description: "Returns the gain volume applied on the audio output stream"
returns:
language:
type: t.number
getRepeat:
description: "Returns the number of times the same message is repeated"
returns:
language:
type: t.number
getInterval:
description: "Returns the amount of time between two repeats"
returns:
language:
type: t.number
toSpeech:
description: "Converts Text-to-Speech and outputs Audio"
params:
text:
type: t.object
generateResource: () -> throw new Error "Function \"generateResource\" is not implemented!"
constructor: () ->
= commons.base @, .class
.language = .language ? 'en-GB'
.volume = .volume ? 40
.repeat = .repeat ? 1
.interval = .interval ? 0
.tmpDir = .tmpDir ? '/tmp'
.enableCache = .enableCache ? true
= null
super()
toSpeech: (data) =>
return new Promise( (resolve, reject) =>
.rejectWithErrorString Promise.reject, __("%s - TTS text provided is null or undefined.", .id) unless ?.text?.parsed?
.then( (resource) =>
repeat = .repeat ? .repeat
interval = (.interval ? .interval)*1000
i = 0
results = []
playback = =>
env.logger.debug __("%s: Starting audio output for iteration: %s", , i+1)
.then( (result) =>
env.logger.debug __("%s: Finished audio output for iteration: %s", , i+1)
results.push result
i++
if i < repeat
setTimeout(playback, interval)
else
if !.text.static or !.enableCache
env.logger.debug __("%s: Static text: %s, Cache enabled: %s. Removing cached file: '%s'", , .text.static, .enableCache, .resource)
Promise.all(results).then( (result) =>
resolve __("'%s' was spoken %s times", .text.parsed, repeat)
).catch(Promise.AggregateError, (error) =>
reject __("'%s' was NOT spoken %s times. Error: %s", .text.parsed, repeat, error)
)
).catch( (error) =>
.rejectWithErrorString Promise.reject, error
)
playback()
).catch( (error) => .rejectWithErrorString Promise.reject, error)
).catch( (error) => .rejectWithErrorString Promise.reject, error )
setVolume: (value) ->
if value is .volume then return
.volume = value
_pcmVolume: (value) ->
volMaxRel = 100
volMaxAbs = 150
return (value/volMaxRel*volMaxAbs/volMaxRel).toPrecision(2)
setVolumeLevel: (volume) ->
?.setVolume()
_speechOut:() =>
return new Promise( (resolve, reject) =>
audioDecoder = new .audioDecoder()
audioDecoder.on('format', (pcmFormat) =>
env.logger.debug pcmFormat
speaker = new Speaker(pcmFormat)
.on('open', () =>
env.logger.debug __("%s: Audio output of '%s' started.", , .text.parsed)
)
.on('error', (error) =>
msg = __("%s: Audio output of '%s' failed. Error: %s", , .text.parsed, error)
env.logger.debug msg
.rejectWithErrorString Promise.reject, error
)
.on('finish', () =>
msg = __("%s: Audio output of '%s' completed successfully.", , .text.parsed)
env.logger.debug msg
resolve msg
)
= new Volume()
.pipe(speaker)
audioDecoder.pipe()
)
streamData = fs.createReadStream(.resource)
streamData.pipe(audioDecoder)
).catch( (error) => .rejectWithErrorString Promise.reject, error )
cacheResource: () =>
env.logger.debug __("%s: Getting TTS Resource for text: %s, language: %s, speed: %s", , .text.parsed, .language, .speed)
return new Promise( (resolve, reject) =>
file =
fs.open(file, 'r', (error, fd) =>
if error
if error.code is "ENOENT"
env.logger.debug("%s: Creating speech resource file: '%s' for text: %s ", , file, .text.parsed)
env.logger.info("%s: Generating speech resource for '%s'", , .text.parsed)
return
.then( (resource) => resolve resource)
.catch( (error) => .rejectWithErrorString Promise.reject, error )
else
# File exists but cannot be read, delete it, and reject with error
env.logger.warning __("%s: %s already exists, but cannot be accessed. Attempting to remove. Error: %s", , file, error.code)
.rejectWithErrorString Promise.reject, error
else
fs.close(fd, () =>
env.logger.debug __("%s: Speech resource for '%s' already exist. Reusing file.", , file)
env.logger.info __("%s: Using cached speech resource for '%s'.", , .text.parsed)
resolve file
)
)
).catch( (error) => .rejectWithErrorString Promise.reject, error )
getLanguage: -> Promise.resolve(.language)
getVolume: -> Promise.resolve(.volume)
getRepeat: -> Promise.resolve(.repeat)
getInterval: -> Promise.resolve(.interval)
getText: -> Promise.resolve(.text.parsed)
getStatic: -> Promise.resolve(.text.static)
getResource: -> Promise.resolve(.resource)
_setData: (obj) ->
= obj
_setResource: (value) ->
if .resource is value then return
.resource = value
'resource', value
_generateHashedFilename: () ->
md5 = Crypto.createHash('md5')
.fileName = .tmpDir + '/pimatic-tts_' + + '_' + md5.update(.text.parsed).digest('hex') + '.' + .audioFormat
return .fileName
_removeResource: (resource) =>
fs.open(resource, 'wx', (error, fd) =>
if error and error.code is "EEXIST"
fs.unlink(resource, (error) =>
if error
env.logger.warn __("%s: Removing resource file '%s' failed. Please remove manually. Reason: %s", , resource, error.code)
return error
)
return true
)
destroy: () ->
super()
return TTSDevice