package main import ( "context" "crypto/tls" "embed" "fmt" "html/template" "io" "io/fs" "log" "net" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "go-speech/handlers" "go-speech/internal/cache" "go-speech/internal/logger" ) //go:embed static var staticFiles embed.FS //go:embed README.md var readmeContent string func main() { // Получение конфигурации из переменных окружения port := getEnv("GO_SPEECH_PORT", "8443") certFile := getEnv("GO_SPEECH_CERT_FILE", "certs/server.crt") keyFile := getEnv("GO_SPEECH_KEY_FILE", "certs/server.key") piperPath := getEnv("GO_SPEECH_PIPER_PATH", "/usr/local/bin/piper") ffmpegPath := getEnv("GO_SPEECH_FFMPEG_PATH", "/usr/bin/ffmpeg") // Получение пути к директории с моделями modelDir := getEnv("GO_SPEECH_MODEL_DIR", "models") // Определение директории кэша (рядом с исполняемым файлом) execPath, err := os.Executable() if err != nil { logger.Warn("Не удалось определить путь к исполняемому файлу: %v", err) execPath = "." } execDir := filepath.Dir(execPath) cacheDir := filepath.Join(execDir, "cache") logger.Debug("Директория исполняемого файла: %s", execDir) logger.Debug("Директория кэша: %s", cacheDir) // Инициализация кэша logger.Debug("Инициализация кэша...") cacheInstance, err := cache.NewCache(cacheDir) if err != nil { logger.Error("Ошибка инициализации кэша: %v", err) log.Fatalf("Ошибка инициализации кэша: %v", err) } // Запуск фоновой горутины для очистки кэша (каждые 5 минут, удаляем файлы старше 3 дней) cacheCleanupInterval := 5 * time.Minute cacheMaxAge := 3 * 24 * time.Hour cacheInstance.StartCleanupRoutine(cacheCleanupInterval, cacheMaxAge) // Детальное логирование конфигурации logger.Info("=== Запуск Go Speech TTS сервера ===") logger.Debug("Режим отладки: ВКЛЮЧЕН") logger.Debug("Конфигурация:") logger.Debug(" PORT: %s", port) logger.Debug(" CERT_FILE: %s", certFile) logger.Debug(" KEY_FILE: %s", keyFile) logger.Debug(" PIPER_PATH: %s", piperPath) logger.Debug(" FFMPEG_PATH: %s", ffmpegPath) logger.Debug(" MODEL_DIR: %s", modelDir) logger.Debug(" CACHE_DIR: %s", cacheDir) logger.Debug(" GO_SPEECH_VOICE: %s", getEnv("GO_SPEECH_VOICE", "ruslan")) logger.Debug(" GO_SPEECH_MODE: %s", getEnv("GO_SPEECH_MODE", "debug")) logger.Info("Директория с моделями: %s", modelDir) logger.Info("Директория кэша: %s", cacheDir) // Проверка наличия сертификатов logger.Debug("Проверка SSL сертификатов...") if _, err := os.Stat(certFile); os.IsNotExist(err) { logger.Error("SSL сертификат не найден: %s", certFile) log.Fatalf("SSL сертификат не найден: %s. Создайте сертификаты или укажите путь через CERT_FILE", certFile) } logger.Debug("SSL сертификат найден: %s", certFile) if _, err := os.Stat(keyFile); os.IsNotExist(err) { logger.Error("SSL ключ не найден: %s", keyFile) log.Fatalf("SSL ключ не найден: %s. Создайте ключ или укажите путь через KEY_FILE", keyFile) } logger.Debug("SSL ключ найден: %s", keyFile) // Загрузка TLS сертификатов logger.Debug("Загрузка TLS сертификатов...") cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { logger.Error("Ошибка загрузки TLS сертификатов: %v", err) log.Fatalf("Ошибка загрузки TLS сертификатов: %v", err) } logger.Debug("TLS сертификаты успешно загружены") // Настройка TLS конфигурации tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } // Статические файлы для фронтенда logger.Debug("Загрузка статических файлов...") staticFS, err := fs.Sub(staticFiles, "static") if err != nil { logger.Error("Ошибка загрузки статических файлов: %v", err) log.Fatalf("Ошибка загрузки статических файлов: %v", err) } logger.Debug("Статические файлы успешно загружены") // Создание HTTP сервера mux := http.NewServeMux() // Инициализация TTS обработчика logger.Debug("Инициализация TTS обработчика...") ttsHandler := handlers.NewTTSHandler(piperPath, modelDir, ffmpegPath, cacheInstance) logger.Debug("Регистрация маршрутов:") // API endpoints mux.HandleFunc("/go-speech/api/v1/tts", ttsHandler.HandleTTS) logger.Debug(" POST /go-speech/api/v1/tts - синтез речи") mux.HandleFunc("/go-speech/api/v1/healthz", ttsHandler.HandleHealthz) logger.Debug(" GET /go-speech/api/v1/healthz - проверка TTS") mux.HandleFunc("/go-speech/api/v1/health", func(w http.ResponseWriter, r *http.Request) { logger.Debug("GET /go-speech/api/v1/health - проверка работоспособности") w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) logger.Debug(" GET /go-speech/api/v1/health - проверка работоспособности") // Веб-интерфейс mux.HandleFunc("/go-speech/front", func(w http.ResponseWriter, r *http.Request) { logger.Debug("GET /go-speech/front - веб-интерфейс") if r.URL.Path != "/go-speech/front" { http.Redirect(w, r, "/go-speech/front", http.StatusMovedPermanently) return } indexFile, err := staticFS.Open("index.html") if err != nil { logger.Error("Ошибка открытия index.html: %v", err) http.Error(w, "Фронтенд не найден", http.StatusNotFound) return } defer indexFile.Close() w.Header().Set("Content-Type", "text/html; charset=utf-8") io.Copy(w, indexFile) }) logger.Debug(" GET /go-speech/front - веб-интерфейс") // Обработчик для статических ресурсов фронтенда mux.Handle("/go-speech/front/", http.StripPrefix("/go-speech/front/", http.FileServer(http.FS(staticFS)))) logger.Debug(" GET /go-speech/front/* - статические ресурсы") // Помощь - рендеринг README.md mux.HandleFunc("/go-speech/help", handleHelp) logger.Debug(" GET /go-speech/help - документация") server := &http.Server{ Addr: "0.0.0.0:" + port, Handler: loggingMiddleware(mux), TLSConfig: tlsConfig, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } logger.Debug("Настройки HTTP сервера:") logger.Debug(" ReadTimeout: %v", server.ReadTimeout) logger.Debug(" WriteTimeout: %v", server.WriteTimeout) logger.Debug(" IdleTimeout: %v", server.IdleTimeout) // Graceful shutdown go func() { // Создаем явный IPv4 listener logger.Debug("Создание IPv4 listener на 0.0.0.0:%s...", port) listener, err := net.Listen("tcp4", "0.0.0.0:"+port) if err != nil { logger.Error("Ошибка создания IPv4 listener: %v", err) log.Fatalf("Ошибка создания IPv4 listener: %v", err) } logger.Debug("IPv4 listener успешно создан: %v", listener.Addr()) // Создаем TLS listener logger.Debug("Создание TLS listener...") tlsListener := tls.NewListener(listener, tlsConfig) logger.Debug("TLS listener успешно создан") logger.Info("Сервер запущен на https://0.0.0.0:%s (IPv4)", port) logger.Info("Простой веб-интерфейс доступен на https://localhost:%s/go-speech/front", port) logger.Info("Справка доступна по пути https://localhost:%s/go-speech/help", port) logger.Debug("Запуск server.Serve()...") if err := server.Serve(tlsListener); err != nil && err != http.ErrServerClosed { logger.Error("Ошибка запуска сервера: %v", err) log.Fatalf("Ошибка запуска сервера: %v", err) } logger.Debug("server.Serve() завершился") }() // Ожидание сигнала для graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) logger.Debug("Ожидание сигнала для остановки (SIGINT/SIGTERM)...") sig := <-quit logger.Info("Получен сигнал: %v", sig) logger.Info("Остановка сервера...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() logger.Debug("Graceful shutdown с таймаутом 5 секунд...") if err := server.Shutdown(ctx); err != nil { logger.Error("Ошибка при остановке сервера: %v", err) log.Fatalf("Ошибка при остановке сервера: %v", err) } logger.Info("Сервер остановлен") } // loggingMiddleware добавляет логирование запросов func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() logger.Debug("Входящий запрос: %s %s от %s", r.Method, r.URL.Path, r.RemoteAddr) logger.Debug(" User-Agent: %s", r.UserAgent()) logger.Debug(" Content-Type: %s", r.Header.Get("Content-Type")) logger.Debug(" Content-Length: %s", r.Header.Get("Content-Length")) next.ServeHTTP(w, r) duration := time.Since(start) logger.Info("%s %s %v", r.Method, r.URL.Path, duration) logger.Debug("Запрос обработан за %v", duration) }) } // getEnv получает переменную окружения или возвращает значение по умолчанию func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // GetModelPath формирует путь к модели на основе выбранного голоса func GetModelPath(modelDir, voice string) string { // Если указан полный путь через MODEL_PATH, используем его if modelPath := os.Getenv("MODEL_PATH"); modelPath != "" { return modelPath } // Формируем путь к модели на основе голоса modelFile := fmt.Sprintf("ru_RU-%s-medium.onnx", voice) return filepath.Join(modelDir, modelFile) } // handleHelp рендерит README.md в HTML func handleHelp(w http.ResponseWriter, r *http.Request) { logger.Debug("GET /go-speech/help - отображение документации") // Простой рендеринг markdown в HTML html := markdownToHTML(readmeContent) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(html)) } // markdownToHTML конвертирует markdown в простой HTML func markdownToHTML(md string) string { html := ` Go Speech - Документация ` // Простая конвертация markdown в HTML lines := strings.Split(md, "\n") inCodeBlock := false inList := false var codeBlock strings.Builder for i, line := range lines { line = strings.TrimRight(line, "\r") trimmedLine := strings.TrimSpace(line) // Обработка блоков кода if strings.HasPrefix(trimmedLine, "```") { if inCodeBlock { // Закрываем блок кода html += "
" + template.HTMLEscapeString(codeBlock.String()) + "
\n" codeBlock.Reset() inCodeBlock = false } else { // Открываем блок кода inCodeBlock = true } if inList { html += "\n" inList = false } continue } if inCodeBlock { codeBlock.WriteString(line + "\n") continue } // Заголовки if strings.HasPrefix(trimmedLine, "# ") { if inList { html += "\n" inList = false } html += "

" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "# ")) + "

\n" continue } if strings.HasPrefix(trimmedLine, "## ") { if inList { html += "\n" inList = false } html += "

" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "## ")) + "

\n" continue } if strings.HasPrefix(trimmedLine, "### ") { if inList { html += "\n" inList = false } html += "

" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "### ")) + "

\n" continue } // Списки if strings.HasPrefix(trimmedLine, "- ") { if !inList { html += "\n" inList = false } // Пустые строки if trimmedLine == "" { if i < len(lines)-1 { html += "
\n" } continue } // Обычный текст content := processInlineCode(template.HTMLEscapeString(trimmedLine)) html += "

" + content + "

\n" } // Закрываем открытые теги if inList { html += "\n" } html += ` ` return html } // processInlineCode обрабатывает inline код в markdown func processInlineCode(text string) string { // Простая обработка inline кода `code` parts := strings.Split(text, "`") result := strings.Builder{} for i, part := range parts { if i%2 == 0 { result.WriteString(part) } else { result.WriteString("") result.WriteString(part) result.WriteString("") } } return result.String() }