v1.0.4 - init version

This commit is contained in:
19 changed files with 2049 additions and 0 deletions

428
main.go Normal file
View File

@@ -0,0 +1,428 @@
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()
}