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 # Binaries
VERSION.txt
certs/ certs/
go-speech go-speech
go-speech*.tar.gz
go-speech*.tar
models/ models/
*.exe *.exe
*.exe~ *.exe~

View File

@@ -16,31 +16,31 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-speech . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-speech .
# Этап 2: Финальный образ с зависимостями # Этап 2: Финальный образ с зависимостями
FROM alpine:latest ARG BASE_IMAGE
FROM ${BASE_IMAGE}
# 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 COPY piper.tar.gz /tmp/piper.tar.gz
# Установка необходимых пакетов # Установка необходимых пакетов
RUN apk add --no-cache \ RUN apt-get update && \
apt-get install -y \
ca-certificates \ ca-certificates \
ffmpeg \ ffmpeg \
curl \ curl \
bash \ bash \
libc6-compat \ libstdc++6 \
libstdc++ tar \
gzip && \
rm -rf /var/lib/apt/lists/*
# Установка Piper TTS # Установка Piper TTS
RUN mkdir -p /opt/piper && \ RUN mkdir -p /opt/piper && \
cd /opt/piper && \ cd /opt/piper && \
tar -xzf /tmp/piper.tar.gz && \ tar -xzf /tmp/piper.tar.gz && \
PIPER_BIN=$(find /opt/piper -name "piper" -type f | head -1) && \ PIPER_BIN=$(find /opt/piper -name "piper" -type f | head -1) && \
chmod +x $PIPER_BIN && \ chmod +x $PIPER_BIN && \
find /opt/piper -name "*.so*" -type f -exec chmod +x {} \; && \ find /opt/piper -name "*.so*" -type f -exec chmod +x {} \; && \
ln -sf $PIPER_BIN /usr/local/bin/piper && \ ln -sf $PIPER_BIN /usr/local/bin/piper
rm -f /tmp/piper.tar.gz
# Создание директорий # Создание директорий
RUN mkdir -p /app/models /app/certs /app/tmp RUN mkdir -p /app/models /app/certs /app/tmp
@@ -50,10 +50,6 @@ COPY models/ /app/models/
# Копирование бинарника из builder # Копирование бинарника из builder
COPY --from=builder /build/go-speech /app/go-speech COPY --from=builder /build/go-speech /app/go-speech
# Примечание: Модели должны быть смонтированы через volume при запуске контейнера
# Пример: -v $(pwd)/models:/app/models:ro
# Или скопированы в образ на этапе сборки, если они включены в репозиторий
# Рабочая директория # Рабочая директория
WORKDIR /app 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 apk add ffmpeg
``` ```
6 Сгенерируйте SSL сертификаты: 6 (Опционально) Сгенерируйте SSL сертификаты вручную:
```bash ```bash
./generate-certs.sh ./generate-certs.sh
``` ```
**Примечание:** Если вы используете HTTPS (`GO_SPEECH_TLS=true`), сертификаты будут автоматически сгенерированы при первом запуске, если они отсутствуют.
7 Запустите сервис: 7 Запустите сервис:
**Запуск в режиме HTTP (по умолчанию):**
```bash ```bash
# Использует порт 8080 по умолчанию
go run main.go 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 ```bash
GO_SPEECH_PORT=8443 \ GO_SPEECH_PORT=8443 \
GO_SPEECH_TLS=true \
GO_SPEECH_PIPER_PATH=/usr/local/bin/piper \ GO_SPEECH_PIPER_PATH=/usr/local/bin/piper \
GO_SPEECH_MODEL_DIR=./models \ GO_SPEECH_MODEL_DIR=./models \
GO_SPEECH_VOICE=ruslan \ GO_SPEECH_VOICE=ruslan \
GO_SPEECH_FFMPEG_PATH=/usr/bin/ffmpeg \ GO_SPEECH_FFMPEG_PATH=/usr/bin/ffmpeg \
GO_SPEECH_CERT_FILE=./certs/server.crt \ GO_SPEECH_CERT_FILE=./certs/server.crt \
GO_SPEECH_KEY_FILE=./certs/server.key \ GO_SPEECH_KEY_FILE=./certs/server.key \
GO_SPEECH_MODE=debug \
go run main.go go run main.go
``` ```
Для использования другого голоса (например, `denis`, `dmitri`, `irina`):
```bash
GO_SPEECH_VOICE=denis go run main.go
```
### Запуск через Podman/Docker ### Запуск через Podman/Docker
1 Соберите образ: 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 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 ```bash
./generate-certs.sh ./generate-certs.sh
``` ```
**Примечание:** При использовании `GO_SPEECH_TLS=true` сертификаты будут автоматически сгенерированы при первом запуске контейнера.
4 Запустите контейнер: 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 ```bash
podman run -d \ podman run -d \
--name go-speech \ --name go-speech \
-p 8443:8443 \ -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 \ -e GO_SPEECH_VOICE=ruslan \
-v $(pwd)/models:/app/models:ro \ -v $(pwd)/models:/app/models:ro \
-v $(pwd)/certs:/app/certs:ro \ -v $(pwd)/certs:/app/certs:ro \
go-speech:latest 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 ```bash
podman run -d \ podman run -d \
--name go-speech \ --name go-speech \
-p 8443:8443 \ -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)/models:/app/models:ro \
-v $(pwd)/certs:/app/certs:ro \ -v $(pwd)/certs:/app/certs:ro \
go-speech:latest go-speech:latest
@@ -180,20 +339,36 @@ sudo systemctl status go-speech
Откройте в браузере: Откройте в браузере:
**Для HTTPS режима:**
``` text ``` text
https://localhost:8443/go-speech/front https://localhost:8443/go-speech/front
``` ```
**Для HTTP режима:**
``` text
http://localhost:8080/go-speech/front
```
### GET /go-speech/help ### GET /go-speech/help
Отображение документации (README.md) в браузере. Отображение документации (README.md) в браузере.
**Доступ:** **Доступ:**
**Для HTTPS режима:**
``` text ``` text
https://localhost:8443/go-speech/help https://localhost:8443/go-speech/help
``` ```
**Для HTTP режима:**
``` text
http://localhost:8080/go-speech/help
```
**Возможности:** **Возможности:**
- Выбор голоса из доступных моделей (Ruslan, Irina, Denis, Dmitri) - Выбор голоса из доступных моделей (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) - `GO_SPEECH_PORT` - Порт для запуска сервера
- `PIPER_PATH` - Путь к бинарнику Piper TTS (по умолчанию: /usr/local/bin/piper) - По умолчанию: `8443` (если `GO_SPEECH_TLS=true`) или `8080` (если TLS отключен)
- `MODEL_DIR` - Директория с моделями (по умолчанию: models) - Можно указать любой доступный порт
- `GO_SPEECH_VOICE` - Имя голоса для синтеза речи (по умолчанию: ruslan) - `GO_SPEECH_VOICE` - Имя голоса для синтеза речи (по умолчанию: `ruslan`)
- Доступные варианты: `ruslan`, `denis`, `dmitri`, `irina` - Доступные варианты: `ruslan`, `denis`, `dmitri`, `irina`
- Путь к модели формируется как: `{MODEL_DIR}/ru_RU-{GO_SPEECH_VOICE}-medium.onnx` - Путь к модели формируется как: `{MODEL_DIR}/ru_RU-{GO_SPEECH_VOICE}-medium.onnx`
- `MODEL_PATH` - Полный путь к модели Piper TTS (опционально, переопределяет автоматический выбор на основе GO_SPEECH_VOICE) - `MODEL_PATH` - Полный путь к модели Piper TTS (опционально)
- `FFMPEG_PATH` - Путь к бинарнику ffmpeg (по умолчанию: /usr/bin/ffmpeg) - Переопределяет автоматический выбор на основе `GO_SPEECH_VOICE`
- `GO_SPEECH_MODE` - Режим работы сервера (по умолчанию: debug) - Используется, если нужно указать конкретную модель напрямую
- `GO_SPEECH_MODE` - Режим работы сервера (по умолчанию: `debug`)
- `release` - Режим продакшена (минимальное логирование) - `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" "crypto/tls"
"embed" "embed"
"fmt" "fmt"
"html/template"
"io" "io"
"io/fs" "io/fs"
"log" "log"
@@ -29,11 +28,42 @@ var staticFiles embed.FS
//go:embed README.md //go:embed README.md
var readmeContent string var readmeContent string
//go:embed VERSION.txt
var versionContent string
// getVersion возвращает версию приложения
func getVersion() string {
if versionContent != "" {
return strings.TrimSpace(versionContent)
}
return "unknown"
}
func main() { 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") certFile := getEnv("GO_SPEECH_CERT_FILE", "certs/server.crt")
keyFile := getEnv("GO_SPEECH_KEY_FILE", "certs/server.key") 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") piperPath := getEnv("GO_SPEECH_PIPER_PATH", "/usr/local/bin/piper")
ffmpegPath := getEnv("GO_SPEECH_FFMPEG_PATH", "/usr/bin/ffmpeg") ffmpegPath := getEnv("GO_SPEECH_FFMPEG_PATH", "/usr/bin/ffmpeg")
@@ -68,45 +98,71 @@ func main() {
logger.Info("=== Запуск Go Speech TTS сервера ===") logger.Info("=== Запуск Go Speech TTS сервера ===")
logger.Debug("Режим отладки: ВКЛЮЧЕН") logger.Debug("Режим отладки: ВКЛЮЧЕН")
logger.Debug("Конфигурация:") logger.Debug("Конфигурация:")
logger.Debug(" PORT: %s", port) logger.Debug(" GO_SPEECH_PORT: %s", port)
logger.Debug(" CERT_FILE: %s", certFile) logger.Debug(" GO_SPEECH_TLS: %v (HTTPS: %v)", getEnv("GO_SPEECH_TLS", "не задано"), useTLS)
logger.Debug(" KEY_FILE: %s", keyFile) logger.Debug(" GO_SPEECH_CERT_FILE: %s", certFile)
logger.Debug(" PIPER_PATH: %s", piperPath) logger.Debug(" GO_SPEECH_KEY_FILE: %s", keyFile)
logger.Debug(" FFMPEG_PATH: %s", ffmpegPath) if caCertFile != "" {
logger.Debug(" MODEL_DIR: %s", modelDir) logger.Debug(" GO_SPEECH_CA_CERT: %s", caCertFile)
logger.Debug(" CACHE_DIR: %s", cacheDir) }
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_VOICE: %s", getEnv("GO_SPEECH_VOICE", "ruslan"))
logger.Debug(" GO_SPEECH_MODE: %s", getEnv("GO_SPEECH_MODE", "debug")) logger.Debug(" GO_SPEECH_MODE: %s", getEnv("GO_SPEECH_MODE", "debug"))
logger.Info("Директория с моделями: %s", modelDir) logger.Info("Директория с моделями: %s", modelDir)
logger.Info("Директория кэша: %s", cacheDir) logger.Info("Директория кэша: %s", cacheDir)
// Проверка наличия сертификатов var tlsConfig *tls.Config
logger.Debug("Проверка SSL сертификатов...")
if _, err := os.Stat(certFile); os.IsNotExist(err) {
logger.Error("SSL сертификат не найден: %s", certFile)
log.Fatalf("SSL сертификат не найден: %s. Создайте сертификаты или укажите путь через CERT_FILE", certFile)
}
logger.Debug("SSL сертификат найден: %s", certFile)
if _, err := os.Stat(keyFile); os.IsNotExist(err) { // Обработка TLS конфигурации только если включен HTTPS
logger.Error("SSL ключ не найден: %s", keyFile) if useTLS {
log.Fatalf("SSL ключ не найден: %s. Создайте ключ или укажите путь через KEY_FILE", keyFile) logger.Debug("Режим HTTPS включен, проверка сертификатов...")
}
logger.Debug("SSL ключ найден: %s", keyFile)
// Загрузка TLS сертификатов // Проверка наличия сертификатов
logger.Debug("Загрузка TLS сертификатов...") certExists := false
cert, err := tls.LoadX509KeyPair(certFile, keyFile) keyExists := false
if err != nil {
logger.Error("Ошибка загрузки TLS сертификатов: %v", err)
log.Fatalf("Ошибка загрузки TLS сертификатов: %v", err)
}
logger.Debug("TLS сертификаты успешно загружены")
// Настройка TLS конфигурации if _, err := os.Stat(certFile); err == nil {
tlsConfig := &tls.Config{ certExists = true
Certificates: []tls.Certificate{cert}, logger.Debug("SSL сертификат найден: %s", certFile)
MinVersion: tls.VersionTLS12, }
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")) w.Write([]byte("OK"))
}) })
logger.Debug(" GET /go-speech/api/v1/health - проверка работоспособности") 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) { mux.HandleFunc("/go-speech/front", func(w http.ResponseWriter, r *http.Request) {
@@ -152,8 +215,20 @@ func main() {
return return
} }
defer indexFile.Close() 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") w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.Copy(w, indexFile) w.Write([]byte(indexContentStr))
}) })
logger.Debug(" GET /go-speech/front - веб-интерфейс") 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)))) mux.Handle("/go-speech/front/", http.StripPrefix("/go-speech/front/", http.FileServer(http.FS(staticFS))))
logger.Debug(" GET /go-speech/front/* - статические ресурсы") 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 // Помощь - рендеринг README.md
mux.HandleFunc("/go-speech/help", handleHelp) mux.HandleFunc("/go-speech/help", handleHelp)
logger.Debug(" GET /go-speech/help - документация") logger.Debug(" GET /go-speech/help - документация")
@@ -168,16 +252,25 @@ func main() {
server := &http.Server{ server := &http.Server{
Addr: "0.0.0.0:" + port, Addr: "0.0.0.0:" + port,
Handler: loggingMiddleware(mux), Handler: loggingMiddleware(mux),
TLSConfig: tlsConfig,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
// Добавляем TLS конфигурацию только если включен HTTPS
if useTLS {
server.TLSConfig = tlsConfig
}
logger.Debug("Настройки HTTP сервера:") logger.Debug("Настройки HTTP сервера:")
logger.Debug(" ReadTimeout: %v", server.ReadTimeout) logger.Debug(" ReadTimeout: %v", server.ReadTimeout)
logger.Debug(" WriteTimeout: %v", server.WriteTimeout) logger.Debug(" WriteTimeout: %v", server.WriteTimeout)
logger.Debug(" IdleTimeout: %v", server.IdleTimeout) logger.Debug(" IdleTimeout: %v", server.IdleTimeout)
if useTLS {
logger.Debug(" TLS: включен")
} else {
logger.Debug(" TLS: отключен")
}
// Graceful shutdown // Graceful shutdown
go func() { go func() {
@@ -190,17 +283,22 @@ func main() {
} }
logger.Debug("IPv4 listener успешно создан: %v", listener.Addr()) logger.Debug("IPv4 listener успешно создан: %v", listener.Addr())
// Создаем TLS listener protocol := "http"
logger.Debug("Создание TLS listener...") if useTLS {
tlsListener := tls.NewListener(listener, tlsConfig) protocol = "https"
logger.Debug("TLS listener успешно создан") // Создаем 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("Сервер запущен на %s://0.0.0.0:%s (IPv4)", protocol, port)
logger.Info("Простой веб-интерфейс доступен на https://localhost:%s/go-speech/front", port) logger.Info("Простой веб-интерфейс доступен на %s://localhost:%s/go-speech/front", protocol, port)
logger.Info("Справка доступна по пути https://localhost:%s/go-speech/help", port) logger.Info("Справка доступна по пути %s://localhost:%s/go-speech/help", protocol, port)
logger.Debug("Запуск server.Serve()...") 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) logger.Error("Ошибка запуска сервера: %v", err)
log.Fatalf("Ошибка запуска сервера: %v", err) log.Fatalf("Ошибка запуска сервера: %v", err)
} }
@@ -226,203 +324,3 @@ func main() {
logger.Info("Сервер остановлен") 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> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Go Speech - TTS</title> <title>Go Speech - TTS</title>
<style> <style>
* { @font-face {
margin: 0; font-family: "Noto Color Emoji";
padding: 0; font-style: normal;
box-sizing: border-box; font-weight: 400;
} font-display: swap;
src: url("/go-speech/fonts/NotoColorEmoji.ttf") format("truetype");
}
body { @font-face {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: "PT Sans";
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); font-style: normal;
min-height: 100vh; font-weight: 400;
display: flex; font-display: swap;
align-items: center; src: url("/go-speech/fonts/PTSans-Regular.ttf") format("truetype");
justify-content: center; }
padding: 20px;
}
.container { @font-face {
background: white; font-family: "PT Sans";
border-radius: 16px; font-style: normal;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); font-weight: 700;
padding: 40px; font-display: swap;
max-width: 600px; src: url("/go-speech/fonts/PTSans-Bold.ttf") format("truetype");
width: 100%; }
}
h1 { @font-face {
color: #333; font-family: "PT Sans";
margin-bottom: 30px; font-style: italic;
text-align: center; font-weight: 400;
font-weight: 600; font-display: swap;
font-size: 28px; src: url("/go-speech/fonts/PTSans-Italic.ttf") format("truetype");
} }
.form-group { @font-face {
margin-bottom: 24px; 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: 0;
margin-bottom: 8px; padding: 0;
color: #555; box-sizing: border-box;
font-weight: 500; }
font-size: 14px;
}
select { body {
width: 100%; font-family: "PT Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
padding: 12px 16px; Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
border: 2px solid #e0e0e0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px; min-height: 100vh;
font-size: 16px; display: flex;
background: white; align-items: center;
color: #333; justify-content: center;
cursor: pointer; padding: 20px;
transition: border-color 0.3s; }
}
select:focus { .container {
outline: none; background: white;
border-color: #667eea; border-radius: 16px;
} box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
textarea { h1 {
width: 100%; color: #333;
padding: 12px 16px; margin-bottom: 30px;
border: 2px solid #e0e0e0; text-align: center;
border-radius: 8px; font-weight: 600;
font-size: 16px; font-size: 28px;
font-family: inherit; font-family: "Noto Color Emoji", -apple-system, BlinkMacSystemFont,
resize: vertical; "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
min-height: 120px; }
color: #333;
transition: border-color 0.3s;
}
textarea:focus { .form-group {
outline: none; margin-bottom: 24px;
border-color: #667eea; }
}
.buttons { label {
display: flex; display: block;
gap: 12px; margin-bottom: 8px;
margin-top: 24px; color: #555;
} font-weight: 700;
font-size: 16px;
font-family: "PT Sans", sans-serif;
}
button { select {
flex: 1; width: 100%;
padding: 14px 24px; padding: 12px 16px;
border: none; border: 2px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
font-size: 16px; font-size: 16px;
font-weight: 500; font-family: "PT Sans", sans-serif;
cursor: pointer; background: white;
transition: all 0.3s; color: #333;
text-transform: uppercase; cursor: pointer;
letter-spacing: 0.5px; transition: border-color 0.3s;
} }
.btn-primary { select:focus {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); outline: none;
color: white; border-color: #667eea;
} }
.btn-primary:hover:not(:disabled) { textarea {
transform: translateY(-2px); width: 100%;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); 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 { textarea:focus {
background: #f5f5f5; outline: none;
color: #333; border-color: #667eea;
} }
.btn-secondary:hover:not(:disabled) { .buttons {
background: #e8e8e8; display: flex;
} gap: 12px;
margin-top: 24px;
}
button:disabled { button {
opacity: 0.6; flex: 1;
cursor: not-allowed; 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 { .btn-primary {
margin-top: 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: none; color: white;
} }
.audio-player.show { .btn-primary:hover:not(:disabled) {
display: block; transform: translateY(-2px);
} box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
audio { .btn-secondary {
width: 100%; background: #f5f5f5;
margin-top: 12px; color: #333;
} }
.status { .btn-secondary:hover:not(:disabled) {
margin-top: 16px; background: #e8e8e8;
padding: 12px; }
border-radius: 8px;
font-size: 14px;
text-align: center;
display: none;
}
.status.show { button:disabled {
display: block; opacity: 0.6;
} cursor: not-allowed;
}
.status.success { .audio-player {
background: #d4edda; margin-top: 24px;
color: #155724; display: none;
border: 1px solid #c3e6cb; }
}
.status.error { .audio-player.show {
background: #f8d7da; display: block;
color: #721c24; }
border: 1px solid #f5c6cb;
}
.status.loading { audio {
background: #d1ecf1; width: 100%;
color: #0c5460; margin-top: 12px;
border: 1px solid #bee5eb; }
}
.loader-overlay { .status {
position: absolute; margin-top: 16px;
top: 0; padding: 12px;
left: 0; border-radius: 8px;
right: 0; font-size: 14px;
bottom: 0; font-family: "PT Sans", sans-serif;
background: rgba(255, 255, 255, 0.95); text-align: center;
display: none; display: none;
align-items: center; }
justify-content: center;
border-radius: 16px;
z-index: 1000;
}
.loader-overlay.show { .status.show {
display: flex; display: block;
} }
.loader { .status.success {
display: flex; background: #d4edda;
flex-direction: column; color: #155724;
align-items: center; border: 1px solid #c3e6cb;
gap: 16px; }
}
.spinner { .status.error {
width: 50px; background: #f8d7da;
height: 50px; color: #721c24;
border: 4px solid #f3f3f3; border: 1px solid #f5c6cb;
border-top: 4px solid #667eea; }
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { .status.loading {
0% { transform: rotate(0deg); } background: #d1ecf1;
100% { transform: rotate(360deg); } color: #0c5460;
} border: 1px solid #bee5eb;
}
.loader-text { .loader-overlay {
color: #667eea; position: absolute;
font-weight: 500; top: 0;
font-size: 16px; 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 { .loader-overlay.show {
position: relative; display: flex;
} }
.form-disabled { .loader {
pointer-events: none; display: flex;
opacity: 0.6; 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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="loader-overlay" id="loaderOverlay"> <div class="loader-overlay" id="loaderOverlay">
<div class="loader"> <div class="loader">
<div class="spinner"></div> <div class="spinner"></div>
<div class="loader-text" id="loaderText">Генерация аудио...</div> <div class="loader-text" id="loaderText">Генерация аудио...</div>
</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> </div>
<h1>🎤 Go Speech TTS</h1> <div class="form-group">
<label for="text">Введите текст для озвучки:</label>
<form id="ttsForm"> <textarea
<div class="form-group"> id="text"
<label for="voice">Выберите голос:</label> name="text"
<select id="voice" name="voice"> placeholder="Введите текст на русском языке..."
<option value="ruslan">Ruslan (мужской)</option> required
<option value="irina">Irina (женский)</option> ></textarea>
<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> </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> </div>
<script> <script>
const form = document.getElementById('ttsForm'); const form = document.getElementById("ttsForm");
const textInput = document.getElementById('text'); const textInput = document.getElementById("text");
const voiceSelect = document.getElementById('voice'); const voiceSelect = document.getElementById("voice");
const speakBtn = document.getElementById('speakBtn'); const speakBtn = document.getElementById("speakBtn");
const downloadBtn = document.getElementById('downloadBtn'); const downloadBtn = document.getElementById("downloadBtn");
const statusDiv = document.getElementById('status'); const statusDiv = document.getElementById("status");
const audioPlayer = document.getElementById('audioPlayer'); const audioPlayer = document.getElementById("audioPlayer");
const audio = document.getElementById('audio'); const audio = document.getElementById("audio");
const loaderOverlay = document.getElementById('loaderOverlay'); const loaderOverlay = document.getElementById("loaderOverlay");
const loaderText = document.getElementById('loaderText'); const loaderText = document.getElementById("loaderText");
const container = document.querySelector('.container'); const container = document.querySelector(".container");
let currentAudioUrl = null; let currentAudioUrl = null;
let currentBlob = null; let currentBlob = null;
let isPlaying = false; let isPlaying = false;
function showStatus(message, type) { function showStatus(message, type) {
statusDiv.textContent = message; statusDiv.textContent = message;
statusDiv.className = `status show ${type}`; 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() { function updateButtonsState() {
statusDiv.className = 'status'; // Блокируем кнопки во время воспроизведения
} speakBtn.disabled = isPlaying;
downloadBtn.disabled = isPlaying || !currentBlob;
}
function setLoading(loading, message = 'Генерация аудио...') { async function generateSpeech(text, voice) {
speakBtn.disabled = loading; try {
downloadBtn.disabled = loading || isPlaying; setLoading(true);
hideStatus();
showStatus("Генерация аудио...", "loading");
if (loading) { const response = await fetch("/go-speech/api/v1/tts", {
speakBtn.textContent = '⏳ Обработка...'; method: "POST",
loaderText.textContent = message; headers: {
loaderOverlay.classList.add('show'); "Content-Type": "application/json",
container.classList.add('form-disabled'); },
} else { body: JSON.stringify({ text: text, voice: voice }),
speakBtn.textContent = '🔊 Озвучить'; });
loaderOverlay.classList.remove('show');
container.classList.remove('form-disabled');
updateButtonsState();
}
}
function updateButtonsState() { if (!response.ok) {
// Блокируем кнопки во время воспроизведения const errorText = await response.text();
speakBtn.disabled = isPlaying; throw new Error(errorText || "Ошибка генерации аудио");
downloadBtn.disabled = isPlaying || !currentBlob; }
}
async function generateSpeech(text, voice) { const blob = await response.blob();
try { currentBlob = blob;
setLoading(true); currentAudioUrl = URL.createObjectURL(blob);
hideStatus();
showStatus('Генерация аудио...', 'loading');
const response = await fetch('/go-speech/api/v1/tts', { audio.src = currentAudioUrl;
method: 'POST', audioPlayer.classList.add("show");
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: text, voice: voice })
});
if (!response.ok) { showStatus("Аудио готово!", "success");
const errorText = await response.text();
throw new Error(errorText || 'Ошибка генерации аудио');
}
const blob = await response.blob(); // Устанавливаем флаг воспроизведения перед автоплеем
currentBlob = blob; isPlaying = true;
currentAudioUrl = URL.createObjectURL(blob); updateButtonsState();
audio.src = currentAudioUrl; // Автоматическое воспроизведение
audioPlayer.classList.add('show'); audio.play().catch((err) => {
console.log("Автовоспроизведение заблокировано:", err);
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', () => {
isPlaying = false; isPlaying = false;
updateButtonsState(); 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', () => { function downloadAudio() {
isPlaying = false; if (!currentBlob) {
updateButtonsState(); showStatus("Нет аудио для скачивания", "error");
showStatus('Воспроизведение завершено', 'success'); return;
setTimeout(hideStatus, 2000); }
});
audio.addEventListener('error', () => { const url = currentAudioUrl;
isPlaying = false; const a = document.createElement("a");
updateButtonsState(); a.href = url;
showStatus('Ошибка воспроизведения', 'error'); a.download = `speech-${voiceSelect.value}-${Date.now()}.ogg`;
}); document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Очистка URL при разгрузке страницы showStatus("Файл скачан", "success");
window.addEventListener('beforeunload', () => { setTimeout(hideStatus, 2000);
if (currentAudioUrl) { }
URL.revokeObjectURL(currentAudioUrl);
} 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> </script>
</body> </body>
</html> </html>