Replace Google's hidden Translate TTS with official Google Cloud TTS and make it automatically generate audio after posting

pull/10/head
Jan-Lukas Else 8 months ago
parent 893caf8ec4
commit eb8c33704b
  1. 10
      config.go
  2. 8
      docs/usage.md
  3. 7
      example-config.yml
  4. 5
      feeds.go
  5. 15
      go.mod
  6. 30
      go.sum
  7. 1
      main.go
  8. 14
      mediaStorage.go
  9. 136
      pkgs/mp3merge/mp3merge.go
  10. 48
      postsFuncs.go
  11. 1
      render.go
  12. 12
      templates/post.gohtml
  13. 209
      tts.go
  14. 18
      utils.go

@ -27,6 +27,7 @@ type config struct {
EasterEgg *configEasterEgg `mapstructure:"easterEgg"`
Debug bool `mapstructure:"debug"`
MapTiles *configMapTiles `mapstructure:"mapTiles"`
TTS *configTTS `mapstructure:"tts"`
initialized bool
}
@ -287,6 +288,11 @@ type configMapTiles struct {
MaxZoom int `mapstructure:"maxZoom"`
}
type configTTS struct {
Enabled bool `mapstructure:"enabled"`
GoogleAPIKey string `mapstructure:"googleApiKey"`
}
func (a *goBlog) loadConfigFile(file string) error {
// Use viper to load the config file
v := viper.New()
@ -372,6 +378,10 @@ func (a *goBlog) initConfig() error {
}
// Check config for each blog
for _, blog := range a.cfg.Blogs {
// Check if language is set
if blog.Lang == "" {
blog.Lang = "en"
}
// Blogroll
if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" {
br.Enabled = false

@ -6,4 +6,10 @@ This section of the documentation is a work in progress!
### Scheduling posts
To schedule a post, create a post with `status: scheduled` and set the `published` field to the desired date. A scheduler runs in the background and checks every 10 seconds if a scheduled post should be published. If there's a post to publish, the post status is changed to `published`. That will also trigger configured hooks. Scheduled posts are only visible when logged in.
To schedule a post, create a post with `status: scheduled` and set the `published` field to the desired date. A scheduler runs in the background and checks every 10 seconds if a scheduled post should be published. If there's a post to publish, the post status is changed to `published`. That will also trigger configured hooks. Scheduled posts are only visible when logged in.
## Text-to-Speech
GoBlog features a button on each post that allows you to read the post's content aloud. By default, that uses an API from the browser to generate the speech. But it's not available on all browsers and on some operating systems it sounds horrible.
There's also the possibility to configure GoBlog to use Google Cloud's Text-to-Speech API. For that take a look at the `example-config.yml` file. If configured and enabled, after publishing a post, GoBlog will automatically generate an audio file, save it to the configured media storage (local file storage by default) and safe the audio file URL to the post's `tts` parameter. After updating a post, you can manually regenerate the audio file by using the button on the post. When deleting a post or regenerating the audio, GoBlog tries to delete the old audio file as well.

@ -136,6 +136,13 @@ mapTiles:
minZoom: 0 # (Optional) Minimum zoom level
maxZoom: 20 # (Optional) Maximum zoom level
# Text-to-Speech (not just using the browser API, but Google Cloud's TTS-API)
# If enabled, it will automatically generate a TTS audio file after publishing a public post that has a section as well
# It's possible to regenerate the audio at any time. That will also try and delete previously generated TTS audio files
tts:
enabled: true
googleApiKey: "xxxxxxxx"
# Blogs
defaultBlog: en # Default blog (needed because you can define multiple blogs)
blogs:

@ -1,6 +1,7 @@
package main
import (
"bytes"
"net/http"
"strings"
"time"
@ -42,12 +43,14 @@ func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r
},
}
for _, p := range posts {
var contentBuf bytes.Buffer
a.min.Write(&contentBuf, contenttype.HTML, []byte(a.feedHtml(p)))
feed.Add(&feeds.Item{
Title: p.RenderedTitle,
Link: &feeds.Link{Href: a.fullPostURL(p)},
Description: a.postSummary(p),
Id: p.Path,
Content: string(a.postHtml(p, true)),
Content: contentBuf.String(),
Created: timeNoErr(dateparse.ParseLocal(p.Published)),
Updated: timeNoErr(dateparse.ParseLocal(p.Updated)),
})

@ -14,7 +14,6 @@ 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/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
github.com/emersion/go-smtp v0.15.0
@ -41,9 +40,9 @@ require (
github.com/schollz/sqlite3dump v1.3.1
github.com/snabb/sitemap v1.0.0
github.com/spf13/cast v1.4.1
github.com/spf13/viper v1.10.0
github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.0
github.com/tdewolff/minify/v2 v2.9.23
github.com/tdewolff/minify/v2 v2.9.24
github.com/thoas/go-funk v0.9.1
github.com/tkrajina/gpxgo v1.1.2
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
@ -52,13 +51,13 @@ require (
// master
github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
golang.org/x/net v0.0.0-20211209124913-491a49abca63
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/text v0.3.7
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
nhooyr.io/websocket v1.8.7
tailscale.com v1.18.1
tailscale.com v1.18.2
// main
willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971
)
@ -112,7 +111,7 @@ require (
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect
github.com/tcnksm/go-httpstat v0.2.0 // indirect
github.com/tdewolff/parse/v2 v2.5.24 // indirect
github.com/tdewolff/parse/v2 v2.5.26 // indirect
github.com/u-root/uio v0.0.0-20210528114334-82958018845c // indirect
github.com/vishvananda/netlink v1.1.1-0.20211101163509-b10eb8fe5cf6 // indirect
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae // indirect
@ -120,7 +119,7 @@ require (
go4.org/mem v0.0.0-20201119185036-c04c5a6ff174 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37 // indirect
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 // indirect
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
golang.zx2c4.com/wireguard v0.0.0-20211116201604-de7c702ace45 // indirect

@ -92,8 +92,6 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
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/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
@ -368,8 +366,8 @@ github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmq
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.10.0 h1:mXH0UwHS4D2HwWZa75im4xIQynLfblmWV7qcWpfv0yk=
github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
github.com/spf13/viper v1.10.1 h1:nuJZuYpG7gTj/XqiUwg8bA0cp1+M2mC3J4g5luUYBKk=
github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@ -387,10 +385,10 @@ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQ
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
github.com/tdewolff/minify/v2 v2.9.23 h1:UrLltJpnJPm7/fYFP3Ue/GD5tHufx2z7ERQihACLkmg=
github.com/tdewolff/minify/v2 v2.9.23/go.mod h1:4o1Mw4T3RLV0CHUny7OEnntezuwoj/FNst4QzrNxIts=
github.com/tdewolff/parse/v2 v2.5.24 h1:sJPG5Viy2lq9NBbnK4KpWEA+17RNZz8EOXVqErHKHgs=
github.com/tdewolff/parse/v2 v2.5.24/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/minify/v2 v2.9.24 h1:4wlbX+U5IgVa8fH//mlwzv+2g47MN7lu3s9HClAHc28=
github.com/tdewolff/minify/v2 v2.9.24/go.mod h1:L/bwPtsU/Xx30MxCndlClCMMiLbqROgkR4vZT+QIGXA=
github.com/tdewolff/parse/v2 v2.5.26 h1:a/q3lwDCi4GIQ+sSbs4UOHuObhqp8GHAhfqop/zDyQQ=
github.com/tdewolff/parse/v2 v2.5.26/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4=
github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
@ -441,8 +439,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -517,8 +515,8 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211209124913-491a49abca63 h1:iocB37TsdFuN6IBRZ+ry36wrkoV51/tl5vOWqkcPGvY=
golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/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=
@ -592,8 +590,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/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-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
@ -780,8 +778,8 @@ rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
software.sslmate.com/src/go-pkcs12 v0.0.0-20180114231543-2291e8f0f237 h1:iAEkCBPbRaflBgZ7o9gjVUuWuvWeV4sytFWg9o+Pj2k=
tailscale.com v1.18.1 h1:3hkMsdpREdz2w0O3YcmOgJkl95ChTT4Dje7wq8prD/E=
tailscale.com v1.18.1/go.mod h1:XzG4o2vtYFkVvmJWPaTGSaOzqlKSRx2WU+aJbrxaVE0=
tailscale.com v1.18.2 h1:97bYZWQ91knzR/JvP24Xz/uaq81zz/Xn47PqDdnjquc=
tailscale.com v1.18.2/go.mod h1:XzG4o2vtYFkVvmJWPaTGSaOzqlKSRx2WU+aJbrxaVE0=
willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971 h1:b4juh5znIpBA1KnzHMP0UB4Cs+3/0b0XfchkWE81FXw=
willnorris.com/go/microformats v1.1.2-0.20210827044458-ff2a6ae41971/go.mod h1:kvVnWrkkEscVAIITCEoiTX66Hcyg59C7q0E49mb9TJ0=
willnorris.com/go/webmention v0.0.0-20211028201829-b0044f1a24d0 h1:3/ozQ2qGZat82ON3AYMTot3gCg/vU7tgn/LYSJbkVPM=

@ -177,6 +177,7 @@ func (app *goBlog) initComponents(logging bool) {
app.initWebmention()
app.initTelegram()
app.initBlogStats()
app.initTTS()
app.initSessions()
app.initIndieAuth()
app.startPostsScheduler()

@ -24,6 +24,11 @@ func (a *goBlog) initMediaStorage() {
})
}
func (a *goBlog) mediaStorageEnabled() bool {
a.initMediaStorage()
return a.mediaStorage != nil
}
type mediaStorageSaveFunc func(filename string, file io.Reader) (location string, err error)
func (a *goBlog) saveMediaFile(filename string, f io.Reader) (string, error) {
@ -92,14 +97,7 @@ func (a *goBlog) initLocalMediaStorage() mediaStorage {
}
func (l *localMediaStorage) save(filename string, file io.Reader) (location string, err error) {
if err = os.MkdirAll(l.path, 0777); err != nil {
return "", err
}
newFile, err := os.Create(filepath.Join(l.path, filename))
if err != nil {
return "", err
}
if _, err = io.Copy(newFile, file); err != nil {
if err = saveToFile(file, filepath.Join(l.path, filename)); err != nil {
return "", err
}
return l.location(filename), nil

@ -1,136 +0,0 @@
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
}

@ -53,12 +53,10 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML {
// Build HTML
var htmlBuilder strings.Builder
// Add audio to the top
if audio, ok := p.Parameters[a.cfg.Micropub.AudioParam]; ok && len(audio) > 0 {
for _, a := range audio {
htmlBuilder.WriteString(`<audio controls preload=none><source src="`)
htmlBuilder.WriteString(a)
htmlBuilder.WriteString(`"/></audio>`)
}
for _, a := range p.Parameters[a.cfg.Micropub.AudioParam] {
htmlBuilder.WriteString(`<audio controls preload=none><source src="`)
htmlBuilder.WriteString(a)
htmlBuilder.WriteString(`"/></audio>`)
}
// Render markdown
htmlContent, err := a.renderMarkdown(p.Content, absolute)
@ -68,14 +66,12 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML {
}
htmlBuilder.Write(htmlContent)
// Add bookmark links to the bottom
if link, ok := p.Parameters[a.cfg.Micropub.BookmarkParam]; ok && len(link) > 0 {
for _, l := range link {
htmlBuilder.WriteString(`<p><a class=u-bookmark-of href="`)
htmlBuilder.WriteString(l)
htmlBuilder.WriteString(`" target=_blank rel=noopener>`)
htmlBuilder.WriteString(l)
htmlBuilder.WriteString(`</a></p>`)
}
for _, l := range p.Parameters[a.cfg.Micropub.BookmarkParam] {
htmlBuilder.WriteString(`<p><a class=u-bookmark-of href="`)
htmlBuilder.WriteString(l)
htmlBuilder.WriteString(`" target=_blank rel=noopener>`)
htmlBuilder.WriteString(l)
htmlBuilder.WriteString(`</a></p>`)
}
// Cache
html := template.HTML(htmlBuilder.String())
@ -86,6 +82,28 @@ func (a *goBlog) postHtml(p *post, absolute bool) template.HTML {
return html
}
func (a *goBlog) feedHtml(p *post) string {
var htmlBuilder strings.Builder
// Add TTS audio to the top
for _, a := range p.Parameters[ttsParameter] {
htmlBuilder.WriteString(`<audio controls preload=none><source src="`)
htmlBuilder.WriteString(a)
htmlBuilder.WriteString(`"/></audio>`)
}
// Add post HTML
htmlBuilder.WriteString(string(a.postHtml(p, true)))
// Add link to interactions and comments
blogConfig := a.cfg.Blogs[defaultIfEmpty(p.Blog, a.cfg.DefaultBlog)]
if cc := blogConfig.Comments; cc != nil && cc.Enabled {
htmlBuilder.WriteString(`<p><a href="`)
htmlBuilder.WriteString(a.getFullAddress(p.Path))
htmlBuilder.WriteString(`#interactions">`)
htmlBuilder.WriteString(a.ts.GetTemplateStringVariant(blogConfig.Lang, "interactions"))
htmlBuilder.WriteString(`</a></p>`)
}
return htmlBuilder.String()
}
const summaryDivider = "<!--more-->"
func (a *goBlog) postSummary(p *post) (summary string) {
@ -244,5 +262,5 @@ func (p *post) Old() bool {
}
func (p *post) TTS() string {
return p.firstParameter("tts")
return p.firstParameter(ttsParameter)
}

@ -81,6 +81,7 @@ func (a *goBlog) initRendering() error {
"mbytes": mBytesString,
"editortemplate": a.editorPostTemplate,
"editorpostdesc": a.editorPostDesc,
"ttsenabled": a.ttsEnabled,
}
baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt))
if err != nil {

@ -36,11 +36,13 @@
<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>
{{ if ttsenabled }}
<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>
{{ end }}
<script defer src="{{ asset "js/formconfirm.js" }}"></script>
</div>
{{ end }}

209
tts.go

@ -1,23 +1,57 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"sync"
"unicode"
"github.com/google/uuid"
"go.goblog.app/app/pkgs/mp3merge"
"go.goblog.app/app/pkgs/contenttype"
)
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)
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()
}
func (a *goBlog) createPostTTSAudio(p *post) error {
// Get required values
lang := a.cfg.Blogs[p.Blog].Lang
@ -53,86 +87,63 @@ func (a *goBlog) createPostTTSAudio(p *post) error {
if err != nil {
return err
}
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)
}
// Set post parameter
if loc != "" {
err = a.db.replacePostParam(p.Path, "tts", []string{loc})
if err != nil {
return err
}
err = a.db.replacePostParam(p.Path, ttsParameter, []string{loc})
if err != nil {
return err
}
// Purge cache
a.cache.purge()
return nil
}
func (a *goBlog) createTTSAudio(lang, text, outputFile string) error {
// Create temporary directory
tmpDir, err := os.MkdirTemp("", "")
if err != nil {
return err
// 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
}
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()
}
// 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
}
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, uuid.NewString()+".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()
}
}()
fileName := path.Base(fileUrl.Path)
if a.getFullAddress(a.mediaFileLocation(fileName)) != audio {
// File is not from the configured media storage
return false
}
wg.Wait()
cancel()
if ttsErr != nil {
return ttsErr
// Try to delete the audio file
err = a.deleteMediaFile(fileName)
if err != nil {
log.Println("failed to delete audio file:", err)
return false
}
return true
}
// Merge MP3s
if err = mp3merge.MergeMP3(outputFile, allFiles); err != nil {
return err
func (a *goBlog) createTTSAudio(lang, text, outputFile string) error {
// 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")
}
return nil
}
func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile string) error {
// Check parameters
if lang == "" {
return errors.New("language not provided")
@ -144,18 +155,30 @@ func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile st
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 body
body := map[string]interface{}{
"audioConfig": map[string]interface{}{
"audioEncoding": "MP3",
},
"input": map[string]interface{}{
"text": text,
},
"voice": map[string]interface{}{
"languageCode": lang,
},
}
jb, err := json.Marshal(body)
if err != nil {
return err
}
// Create request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://translate.google.com/translate_tts?"+ttsUrlVals.Encode(), nil)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://texttospeech.googleapis.com/v1beta1/text:synthesize?key="+gctts.GoogleAPIKey, bytes.NewReader(jb))
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")
req.Header.Set(contentType, contenttype.JSON)
req.Header.Set(userAgent, appUserAgent)
// Do request
res, err := a.httpClient.Do(req)
@ -164,23 +187,23 @@ func (a *goBlog) downloadTTSAudio(ctx context.Context, lang, text, outputFile st
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("TTS: got status: %s, text: %s", res.Status, text)
return fmt.Errorf("got status: %s, text: %s", res.Status, text)
}
// Save response
if err = os.MkdirAll(path.Dir(outputFile), os.ModePerm); err != nil {
// Decode response
var content map[string]interface{}
if err = json.NewDecoder(res.Body).Decode(&content); 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
if encoded, ok := content["audioContent"]; ok {
if encodedStr, ok := encoded.(string); ok {
if audio, err := base64.StdEncoding.DecodeString(encodedStr); err == nil {
os.WriteFile(outputFile, audio, os.ModePerm)
return nil
} else {
return err
}
}
}
return nil
return errors.New("no audio content")
}

@ -9,7 +9,9 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
@ -309,3 +311,19 @@ func doHandlerRequest(req *http.Request, handler http.Handler) (*http.Response,
}
return client.Do(req)
}
func saveToFile(reader io.Reader, fileName string) error {
// Create folder path if not exists
if err := os.MkdirAll(filepath.Dir(fileName), os.ModePerm); err != nil {
return err
}
// Create file
out, err := os.Create(fileName)
if err != nil {
return err
}
// Copy response to file
defer out.Close()
_, err = io.Copy(out, reader)
return err
}

Loading…
Cancel
Save