├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── example ├── main.go └── server.go └── homecast.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | vendor/ 14 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/barnybug/go-cast" 7 | packages = [ 8 | ".", 9 | "api", 10 | "controllers", 11 | "events", 12 | "log", 13 | "net" 14 | ] 15 | revision = "a92c32bbc819c2f66ea81659a2f8b40aa58e3203" 16 | 17 | [[projects]] 18 | name = "github.com/gogo/protobuf" 19 | packages = ["proto"] 20 | revision = "1adfc126b41513cc696b209667c8656ea7aac67c" 21 | version = "v1.0.0" 22 | 23 | [[projects]] 24 | branch = "master" 25 | name = "github.com/hashicorp/go.net" 26 | packages = [ 27 | "internal/iana", 28 | "ipv4", 29 | "ipv6" 30 | ] 31 | revision = "104dcad90073cd8d1e6828b2af19185b60cf3e29" 32 | 33 | [[projects]] 34 | branch = "master" 35 | name = "github.com/hashicorp/mdns" 36 | packages = ["."] 37 | revision = "4e527d9d808175f132f949523e640c699e4253bb" 38 | 39 | [[projects]] 40 | name = "github.com/miekg/dns" 41 | packages = ["."] 42 | revision = "83c435cc65d2862736428b9b4d07d0ab10ad3e4d" 43 | version = "v1.0.5" 44 | 45 | [[projects]] 46 | branch = "master" 47 | name = "golang.org/x/crypto" 48 | packages = [ 49 | "ed25519", 50 | "ed25519/internal/edwards25519" 51 | ] 52 | revision = "b49d69b5da943f7ef3c9cf91c8777c1f78a0cc3c" 53 | 54 | [[projects]] 55 | branch = "master" 56 | name = "golang.org/x/net" 57 | packages = [ 58 | "bpf", 59 | "context", 60 | "internal/iana", 61 | "internal/socket", 62 | "ipv4", 63 | "ipv6" 64 | ] 65 | revision = "5f9ae10d9af5b1c89ae6904293b14b064d4ada23" 66 | 67 | [solve-meta] 68 | analyzer-name = "dep" 69 | analyzer-version = 1 70 | inputs-digest = "86071cb02809e025c5bc219380abfa83b614cfd5f5014dadd4abc851fa577f53" 71 | solver-name = "gps-cdcl" 72 | solver-version = 1 73 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | branch = "master" 30 | name = "github.com/hashicorp/mdns" 31 | 32 | [prune] 33 | go-tests = true 34 | unused-packages = true 35 | 36 | [[constraint]] 37 | branch = "master" 38 | name = "github.com/barnybug/go-cast" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Masayuki Hamasaki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homecast 2 | 3 | > Make your speaker speak. 4 | 5 | `homecast` is a Go package to enable text-to-speech on Google Home in local network. 6 | 7 | This is Go version of [noelportugal/google-home-notifier](https://github.com/noelportugal/google-home-notifier) 8 | 9 | ## Install 10 | ```bash 11 | $ go get -u github.com/ikasamah/homecast 12 | ``` 13 | 14 | ## Usage 15 | ```golang 16 | ctx := context.Background() 17 | devices := homecast.LookupAndConnect(ctx) 18 | 19 | for _, device := range devices { 20 | err := device.Speak(ctx, "Hello World", "en") 21 | } 22 | ``` 23 | 24 | ## Run example 25 | ```bash 26 | $ go run $GOPATH/src/github.com/ikasamah/homecast/example/main.go 27 | ``` 28 | 29 | 30 | ## Server erxample 31 | ```bash 32 | $ go run $GOPATH/src/github.com/ikasamah/homecast/example/server.go 33 | ``` 34 | Then, access following URL in your browser. 35 | 36 | http://localhost:8080/?text=Ciao&lang=it 37 | 38 | 39 | ## Author 40 | [Masayuki Hamasaki](https://github.com/ikasamah) 41 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ikasamah/homecast" 8 | ) 9 | 10 | func main() { 11 | ctx := context.Background() 12 | devices := homecast.LookupAndConnect(ctx) 13 | 14 | for _, device := range devices { 15 | fmt.Printf("Device: [%s:%d]%s", device.AddrV4, device.Port, device.Name) 16 | 17 | if err := device.Speak(ctx, "Hello World", "en"); err != nil { 18 | fmt.Printf("Failed to speak: %v", err) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/ikasamah/homecast" 11 | ) 12 | 13 | func main() { 14 | port := flag.Int("port", 8080, "Listen port") 15 | defaultLang := flag.String("lang", "en", "Default language to speak") 16 | flag.Parse() 17 | 18 | ctx := context.Background() 19 | devices := homecast.LookupAndConnect(ctx) 20 | defer func() { 21 | for _, device := range devices { 22 | device.Close() 23 | } 24 | }() 25 | 26 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 27 | text := r.FormValue("text") 28 | lang := r.FormValue("lang") 29 | 30 | if text == "" { 31 | log.Printf("[INFO] Skip request due to no text given") 32 | w.WriteHeader(http.StatusBadRequest) 33 | return 34 | } 35 | 36 | if lang == "" { 37 | lang = *defaultLang 38 | } 39 | 40 | for _, device := range devices { 41 | if err := device.Speak(ctx, text, lang); err != nil { 42 | log.Printf("[ERROR] Failed to speak: %v", err) 43 | } 44 | } 45 | }) 46 | 47 | addr := fmt.Sprintf(":%d", *port) 48 | if err := http.ListenAndServe(addr, nil); err != nil { 49 | log.Fatal("ListenAndServe: ", err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /homecast.go: -------------------------------------------------------------------------------- 1 | package homecast 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/barnybug/go-cast" 11 | "github.com/barnybug/go-cast/controllers" 12 | castnet "github.com/barnybug/go-cast/net" 13 | "github.com/micro/mdns" 14 | ) 15 | 16 | const ( 17 | googleCastServiceName = "_googlecast._tcp" 18 | googleHomeModelInfo = "md=Google Home" 19 | ) 20 | 21 | // CastDevice is cast-able device contains cast client 22 | type CastDevice struct { 23 | *mdns.ServiceEntry 24 | client *cast.Client 25 | } 26 | 27 | // Connect connects required services to cast 28 | func (g *CastDevice) Connect(ctx context.Context) error { 29 | return g.client.Connect(ctx) 30 | } 31 | 32 | // Close calls client's close func 33 | func (g *CastDevice) Close() { 34 | g.client.Close() 35 | } 36 | 37 | // Speak speaks given text on cast device 38 | func (g *CastDevice) Speak(ctx context.Context, text, lang string) error { 39 | url, err := tts(text, lang) 40 | if err != nil { 41 | return err 42 | } 43 | return g.Play(ctx, url) 44 | } 45 | 46 | // Play plays media contents on cast device 47 | func (g *CastDevice) Play(ctx context.Context, url *url.URL) error { 48 | conn := castnet.NewConnection() 49 | if err := conn.Connect(ctx, g.AddrV4, g.Port); err != nil { 50 | return err 51 | } 52 | defer conn.Close() 53 | 54 | status, err := g.client.Receiver().LaunchApp(ctx, cast.AppMedia) 55 | if err != nil { 56 | return err 57 | } 58 | app := status.GetSessionByAppId(cast.AppMedia) 59 | 60 | cc := controllers.NewConnectionController(conn, g.client.Events, cast.DefaultSender, *app.TransportId) 61 | if err := cc.Start(ctx); err != nil { 62 | return err 63 | } 64 | media := controllers.NewMediaController(conn, g.client.Events, cast.DefaultSender, *app.TransportId) 65 | if err := media.Start(ctx); err != nil { 66 | return err 67 | } 68 | 69 | mediaItem := controllers.MediaItem{ 70 | ContentId: url.String(), 71 | ContentType: "audio/mp3", 72 | StreamType: "BUFFERED", 73 | } 74 | 75 | log.Printf("[INFO] Load media: content_id=%s", mediaItem.ContentId) 76 | _, err = media.LoadMedia(ctx, mediaItem, 0, true, nil) 77 | 78 | return err 79 | } 80 | 81 | // LookupAndConnect retrieves cast-able google home devices 82 | func LookupAndConnect(ctx context.Context) []*CastDevice { 83 | entriesCh := make(chan *mdns.ServiceEntry, 4) 84 | 85 | results := make([]*CastDevice, 0, 4) 86 | go func() { 87 | for entry := range entriesCh { 88 | log.Printf("[INFO] ServiceEntry detected: [%s:%d]%s", entry.AddrV4, entry.Port, entry.Name) 89 | for _, field := range entry.InfoFields { 90 | if strings.HasPrefix(field, googleHomeModelInfo) { 91 | client := cast.NewClient(entry.AddrV4, entry.Port) 92 | if err := client.Connect(ctx); err != nil { 93 | log.Printf("[ERROR] Failed to connect: %s", err) 94 | continue 95 | } 96 | results = append(results, &CastDevice{entry, client}) 97 | } 98 | } 99 | } 100 | }() 101 | 102 | mdns.Lookup(googleCastServiceName, entriesCh) 103 | close(entriesCh) 104 | 105 | return results 106 | } 107 | 108 | // tts provides text-to-speech sound url. 109 | // NOTE: it seems to be unofficial. 110 | func tts(text, lang string) (*url.URL, error) { 111 | base := "https://translate.google.com/translate_tts?client=tw-ob&ie=UTF-8&q=%s&tl=%s" 112 | return url.Parse(fmt.Sprintf(base, url.QueryEscape(text), url.QueryEscape(lang))) 113 | } 114 | --------------------------------------------------------------------------------