before stt adding

This commit is contained in:
2025-11-28 17:43:00 +06:00
parent fccafad6de
commit f933c315e8
17 changed files with 10002 additions and 672 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# Binaries
VERSION.txt
certs/
go-speech
go-speech*.tar.gz
go-speech*.tar
models/
*.exe
*.exe~

View File

@@ -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
View 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
View File

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

384
main.go
View File

@@ -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 := `<!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()
}

View File

@@ -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
View 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
View 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

Binary file not shown.

2152
static/fonts/PTSans-Bold.ttf Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,452 +1,517 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Go Speech - TTS</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@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");
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@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");
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
@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");
}
h1 {
color: #333;
margin-bottom: 30px;
text-align: center;
font-weight: 600;
font-size: 28px;
}
@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");
}
.form-group {
margin-bottom: 24px;
}
@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");
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
background: white;
color: #333;
cursor: pointer;
transition: border-color 0.3s;
}
body {
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;
align-items: center;
justify-content: center;
padding: 20px;
}
select:focus {
outline: none;
border-color: #667eea;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
font-family: inherit;
resize: vertical;
min-height: 120px;
color: #333;
transition: border-color 0.3s;
}
h1 {
color: #333;
margin-bottom: 30px;
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;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
.form-group {
margin-bottom: 24px;
}
.buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 700;
font-size: 16px;
font-family: "PT Sans", sans-serif;
}
button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
font-family: "PT Sans", sans-serif;
background: white;
color: #333;
cursor: pointer;
transition: border-color 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
select:focus {
outline: none;
border-color: #667eea;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
font-family: "PT Sans", sans-serif;
resize: vertical;
min-height: 120px;
color: #333;
transition: border-color 0.3s;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
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;
}
.audio-player {
margin-top: 24px;
display: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.audio-player.show {
display: block;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
audio {
width: 100%;
margin-top: 12px;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.status {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
text-align: center;
display: none;
}
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.status.show {
display: block;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.audio-player {
margin-top: 24px;
display: none;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.audio-player.show {
display: block;
}
.status.loading {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
audio {
width: 100%;
margin-top: 12px;
}
.loader-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: none;
align-items: center;
justify-content: center;
border-radius: 16px;
z-index: 1000;
}
.status {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
font-family: "PT Sans", sans-serif;
text-align: center;
display: none;
}
.loader-overlay.show {
display: flex;
}
.status.show {
display: block;
}
.loader {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status.loading {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.loader-text {
color: #667eea;
font-weight: 500;
font-size: 16px;
}
.loader-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: none;
align-items: center;
justify-content: center;
border-radius: 16px;
z-index: 1000;
}
.container {
position: relative;
}
.loader-overlay.show {
display: flex;
}
.form-disabled {
pointer-events: none;
opacity: 0.6;
.loader {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
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 {
position: relative;
}
.form-disabled {
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>
</head>
<body>
<div class="container">
<div class="loader-overlay" id="loaderOverlay">
<div class="loader">
<div class="spinner"></div>
<div class="loader-text" id="loaderText">Генерация аудио...</div>
</div>
<div class="loader-overlay" id="loaderOverlay">
<div class="loader">
<div class="spinner"></div>
<div class="loader-text" id="loaderText">Генерация аудио...</div>
</div>
</div>
<h1>🎤 Go Speech TTS</h1>
<form id="ttsForm">
<div class="form-group">
<label for="voice">Выберите голос:</label>
<select id="voice" name="voice">
<option value="ruslan">Ruslan (мужской)</option>
<option value="irina">Irina (женский)</option>
<option value="denis">Denis (мужской)</option>
<option value="dmitri">Dmitri (мужской)</option>
</select>
</div>
<h1>🎤 Go Speech TTS</h1>
<form id="ttsForm">
<div class="form-group">
<label for="voice">Выберите голос:</label>
<select id="voice" name="voice">
<option value="ruslan">Ruslan (мужской)</option>
<option value="irina">Irina (женский)</option>
<option value="denis">Denis (мужской)</option>
<option value="dmitri">Dmitri (мужской)</option>
</select>
</div>
<div class="form-group">
<label for="text">Введите текст для озвучки:</label>
<textarea
id="text"
name="text"
placeholder="Введите текст на русском языке..."
required
></textarea>
</div>
<div class="buttons">
<button type="submit" class="btn-primary" id="speakBtn">
🔊 Озвучить
</button>
<button type="button" class="btn-secondary" id="downloadBtn" disabled>
💾 Скачать
</button>
</div>
</form>
<div class="status" id="status"></div>
<div class="audio-player" id="audioPlayer">
<audio id="audio" controls></audio>
<div class="form-group">
<label for="text">Введите текст для озвучки:</label>
<textarea
id="text"
name="text"
placeholder="Введите текст на русском языке..."
required
></textarea>
</div>
<div class="buttons">
<button type="submit" class="btn-primary" id="speakBtn">
🔊 Озвучить
</button>
<button type="button" class="btn-secondary" id="downloadBtn" disabled>
💾 Скачать
</button>
</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">
<audio id="audio" controls></audio>
</div>
</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;
let isPlaying = false;
let currentAudioUrl = null;
let currentBlob = null;
let isPlaying = false;
function showStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = `status show ${type}`;
function showStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = `status show ${type}`;
}
function hideStatus() {
statusDiv.className = "status";
}
function setLoading(loading, message = "Генерация аудио...") {
speakBtn.disabled = loading;
downloadBtn.disabled = loading || isPlaying;
if (loading) {
speakBtn.textContent = "⏳ Обработка...";
loaderText.textContent = message;
loaderOverlay.classList.add("show");
container.classList.add("form-disabled");
} else {
speakBtn.textContent = "🔊 Озвучить";
loaderOverlay.classList.remove("show");
container.classList.remove("form-disabled");
updateButtonsState();
}
}
function hideStatus() {
statusDiv.className = 'status';
}
function updateButtonsState() {
// Блокируем кнопки во время воспроизведения
speakBtn.disabled = isPlaying;
downloadBtn.disabled = isPlaying || !currentBlob;
}
function setLoading(loading, message = 'Генерация аудио...') {
speakBtn.disabled = loading;
downloadBtn.disabled = loading || isPlaying;
async function generateSpeech(text, voice) {
try {
setLoading(true);
hideStatus();
showStatus("Генерация аудио...", "loading");
if (loading) {
speakBtn.textContent = '⏳ Обработка...';
loaderText.textContent = message;
loaderOverlay.classList.add('show');
container.classList.add('form-disabled');
} else {
speakBtn.textContent = '🔊 Озвучить';
loaderOverlay.classList.remove('show');
container.classList.remove('form-disabled');
updateButtonsState();
}
}
const response = await fetch("/go-speech/api/v1/tts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: text, voice: voice }),
});
function updateButtonsState() {
// Блокируем кнопки во время воспроизведения
speakBtn.disabled = isPlaying;
downloadBtn.disabled = isPlaying || !currentBlob;
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || "Ошибка генерации аудио");
}
async function generateSpeech(text, voice) {
try {
setLoading(true);
hideStatus();
showStatus('Генерация аудио...', 'loading');
const blob = await response.blob();
currentBlob = blob;
currentAudioUrl = URL.createObjectURL(blob);
const response = await fetch('/go-speech/api/v1/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: text, voice: voice })
});
audio.src = currentAudioUrl;
audioPlayer.classList.add("show");
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Ошибка генерации аудио');
}
showStatus("Аудио готово!", "success");
const blob = await response.blob();
currentBlob = blob;
currentAudioUrl = URL.createObjectURL(blob);
// Устанавливаем флаг воспроизведения перед автоплеем
isPlaying = true;
updateButtonsState();
audio.src = currentAudioUrl;
audioPlayer.classList.add('show');
showStatus('Аудио готово!', 'success');
// Устанавливаем флаг воспроизведения перед автоплеем
isPlaying = true;
updateButtonsState();
// Автоматическое воспроизведение
audio.play().catch(err => {
console.log('Автовоспроизведение заблокировано:', err);
isPlaying = false;
updateButtonsState();
});
} catch (error) {
console.error('Ошибка:', error);
showStatus('Ошибка: ' + error.message, 'error');
audioPlayer.classList.remove('show');
currentBlob = null;
isPlaying = false;
} finally {
setLoading(false);
}
}
function downloadAudio() {
if (!currentBlob) {
showStatus('Нет аудио для скачивания', 'error');
return;
}
const url = currentAudioUrl;
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');
setTimeout(hideStatus, 2000);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = textInput.value.trim();
if (!text) {
showStatus('Введите текст для озвучки', 'error');
return;
}
if (text.length > 5000) {
showStatus('Текст слишком длинный (максимум 5000 символов)', 'error');
return;
}
const voice = voiceSelect.value;
await generateSpeech(text, voice);
});
downloadBtn.addEventListener('click', downloadAudio);
// Отслеживание событий воспроизведения аудио
audio.addEventListener('play', () => {
isPlaying = true;
updateButtonsState();
showStatus('Воспроизведение...', 'loading');
});
audio.addEventListener('pause', () => {
// Автоматическое воспроизведение
audio.play().catch((err) => {
console.log("Автовоспроизведение заблокировано:", err);
isPlaying = false;
updateButtonsState();
hideStatus();
});
});
} catch (error) {
console.error("Ошибка:", error);
showStatus("Ошибка: " + error.message, "error");
audioPlayer.classList.remove("show");
currentBlob = null;
isPlaying = false;
} finally {
setLoading(false);
}
}
audio.addEventListener('ended', () => {
isPlaying = false;
updateButtonsState();
showStatus('Воспроизведение завершено', 'success');
setTimeout(hideStatus, 2000);
});
function downloadAudio() {
if (!currentBlob) {
showStatus("Нет аудио для скачивания", "error");
return;
}
audio.addEventListener('error', () => {
isPlaying = false;
updateButtonsState();
showStatus('Ошибка воспроизведения', 'error');
});
const url = currentAudioUrl;
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);
// Очистка URL при разгрузке страницы
window.addEventListener('beforeunload', () => {
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
}
});
showStatus("Файл скачан", "success");
setTimeout(hideStatus, 2000);
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = textInput.value.trim();
if (!text) {
showStatus("Введите текст для озвучки", "error");
return;
}
if (text.length > 5000) {
showStatus("Текст слишком длинный (максимум 5000 символов)", "error");
return;
}
const voice = voiceSelect.value;
await generateSpeech(text, voice);
});
downloadBtn.addEventListener("click", downloadAudio);
// Отслеживание событий воспроизведения аудио
audio.addEventListener("play", () => {
isPlaying = true;
updateButtonsState();
showStatus("Воспроизведение...", "loading");
});
audio.addEventListener("pause", () => {
isPlaying = false;
updateButtonsState();
hideStatus();
});
audio.addEventListener("ended", () => {
isPlaying = false;
updateButtonsState();
showStatus("Воспроизведение завершено", "success");
setTimeout(hideStatus, 2000);
});
audio.addEventListener("error", () => {
isPlaying = false;
updateButtonsState();
showStatus("Ошибка воспроизведения", "error");
});
// Очистка URL при разгрузке страницы
window.addEventListener("beforeunload", () => {
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
}
});
</script>
</body>
</body>
</html>