diff --git a/.gitignore b/.gitignore index 80c8ca3..02d5e05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Binaries +VERSION.txt certs/ go-speech +go-speech*.tar.gz +go-speech*.tar models/ *.exe *.exe~ diff --git a/Dockerfile b/Dockerfile index 6fe57fd..5d21725 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,31 +16,31 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-speech . # Этап 2: Финальный образ с зависимостями -FROM alpine:latest - -# RUN curl -L https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz -o /tmp/piper.tar.gz +ARG BASE_IMAGE +FROM ${BASE_IMAGE} COPY piper.tar.gz /tmp/piper.tar.gz # Установка необходимых пакетов -RUN apk add --no-cache \ +RUN apt-get update && \ + apt-get install -y \ ca-certificates \ ffmpeg \ curl \ bash \ - libc6-compat \ - libstdc++ + libstdc++6 \ + tar \ + gzip && \ + rm -rf /var/lib/apt/lists/* # Установка Piper TTS - RUN mkdir -p /opt/piper && \ cd /opt/piper && \ tar -xzf /tmp/piper.tar.gz && \ PIPER_BIN=$(find /opt/piper -name "piper" -type f | head -1) && \ chmod +x $PIPER_BIN && \ find /opt/piper -name "*.so*" -type f -exec chmod +x {} \; && \ - ln -sf $PIPER_BIN /usr/local/bin/piper && \ - rm -f /tmp/piper.tar.gz + ln -sf $PIPER_BIN /usr/local/bin/piper # Создание директорий RUN mkdir -p /app/models /app/certs /app/tmp @@ -50,10 +50,6 @@ COPY models/ /app/models/ # Копирование бинарника из builder COPY --from=builder /build/go-speech /app/go-speech -# Примечание: Модели должны быть смонтированы через volume при запуске контейнера -# Пример: -v $(pwd)/models:/app/models:ro -# Или скопированы в образ на этапе сборки, если они включены в репозиторий - # Рабочая директория WORKDIR /app diff --git a/Dockerfile copy b/Dockerfile copy new file mode 100644 index 0000000..8555ef6 --- /dev/null +++ b/Dockerfile copy @@ -0,0 +1,77 @@ +# Multi-stage build для оптимизации размера образа + +# Этап 1: Сборка Go приложения +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +# Копирование go mod файлов +COPY go.mod ./ +RUN go mod download + +# Копирование исходного кода +COPY . . + +# Сборка приложения +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-speech . + +# Этап 2: Финальный образ с зависимостями +FROM alpine:latest + +# RUN curl -L https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz -o /tmp/piper.tar.gz + +COPY piper.tar.gz /tmp/piper.tar.gz + +# Установка необходимых пакетов +RUN apk add --no-cache \ + ca-certificates \ + ffmpeg \ + curl \ + bash \ + libc6-compat \ + libstdc++ \ + rm -rf /var/lib/apt/lists/* + +# Установка Piper TTS + +RUN mkdir -p /opt/piper && \ + cd /opt/piper && \ + tar -xzf /tmp/piper.tar.gz && \ + PIPER_BIN=$(find /opt/piper -name "piper" -type f | head -1) && \ + chmod +x $PIPER_BIN && \ + find /opt/piper -name "*.so*" -type f -exec chmod +x {} \; && \ + ln -sf $PIPER_BIN /usr/local/bin/piper && \ + rm -f /tmp/piper.tar.gz + +# Создание директорий +RUN mkdir -p /app/models /app/certs /app/tmp + +COPY models/ /app/models/ + +# Копирование бинарника из builder +COPY --from=builder /build/go-speech /app/go-speech + +# Примечание: Модели должны быть смонтированы через volume при запуске контейнера +# Пример: -v $(pwd)/models:/app/models:ro +# Или скопированы в образ на этапе сборки, если они включены в репозиторий + +# Рабочая директория +WORKDIR /app + +# Переменные окружения по умолчанию +ENV PORT=8443 +ENV PIPER_PATH=/usr/local/bin/piper +ENV PIPER_BIN_PATH=/opt/piper +ENV MODEL_DIR=/app/models +ENV GO_SPEECH_VOICE=ruslan +ENV FFMPEG_PATH=/usr/bin/ffmpeg +ENV CERT_FILE=/app/certs/server.crt +ENV KEY_FILE=/app/certs/server.key +ENV LD_LIBRARY_PATH=/opt/piper:/usr/lib:/usr/local/lib:${LD_LIBRARY_PATH} + +# Экспорт порта +EXPOSE 8443 + +# Запуск приложения +CMD ["./go-speech"] + diff --git a/README.md b/README.md index 77bd55d..c2b5ed5 100644 --- a/README.md +++ b/README.md @@ -64,37 +64,110 @@ sudo apt-get install ffmpeg apk add ffmpeg ``` -6 Сгенерируйте SSL сертификаты: +6 (Опционально) Сгенерируйте SSL сертификаты вручную: ```bash ./generate-certs.sh ``` +**Примечание:** Если вы используете HTTPS (`GO_SPEECH_TLS=true`), сертификаты будут автоматически сгенерированы при первом запуске, если они отсутствуют. + 7 Запустите сервис: +**Запуск в режиме HTTP (по умолчанию):** + ```bash +# Использует порт 8080 по умолчанию go run main.go ``` -Или с переменными окружения: +**Запуск в режиме HTTP с указанием порта:** + +```bash +GO_SPEECH_PORT=3000 go run main.go +``` + +**Запуск в режиме HTTPS с автогенерацией сертификатов:** + +```bash +GO_SPEECH_TLS=true \ +GO_SPEECH_PORT=8443 \ +GO_SPEECH_VOICE=ruslan \ +go run main.go +``` + +**Запуск в режиме HTTPS с указанием доменов для сертификата:** + +```bash +GO_SPEECH_TLS=true \ +GO_SPEECH_PORT=8443 \ +GO_SPEECH_TLS_DOMAINS="example.com,api.example.com" \ +GO_SPEECH_VOICE=ruslan \ +go run main.go +``` + +**Запуск в режиме HTTPS с использованием CA сертификата:** + +```bash +GO_SPEECH_TLS=true \ +GO_SPEECH_PORT=8443 \ +GO_SPEECH_CA_CERT=./ca/ca.crt \ +GO_SPEECH_TLS_DOMAINS="example.com" \ +GO_SPEECH_VOICE=ruslan \ +go run main.go +``` + +**Запуск с разными голосами:** + +```bash +# Голос Irina (женский) +GO_SPEECH_VOICE=irina go run main.go + +# Голос Denis (мужской) +GO_SPEECH_VOICE=denis go run main.go + +# Голос Dmitri (мужской) +GO_SPEECH_VOICE=dmitri go run main.go +``` + +**Запуск с указанием конкретной модели через MODEL_PATH:** + +```bash +GO_SPEECH_VOICE=ruslan \ +MODEL_PATH=/path/to/custom/ru_RU-ruslan-medium.onnx \ +go run main.go +``` + +**Запуск в режиме продакшена (минимальное логирование):** + +```bash +GO_SPEECH_MODE=release go run main.go +``` + +**Запуск с кастомными путями к утилитам:** + +```bash +GO_SPEECH_PIPER_PATH=/opt/piper/bin/piper \ +GO_SPEECH_FFMPEG_PATH=/opt/ffmpeg/bin/ffmpeg \ +GO_SPEECH_MODEL_DIR=/data/models \ +go run main.go +``` + +**Полный пример с переменными окружения:** ```bash GO_SPEECH_PORT=8443 \ +GO_SPEECH_TLS=true \ GO_SPEECH_PIPER_PATH=/usr/local/bin/piper \ GO_SPEECH_MODEL_DIR=./models \ GO_SPEECH_VOICE=ruslan \ GO_SPEECH_FFMPEG_PATH=/usr/bin/ffmpeg \ GO_SPEECH_CERT_FILE=./certs/server.crt \ GO_SPEECH_KEY_FILE=./certs/server.key \ +GO_SPEECH_MODE=debug \ go run main.go ``` -Для использования другого голоса (например, `denis`, `dmitri`, `irina`): - -```bash -GO_SPEECH_VOICE=denis go run main.go -``` - ### Запуск через Podman/Docker 1 Соберите образ: @@ -111,31 +184,117 @@ curl -L https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/denis/ curl -L https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/denis/medium/ru_RU-denis-medium.onnx.json -o models/ru_RU-denis-medium.onnx.json ``` -3 Сгенерируйте сертификаты: +3 (Опционально) Сгенерируйте сертификаты вручную: ```bash ./generate-certs.sh ``` +**Примечание:** При использовании `GO_SPEECH_TLS=true` сертификаты будут автоматически сгенерированы при первом запуске контейнера. + 4 Запустите контейнер: +**Запуск в режиме HTTP:** + +```bash +podman run -d \ + --name go-speech \ + -p 8080:8080 \ + -e GO_SPEECH_PORT=8080 \ + -e GO_SPEECH_VOICE=ruslan \ + -v $(pwd)/models:/app/models:ro \ + go-speech:latest +``` + +**Запуск в режиме HTTPS с автогенерацией сертификатов:** + ```bash podman run -d \ --name go-speech \ -p 8443:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_PORT=8443 \ + -e GO_SPEECH_VOICE=ruslan \ + -e GO_SPEECH_TLS_DOMAINS="example.com,api.example.com" \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs \ + go-speech:latest +``` + +**Запуск с предварительно созданными сертификатами:** + +```bash +podman run -d \ + --name go-speech \ + -p 8443:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_PORT=8443 \ -e GO_SPEECH_VOICE=ruslan \ -v $(pwd)/models:/app/models:ro \ -v $(pwd)/certs:/app/certs:ro \ go-speech:latest ``` -Для использования другого голоса: +**Запуск с разными голосами:** + +```bash +# Голос Irina +podman run -d \ + --name go-speech-irina \ + -p 8443:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_VOICE=irina \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest + +# Голос Denis +podman run -d \ + --name go-speech-denis \ + -p 8444:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_VOICE=denis \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest + +# Голос Dmitri +podman run -d \ + --name go-speech-dmitri \ + -p 8445:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_VOICE=dmitri \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest +``` + +**Запуск в режиме продакшена:** ```bash podman run -d \ --name go-speech \ -p 8443:8443 \ - -e GO_SPEECH_VOICE=denis \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_MODE=release \ + -e GO_SPEECH_VOICE=ruslan \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest +``` + +**Запуск с кастомными путями и портом:** + +```bash +podman run -d \ + --name go-speech \ + -p 9000:9000 \ + -e GO_SPEECH_PORT=9000 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_PIPER_PATH=/usr/local/bin/piper \ + -e GO_SPEECH_FFMPEG_PATH=/usr/bin/ffmpeg \ + -e GO_SPEECH_MODEL_DIR=/app/models \ + -e GO_SPEECH_VOICE=ruslan \ -v $(pwd)/models:/app/models:ro \ -v $(pwd)/certs:/app/certs:ro \ go-speech:latest @@ -180,20 +339,36 @@ sudo systemctl status go-speech Откройте в браузере: +**Для HTTPS режима:** + ``` text https://localhost:8443/go-speech/front ``` +**Для HTTP режима:** + +``` text +http://localhost:8080/go-speech/front +``` + ### GET /go-speech/help Отображение документации (README.md) в браузере. **Доступ:** +**Для HTTPS режима:** + ``` text https://localhost:8443/go-speech/help ``` +**Для HTTP режима:** + +``` text +http://localhost:8080/go-speech/help +``` + **Возможности:** - Выбор голоса из доступных моделей (Ruslan, Irina, Denis, Dmitri) @@ -281,20 +456,53 @@ curl https://localhost:8443/go-speech/api/v1/health --insecure ## Переменные окружения -- `PORT` - Порт для HTTPS сервера (по умолчанию: 8443) -- `CERT_FILE` - Путь к SSL сертификату (по умолчанию: certs/server.crt) -- `KEY_FILE` - Путь к SSL приватному ключу (по умолчанию: certs/server.key) -- `PIPER_PATH` - Путь к бинарнику Piper TTS (по умолчанию: /usr/local/bin/piper) -- `MODEL_DIR` - Директория с моделями (по умолчанию: models) -- `GO_SPEECH_VOICE` - Имя голоса для синтеза речи (по умолчанию: ruslan) +### Основные настройки + +- `GO_SPEECH_PORT` - Порт для запуска сервера + - По умолчанию: `8443` (если `GO_SPEECH_TLS=true`) или `8080` (если TLS отключен) + - Можно указать любой доступный порт +- `GO_SPEECH_VOICE` - Имя голоса для синтеза речи (по умолчанию: `ruslan`) - Доступные варианты: `ruslan`, `denis`, `dmitri`, `irina` - Путь к модели формируется как: `{MODEL_DIR}/ru_RU-{GO_SPEECH_VOICE}-medium.onnx` -- `MODEL_PATH` - Полный путь к модели Piper TTS (опционально, переопределяет автоматический выбор на основе GO_SPEECH_VOICE) -- `FFMPEG_PATH` - Путь к бинарнику ffmpeg (по умолчанию: /usr/bin/ffmpeg) -- `GO_SPEECH_MODE` - Режим работы сервера (по умолчанию: debug) +- `MODEL_PATH` - Полный путь к модели Piper TTS (опционально) + - Переопределяет автоматический выбор на основе `GO_SPEECH_VOICE` + - Используется, если нужно указать конкретную модель напрямую +- `GO_SPEECH_MODE` - Режим работы сервера (по умолчанию: `debug`) - `release` - Режим продакшена (минимальное логирование) - Любое другое значение - Режим разработки (подробное логирование всех операций) +### Настройки TLS/HTTPS + +- `GO_SPEECH_TLS` - Включение HTTPS режима (по умолчанию: не задано, используется HTTP) + - Если установлено в `true` - сервер запускается по HTTPS + - Если не задано или `false` - сервер запускается по HTTP + - При включенном HTTPS и отсутствии сертификатов - они будут автоматически сгенерированы + +- `GO_SPEECH_CERT_FILE` - Путь к файлу TLS сертификата (по умолчанию: `certs/server.crt`) + - Используется только при `GO_SPEECH_TLS=true` + - Если файл отсутствует и TLS включен - будет автоматически сгенерирован + +- `GO_SPEECH_KEY_FILE` - Путь к файлу приватного ключа (по умолчанию: `certs/server.key`) + - Используется только при `GO_SPEECH_TLS=true` + - Если файл отсутствует и TLS включен - будет автоматически сгенерирован + +- `GO_SPEECH_TLS_DOMAINS` - Список доменов для добавления в сертификат (опционально) + - Формат: домены через запятую, например: `example.com,api.example.com` + - В сертификат всегда автоматически добавляются: `localhost`, `127.0.0.1` + - Используется только при автогенерации сертификатов + +- `GO_SPEECH_CA_CERT` - Путь к CA сертификату для подписи сертификата (опционально) + - Если задан и файл существует - сертификат будет подписан этим CA + - CA ключ должен находиться в том же каталоге с расширением `.key` + - Если CA не найден - генерируется самоподписанный сертификат + - Используется только при автогенерации сертификатов + +### Пути к утилитам + +- `GO_SPEECH_PIPER_PATH` - Путь к исполняемому файлу Piper TTS (по умолчанию: `/usr/local/bin/piper`) +- `GO_SPEECH_FFMPEG_PATH` - Путь к исполняемому файлу ffmpeg (по умолчанию: `/usr/bin/ffmpeg`) +- `GO_SPEECH_MODEL_DIR` - Директория с моделями Piper TTS (по умолчанию: `models`) + ## Кэширование Сервер автоматически кэширует сгенерированные аудио файлы для ускорения обработки повторных запросов. diff --git a/main-funcs.go b/main-funcs.go new file mode 100644 index 0000000..b964455 --- /dev/null +++ b/main-funcs.go @@ -0,0 +1,385 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "go-speech/internal/logger" + "html/template" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" +) + +// generateCertificate генерирует самоподписанный TLS сертификат +// certFile - путь к файлу сертификата +// keyFile - путь к файлу приватного ключа +// caCertFile - путь к CA сертификату (опционально, если задан и существует - используется для подписи) +// domains - список доменов через запятую для добавления в сертификат (всегда добавляются localhost и 127.0.0.1) +func generateCertificate(certFile, keyFile, caCertFile, domains string) error { + // Создаем директорию для сертификатов если не существует + certDir := filepath.Dir(certFile) + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("не удалось создать директорию для сертификатов: %v", err) + } + + // Парсим домены из переменной окружения + domainList := []string{"localhost", "127.0.0.1"} + if domains != "" { + parts := strings.SplitSeq(domains, ",") + for part := range parts { + part = strings.TrimSpace(part) + if part != "" { + found := slices.Contains(domainList, part) + if !found { + domainList = append(domainList, part) + } + } + } + } + + logger.Debug("Генерация сертификата для доменов: %v", domainList) + + // Генерируем приватный ключ + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("ошибка генерации приватного ключа: %v", err) + } + + // Создаем шаблон сертификата + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("ошибка генерации серийного номера: %v", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Country: []string{"RU"}, + Organization: []string{"direct-dev.ru"}, + OrganizationalUnit: []string{"TTS Service"}, + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 3 * 24 * time.Hour), // 10 лет + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: domainList, + } + + // Загружаем CA сертификат и ключ если указаны + var caCert *x509.Certificate + var caKey *rsa.PrivateKey + var parent *x509.Certificate + var signer any + + if caCertFile != "" { + if _, err := os.Stat(caCertFile); err == nil { + logger.Debug("Загрузка CA сертификата из %s", caCertFile) + caCertData, err := os.ReadFile(caCertFile) + if err != nil { + return fmt.Errorf("ошибка чтения CA сертификата: %v", err) + } + + block, _ := pem.Decode(caCertData) + if block == nil { + return fmt.Errorf("не удалось декодировать CA сертификат") + } + + caCert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("ошибка парсинга CA сертификата: %v", err) + } + + // Ищем CA ключ (ожидается в том же каталоге с расширением .key) + caKeyFile := strings.TrimSuffix(caCertFile, ".crt") + ".key" + if strings.HasSuffix(caCertFile, ".pem") { + caKeyFile = strings.TrimSuffix(caCertFile, ".pem") + ".key" + } + + if _, err := os.Stat(caKeyFile); err == nil { + logger.Debug("Загрузка CA ключа из %s", caKeyFile) + caKeyData, err := os.ReadFile(caKeyFile) + if err != nil { + return fmt.Errorf("ошибка чтения CA ключа: %v", err) + } + + block, _ := pem.Decode(caKeyData) + if block == nil { + return fmt.Errorf("не удалось декодировать CA ключ") + } + + caKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Пробуем PKCS8 формат + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("ошибка парсинга CA ключа: %v", err) + } + var ok bool + caKey, ok = key.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("CA ключ не является RSA ключом") + } + } + + parent = caCert + signer = caKey + logger.Info("Используется CA сертификат для подписи") + } else { + logger.Warn("CA ключ не найден (%s), генерируем самоподписанный сертификат", caKeyFile) + } + } else { + logger.Warn("CA сертификат не найден (%s), генерируем самоподписанный сертификат", caCertFile) + } + } + + // Если CA не используется - создаем самоподписанный сертификат + if parent == nil { + parent = &template + signer = privateKey + logger.Info("Генерация самоподписанного сертификата") + } + + // Создаем сертификат + certDER, err := x509.CreateCertificate(rand.Reader, &template, parent, &privateKey.PublicKey, signer) + if err != nil { + return fmt.Errorf("ошибка создания сертификата: %v", err) + } + + // Сохраняем сертификат + certOut, err := os.Create(certFile) + if err != nil { + return fmt.Errorf("ошибка создания файла сертификата: %v", err) + } + defer certOut.Close() + + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + return fmt.Errorf("ошибка записи сертификата: %v", err) + } + + // Сохраняем приватный ключ + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("ошибка создания файла ключа: %v", err) + } + defer keyOut.Close() + + keyDER := x509.MarshalPKCS1PrivateKey(privateKey) + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}); err != nil { + return fmt.Errorf("ошибка записи ключа: %v", err) + } + + logger.Info("Сертификат успешно сгенерирован") + return nil +} + +// 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() +} diff --git a/main.go b/main.go index e51b406..8a51b2f 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "embed" "fmt" - "html/template" "io" "io/fs" "log" @@ -29,11 +28,42 @@ var staticFiles embed.FS //go:embed README.md var readmeContent string +//go:embed VERSION.txt +var versionContent string + +// getVersion возвращает версию приложения +func getVersion() string { + if versionContent != "" { + return strings.TrimSpace(versionContent) + } + return "unknown" +} + func main() { + // Обработка флага -V для вывода версии + if len(os.Args) > 1 && (os.Args[1] == "-V" || os.Args[1] == "--version") { + fmt.Printf("Go Speech TTS версия: %s\n", getVersion()) + os.Exit(0) + } + // Получение конфигурации из переменных окружения - port := getEnv("GO_SPEECH_PORT", "8443") + + // Определение режима работы: HTTPS или HTTP + // GO_SPEECH_TLS=true включает HTTPS, по умолчанию (если не задано) - HTTP + useTLS := getEnv("GO_SPEECH_TLS", "false") == "true" + + port := "8443" + if useTLS { + port = getEnv("GO_SPEECH_PORT", "8443") + } else { + port = getEnv("GO_SPEECH_PORT", "8080") + } + certFile := getEnv("GO_SPEECH_CERT_FILE", "certs/server.crt") keyFile := getEnv("GO_SPEECH_KEY_FILE", "certs/server.key") + caCertFile := getEnv("GO_SPEECH_CA_CERT", "") // Путь к CA сертификату (опционально) + tlsDomains := getEnv("GO_SPEECH_TLS_DOMAINS", "") // Домены для сертификата через запятую + piperPath := getEnv("GO_SPEECH_PIPER_PATH", "/usr/local/bin/piper") ffmpegPath := getEnv("GO_SPEECH_FFMPEG_PATH", "/usr/bin/ffmpeg") @@ -68,45 +98,71 @@ func main() { 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_PORT: %s", port) + logger.Debug(" GO_SPEECH_TLS: %v (HTTPS: %v)", getEnv("GO_SPEECH_TLS", "не задано"), useTLS) + logger.Debug(" GO_SPEECH_CERT_FILE: %s", certFile) + logger.Debug(" GO_SPEECH_KEY_FILE: %s", keyFile) + if caCertFile != "" { + logger.Debug(" GO_SPEECH_CA_CERT: %s", caCertFile) + } + if tlsDomains != "" { + logger.Debug(" GO_SPEECH_TLS_DOMAINS: %s", tlsDomains) + } + logger.Debug(" GO_SPEECH_PIPER_PATH: %s", piperPath) + logger.Debug(" GO_SPEECH_FFMPEG_PATH: %s", ffmpegPath) + logger.Debug(" GO_SPEECH_MODEL_DIR: %s", modelDir) + logger.Debug(" GO_SPEECH_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) + var tlsConfig *tls.Config - 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 конфигурации только если включен HTTPS + if useTLS { + logger.Debug("Режим HTTPS включен, проверка сертификатов...") - // Загрузка 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 сертификаты успешно загружены") + // Проверка наличия сертификатов + certExists := false + keyExists := false - // Настройка TLS конфигурации - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + if _, err := os.Stat(certFile); err == nil { + certExists = true + logger.Debug("SSL сертификат найден: %s", certFile) + } + + if _, err := os.Stat(keyFile); err == nil { + keyExists = true + logger.Debug("SSL ключ найден: %s", keyFile) + } + + // Если сертификаты отсутствуют - генерируем их + if !certExists || !keyExists { + logger.Info("Сертификаты не найдены, начинаем генерацию...") + if err := generateCertificate(certFile, keyFile, caCertFile, tlsDomains); err != nil { + logger.Error("Ошибка генерации сертификатов: %v", err) + log.Fatalf("Ошибка генерации сертификатов: %v", err) + } + logger.Info("Сертификаты успешно сгенерированы: %s, %s", certFile, 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, + } + } else { + logger.Info("Режим HTTP (TLS отключен)") } // Статические файлы для фронтенда @@ -137,6 +193,13 @@ func main() { w.Write([]byte("OK")) }) logger.Debug(" GET /go-speech/api/v1/health - проверка работоспособности") + mux.HandleFunc("/go-speech/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + logger.Debug("GET /go-speech/api/v1/version - получение версии") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"version":"%s"}`, getVersion()) + }) + logger.Debug(" GET /go-speech/api/v1/version - получение версии") // Веб-интерфейс mux.HandleFunc("/go-speech/front", func(w http.ResponseWriter, r *http.Request) { @@ -152,8 +215,20 @@ func main() { return } defer indexFile.Close() + + // Читаем содержимое index.html + indexContent, err := io.ReadAll(indexFile) + if err != nil { + logger.Error("Ошибка чтения index.html: %v", err) + http.Error(w, "Ошибка чтения фронтенда", http.StatusInternalServerError) + return + } + + // Заменяем плейсхолдер версии + indexContentStr := strings.ReplaceAll(string(indexContent), "{{VERSION}}", getVersion()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") - io.Copy(w, indexFile) + w.Write([]byte(indexContentStr)) }) logger.Debug(" GET /go-speech/front - веб-интерфейс") @@ -161,6 +236,15 @@ func main() { mux.Handle("/go-speech/front/", http.StripPrefix("/go-speech/front/", http.FileServer(http.FS(staticFS)))) logger.Debug(" GET /go-speech/front/* - статические ресурсы") + // Обработчик для шрифтов + fontsFS, err := fs.Sub(staticFiles, "static/fonts") + if err == nil { + mux.Handle("/go-speech/fonts/", http.StripPrefix("/go-speech/fonts/", http.FileServer(http.FS(fontsFS)))) + logger.Debug(" GET /go-speech/fonts/* - шрифты") + } else { + logger.Warn("Не удалось загрузить шрифты: %v", err) + } + // Помощь - рендеринг README.md mux.HandleFunc("/go-speech/help", handleHelp) logger.Debug(" GET /go-speech/help - документация") @@ -168,16 +252,25 @@ func main() { 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, } + // Добавляем TLS конфигурацию только если включен HTTPS + if useTLS { + server.TLSConfig = tlsConfig + } + logger.Debug("Настройки HTTP сервера:") logger.Debug(" ReadTimeout: %v", server.ReadTimeout) logger.Debug(" WriteTimeout: %v", server.WriteTimeout) logger.Debug(" IdleTimeout: %v", server.IdleTimeout) + if useTLS { + logger.Debug(" TLS: включен") + } else { + logger.Debug(" TLS: отключен") + } // Graceful shutdown go func() { @@ -190,17 +283,22 @@ func main() { } logger.Debug("IPv4 listener успешно создан: %v", listener.Addr()) - // Создаем TLS listener - logger.Debug("Создание TLS listener...") - tlsListener := tls.NewListener(listener, tlsConfig) - logger.Debug("TLS listener успешно создан") + protocol := "http" + if useTLS { + protocol = "https" + // Создаем TLS listener + logger.Debug("Создание TLS listener...") + tlsListener := tls.NewListener(listener, tlsConfig) + logger.Debug("TLS listener успешно создан") + listener = tlsListener + } - 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.Info("Сервер запущен на %s://0.0.0.0:%s (IPv4)", protocol, port) + logger.Info("Простой веб-интерфейс доступен на %s://localhost:%s/go-speech/front", protocol, port) + logger.Info("Справка доступна по пути %s://localhost:%s/go-speech/help", protocol, port) logger.Debug("Запуск server.Serve()...") - if err := server.Serve(tlsListener); err != nil && err != http.ErrServerClosed { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { logger.Error("Ошибка запуска сервера: %v", err) log.Fatalf("Ошибка запуска сервера: %v", err) } @@ -226,203 +324,3 @@ func main() { 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() -} diff --git a/podman-run.sh b/podman-run.sh deleted file mode 100644 index 7e3e986..0000000 --- a/podman-run.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -podman run -d --name go-speech --restart=always -p 7443:7443 \ --v "$(pwd)/certs:/app/certs:ro" \ --e GO_SPEECH_VOICE=ruslan \ --e GO_SPEECH_PORT=7443 \ -kuznetcovay/go-speech:v1.0.4 diff --git a/shell/build-n-export.sh b/shell/build-n-export.sh new file mode 100644 index 0000000..0e45be5 --- /dev/null +++ b/shell/build-n-export.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# Скрипт для сборки Podman образа и опционального экспорта в tar.gz +# Использование: ./build-n-export.sh [export] +# Пример: ./build-n-export.sh registry.altlinux.org/alt/base:p10 kuznetcovay go-speech v1.0.4 true +# ./build-n-export.sh registry.altlinux.org/alt/base:p10 kuznetcovay go-speech v1.0.4 false (только сборка) + +set -e # Остановка при ошибке + +# Проверка аргументов +if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ] || [ -z "$4" ]; then + echo "Ошибка: Недостаточно аргументов" + echo "Использование: $0 [export]" + echo " base_image - базовый образ для второго этапа сборки (например: registry.altlinux.org/alt/base:p10)" + echo " registry - реестр для образа" + echo " image_name - имя образа" + echo " version - версия образа" + echo " export - true/yes/1 - экспортировать образ (по умолчанию: false)" + echo "" + echo "Пример: $0 registry.altlinux.org/alt/base:p10 kuznetcovay go-speech v1.0.4 true" + echo " $0 registry.altlinux.org/alt/base:p10 kuznetcovay go-speech v1.0.4 false (только сборка)" + exit 1 +fi + +BASE_IMAGE="$1" +REGISTRY="$2" +IMAGE_NAME="$3" +VERSION="$4" +EXPORT="${5:-false}" # По умолчанию false, если не указано +FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${VERSION}" +OUTPUT_FILE="go-speech-${VERSION}.tar.gz" + +# Сохранение версии в VERSION.txt +echo "${VERSION}" > VERSION.txt +echo "✓ Версия сохранена в VERSION.txt: ${VERSION}" + +# Нормализация значения export (приводим к нижнему регистру) +EXPORT=$(echo "$EXPORT" | tr '[:upper:]' '[:lower:]') + +# Проверка значения export +if [[ "$EXPORT" == "true" || "$EXPORT" == "yes" || "$EXPORT" == "1" ]]; then + DO_EXPORT=true +else + DO_EXPORT=false +fi + +echo "=== Сборка Podman образа ===" +echo "Базовый образ: ${BASE_IMAGE}" +echo "Образ: ${FULL_IMAGE_NAME}" +echo "Экспорт: ${DO_EXPORT}" +echo "" + +# Сборка образа с передачей базового образа через build arg +podman build --build-arg BASE_IMAGE="${BASE_IMAGE}" -t "${FULL_IMAGE_NAME}" . + +if [ $? -ne 0 ]; then + echo "Ошибка: Не удалось собрать образ" + exit 1 +fi + +echo "" +echo "✓ Образ успешно собран" +echo "" + +# Экспорт образа в tar.gz (только если указано) +if [ "$DO_EXPORT" = true ]; then + echo "=== Экспорт образа в tar.gz ===" + echo "Файл: ${OUTPUT_FILE}" + echo "" + + podman save "${FULL_IMAGE_NAME}" | gzip > "${OUTPUT_FILE}" + + if [ $? -ne 0 ]; then + echo "Ошибка: Не удалось экспортировать образ" + exit 1 + fi + + # Проверка размера файла + FILE_SIZE=$(du -h "${OUTPUT_FILE}" | cut -f1) + echo "✓ Образ успешно экспортирован" + echo "Размер файла: ${FILE_SIZE}" + echo "" + echo "Файл сохранен: ${OUTPUT_FILE}" +else + echo "Экспорт пропущен (параметр export = false)" +fi \ No newline at end of file diff --git a/docker-run.sh b/shell/docker-run.sh similarity index 100% rename from docker-run.sh rename to shell/docker-run.sh diff --git a/generate-certs.sh b/shell/generate-certs.sh similarity index 100% rename from generate-certs.sh rename to shell/generate-certs.sh diff --git a/shell/podman-run.sh b/shell/podman-run.sh new file mode 100644 index 0000000..a5bf28d --- /dev/null +++ b/shell/podman-run.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# -v "$(pwd)/certs:/app/certs:ro" \ +podman run -p 17443:8080 \ +-e GO_SPEECH_VOICE=ruslan \ +-e GO_SPEECH_PORT=8080 \ +-e GO_SPEECH_TLS=true \ +kuznetcovay/go-speech:v1.0.5-alt + + +# podman run -p 17443:8080 -e GO_SPEECH_VOICE=ruslan -e GO_SPEECH_PORT=8080 -e GO_SPEECH_TLS=true kuznetcovay/go-speech:v1.0.5-alt \ No newline at end of file diff --git a/static/fonts/NotoColorEmoji.ttf b/static/fonts/NotoColorEmoji.ttf new file mode 100644 index 0000000..4fc7503 Binary files /dev/null and b/static/fonts/NotoColorEmoji.ttf differ diff --git a/static/fonts/PTSans-Bold.ttf b/static/fonts/PTSans-Bold.ttf new file mode 100644 index 0000000..fd412f3 --- /dev/null +++ b/static/fonts/PTSans-Bold.ttf @@ -0,0 +1,2152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/static/fonts/PTSans-BoldItalic.ttf b/static/fonts/PTSans-BoldItalic.ttf new file mode 100644 index 0000000..d9c19d8 --- /dev/null +++ b/static/fonts/PTSans-BoldItalic.ttf @@ -0,0 +1,2152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/static/fonts/PTSans-Italic.ttf b/static/fonts/PTSans-Italic.ttf new file mode 100644 index 0000000..6c6578b --- /dev/null +++ b/static/fonts/PTSans-Italic.ttf @@ -0,0 +1,2152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/static/fonts/PTSans-Regular.ttf b/static/fonts/PTSans-Regular.ttf new file mode 100644 index 0000000..cbca32e --- /dev/null +++ b/static/fonts/PTSans-Regular.ttf @@ -0,0 +1,2152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/static/index.html b/static/index.html index 1d3c4b3..7a8b005 100644 --- a/static/index.html +++ b/static/index.html @@ -1,452 +1,517 @@ - - - + + + Go Speech - TTS - - + +
-
-
-
-
Генерация аудио...
-
+
+
+
+
Генерация аудио...
+
+
+ +

🎤 Go Speech TTS

+ +
+
+ +
-

🎤 Go Speech TTS

- - -
- - -
- -
- - -
- -
- - -
-
- -
- -
- +
+ +
+ +
+ + +
+ + +
+ версия: {{VERSION}} +
+ +
+ +
+ +
- + -