Files
go-speech/main.go
2025-11-25 15:08:04 +06:00

429 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}