mirror of https://github.com/jlelse/GoBlog
Improved TTS
This commit is contained in:
parent
d48f4f556a
commit
74ea0f5576
17
editor.go
17
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)
|
||||
}
|
||||
|
|
9
go.mod
9
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
|
||||
)
|
||||
|
|
16
go.sum
16
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=
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -255,6 +255,7 @@ details summary {
|
|||
|
||||
nav,
|
||||
#post-actions,
|
||||
#tts,
|
||||
#related,
|
||||
#interactions,
|
||||
#posteditactions,
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
30
postsDb.go
30
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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -213,6 +213,7 @@ details summary > *:first-child {
|
|||
|
||||
nav,
|
||||
#post-actions,
|
||||
#tts,
|
||||
#related,
|
||||
#interactions,
|
||||
#posteditactions,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})()
|
|
@ -33,6 +33,11 @@
|
|||
<input type="hidden" name="url" value="{{ .Canonical }}">
|
||||
<input type="submit" value="{{ string .Blog.Lang "delete" }}" class="confirm" data-confirmmessage="{{ string .Blog.Lang "confirmdelete" }}">
|
||||
</form>
|
||||
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}">
|
||||
<input type="hidden" name="editoraction" value="tts">
|
||||
<input type="hidden" name="url" value="{{ .Canonical }}">
|
||||
<input type="submit" value="{{ string .Blog.Lang "gentts" }}">
|
||||
</form>
|
||||
<script defer src="{{ asset "js/formconfirm.js" }}"></script>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
|
@ -4,6 +4,15 @@
|
|||
<a id="translateBtn" href="https://translate.google.com/translate?u={{ absolute .Data.Path }}" target="_blank" rel="nofollow noopener noreferrer" class="button">{{ string .Blog.Lang "translate" }}</a>
|
||||
<script defer src="{{ asset "js/translate.js" }}"></script>
|
||||
<button id="speakBtn" class="hide" data-speak="{{ string .Blog.Lang "speak" }}" data-stopspeak="{{ string .Blog.Lang "stopspeak" }}"></button>
|
||||
{{ if .Data.TTS }}
|
||||
<script defer src="{{ asset "js/tts.js" }}"></script>
|
||||
{{ else }}
|
||||
<script defer src="{{ asset "js/speak.js" }}"></script>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if .Data.TTS }}
|
||||
<div class="p hide" id="tts">
|
||||
<audio controls preload=none id="tts-audio"><source src="{{ .Data.TTS }}"/></audio>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -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"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue