before stt adding
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
# Binaries
|
||||
VERSION.txt
|
||||
certs/
|
||||
go-speech
|
||||
go-speech*.tar.gz
|
||||
go-speech*.tar
|
||||
models/
|
||||
*.exe
|
||||
*.exe~
|
||||
|
||||
22
Dockerfile
22
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
|
||||
|
||||
|
||||
77
Dockerfile copy
Normal file
77
Dockerfile copy
Normal file
@@ -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"]
|
||||
|
||||
248
README.md
248
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`)
|
||||
|
||||
## Кэширование
|
||||
|
||||
Сервер автоматически кэширует сгенерированные аудио файлы для ускорения обработки повторных запросов.
|
||||
|
||||
385
main-funcs.go
Normal file
385
main-funcs.go
Normal file
@@ -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 := `<!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()
|
||||
}
|
||||
350
main.go
350
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,31 +98,54 @@ 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)
|
||||
// Обработка TLS конфигурации только если включен HTTPS
|
||||
if useTLS {
|
||||
logger.Debug("Режим HTTPS включен, проверка сертификатов...")
|
||||
|
||||
// Проверка наличия сертификатов
|
||||
certExists := false
|
||||
keyExists := false
|
||||
|
||||
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 сертификатов...")
|
||||
@@ -104,10 +157,13 @@ func main() {
|
||||
logger.Debug("TLS сертификаты успешно загружены")
|
||||
|
||||
// Настройка TLS конфигурации
|
||||
tlsConfig := &tls.Config{
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
} else {
|
||||
logger.Info("Режим HTTP (TLS отключен)")
|
||||
}
|
||||
|
||||
// Статические файлы для фронтенда
|
||||
logger.Debug("Загрузка статических файлов...")
|
||||
@@ -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())
|
||||
|
||||
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 := `<!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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
86
shell/build-n-export.sh
Normal file
86
shell/build-n-export.sh
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Скрипт для сборки Podman образа и опционального экспорта в tar.gz
|
||||
# Использование: ./build-n-export.sh <base_image> <registry> <image_name> <version> [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 <base_image> <registry> <image_name> <version> [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
|
||||
11
shell/podman-run.sh
Normal file
11
shell/podman-run.sh
Normal file
@@ -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
|
||||
BIN
static/fonts/NotoColorEmoji.ttf
Normal file
BIN
static/fonts/NotoColorEmoji.ttf
Normal file
Binary file not shown.
2152
static/fonts/PTSans-Bold.ttf
Normal file
2152
static/fonts/PTSans-Bold.ttf
Normal file
File diff suppressed because one or more lines are too long
2152
static/fonts/PTSans-BoldItalic.ttf
Normal file
2152
static/fonts/PTSans-BoldItalic.ttf
Normal file
File diff suppressed because one or more lines are too long
2152
static/fonts/PTSans-Italic.ttf
Normal file
2152
static/fonts/PTSans-Italic.ttf
Normal file
File diff suppressed because one or more lines are too long
2152
static/fonts/PTSans-Regular.ttf
Normal file
2152
static/fonts/PTSans-Regular.ttf
Normal file
File diff suppressed because one or more lines are too long
@@ -1,10 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Go Speech - TTS</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Noto Color Emoji";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/go-speech/fonts/NotoColorEmoji.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PT Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/go-speech/fonts/PTSans-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PT Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/go-speech/fonts/PTSans-Bold.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PT Sans";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/go-speech/fonts/PTSans-Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PT Sans";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/go-speech/fonts/PTSans-BoldItalic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -12,7 +52,8 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-family: "PT Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -36,6 +77,8 @@
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
font-family: "Noto Color Emoji", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -46,8 +89,9 @@
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
font-family: "PT Sans", sans-serif;
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -56,6 +100,7 @@
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-family: "PT Sans", sans-serif;
|
||||
background: white;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
@@ -73,7 +118,7 @@
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
font-family: "PT Sans", sans-serif;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
color: #333;
|
||||
@@ -102,6 +147,9 @@
|
||||
transition: all 0.3s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: "Noto Color Emoji", "PT Sans", -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -147,6 +195,7 @@
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: "PT Sans", sans-serif;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
@@ -208,14 +257,19 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loader-text {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
font-family: "PT Sans", sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -226,6 +280,13 @@
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -270,6 +331,12 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="version-info">
|
||||
<span style="font-size: 0.85em; color: #999; font-weight: normal"
|
||||
>версия: {{VERSION}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<div class="audio-player" id="audioPlayer">
|
||||
@@ -278,17 +345,17 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('ttsForm');
|
||||
const textInput = document.getElementById('text');
|
||||
const voiceSelect = document.getElementById('voice');
|
||||
const speakBtn = document.getElementById('speakBtn');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const audio = document.getElementById('audio');
|
||||
const loaderOverlay = document.getElementById('loaderOverlay');
|
||||
const loaderText = document.getElementById('loaderText');
|
||||
const container = document.querySelector('.container');
|
||||
const form = document.getElementById("ttsForm");
|
||||
const textInput = document.getElementById("text");
|
||||
const voiceSelect = document.getElementById("voice");
|
||||
const speakBtn = document.getElementById("speakBtn");
|
||||
const downloadBtn = document.getElementById("downloadBtn");
|
||||
const statusDiv = document.getElementById("status");
|
||||
const audioPlayer = document.getElementById("audioPlayer");
|
||||
const audio = document.getElementById("audio");
|
||||
const loaderOverlay = document.getElementById("loaderOverlay");
|
||||
const loaderText = document.getElementById("loaderText");
|
||||
const container = document.querySelector(".container");
|
||||
|
||||
let currentAudioUrl = null;
|
||||
let currentBlob = null;
|
||||
@@ -300,22 +367,22 @@
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
statusDiv.className = 'status';
|
||||
statusDiv.className = "status";
|
||||
}
|
||||
|
||||
function setLoading(loading, message = 'Генерация аудио...') {
|
||||
function setLoading(loading, message = "Генерация аудио...") {
|
||||
speakBtn.disabled = loading;
|
||||
downloadBtn.disabled = loading || isPlaying;
|
||||
|
||||
if (loading) {
|
||||
speakBtn.textContent = '⏳ Обработка...';
|
||||
speakBtn.textContent = "⏳ Обработка...";
|
||||
loaderText.textContent = message;
|
||||
loaderOverlay.classList.add('show');
|
||||
container.classList.add('form-disabled');
|
||||
loaderOverlay.classList.add("show");
|
||||
container.classList.add("form-disabled");
|
||||
} else {
|
||||
speakBtn.textContent = '🔊 Озвучить';
|
||||
loaderOverlay.classList.remove('show');
|
||||
container.classList.remove('form-disabled');
|
||||
speakBtn.textContent = "🔊 Озвучить";
|
||||
loaderOverlay.classList.remove("show");
|
||||
container.classList.remove("form-disabled");
|
||||
updateButtonsState();
|
||||
}
|
||||
}
|
||||
@@ -330,19 +397,19 @@
|
||||
try {
|
||||
setLoading(true);
|
||||
hideStatus();
|
||||
showStatus('Генерация аудио...', 'loading');
|
||||
showStatus("Генерация аудио...", "loading");
|
||||
|
||||
const response = await fetch('/go-speech/api/v1/tts', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/go-speech/api/v1/tts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text: text, voice: voice })
|
||||
body: JSON.stringify({ text: text, voice: voice }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Ошибка генерации аудио');
|
||||
throw new Error(errorText || "Ошибка генерации аудио");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
@@ -350,25 +417,24 @@
|
||||
currentAudioUrl = URL.createObjectURL(blob);
|
||||
|
||||
audio.src = currentAudioUrl;
|
||||
audioPlayer.classList.add('show');
|
||||
audioPlayer.classList.add("show");
|
||||
|
||||
showStatus('Аудио готово!', 'success');
|
||||
showStatus("Аудио готово!", "success");
|
||||
|
||||
// Устанавливаем флаг воспроизведения перед автоплеем
|
||||
isPlaying = true;
|
||||
updateButtonsState();
|
||||
|
||||
// Автоматическое воспроизведение
|
||||
audio.play().catch(err => {
|
||||
console.log('Автовоспроизведение заблокировано:', err);
|
||||
audio.play().catch((err) => {
|
||||
console.log("Автовоспроизведение заблокировано:", err);
|
||||
isPlaying = false;
|
||||
updateButtonsState();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка:', error);
|
||||
showStatus('Ошибка: ' + error.message, 'error');
|
||||
audioPlayer.classList.remove('show');
|
||||
console.error("Ошибка:", error);
|
||||
showStatus("Ошибка: " + error.message, "error");
|
||||
audioPlayer.classList.remove("show");
|
||||
currentBlob = null;
|
||||
isPlaying = false;
|
||||
} finally {
|
||||
@@ -378,33 +444,33 @@
|
||||
|
||||
function downloadAudio() {
|
||||
if (!currentBlob) {
|
||||
showStatus('Нет аудио для скачивания', 'error');
|
||||
showStatus("Нет аудио для скачивания", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = currentAudioUrl;
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `speech-${voiceSelect.value}-${Date.now()}.ogg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
showStatus('Файл скачан', 'success');
|
||||
showStatus("Файл скачан", "success");
|
||||
setTimeout(hideStatus, 2000);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const text = textInput.value.trim();
|
||||
|
||||
if (!text) {
|
||||
showStatus('Введите текст для озвучки', 'error');
|
||||
showStatus("Введите текст для озвучки", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length > 5000) {
|
||||
showStatus('Текст слишком длинный (максимум 5000 символов)', 'error');
|
||||
showStatus("Текст слишком длинный (максимум 5000 символов)", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -412,36 +478,36 @@
|
||||
await generateSpeech(text, voice);
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener('click', downloadAudio);
|
||||
downloadBtn.addEventListener("click", downloadAudio);
|
||||
|
||||
// Отслеживание событий воспроизведения аудио
|
||||
audio.addEventListener('play', () => {
|
||||
audio.addEventListener("play", () => {
|
||||
isPlaying = true;
|
||||
updateButtonsState();
|
||||
showStatus('Воспроизведение...', 'loading');
|
||||
showStatus("Воспроизведение...", "loading");
|
||||
});
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
audio.addEventListener("pause", () => {
|
||||
isPlaying = false;
|
||||
updateButtonsState();
|
||||
hideStatus();
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
audio.addEventListener("ended", () => {
|
||||
isPlaying = false;
|
||||
updateButtonsState();
|
||||
showStatus('Воспроизведение завершено', 'success');
|
||||
showStatus("Воспроизведение завершено", "success");
|
||||
setTimeout(hideStatus, 2000);
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
audio.addEventListener("error", () => {
|
||||
isPlaying = false;
|
||||
updateButtonsState();
|
||||
showStatus('Ошибка воспроизведения', 'error');
|
||||
showStatus("Ошибка воспроизведения", "error");
|
||||
});
|
||||
|
||||
// Очистка URL при разгрузке страницы
|
||||
window.addEventListener('beforeunload', () => {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (currentAudioUrl) {
|
||||
URL.revokeObjectURL(currentAudioUrl);
|
||||
}
|
||||
@@ -449,4 +515,3 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user