429 lines
15 KiB
Go
429 lines
15 KiB
Go
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 := `<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Go Speech - Документация</title>
|
||
<style>
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 40px 20px;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
background: #f5f5f5;
|
||
}
|
||
h1, h2, h3 { color: #667eea; margin-top: 30px; }
|
||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', monospace; }
|
||
pre { background: #f4f4f4; padding: 15px; border-radius: 8px; overflow-x: auto; }
|
||
pre code { background: none; padding: 0; }
|
||
a { color: #667eea; text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
blockquote { border-left: 4px solid #667eea; padding-left: 20px; margin-left: 0; color: #666; }
|
||
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
|
||
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
|
||
th { background: #667eea; color: white; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
`
|
||
|
||
// Простая конвертация 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 += "<pre><code>" + template.HTMLEscapeString(codeBlock.String()) + "</code></pre>\n"
|
||
codeBlock.Reset()
|
||
inCodeBlock = false
|
||
} else {
|
||
// Открываем блок кода
|
||
inCodeBlock = true
|
||
}
|
||
if inList {
|
||
html += "</ul>\n"
|
||
inList = false
|
||
}
|
||
continue
|
||
}
|
||
|
||
if inCodeBlock {
|
||
codeBlock.WriteString(line + "\n")
|
||
continue
|
||
}
|
||
|
||
// Заголовки
|
||
if strings.HasPrefix(trimmedLine, "# ") {
|
||
if inList {
|
||
html += "</ul>\n"
|
||
inList = false
|
||
}
|
||
html += "<h1>" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "# ")) + "</h1>\n"
|
||
continue
|
||
}
|
||
if strings.HasPrefix(trimmedLine, "## ") {
|
||
if inList {
|
||
html += "</ul>\n"
|
||
inList = false
|
||
}
|
||
html += "<h2>" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "## ")) + "</h2>\n"
|
||
continue
|
||
}
|
||
if strings.HasPrefix(trimmedLine, "### ") {
|
||
if inList {
|
||
html += "</ul>\n"
|
||
inList = false
|
||
}
|
||
html += "<h3>" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "### ")) + "</h3>\n"
|
||
continue
|
||
}
|
||
|
||
// Списки
|
||
if strings.HasPrefix(trimmedLine, "- ") {
|
||
if !inList {
|
||
html += "<ul>\n"
|
||
inList = true
|
||
}
|
||
content := strings.TrimPrefix(trimmedLine, "- ")
|
||
// Обработка inline кода в списках
|
||
content = processInlineCode(content)
|
||
html += "<li>" + content + "</li>\n"
|
||
continue
|
||
}
|
||
|
||
// Закрываем список если он был открыт
|
||
if inList && trimmedLine == "" {
|
||
html += "</ul>\n"
|
||
inList = false
|
||
}
|
||
|
||
// Пустые строки
|
||
if trimmedLine == "" {
|
||
if i < len(lines)-1 {
|
||
html += "<br>\n"
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Обычный текст
|
||
content := processInlineCode(template.HTMLEscapeString(trimmedLine))
|
||
html += "<p>" + content + "</p>\n"
|
||
}
|
||
|
||
// Закрываем открытые теги
|
||
if inList {
|
||
html += "</ul>\n"
|
||
}
|
||
|
||
html += `</body>
|
||
</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("<code>")
|
||
result.WriteString(part)
|
||
result.WriteString("</code>")
|
||
}
|
||
}
|
||
return result.String()
|
||
}
|