package model import ( "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "voicesnap/internal/logger" ) // ProgressCallback is called with download progress (percent, downloaded bytes, total bytes). type ProgressCallback func(percent float64, downloaded, total int64) // Download downloads and extracts the ASR model. // It tries the primary URL first, then falls back to the fallback URL. func Download(primaryURL, fallbackURL, modelsDir string, progress ProgressCallback) error { if err := os.MkdirAll(modelsDir, 0755); err != nil { return fmt.Errorf("failed to create models dir: %w", err) } tempFile := filepath.Join(modelsDir, "model_package.tar.bz2") // Try primary URL first logger.Info("Downloading model from primary URL: %s", primaryURL) err := downloadFile(primaryURL, tempFile, progress) if err != nil { logger.Info("Primary URL failed: %v, trying fallback", err) err = downloadFile(fallbackURL, tempFile, progress) if err != nil { return fmt.Errorf("download failed from both URLs: %w", err) } } // Extract logger.Info("Extracting model archive...") if err := extractModel(tempFile, modelsDir); err != nil { return fmt.Errorf("extraction failed: %w", err) } // Cleanup temp file os.Remove(tempFile) logger.Info("Model download and extraction complete") return nil } func downloadFile(url, destPath string, progress ProgressCallback) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) } totalBytes := resp.ContentLength out, err := os.Create(destPath) if err != nil { return err } defer out.Close() buf := make([]byte, 32*1024) var downloaded int64 lastPercent := -1.0 for { n, readErr := resp.Body.Read(buf) if n > 0 { if _, writeErr := out.Write(buf[:n]); writeErr != nil { return writeErr } downloaded += int64(n) if totalBytes > 0 && progress != nil { pct := float64(downloaded) / float64(totalBytes) * 100 if pct-lastPercent >= 0.5 { lastPercent = pct progress(pct, downloaded, totalBytes) } } } if readErr != nil { if readErr == io.EOF { break } return readErr } } return nil } func extractModel(archivePath, destDir string) error { // Use system tar to extract cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output)) } // Rename extracted directory to "sensevoice" entries, err := os.ReadDir(destDir) if err != nil { return err } for _, entry := range entries { if entry.IsDir() && strings.HasPrefix(entry.Name(), "sherpa-onnx-sense-voice") { targetDir := filepath.Join(destDir, "sensevoice") srcDir := filepath.Join(destDir, entry.Name()) // Remove existing target os.RemoveAll(targetDir) if err := os.Rename(srcDir, targetDir); err != nil { return fmt.Errorf("failed to rename model dir: %w", err) } break } } return nil }