diff --git a/editor.go b/editor.go index 565b5ba..8c3226a 100644 --- a/editor.go +++ b/editor.go @@ -69,6 +69,23 @@ func (a *goBlog) serveEditorPost(w http.ResponseWriter, r *http.Request) { a.editorMicropubPost(w, req, false) case "upload": a.editorMicropubPost(w, r, true) + case "tts": + parsedURL, err := url.Parse(r.FormValue("url")) + if err != nil { + a.serveError(w, r, err.Error(), http.StatusBadRequest) + return + } + post, err := a.getPost(parsedURL.Path) + if err != nil { + a.serveError(w, r, err.Error(), http.StatusBadRequest) + return + } + if err = a.createPostTTSAudio(post); err != nil { + a.serveError(w, r, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, post.Path, http.StatusFound) + return default: a.serveError(w, r, "Unknown editoraction", http.StatusBadRequest) } diff --git a/go.mod b/go.mod index fb0ab5d..bbc7944 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/cretz/bine v0.2.0 github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f github.com/dgraph-io/ristretto v0.1.0 + github.com/dmulholl/mp3lib v1.0.0 github.com/elnormous/contenttype v1.0.0 github.com/go-chi/chi/v5 v5.0.4 github.com/go-fed/httpsig v1.1.0 @@ -43,7 +44,7 @@ require ( // master github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 - golang.org/x/net v0.0.0-20210825183410-e898025ed96a + golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f golang.org/x/sync v0.0.0-20210220032951-036812b2e83c gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b willnorris.com/go/microformats v1.1.1 @@ -65,7 +66,7 @@ require ( github.com/lestrrat-go/strftime v1.0.5 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect - github.com/pelletier/go-toml v1.9.3 // indirect + github.com/pelletier/go-toml v1.9.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/snabb/diagio v1.0.0 // indirect @@ -74,8 +75,8 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/tdewolff/parse/v2 v2.5.19 // indirect - golang.org/x/sys v0.0.0-20210902050250-f475640dd07b // indirect + golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34 // indirect golang.org/x/text v0.3.7 // indirect - gopkg.in/ini.v1 v1.62.0 // indirect + gopkg.in/ini.v1 v1.63.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index bdd56a7..d330155 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,8 @@ github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/Lu github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dmulholl/mp3lib v1.0.0 h1:PZq24kJBIk5zIxi/t6Qp8/EOAbAqThyrUCpkUKLBeWQ= +github.com/dmulholl/mp3lib v1.0.0/go.mod h1:4RoA+iht/khfwxmH1ieoxZTzYVbb0am/zdvFkyGRr6I= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/elnormous/contenttype v1.0.0 h1:cTLou7K7uQMsPEmRiTJosAznsPcYuoBmXMrFAf86t2A= @@ -290,8 +292,9 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/paulmach/go.geojson v1.4.0 h1:5x5moCkCtDo5x8af62P9IOAYGQcYHtxz2QJ3x1DoCgY= github.com/paulmach/go.geojson v1.4.0/go.mod h1:YaKx1hKpWF+T2oj2lFJPsW/t1Q5e1jQI61eoQSTwpIs= -github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= +github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -459,8 +462,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210825183410-e898025ed96a h1:bRuuGXV8wwSdGTB+CtJf+FjgO1APK1CoO39T4BN/XBw= -golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f h1:w6wWR0H+nyVpbSAQbzVEIACVyr/h8l/BEkY6Sokc7Eg= +golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -529,8 +532,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210902050250-f475640dd07b h1:S7hKs0Flbq0bbc9xgYt4stIEG1zNDFqyrPwAX2Wj/sE= -golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34 h1:GkvMjFtXUmahfDtashnc1mnrCtuBVcwse5QV2lUk/tI= +golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -708,8 +711,9 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.63.0 h1:2t0h8NA59dpVQpa5Yh8cIcR6nHAeBIEk0zlLVqfw4N4= +gopkg.in/ini.v1 v1.63.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/markdown.go b/markdown.go index 52eafe6..f805828 100644 --- a/markdown.go +++ b/markdown.go @@ -86,6 +86,9 @@ func (a *goBlog) safeRenderMarkdownAsHTML(source string) template.HTML { } func (a *goBlog) renderText(s string) string { + if s == "" { + return "" + } h, err := a.renderMarkdown(s, false) if err != nil { return "" @@ -94,6 +97,9 @@ func (a *goBlog) renderText(s string) string { } func (a *goBlog) renderMdTitle(s string) string { + if s == "" { + return "" + } var buffer bytes.Buffer err := a.titleMd.Convert([]byte(s), &buffer) if err != nil { diff --git a/original-assets/styles/styles.scss b/original-assets/styles/styles.scss index 65897e8..caaf238 100644 --- a/original-assets/styles/styles.scss +++ b/original-assets/styles/styles.scss @@ -255,6 +255,7 @@ details summary { nav, #post-actions, + #tts, #related, #interactions, #posteditactions, diff --git a/pkgs/mp3merge/mp3merge.go b/pkgs/mp3merge/mp3merge.go new file mode 100644 index 0000000..8ded502 --- /dev/null +++ b/pkgs/mp3merge/mp3merge.go @@ -0,0 +1,136 @@ +package mp3merge + +import ( + "errors" + "io" + "os" + "path/filepath" + + "github.com/dmulholl/mp3lib" + "github.com/thoas/go-funk" +) + +// Inspired by https://github.com/dmulholl/mp3cat/blob/2ec1e4fe4d995ebd41bf1887b3cab8e2a569b3d4/mp3cat.go + +// Merge multiple mp3 files into one file +func MergeMP3(out string, in []string) error { + + var totalFrames, totalBytes uint32 + var firstBitRate int + var isVBR bool + + // Check if output file is included in input files + if funk.ContainsString(in, out) { + return errors.New("the list of input files includes the output file") + } + + // Create the output file. + if err := os.MkdirAll(filepath.Dir(out), os.ModePerm); err != nil { + return err + } + outfile, err := os.Create(out) + if err != nil { + return err + } + + // Loop over the input files and append their MP3 frames to the output file. + for _, inpath := range in { + infile, err := os.Open(inpath) + if err != nil { + return err + } + + isFirstFrame := true + + for { + // Read the next frame from the input file. + frame := mp3lib.NextFrame(infile) + if frame == nil { + break + } + + // Skip the first frame if it's a VBR header. + if isFirstFrame { + isFirstFrame = false + if mp3lib.IsXingHeader(frame) || mp3lib.IsVbriHeader(frame) { + continue + } + } + + // If we detect more than one bitrate we'll need to add a VBR + // header to the output file. + if firstBitRate == 0 { + firstBitRate = frame.BitRate + } else if frame.BitRate != firstBitRate { + isVBR = true + } + + // Write the frame to the output file. + _, err := outfile.Write(frame.RawBytes) + if err != nil { + return err + } + + totalFrames += 1 + totalBytes += uint32(len(frame.RawBytes)) + } + + _ = infile.Close() + } + + _ = outfile.Close() + + // If we detected multiple bitrates, prepend a VBR header to the file. + if isVBR { + err = addXingHeader(out, totalFrames, totalBytes) + if err != nil { + return err + } + } + + return nil + +} + +// Prepend an Xing VBR header to the specified MP3 file. +func addXingHeader(filepath string, totalFrames, totalBytes uint32) error { + tmpSuffix := ".mp3merge.tmp" + + outputFile, err := os.Create(filepath + tmpSuffix) + if err != nil { + return err + } + + inputFile, err := os.Open(filepath) + if err != nil { + return err + } + + xingHeader := mp3lib.NewXingHeader(totalFrames, totalBytes) + + _, err = outputFile.Write(xingHeader.RawBytes) + if err != nil { + return err + } + + _, err = io.Copy(outputFile, inputFile) + if err != nil { + return err + } + + _ = outputFile.Close() + _ = inputFile.Close() + + err = os.Remove(filepath) + if err != nil { + return err + } + + err = os.Rename(filepath+tmpSuffix, filepath) + if err != nil { + return err + } + + return nil + +} diff --git a/postsDb.go b/postsDb.go index 0d6b8cd..1f882b6 100644 --- a/postsDb.go +++ b/postsDb.go @@ -232,6 +232,36 @@ func (a *goBlog) deletePostFromDb(path string) (*post, error) { return p, nil } +func (db *database) replacePostParam(path, param string, values []string) error { + // Lock post creation + db.pcm.Lock() + defer db.pcm.Unlock() + // Build SQL + var sqlBuilder strings.Builder + var sqlArgs = []interface{}{dbNoCache} + // Start transaction + sqlBuilder.WriteString("begin;") + // Delete old post + sqlBuilder.WriteString("delete from post_parameters where path = ? and parameter = ?;") + sqlArgs = append(sqlArgs, path, param) + // Insert new post parameters + for _, value := range values { + if value != "" { + sqlBuilder.WriteString("insert into post_parameters (path, parameter, value) values (?, ?, ?);") + sqlArgs = append(sqlArgs, path, param, value) + } + } + // Commit transaction + sqlBuilder.WriteString("commit;") + // Execute + if _, err := db.exec(sqlBuilder.String(), sqlArgs...); err != nil { + return err + } + // Update FTS index + db.rebuildFTSIndex() + return nil +} + type postsRequestConfig struct { search string blog string diff --git a/postsFuncs.go b/postsFuncs.go index 1f43adc..4f1427a 100644 --- a/postsFuncs.go +++ b/postsFuncs.go @@ -242,3 +242,7 @@ func (p *post) Old() bool { } return pubDate.AddDate(1, 0, 0).Before(time.Now()) } + +func (p *post) TTS() string { + return p.firstParameter("tts") +} diff --git a/templates/assets/css/styles.css b/templates/assets/css/styles.css index d8db4e4..dcf6999 100644 --- a/templates/assets/css/styles.css +++ b/templates/assets/css/styles.css @@ -213,6 +213,7 @@ details summary > *:first-child { nav, #post-actions, +#tts, #related, #interactions, #posteditactions, diff --git a/templates/assets/js/speak.js b/templates/assets/js/speak.js index 0199f64..4d46c59 100644 --- a/templates/assets/js/speak.js +++ b/templates/assets/js/speak.js @@ -5,9 +5,9 @@ let speech = window.speechSynthesis if (speech) { - speakButton.classList.remove('hide') speakButton.onclick = startSpeak speakButton.textContent = speakButton.dataset.speak + speakButton.classList.remove('hide') } function query(selector) { diff --git a/templates/assets/js/tts.js b/templates/assets/js/tts.js new file mode 100644 index 0000000..1496aeb --- /dev/null +++ b/templates/assets/js/tts.js @@ -0,0 +1,36 @@ +(function () { + let speakButton = query('#speakBtn') + let ttsAudio = query('#tts-audio') + + let init = false + + speakButton.textContent = speakButton.dataset.speak + speakButton.classList.remove('hide') + speakButton.addEventListener('click', function () { + if (!init) { + init = true + query('#tts').classList.remove('hide') + ttsAudio.play() + } else { + togglePlay() + } + }) + + let isPlaying = false + + function togglePlay() { + isPlaying ? ttsAudio.pause() : ttsAudio.play() + } + ttsAudio.onplaying = function() { + isPlaying = true + speakButton.textContent = speakButton.dataset.stopspeak + } + ttsAudio.onpause = function() { + isPlaying = false + speakButton.textContent = speakButton.dataset.speak + } + + function query(selector) { + return document.querySelector(selector) + } +})() \ No newline at end of file diff --git a/templates/post.gohtml b/templates/post.gohtml index e8de51e..520f779 100644 --- a/templates/post.gohtml +++ b/templates/post.gohtml @@ -33,6 +33,11 @@ +
+ + + +
{{ end }} diff --git a/templates/postactions.gohtml b/templates/postactions.gohtml index dd3531d..dba31d8 100644 --- a/templates/postactions.gohtml +++ b/templates/postactions.gohtml @@ -4,6 +4,15 @@ {{ string .Blog.Lang "translate" }}  + {{ if .Data.TTS }} + + {{ else }} + {{ end }} +{{ if .Data.TTS }} +
+ +
+{{ end }} {{ end }} \ No newline at end of file diff --git a/templates/strings/de.yaml b/templates/strings/de.yaml index 96744ed..8179192 100644 --- a/templates/strings/de.yaml +++ b/templates/strings/de.yaml @@ -17,6 +17,7 @@ editor: "Editor" editorpostdesc: "Leere Parameter werden automatisch entfernt. Mehr mögliche Parameter: %s. Mögliche Zustände für `%s`: %s." emailopt: "E-Mail (optional)" fileuses: "Datei-Verwendungen" +gentts: "Text-To-Speech-Audio erzeugen" interactions: "Interaktionen & Kommentare" interactionslabel: "Hast du eine Antwort hierzu veröffentlicht? Füge hier die URL ein." likeof: "Gefällt mir von" diff --git a/templates/strings/default.yaml b/templates/strings/default.yaml index aad5115..1c42e7a 100644 --- a/templates/strings/default.yaml +++ b/templates/strings/default.yaml @@ -21,6 +21,7 @@ editorpostdesc: "Empty parameters are removed automatically. More possible param emailopt: "Email (optional)" feed: "Feed" fileuses: "file uses" +gentts: "Generate Text-To-Speech audio" indieauth: "IndieAuth" interactions: "Interactions & Comments" interactionslabel: "Have you published a response to this? Paste the URL here." diff --git a/tts.go b/tts.go new file mode 100644 index 0000000..b9586bb --- /dev/null +++ b/tts.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "sync" + "unicode" + + "go.goblog.app/app/pkgs/mp3merge" +) + +func (a *goBlog) createPostTTSAudio(p *post) error { + // Get required values + lang := a.cfg.Blogs[p.Blog].Lang + if lang == "" { + lang = "en" + } + text := a.renderMdTitle(p.Title()) + "\n\n" + cleanHTMLText(string(a.postHtml(p, false))) + + // Generate audio file + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + return err + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + outputFileName := filepath.Join(tmpDir, "audio.mp3") + err = a.createTTSAudio(lang, text, outputFileName) + if err != nil { + return err + } + + // Save new audio file + file, err := os.Open(outputFileName) + if err != nil { + return err + } + fileHash, err := getSHA256(file) + if err != nil { + return err + } + loc, err := a.saveMediaFile(fileHash+".mp3", file) + if err != nil { + return err + } + + // Set post parameter + if loc != "" { + err = a.db.replacePostParam(p.Path, "tts", []string{loc}) + if err != nil { + return err + } + } + + return nil +} + +func (a *goBlog) createTTSAudio(lang, text, outputFile string) error { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + return err + } + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // Split text + textParts := []string{} + var textPartBuilder strings.Builder + textRunes := []rune(text) + for i, r := range textRunes { + textPartBuilder.WriteRune(r) + newText := false + if strings.ContainsRune(",.:!?)", r) && i+1 < len(textRunes) && unicode.IsSpace(textRunes[i+1]) { + newText = true + } else if r == '\n' { + newText = true + } else if textPartBuilder.Len() > 500 && unicode.IsSpace(r) { + newText = true + } + if newText { + textParts = append(textParts, textPartBuilder.String()) + textPartBuilder.Reset() + } + } + textParts = append(textParts, textPartBuilder.String()) + + // Start request for every text part + allFiles := []string{} + var wg sync.WaitGroup + var ttsErr error + ctx, cancel := context.WithCancel(context.Background()) + for _, s := range textParts { + s := strings.TrimSpace(s) + if s == "" { + continue + } + fileName := filepath.Join(tmpDir, generateRandomString(10)+".mp3") + allFiles = append(allFiles, fileName) + wg.Add(1) + go func() { + defer wg.Done() + err := a.downloadTTSAudio(ctx, lang, s, fileName) + if err != nil && ttsErr == nil { + ttsErr = err + cancel() + } + }() + } + wg.Wait() + cancel() + if ttsErr != nil { + return ttsErr + } + + // Merge MP3s + if err = mp3merge.MergeMP3(outputFile, allFiles); err != nil { + return err + } + + return nil +} + +func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile string) error { + // Check parameters + if lang == "" { + return errors.New("language not provided") + } + if text == "" { + return errors.New("empty text") + } + if outputFile == "" { + return errors.New("output file not provided") + } + + // Encode params + ttsUrlVals := url.Values{} + ttsUrlVals.Set("client", "tw-ob") + ttsUrlVals.Set("tl", lang) + ttsUrlVals.Set("q", strings.TrimSpace(text)) + + // Create request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://translate.google.com/translate_tts?"+ttsUrlVals.Encode(), nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0") + + // Do request + res, err := a.httpClient.Do(req) + if err != nil { + return err + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("TTS: got status: %s, text: %s", res.Status, text) + } + + // Save response + if err = os.MkdirAll(path.Dir(outputFile), os.ModePerm); err != nil { + return err + } + out, err := os.Create(outputFile) + if err != nil { + return err + } + defer func() { + _ = out.Close() + }() + if _, err = io.Copy(out, res.Body); err != nil { + return err + } + + return nil +}