diff --git a/README.md b/README.md index c9787fb..4a1870a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ # ahsai AH Soft フリーテキスト音声合成 demo API + +## demo +Just run go test to hear the voice below + + + +```go +package ahsai + +import "testing" + +func TestAPI(t *testing.T) { + s := NewSpeaker() + err := s.SetName("東北イタコ") + if err != nil { + t.Fatal(err) + } + u, err := s.Speak("こんにちは、世界") + if err != nil { + t.Fatal(err) + } + t.Log(u) + err = PlayOgg(u) + if err != nil { + t.Fatal(err) + } + err = SaveOggToWav(u, "test.wav") + if err != nil { + t.Fatal(err) + } +} +``` + +## supported speakers +- 琴葉葵 +- 琴葉茜 +- 紲星あかり +- 吉田くん +- 東北ずん子 +- 月読アイ +- 月読ショウタ +- 民安ともえ +- 結月ゆかり +- 水奈瀬コウ +- 京町セイカ +- 東北きりたん +- 桜乃そら +- 東北イタコ +- ついなちゃん標準語 +- ついなちゃん関西弁 +- 伊織弓鶴 +- 音街ウナ \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..da0cc36 --- /dev/null +++ b/api.go @@ -0,0 +1,85 @@ +package ahsai + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const api = "https://cloud.ai-j.jp/demo/aitalk2webapi_nop.php?callback=callback&speaker_id=%d&text=%s&ext=ogg&volume=%.1f&speed=%.1f&pitch=%.1f&range=%.1f&anger=%.1f&sadness=%.1f&joy=%.1f&_=%d" + +var ( + speakers = map[string]uint32{ + "琴葉葵": 551, + "琴葉茜": 552, + "紲星あかり": 554, + "吉田くん": 1201, + "東北ずん子": 1202, + "月読アイ": 1203, + "月読ショウタ": 1204, + "民安ともえ": 1205, + "結月ゆかり": 1206, + "水奈瀬コウ": 1207, + "京町セイカ": 1208, + "東北きりたん": 1209, + "桜乃そら": 1210, + "東北イタコ": 1211, + "ついなちゃん標準語": 1212, + "ついなちゃん関西弁": 1213, + "伊織弓鶴": 1214, + "音街ウナ": 2006, + } +) + +type Speaker struct { + id uint32 + Volume, Speed, Pitch, Range, Anger, Sadness, Joy float32 +} + +func NewSpeaker() (s Speaker) { + s.Volume = 1 + s.Speed = 1 + s.Pitch = 1 + s.Range = 1 + return +} + +var ( + // ErrTextTooLong 文本超过 100 字 + ErrTextTooLong = errors.New("text too long") + // ErrNoSuchSpeaker 查无此人 + ErrNoSuchSpeaker = errors.New("no such speaker") +) + +func (s *Speaker) SetName(name string) error { + id, ok := speakers[name] + if !ok { + return ErrNoSuchSpeaker + } + s.id = id + return nil +} + +// Speak text, return ogg url +func (s *Speaker) Speak(text string) (string, error) { + if len([]rune(text)) > 100 { + return "", ErrTextTooLong + } + resp, err := http.Get(fmt.Sprintf(api, s.id, url.QueryEscape(text), s.Volume, s.Speed, s.Pitch, s.Range, s.Anger, s.Sadness, s.Joy, time.Now().UnixMilli())) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + data = bytes.TrimPrefix(data, []byte(`callback({"url":"`)) + data = bytes.TrimSuffix(data, []byte(`"})`)) + data = bytes.ReplaceAll(data, []byte(`\/`), []byte(`/`)) + return "https:" + string(data), nil +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..287e581 --- /dev/null +++ b/api_test.go @@ -0,0 +1,24 @@ +package ahsai + +import "testing" + +func TestAPI(t *testing.T) { + s := NewSpeaker() + err := s.SetName("東北イタコ") + if err != nil { + t.Fatal(err) + } + u, err := s.Speak("こんにちは、世界") + if err != nil { + t.Fatal(err) + } + t.Log(u) + err = PlayOgg(u) + if err != nil { + t.Fatal(err) + } + err = SaveOggToWav(u, "test.wav") + if err != nil { + t.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6354701 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/fumiama/ahsai + +go 1.17 + +require github.com/faiface/beep v1.1.0 + +require ( + github.com/hajimehoshi/oto v0.7.1 // indirect + github.com/jfreymuth/oggvorbis v1.0.1 // indirect + github.com/jfreymuth/vorbis v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect + golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect + golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect + golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d04e859 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= +github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= +github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= +github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= +github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= +github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jfreymuth/oggvorbis v1.0.1 h1:NT0eXBgE2WHzu6RT/6zcb2H10Kxj6Fm3PccT0LE6bqw= +github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U= +github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= +github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= +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= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/ogg.go b/ogg.go new file mode 100644 index 0000000..c48f4fa --- /dev/null +++ b/ogg.go @@ -0,0 +1,76 @@ +package ahsai + +import ( + "net/http" + "os" + "time" + + "github.com/faiface/beep" + "github.com/faiface/beep/speaker" + "github.com/faiface/beep/vorbis" + "github.com/faiface/beep/wav" +) + +func SaveOggToWav(u, path string) error { + resp, err := http.Get(u) + if err != nil { + return err + } + s, format, err := vorbis.Decode(resp.Body) + if err != nil { + resp.Body.Close() + return err + } + defer s.Close() + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + tmp := make([][2]float64, 1024) + _, _ = s.Stream(tmp) + sum := (tmp[0][0] + tmp[0][1]) / 2 + for sum > 1e-32 || sum < -1e-32 { + _, _ = s.Stream(tmp) + sum = (tmp[0][0] + tmp[0][1]) / 2 + for j := 1; j < 1024; j++ { + sum += (tmp[j][0] + tmp[j][1]) / 2 + sum /= 2 + } + } + return wav.Encode(f, s, format) +} + +func PlayOgg(u string) error { + resp, err := http.Get(u) + if err != nil { + return err + } + s, format, err := vorbis.Decode(resp.Body) + if err != nil { + resp.Body.Close() + return err + } + defer s.Close() + tmp := make([][2]float64, 1024) + _, _ = s.Stream(tmp) + sum := (tmp[0][0] + tmp[0][1]) / 2 + for sum > 1e-32 || sum < -1e-32 { + _, _ = s.Stream(tmp) + sum = (tmp[0][0] + tmp[0][1]) / 2 + for j := 1; j < 1024; j++ { + sum += (tmp[j][0] + tmp[j][1]) / 2 + sum /= 2 + } + } + err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/32)) + if err != nil { + return err + } + done := make(chan struct{}) + speaker.Play(beep.Seq(s, beep.Callback(func() { + done <- struct{}{} + }))) + <-done + return nil +} diff --git a/test.wav b/test.wav new file mode 100644 index 0000000..5cb6d8d Binary files /dev/null and b/test.wav differ