2021-09-07 20:16:28 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2021-12-30 11:40:21 +00:00
|
|
|
"bytes"
|
2021-09-07 20:16:28 +00:00
|
|
|
"context"
|
2021-12-16 19:21:54 +00:00
|
|
|
"encoding/base64"
|
2021-09-07 20:16:28 +00:00
|
|
|
"errors"
|
2021-12-30 11:40:21 +00:00
|
|
|
"html"
|
|
|
|
"io"
|
2021-12-16 19:21:54 +00:00
|
|
|
"log"
|
2022-01-04 08:48:37 +00:00
|
|
|
"net/http"
|
2021-09-07 20:16:28 +00:00
|
|
|
"net/url"
|
|
|
|
"path"
|
2021-12-30 11:40:21 +00:00
|
|
|
"strings"
|
2022-01-05 09:56:53 +00:00
|
|
|
"sync"
|
2021-09-07 20:16:28 +00:00
|
|
|
|
2021-12-26 08:18:08 +00:00
|
|
|
"github.com/carlmjohnson/requests"
|
2022-01-05 09:56:53 +00:00
|
|
|
"go.goblog.app/app/pkgs/mp3merge"
|
2021-09-07 20:16:28 +00:00
|
|
|
)
|
|
|
|
|
2021-12-16 19:21:54 +00:00
|
|
|
const ttsParameter = "tts"
|
|
|
|
|
|
|
|
func (a *goBlog) initTTS() {
|
|
|
|
if !a.ttsEnabled() {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
createOrUpdate := func(p *post) {
|
|
|
|
// Automatically create audio for published section posts only
|
|
|
|
if !p.isPublishedSectionPost() {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Check if there is already a tts audio file
|
|
|
|
if p.firstParameter(ttsParameter) != "" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Create TTS audio
|
|
|
|
err := a.createPostTTSAudio(p)
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("create post audio for %s failed: %v", p.Path, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
a.pPostHooks = append(a.pPostHooks, createOrUpdate)
|
|
|
|
a.pUpdateHooks = append(a.pUpdateHooks, createOrUpdate)
|
2022-01-03 12:55:44 +00:00
|
|
|
a.pUndeleteHooks = append(a.pUndeleteHooks, createOrUpdate)
|
2021-12-16 19:21:54 +00:00
|
|
|
a.pDeleteHooks = append(a.pDeleteHooks, func(p *post) {
|
|
|
|
// Try to delete the audio file
|
|
|
|
_ = a.deletePostTTSAudio(p)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a *goBlog) ttsEnabled() bool {
|
|
|
|
tts := a.cfg.TTS
|
|
|
|
// Requires media storage as well
|
|
|
|
return tts != nil && tts.Enabled && tts.GoogleAPIKey != "" && a.mediaStorageEnabled()
|
|
|
|
}
|
|
|
|
|
2021-09-07 20:16:28 +00:00
|
|
|
func (a *goBlog) createPostTTSAudio(p *post) error {
|
|
|
|
// Get required values
|
2022-01-05 09:56:53 +00:00
|
|
|
lang := defaultIfEmpty(a.cfg.Blogs[p.Blog].Lang, "en")
|
|
|
|
|
|
|
|
// Create TTS text parts
|
|
|
|
parts := []string{}
|
|
|
|
// Add title if available
|
|
|
|
if title := p.Title(); title != "" {
|
|
|
|
parts = append(parts, a.renderMdTitle(title))
|
|
|
|
}
|
|
|
|
// Add body split into paragraphs because of 5000 character limit
|
|
|
|
parts = append(parts, strings.Split(htmlText(string(a.postHtml(p, false))), "\n\n")...)
|
|
|
|
|
|
|
|
// Create TTS audio for each part
|
|
|
|
partsBuffers := make([]io.Reader, len(parts))
|
|
|
|
var errs []error
|
|
|
|
var lock sync.Mutex
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for i, part := range parts {
|
|
|
|
// Increase wait group
|
|
|
|
wg.Add(1)
|
|
|
|
go func(i int, part string) {
|
|
|
|
// Build SSML
|
|
|
|
ssml := "<speak>" + html.EscapeString(part) + "<break time=\"500ms\"/></speak>"
|
|
|
|
// Create TTS audio
|
|
|
|
var audioBuffer bytes.Buffer
|
|
|
|
err := a.createTTSAudio(lang, ssml, &audioBuffer)
|
|
|
|
if err != nil {
|
|
|
|
lock.Lock()
|
|
|
|
errs = append(errs, err)
|
|
|
|
lock.Unlock()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Append buffer to partsBuffers
|
|
|
|
lock.Lock()
|
|
|
|
partsBuffers[i] = &audioBuffer
|
|
|
|
lock.Unlock()
|
|
|
|
// Decrease wait group
|
|
|
|
wg.Done()
|
|
|
|
}(i, part)
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
|
|
|
|
2022-01-05 09:56:53 +00:00
|
|
|
// Wait for all parts to be created
|
|
|
|
wg.Wait()
|
2021-09-07 20:16:28 +00:00
|
|
|
|
2022-01-05 09:56:53 +00:00
|
|
|
// Check if any errors occurred
|
|
|
|
if len(errs) > 0 {
|
|
|
|
return errs[0]
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
2021-12-30 11:40:21 +00:00
|
|
|
|
2022-01-05 09:56:53 +00:00
|
|
|
// Merge partsBuffers into final buffer
|
|
|
|
var final bytes.Buffer
|
2022-01-09 20:08:38 +00:00
|
|
|
if err := mp3merge.MergeMP3(&final, partsBuffers...); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-01-05 09:56:53 +00:00
|
|
|
|
2021-12-30 11:40:21 +00:00
|
|
|
// Save audio
|
2022-01-05 09:56:53 +00:00
|
|
|
audioReader := bytes.NewReader(final.Bytes())
|
2021-12-30 11:40:21 +00:00
|
|
|
fileHash, err := getSHA256(audioReader)
|
2021-09-07 20:16:28 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2021-12-30 11:40:21 +00:00
|
|
|
loc, err := a.saveMediaFile(fileHash+".mp3", audioReader)
|
2021-09-07 20:16:28 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2021-12-16 19:21:54 +00:00
|
|
|
if loc == "" {
|
|
|
|
return errors.New("no media location for tts audio")
|
|
|
|
}
|
|
|
|
|
|
|
|
if old := p.firstParameter(ttsParameter); old != "" && old != loc {
|
|
|
|
// Already has tts audio, but with different location
|
|
|
|
// Try to delete the old audio file
|
|
|
|
_ = a.deletePostTTSAudio(p)
|
|
|
|
}
|
2021-09-07 20:16:28 +00:00
|
|
|
|
|
|
|
// Set post parameter
|
2021-12-16 19:21:54 +00:00
|
|
|
err = a.db.replacePostParam(p.Path, ttsParameter, []string{loc})
|
|
|
|
if err != nil {
|
|
|
|
return err
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
|
|
|
|
2021-12-16 19:21:54 +00:00
|
|
|
// Purge cache
|
|
|
|
a.cache.purge()
|
|
|
|
|
2021-09-07 20:16:28 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-12-16 19:21:54 +00:00
|
|
|
// Tries to delete the tts audio file, but doesn't remove the post parameter
|
|
|
|
func (a *goBlog) deletePostTTSAudio(p *post) bool {
|
|
|
|
// Check if post has tts audio
|
|
|
|
audio := p.firstParameter(ttsParameter)
|
|
|
|
if audio == "" {
|
|
|
|
return false
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
2021-12-16 19:21:54 +00:00
|
|
|
// Get filename and check if file is from the configured media storage
|
|
|
|
fileUrl, err := url.Parse(audio)
|
|
|
|
if err != nil {
|
|
|
|
// Failed to parse audio url
|
|
|
|
log.Println("failed to parse audio url:", err)
|
|
|
|
return false
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
2021-12-16 19:21:54 +00:00
|
|
|
fileName := path.Base(fileUrl.Path)
|
|
|
|
if a.getFullAddress(a.mediaFileLocation(fileName)) != audio {
|
|
|
|
// File is not from the configured media storage
|
|
|
|
return false
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
2021-12-16 19:21:54 +00:00
|
|
|
// Try to delete the audio file
|
|
|
|
err = a.deleteMediaFile(fileName)
|
|
|
|
if err != nil {
|
|
|
|
log.Println("failed to delete audio file:", err)
|
|
|
|
return false
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
2021-12-16 19:21:54 +00:00
|
|
|
return true
|
|
|
|
}
|
2021-09-07 20:16:28 +00:00
|
|
|
|
2021-12-30 11:40:21 +00:00
|
|
|
func (a *goBlog) createTTSAudio(lang, ssml string, w io.Writer) error {
|
2021-12-16 19:21:54 +00:00
|
|
|
// Check if Google Cloud TTS is enabled
|
|
|
|
gctts := a.cfg.TTS
|
|
|
|
if !gctts.Enabled || gctts.GoogleAPIKey == "" {
|
|
|
|
return errors.New("missing config for Google Cloud TTS")
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Check parameters
|
|
|
|
if lang == "" {
|
|
|
|
return errors.New("language not provided")
|
|
|
|
}
|
2021-12-30 11:40:21 +00:00
|
|
|
if ssml == "" {
|
2021-09-07 20:16:28 +00:00
|
|
|
return errors.New("empty text")
|
|
|
|
}
|
2021-12-30 11:40:21 +00:00
|
|
|
if w == nil {
|
|
|
|
return errors.New("writer not provided")
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
|
|
|
|
2021-12-16 19:21:54 +00:00
|
|
|
// Create request body
|
|
|
|
body := map[string]interface{}{
|
|
|
|
"audioConfig": map[string]interface{}{
|
|
|
|
"audioEncoding": "MP3",
|
|
|
|
},
|
|
|
|
"input": map[string]interface{}{
|
2021-12-30 11:40:21 +00:00
|
|
|
"ssml": ssml,
|
2021-12-16 19:21:54 +00:00
|
|
|
},
|
|
|
|
"voice": map[string]interface{}{
|
|
|
|
"languageCode": lang,
|
|
|
|
},
|
|
|
|
}
|
2021-09-07 20:16:28 +00:00
|
|
|
|
|
|
|
// Do request
|
2021-12-26 08:18:08 +00:00
|
|
|
var response map[string]interface{}
|
|
|
|
err := requests.
|
|
|
|
URL("https://texttospeech.googleapis.com/v1beta1/text:synthesize").
|
|
|
|
Param("key", gctts.GoogleAPIKey).
|
|
|
|
Client(a.httpClient).
|
|
|
|
UserAgent(appUserAgent).
|
2022-01-04 08:48:37 +00:00
|
|
|
Method(http.MethodPost).
|
2021-12-26 08:18:08 +00:00
|
|
|
BodyJSON(body).
|
|
|
|
ToJSON(&response).
|
|
|
|
Fetch(context.Background())
|
2021-09-07 20:16:28 +00:00
|
|
|
if err != nil {
|
2021-12-26 08:18:08 +00:00
|
|
|
return errors.New("tts request failed: " + err.Error())
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
|
|
|
|
2021-12-16 19:21:54 +00:00
|
|
|
// Decode response
|
2021-12-26 08:18:08 +00:00
|
|
|
if encoded, ok := response["audioContent"]; ok {
|
2021-12-16 19:21:54 +00:00
|
|
|
if encodedStr, ok := encoded.(string); ok {
|
|
|
|
if audio, err := base64.StdEncoding.DecodeString(encodedStr); err == nil {
|
2021-12-30 11:40:21 +00:00
|
|
|
_, err := w.Write(audio)
|
|
|
|
return err
|
2021-12-16 19:21:54 +00:00
|
|
|
} else {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|
2021-12-16 19:21:54 +00:00
|
|
|
return errors.New("no audio content")
|
2021-09-07 20:16:28 +00:00
|
|
|
}
|