Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions cmd/tts.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
Expand Down Expand Up @@ -227,11 +226,23 @@ func kokoroReleasesAPIURL() string {
return "https://api.github.com/repos/thewh1teagle/kokoro-onnx/releases?per_page=30"
}

// kokoroAsset keeps the validated local filename separate from the download URL.
type kokoroAsset struct {
Name string
URL string
}

// kokoroFallbackAssets pins a known-good release for when the releases API
// is unreachable (rate limit, air gap without an API mirror).
var kokoroFallbackAssets = map[string]string{
"kokoro-v*.onnx": "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx",
"voices-v*.bin": "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin",
var kokoroFallbackAssets = map[string]kokoroAsset{
"kokoro-v*.onnx": {
Name: "kokoro-v1.0.onnx",
URL: "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx",
},
"voices-v*.bin": {
Name: "voices-v1.0.bin",
URL: "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin",
},
}

// kokoroAssetNames matches the canonical full-precision asset per model
Expand All @@ -243,11 +254,11 @@ var kokoroAssetNames = map[string]*regexp.Regexp{
"voices-v*.bin": regexp.MustCompile(`^voices-v\d+(\.\d+)*\.bin$`),
}

// resolveKokoroModelAssets maps each model pattern to the download URL of
// resolveKokoroModelAssets maps each model pattern to the name and URL of
// the matching asset in the newest upstream "model-files-*" release that
// carries all required files, so new model versions are picked up without a
// CLI change.
func resolveKokoroModelAssets() (map[string]string, error) {
func resolveKokoroModelAssets() (map[string]kokoroAsset, error) {
client := &http.Client{Timeout: 30 * time.Second, Transport: httpx.UserAgentTransport{UserAgent: cliUserAgent()}}
resp, err := client.Get(kokoroReleasesAPIURL())
if err != nil {
Expand Down Expand Up @@ -275,11 +286,11 @@ func resolveKokoroModelAssets() (map[string]string, error) {
if !strings.HasPrefix(rel.TagName, "model-files-") {
continue
}
assets := make(map[string]string, len(kokoroModelPatterns))
assets := make(map[string]kokoroAsset, len(kokoroModelPatterns))
for _, pattern := range kokoroModelPatterns {
for _, a := range rel.Assets {
if kokoroAssetNames[pattern].MatchString(a.Name) {
assets[pattern] = a.BrowserDownloadURL
assets[pattern] = kokoroAsset{Name: a.Name, URL: a.BrowserDownloadURL}
break
}
}
Expand Down Expand Up @@ -308,13 +319,17 @@ func downloadKokoroModels(configDir string) error {
if kokoroModelPath(pattern) != "" {
continue
}
url, ok := assets[pattern]
asset, ok := assets[pattern]
if !ok {
return fmt.Errorf("no download source for %s", pattern)
}
dest := filepath.Join(configDir, path.Base(url))
if err := downloadFile(dest, url); err != nil {
return fmt.Errorf("failed to download %s: %w", path.Base(url), err)
// Apply the same filename constraint to resolved and fallback assets.
if !kokoroAssetNames[pattern].MatchString(asset.Name) {
return fmt.Errorf("invalid asset name %q for %s", asset.Name, pattern)
}
dest := filepath.Join(configDir, asset.Name)
if err := downloadFile(dest, asset.URL); err != nil {
return fmt.Errorf("failed to download %s: %w", asset.Name, err)
}
}
return nil
Expand Down
129 changes: 123 additions & 6 deletions cmd/tts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package cmd

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/spotify/save-to-spotify/config"
)

// The upstream kokoro-onnx repo mixes library releases, language-specific
Expand Down Expand Up @@ -41,11 +44,11 @@ func TestResolveKokoroModelAssets(t *testing.T) {
t.Fatalf("resolveKokoroModelAssets: %v", err)
}

if got := assets["kokoro-v*.onnx"]; !strings.HasSuffix(got, "/kokoro-v1.0.onnx") {
t.Errorf("onnx asset = %q, want the canonical kokoro-v1.0.onnx (not zh or quantized variants)", got)
if got := assets["kokoro-v*.onnx"]; got.Name != "kokoro-v1.0.onnx" || !strings.HasSuffix(got.URL, "/kokoro-v1.0.onnx") {
t.Errorf("onnx asset = %+v, want the canonical kokoro-v1.0.onnx (not zh or quantized variants)", got)
}
if got := assets["voices-v*.bin"]; !strings.HasSuffix(got, "/voices-v1.0.bin") {
t.Errorf("voices asset = %q, want voices-v1.0.bin", got)
if got := assets["voices-v*.bin"]; got.Name != "voices-v1.0.bin" || !strings.HasSuffix(got.URL, "/voices-v1.0.bin") {
t.Errorf("voices asset = %+v, want voices-v1.0.bin", got)
}
}

Expand All @@ -72,8 +75,122 @@ func TestResolveKokoroModelAssetsFutureRelease(t *testing.T) {
if err != nil {
t.Fatalf("resolveKokoroModelAssets: %v", err)
}
if got := assets["kokoro-v*.onnx"]; !strings.HasSuffix(got, "/kokoro-v1.2.onnx") {
t.Errorf("onnx asset = %q, want the newest kokoro-v1.2.onnx", got)
if got := assets["kokoro-v*.onnx"]; got.Name != "kokoro-v1.2.onnx" || !strings.HasSuffix(got.URL, "/kokoro-v1.2.onnx") {
t.Errorf("onnx asset = %+v, want the newest kokoro-v1.2.onnx", got)
}
}

func TestDownloadKokoroModelsUsesAssetNames(t *testing.T) {
for _, source := range []string{"releases", "fallback"} {
t.Run(source, func(t *testing.T) {
for _, existingConfig := range []bool{false, true} {
t.Run(fmt.Sprintf("existing_config=%t", existingConfig), func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
configDir, err := config.ConfigDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(configDir, 0700); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(configDir, "tts.json")
const originalConfig = `{"default":"openai","engines":[]}`
if existingConfig {
if err := os.WriteFile(configPath, []byte(originalConfig), 0600); err != nil {
t.Fatal(err)
}
}

const modelBody = "model fixture"
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/releases":
if source == "fallback" {
http.Error(w, "API unavailable", http.StatusServiceUnavailable)
return
}
fmt.Fprintf(w, `[{"tag_name":"model-files-v9.9","assets":[
{"name":"kokoro-v9.9.onnx","browser_download_url":%q},
{"name":"voices-v9.9.bin","browser_download_url":%q}
]}]`, srv.URL+"/tts.json", srv.URL+"/opaque-download?signature=123")
case "/tts.json", "/opaque-download":
w.Write([]byte(modelBody))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("SAVE_TO_SPOTIFY_KOKORO_RELEASES_API_URL", srv.URL+"/releases")
if source == "fallback" {
original := kokoroFallbackAssets
t.Cleanup(func() { kokoroFallbackAssets = original })
kokoroFallbackAssets = map[string]kokoroAsset{
"kokoro-v*.onnx": {Name: "kokoro-v9.9.onnx", URL: srv.URL + "/tts.json"},
"voices-v*.bin": {Name: "voices-v9.9.bin", URL: srv.URL + "/opaque-download?signature=123"},
}
}

if err := downloadKokoroModels(configDir); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(configPath)
if existingConfig {
if err != nil || string(data) != originalConfig {
t.Errorf("TTS config changed: got %q, err %v", data, err)
}
} else if !os.IsNotExist(err) {
t.Errorf("unexpected TTS config created: got %q, err %v", data, err)
}
for _, name := range []string{"kokoro-v9.9.onnx", "voices-v9.9.bin"} {
data, err := os.ReadFile(filepath.Join(configDir, name))
if err != nil || string(data) != modelBody {
t.Errorf("model %s = %q, err %v", name, data, err)
}
}
entries, err := os.ReadDir(configDir)
if err != nil {
t.Fatal(err)
}
wantFiles := 2
if existingConfig {
wantFiles++
}
if len(entries) != wantFiles {
t.Errorf("config directory contains %d entries, want %d: %v", len(entries), wantFiles, entries)
}
})
}
})
}
}

func TestDownloadKokoroModelsRejectsInvalidFallbackName(t *testing.T) {
for _, name := range []string{"tts.json", "../tts.json", `..\tts.json`, "voices-v1.0.bin", ""} {
t.Run(name, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
configDir, err := config.ConfigDir()
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/releases" {
t.Errorf("invalid asset was requested: %s", r.URL.Path)
}
http.Error(w, "API unavailable", http.StatusServiceUnavailable)
}))
defer srv.Close()
t.Setenv("SAVE_TO_SPOTIFY_KOKORO_RELEASES_API_URL", srv.URL+"/releases")
original := kokoroFallbackAssets
t.Cleanup(func() { kokoroFallbackAssets = original })
kokoroFallbackAssets = map[string]kokoroAsset{
"kokoro-v*.onnx": {Name: name, URL: srv.URL + "/model"},
}

if err := downloadKokoroModels(configDir); err == nil || !strings.Contains(err.Error(), "invalid asset name") {
t.Fatalf("want invalid asset name error, got %v", err)
}
})
}
}

Expand Down
Loading