v1.0.4 - init version

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

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
*.md
!README.md
.git
.gitignore
*.log
tmp/
*.swp
*.swo
*~

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Binaries
certs/
go-speech
models/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Logs
*.log
# Temporary files
tmp/
temp/
# Cache directory
cache/
# SSL certificates (можно добавить в репозиторий для разработки, но обычно не стоит)
# certs/*.crt
# certs/*.key
# Models (обычно большие файлы, не должны быть в репозитории)
models/*.onnx
models/*.onnx.json
# Build artifacts
dist/
build/

76
Dockerfile Normal file
View File

@@ -0,0 +1,76 @@
# 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++
# Установка 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"]

443
README.md Normal file
View File

@@ -0,0 +1,443 @@
# Go Speech - TTS микросервис
Микросервис на Go для преобразования русского текста в речь через Piper TTS с HTTPS API.
## Возможности
- HTTP/HTTPS API для синтеза речи
- Веб-интерфейс для удобного использования через браузер
- Поддержка русского языка через Piper TTS
- Выбор из 4 голосов: Ruslan, Irina, Denis, Dmitri
- Локальная генерация речи (без внешних API)
- Возврат аудио в формате OGG
- Встроенный фронтенд в бинарнике (не требует отдельных файлов)
- Кэширование сгенерированных аудио файлов для ускорения повторных запросов
- Автоматическая очистка кэша (удаление файлов старше 3 дней)
- Контейнеризация через Podman/Docker
## Требования
- Go 1.21+
- Piper TTS (устанавливается автоматически в Docker образе)
- ffmpeg (для конвертации в OGG)
- SSL сертификаты (самоподписанные для разработки)
## Установка и запуск
### Локальная разработка
1 Клонируйте репозиторий:
```bash
git clone <repository-url>
cd go-speech
```
2 Установите зависимости:
```bash
go mod download
```
3 Скачайте русскую модель Piper TTS:
```bash
mkdir -p models
curl -L https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/denis/medium/ru_RU-denis-medium.onnx -o models/ru_RU-denis-medium.onnx
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
```
4 Установите Piper TTS локально (если запускаете вне Docker):
```bash
# Скачайте бинарник с https://github.com/rhasspy/piper/releases
# И поместите в PATH или укажите путь через переменную окружения PIPER_PATH
```
5 Установите ffmpeg:
```bash
# Ubuntu/Debian
sudo apt-get install ffmpeg
# Alpine
apk add ffmpeg
```
6 Сгенерируйте SSL сертификаты:
```bash
./generate-certs.sh
```
7 Запустите сервис:
```bash
go run main.go
```
Или с переменными окружения:
```bash
GO_SPEECH_PORT=8443 \
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 run main.go
```
Для использования другого голоса (например, `denis`, `dmitri`, `irina`):
```bash
GO_SPEECH_VOICE=denis go run main.go
```
### Запуск через Podman/Docker
1 Соберите образ:
```bash
podman build -t go-speech:latest .
```
2 Скачайте модель (если не включена в образ):
```bash
mkdir -p models
curl -L https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/denis/medium/ru_RU-denis-medium.onnx -o models/ru_RU-denis-medium.onnx
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 Сгенерируйте сертификаты:
```bash
./generate-certs.sh
```
4 Запустите контейнер:
```bash
podman run -d \
--name go-speech \
-p 8443:8443 \
-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 8443:8443 \
-e GO_SPEECH_VOICE=denis \
-v $(pwd)/models:/app/models:ro \
-v $(pwd)/certs:/app/certs:ro \
go-speech:latest
```
### Запуск как systemd сервис
1. Отредактируйте `go-speech.service` и укажите правильные пути к моделям и сертификатам
2. Скопируйте unit файл:
```bash
sudo cp go-speech.service /etc/systemd/system/
```
3 Перезагрузите systemd:
```bash
sudo systemctl daemon-reload
```
4 Запустите сервис:
```bash
sudo systemctl start go-speech
sudo systemctl enable go-speech
```
5 Проверьте статус:
```bash
sudo systemctl status go-speech
```
## Веб-интерфейс
### GET /go-speech/front
Встроенный веб-интерфейс для синтеза речи через браузер.
**Доступ:**
Откройте в браузере:
``` text
https://localhost:8443/go-speech/front
```
### GET /go-speech/help
Отображение документации (README.md) в браузере.
**Доступ:**
``` text
https://localhost:8443/go-speech/help
```
**Возможности:**
- Выбор голоса из доступных моделей (Ruslan, Irina, Denis, Dmitri)
- Многострочное поле для ввода текста
- Кнопка "Озвучить" - генерирует и проигрывает аудио в браузере
- Кнопка "Скачать" - скачивает файл озвучки
- Минималистичный дизайн
Фронтенд полностью встроен в бинарник и не требует дополнительных файлов.
## API
Все API endpoints доступны по префиксу `/go-speech/api/v1/`
### POST /go-speech/api/v1/tts
Преобразует текст в речь и возвращает OGG аудио файл.
**Запрос (без указания голоса, используется голос по умолчанию):**
```bash
curl -X POST https://localhost:8443/go-speech/api/v1/tts \
-H "Content-Type: application/json" \
-d '{"text": "Привет, это тестовый текст"}' \
--insecure \
-o output.ogg
```
**Запрос (с указанием голоса):**
```bash
curl -X POST https://localhost:8443/go-speech/api/v1/tts \
-H "Content-Type: application/json" \
-d '{"text": "Привет, это тестовый текст", "voice": "irina"}' \
--insecure \
-o output-irina.ogg
```
**Параметры запроса:**
- `text` (обязательный) - Текст для озвучки (максимум 5000 символов)
- `voice` (опциональный) - Голос для синтеза: `ruslan` (по умолчанию), `irina`, `denis`, `dmitri`
**Ответ:**
- Content-Type: `audio/ogg`
- Body: OGG аудио файл
- Content-Disposition: `inline; filename=speech-{voice}.ogg`
**Коды ответа:**
- `200` - Успех
- `400` - Неверный запрос (пустой текст, слишком длинный текст, неверный голос)
- `405` - Неверный HTTP метод
- `500` - Ошибка сервера
### GET /go-speech/api/v1/healthz
Генерирует аудио файл со словом "Окей" и возвращает его. Используется для проверки работоспособности TTS.
**Запрос:**
```bash
curl -k https://localhost:8443/go-speech/api/v1/healthz -o healthz.ogg
```
**Ответ:**
- Content-Type: `audio/ogg`
- Body: OGG аудио файл со словом "Окей"
### GET /go-speech/api/v1/health
Проверка работоспособности сервиса.
**Запрос:**
```bash
curl https://localhost:8443/go-speech/api/v1/health --insecure
```
**Ответ:**
- `200 OK` - Сервис работает
## Переменные окружения
- `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)
- Доступные варианты: `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)
- `release` - Режим продакшена (минимальное логирование)
- Любое другое значение - Режим разработки (подробное логирование всех операций)
## Кэширование
Сервер автоматически кэширует сгенерированные аудио файлы для ускорения обработки повторных запросов.
### Как работает кэш
- **Директория кэша**: Создается автоматически в директории рядом с исполняемым файлом (`./cache/`)
- **Ключ кэша**: SHA256 хеш от комбинации имени модели и текста (`SHA256(model_name:text)`)
- **Проверка кэша**: При каждом запросе сначала проверяется наличие файла в кэше
- **Сохранение**: После генерации аудио файл автоматически сохраняется в кэш
- **Автоочистка**: Фоновая горутина каждые 5 минут удаляет файлы старше 3 дней
### Преимущества кэширования
- Ускорение повторных запросов с тем же текстом и голосом
- Снижение нагрузки на систему (не требуется повторная генерация)
- Автоматическое управление размером кэша
### Пример работы
```bash
# Первый запрос - генерация и сохранение в кэш
curl -X POST https://localhost:8443/tts \
-H "Content-Type: application/json" \
-d '{"text": "Привет", "voice": "irina"}' \
--insecure -o output1.ogg
# Второй запрос с тем же текстом - используется кэш (быстрее)
curl -X POST https://localhost:8443/tts \
-H "Content-Type: application/json" \
-d '{"text": "Привет", "voice": "irina"}' \
--insecure -o output2.ogg
```
**Примечание**: Кэш работает автоматически и не требует дополнительной настройки.
## Логирование
Сервер поддерживает два режима логирования, управляемые переменной окружения `GO_SPEECH_MODE`:
### Режим разработки (по умолчанию)
Когда `GO_SPEECH_MODE` не установлена или имеет любое значение кроме `release`, включается подробное логирование:
- Детальная информация о конфигурации при запуске
- Логирование всех входящих HTTP запросов с параметрами
- Подробная информация о процессе генерации аудио
- Логирование выполнения команд Piper TTS и ffmpeg
- Информация о размерах файлов и времени выполнения операций
- Детальные сообщения об ошибках с контекстом
**Пример вывода в режиме разработки:**
``` text
[INFO] === Запуск Go Speech TTS сервера ===
[DEBUG] Режим отладки: ВКЛЮЧЕН
[DEBUG] Конфигурация:
[DEBUG] PORT: 8443
[DEBUG] PIPER_PATH: /usr/local/bin/piper
[DEBUG] === Обработка TTS запроса ===
[DEBUG] Тело запроса: {"text":"Привет","voice":"irina"}
[DEBUG] === Генерация аудио ===
[DEBUG] Запуск Piper TTS для генерации WAV...
```
### Режим продакшена
Установите `GO_SPEECH_MODE=release` для минимального логирования:
- Только критичные ошибки и важные события
- Базовая информация о запросах (метод, путь, время выполнения)
- Без детальной отладочной информации
**Пример запуска в режиме продакшена:**
```bash
GO_SPEECH_MODE=release ./go-speech
```
## Структура проекта
``` text
go-speech/
├── main.go # Основной HTTP сервер
├── handlers/ # HTTP обработчики
│ └── tts.go # Обработчик TTS запросов
├── tts/ # TTS модуль
│ └── piper.go # Интеграция с Piper TTS
├── internal/ # Внутренние пакеты
│ ├── logger/ # Модуль логирования
│ │ └── logger.go # Логирование с поддержкой режимов
│ └── cache/ # Модуль кэширования
│ └── cache.go # Кэширование аудио файлов
├── cache/ # Директория кэша (создается автоматически)
├── static/ # Статические файлы фронтенда
│ └── index.html # Веб-интерфейс (встраивается в бинарник)
├── models/ # Модели Piper TTS
├── certs/ # SSL сертификаты
├── Dockerfile # Контейнер для Podman/Docker
├── go-speech.service # Systemd unit файл
├── generate-certs.sh # Скрипт генерации сертификатов
└── README.md # Документация
```
## Разработка
### Сборка
```bash
go build -o go-speech .
```
### Тестирование
```bash
# Запустите сервис
go run main.go
# Откройте веб-интерфейс в браузере
# https://localhost:8443/go-speech/front
# Или документацию
# https://localhost:8443/go-speech/help
# Или используйте API напрямую
curl -X POST https://localhost:8443/go-speech/api/v1/tts \
-H "Content-Type: application/json" \
-d '{"text": "Тестовый текст"}' \
--insecure \
-o test.ogg
# С указанием голоса
curl -X POST https://localhost:8443/go-speech/api/v1/tts \
-H "Content-Type: application/json" \
-d '{"text": "Привет, это тестовый текст", "voice": "irina"}' \
--insecure \
-o output-irina.ogg
# Проверка работоспособности
curl -k https://localhost:8443/go-speech/api/v1/healthz -o healthz.ogg
curl https://localhost:8443/go-speech/api/v1/health --insecure
```
## Лицензия
MIT

7
docker-run.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
docker run -d --name go-speech --restart=always -p 18443:18443 \
-v "$(pwd)/certs:/app/certs:ro" \
-e GO_SPEECH_VOICE=ruslan \
-e GO_SPEECH_PORT=18443 \
kuznetcovay/go-speech:v1.0.4

25
generate-certs.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Скрипт для генерации самоподписанных SSL сертификатов
CERT_DIR="certs"
CERT_FILE="$CERT_DIR/server.crt"
KEY_FILE="$CERT_DIR/server.key"
# Создание директории если не существует
mkdir -p "$CERT_DIR"
# Генерация самоподписанного сертификата
openssl req -x509 -newkey rsa:4096 -keyout "$KEY_FILE" -out "$CERT_FILE" \
-days 3650 -nodes \
-subj "/C=RU/ST=Moscow/L=Moscow/O=Go-Speech/CN=localhost" \
-addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1"
echo "Сертификаты созданы:"
echo " Certificate: $CERT_FILE"
echo " Private Key: $KEY_FILE"
echo ""
echo "Для использования в браузере, добавьте сертификат в доверенные:"
echo " sudo cp $CERT_FILE /usr/local/share/ca-certificates/go-speech.crt"
echo " sudo update-ca-certificates"

18
go-speech.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=Go Speech TTS Service
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/bin/podman run --rm --pull=never -p 7443:7443 \
-e GO_SPEECH_PORT=7443 \
-v /home/su/projects/golang/go-speech/certs:/app/certs:ro \
kuznetcovay/go-speech:v1.0.4
ExecStop=/usr/bin/podman stop -t 10 go-speech
Restart=always
RestartSec=10
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module go-speech
go 1.25.0

238
handlers/tts.go Normal file
View File

@@ -0,0 +1,238 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"go-speech/internal/cache"
"go-speech/internal/logger"
"go-speech/tts"
)
type TTSHandler struct {
ttsService *tts.PiperService
modelDir string
cache *cache.Cache
}
func NewTTSHandler(piperPath, modelDir, ffmpegPath string, cacheInstance *cache.Cache) *TTSHandler {
service := tts.NewPiperService(piperPath, modelDir, ffmpegPath)
return &TTSHandler{
ttsService: service,
modelDir: modelDir,
cache: cacheInstance,
}
}
type TTSRequest struct {
Text string `json:"text"`
Voice string `json:"voice"`
}
func (h *TTSHandler) HandleTTS(w http.ResponseWriter, r *http.Request) {
logger.Debug("=== Обработка TTS запроса ===")
if r.Method != http.MethodPost {
logger.Warn("Неподдерживаемый метод: %s", r.Method)
http.Error(w, "Метод не поддерживается. Используйте POST", http.StatusMethodNotAllowed)
return
}
// Проверка Content-Type
contentType := r.Header.Get("Content-Type")
logger.Debug("Content-Type: %s", contentType)
if contentType != "application/json" {
logger.Warn("Неверный Content-Type: %s", contentType)
http.Error(w, "Content-Type должен быть application/json", http.StatusBadRequest)
return
}
// Чтение тела запроса
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("Ошибка чтения тела запроса: %v", err)
http.Error(w, "Ошибка чтения запроса: "+err.Error(), http.StatusBadRequest)
return
}
logger.Debug("Тело запроса: %s", string(bodyBytes))
var req TTSRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
logger.Error("Ошибка парсинга JSON: %v", err)
http.Error(w, "Неверный формат JSON: "+err.Error(), http.StatusBadRequest)
return
}
logger.Debug("Распарсенный запрос:")
logger.Debug(" Text: %s (длина: %d)", req.Text, len(req.Text))
logger.Debug(" Voice: %s", req.Voice)
// Валидация текста
if req.Text == "" {
http.Error(w, "Поле 'text' не может быть пустым", http.StatusBadRequest)
return
}
if len(req.Text) > 5000 {
http.Error(w, "Текст слишком длинный (максимум 5000 символов)", http.StatusBadRequest)
return
}
// Определение голоса (по умолчанию ruslan)
voice := req.Voice
if voice == "" {
voice = "ruslan"
logger.Debug("Голос не указан, используется по умолчанию: ruslan")
}
// Валидация голоса
validVoices := map[string]bool{
"ruslan": true,
"irina": true,
"denis": true,
"dmitri": true,
}
if !validVoices[voice] {
logger.Warn("Неверный голос: %s", voice)
http.Error(w, "Неверный голос. Доступные: ruslan, irina, denis, dmitri", http.StatusBadRequest)
return
}
logger.Debug("Выбранный голос: %s", voice)
// Формируем имя модели для кэша
modelName := fmt.Sprintf("ru_RU-%s-medium.onnx", voice)
cacheKey := cache.GetCacheKey(modelName, req.Text)
logger.Debug("Ключ кэша: %s", cacheKey)
// Проверка кэша
var audioPath string
var tempFileToDelete string // Путь к временному файлу для удаления
if h.cache != nil && h.cache.Exists(cacheKey) {
logger.Debug("Файл найден в кэше, используем кэш")
// Используем путь к файлу в кэше
audioPath = h.cache.GetPath(cacheKey)
logger.Debug("Используется файл из кэша: %s", audioPath)
} else {
// Генерация аудио
logger.Debug("Файл не найден в кэше, начинаем генерацию...")
logger.Debug(" Текст: %s", req.Text)
logger.Debug(" Голос: %s", voice)
generatedPath, err := h.ttsService.GenerateAudio(req.Text, voice)
if err != nil {
logger.Error("Ошибка генерации аудио: %v", err)
log.Printf("Ошибка генерации аудио: %v", err)
http.Error(w, "Ошибка генерации аудио: "+err.Error(), http.StatusInternalServerError)
return
}
audioPath = generatedPath
tempFileToDelete = generatedPath // Сохраняем путь для удаления
logger.Debug("Аудио успешно сгенерировано: %s", audioPath)
// Сохранение в кэш
if h.cache != nil {
logger.Debug("Сохранение файла в кэш...")
if err := h.cache.Put(cacheKey, audioPath); err != nil {
logger.Warn("Ошибка сохранения в кэш: %v", err)
} else {
logger.Debug("Файл успешно сохранен в кэш")
}
}
}
// Удаляем временный файл только если он был сгенерирован (не из кэша)
if tempFileToDelete != "" {
defer func() {
if err := os.Remove(tempFileToDelete); err != nil {
logger.Warn("Ошибка удаления временного файла %s: %v", tempFileToDelete, err)
} else {
logger.Debug("Временный файл удален: %s", tempFileToDelete)
}
}()
}
// Открытие файла
logger.Debug("Открытие аудио файла: %s", audioPath)
audioFile, err := os.Open(audioPath)
if err != nil {
logger.Error("Ошибка открытия аудио файла: %v", err)
log.Printf("Ошибка открытия аудио файла: %v", err)
http.Error(w, "Ошибка чтения аудио файла", http.StatusInternalServerError)
return
}
defer audioFile.Close()
// Получение информации о файле
fileInfo, err := audioFile.Stat()
if err != nil {
logger.Error("Ошибка получения информации о файле: %v", err)
log.Printf("Ошибка получения информации о файле: %v", err)
http.Error(w, "Ошибка чтения аудио файла", http.StatusInternalServerError)
return
}
logger.Debug("Размер аудио файла: %d байт", fileInfo.Size())
// Установка заголовков ответа
logger.Debug("Установка HTTP заголовков ответа")
w.Header().Set("Content-Type", "audio/ogg")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=speech-%s.ogg", voice))
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
logger.Debug("Отправка аудио файла клиенту...")
// Отправка файла
bytesWritten, err := io.Copy(w, audioFile)
if err != nil {
logger.Error("Ошибка отправки аудио файла: %v", err)
log.Printf("Ошибка отправки аудио файла: %v", err)
return
}
logger.Debug("Аудио файл успешно отправлен: %d байт", bytesWritten)
logger.Debug("=== TTS запрос обработан успешно ===")
}
// HandleHealthz генерирует аудио файл со словом "Окей" и возвращает его
func (h *TTSHandler) HandleHealthz(w http.ResponseWriter, r *http.Request) {
// Генерация аудио со словом "Окей" (используем голос по умолчанию ruslan)
audioPath, err := h.ttsService.GenerateAudio("Окей", "ruslan")
if err != nil {
log.Printf("Ошибка генерации аудио для healthz: %v", err)
http.Error(w, "Ошибка генерации аудио: "+err.Error(), http.StatusInternalServerError)
return
}
defer os.Remove(audioPath) // Удаляем временный файл после отправки
// Открытие файла
audioFile, err := os.Open(audioPath)
if err != nil {
log.Printf("Ошибка открытия аудио файла: %v", err)
http.Error(w, "Ошибка чтения аудио файла", http.StatusInternalServerError)
return
}
defer audioFile.Close()
// Получение информации о файле
fileInfo, err := audioFile.Stat()
if err != nil {
log.Printf("Ошибка получения информации о файле: %v", err)
http.Error(w, "Ошибка чтения аудио файла", http.StatusInternalServerError)
return
}
// Установка заголовков ответа
w.Header().Set("Content-Type", "audio/ogg")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Disposition", "attachment; filename=healthz.ogg")
w.WriteHeader(http.StatusOK)
// Отправка файла
if _, err := io.Copy(w, audioFile); err != nil {
log.Printf("Ошибка отправки аудио файла: %v", err)
return
}
}

BIN
healthz.ogg Normal file

Binary file not shown.

70
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,70 @@
package logger
import (
"fmt"
"log"
"os"
"runtime"
"strings"
)
var (
debugMode bool
logger *log.Logger
)
func init() {
mode := os.Getenv("GO_SPEECH_MODE")
debugMode = (mode != "release")
logger = log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds)
}
// IsDebug возвращает true, если включен режим отладки
func IsDebug() bool {
return debugMode
}
// Debug логирует сообщение только в режиме отладки
func Debug(format string, v ...interface{}) {
if debugMode {
logMessage("DEBUG", format, v...)
}
}
// Info логирует информационное сообщение
func Info(format string, v ...interface{}) {
logMessage("INFO", format, v...)
}
// Error логирует сообщение об ошибке
func Error(format string, v ...interface{}) {
logMessage("ERROR", format, v...)
}
// Warn логирует предупреждение
func Warn(format string, v ...interface{}) {
logMessage("WARN", format, v...)
}
// DebugWithContext логирует сообщение с контекстом (файл и строка)
func DebugWithContext(format string, v ...interface{}) {
if debugMode {
_, file, line, ok := runtime.Caller(1)
if ok {
file = file[strings.LastIndex(file, "/")+1:]
logMessage("DEBUG", fmt.Sprintf("[%s:%d] %s", file, line, format), v...)
} else {
logMessage("DEBUG", format, v...)
}
}
}
// logMessage форматирует и выводит сообщение
func logMessage(level, format string, v ...interface{}) {
message := format
if len(v) > 0 {
message = fmt.Sprintf(format, v...)
}
logger.Printf("[%s] %s", level, message)
}

428
main.go Normal file
View File

@@ -0,0 +1,428 @@
package main
import (
"context"
"crypto/tls"
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"go-speech/handlers"
"go-speech/internal/cache"
"go-speech/internal/logger"
)
//go:embed static
var staticFiles embed.FS
//go:embed README.md
var readmeContent string
func main() {
// Получение конфигурации из переменных окружения
port := getEnv("GO_SPEECH_PORT", "8443")
certFile := getEnv("GO_SPEECH_CERT_FILE", "certs/server.crt")
keyFile := getEnv("GO_SPEECH_KEY_FILE", "certs/server.key")
piperPath := getEnv("GO_SPEECH_PIPER_PATH", "/usr/local/bin/piper")
ffmpegPath := getEnv("GO_SPEECH_FFMPEG_PATH", "/usr/bin/ffmpeg")
// Получение пути к директории с моделями
modelDir := getEnv("GO_SPEECH_MODEL_DIR", "models")
// Определение директории кэша (рядом с исполняемым файлом)
execPath, err := os.Executable()
if err != nil {
logger.Warn("Не удалось определить путь к исполняемому файлу: %v", err)
execPath = "."
}
execDir := filepath.Dir(execPath)
cacheDir := filepath.Join(execDir, "cache")
logger.Debug("Директория исполняемого файла: %s", execDir)
logger.Debug("Директория кэша: %s", cacheDir)
// Инициализация кэша
logger.Debug("Инициализация кэша...")
cacheInstance, err := cache.NewCache(cacheDir)
if err != nil {
logger.Error("Ошибка инициализации кэша: %v", err)
log.Fatalf("Ошибка инициализации кэша: %v", err)
}
// Запуск фоновой горутины для очистки кэша (каждые 5 минут, удаляем файлы старше 3 дней)
cacheCleanupInterval := 5 * time.Minute
cacheMaxAge := 3 * 24 * time.Hour
cacheInstance.StartCleanupRoutine(cacheCleanupInterval, cacheMaxAge)
// Детальное логирование конфигурации
logger.Info("=== Запуск Go Speech TTS сервера ===")
logger.Debug("Режим отладки: ВКЛЮЧЕН")
logger.Debug("Конфигурация:")
logger.Debug(" PORT: %s", port)
logger.Debug(" CERT_FILE: %s", certFile)
logger.Debug(" KEY_FILE: %s", keyFile)
logger.Debug(" PIPER_PATH: %s", piperPath)
logger.Debug(" FFMPEG_PATH: %s", ffmpegPath)
logger.Debug(" MODEL_DIR: %s", modelDir)
logger.Debug(" CACHE_DIR: %s", cacheDir)
logger.Debug(" GO_SPEECH_VOICE: %s", getEnv("GO_SPEECH_VOICE", "ruslan"))
logger.Debug(" GO_SPEECH_MODE: %s", getEnv("GO_SPEECH_MODE", "debug"))
logger.Info("Директория с моделями: %s", modelDir)
logger.Info("Директория кэша: %s", cacheDir)
// Проверка наличия сертификатов
logger.Debug("Проверка SSL сертификатов...")
if _, err := os.Stat(certFile); os.IsNotExist(err) {
logger.Error("SSL сертификат не найден: %s", certFile)
log.Fatalf("SSL сертификат не найден: %s. Создайте сертификаты или укажите путь через CERT_FILE", certFile)
}
logger.Debug("SSL сертификат найден: %s", certFile)
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
logger.Error("SSL ключ не найден: %s", keyFile)
log.Fatalf("SSL ключ не найден: %s. Создайте ключ или укажите путь через KEY_FILE", keyFile)
}
logger.Debug("SSL ключ найден: %s", keyFile)
// Загрузка TLS сертификатов
logger.Debug("Загрузка TLS сертификатов...")
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
logger.Error("Ошибка загрузки TLS сертификатов: %v", err)
log.Fatalf("Ошибка загрузки TLS сертификатов: %v", err)
}
logger.Debug("TLS сертификаты успешно загружены")
// Настройка TLS конфигурации
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
// Статические файлы для фронтенда
logger.Debug("Загрузка статических файлов...")
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Error("Ошибка загрузки статических файлов: %v", err)
log.Fatalf("Ошибка загрузки статических файлов: %v", err)
}
logger.Debug("Статические файлы успешно загружены")
// Создание HTTP сервера
mux := http.NewServeMux()
// Инициализация TTS обработчика
logger.Debug("Инициализация TTS обработчика...")
ttsHandler := handlers.NewTTSHandler(piperPath, modelDir, ffmpegPath, cacheInstance)
logger.Debug("Регистрация маршрутов:")
// API endpoints
mux.HandleFunc("/go-speech/api/v1/tts", ttsHandler.HandleTTS)
logger.Debug(" POST /go-speech/api/v1/tts - синтез речи")
mux.HandleFunc("/go-speech/api/v1/healthz", ttsHandler.HandleHealthz)
logger.Debug(" GET /go-speech/api/v1/healthz - проверка TTS")
mux.HandleFunc("/go-speech/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
logger.Debug("GET /go-speech/api/v1/health - проверка работоспособности")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
logger.Debug(" GET /go-speech/api/v1/health - проверка работоспособности")
// Веб-интерфейс
mux.HandleFunc("/go-speech/front", func(w http.ResponseWriter, r *http.Request) {
logger.Debug("GET /go-speech/front - веб-интерфейс")
if r.URL.Path != "/go-speech/front" {
http.Redirect(w, r, "/go-speech/front", http.StatusMovedPermanently)
return
}
indexFile, err := staticFS.Open("index.html")
if err != nil {
logger.Error("Ошибка открытия index.html: %v", err)
http.Error(w, "Фронтенд не найден", http.StatusNotFound)
return
}
defer indexFile.Close()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.Copy(w, indexFile)
})
logger.Debug(" GET /go-speech/front - веб-интерфейс")
// Обработчик для статических ресурсов фронтенда
mux.Handle("/go-speech/front/", http.StripPrefix("/go-speech/front/", http.FileServer(http.FS(staticFS))))
logger.Debug(" GET /go-speech/front/* - статические ресурсы")
// Помощь - рендеринг README.md
mux.HandleFunc("/go-speech/help", handleHelp)
logger.Debug(" GET /go-speech/help - документация")
server := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: loggingMiddleware(mux),
TLSConfig: tlsConfig,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
logger.Debug("Настройки HTTP сервера:")
logger.Debug(" ReadTimeout: %v", server.ReadTimeout)
logger.Debug(" WriteTimeout: %v", server.WriteTimeout)
logger.Debug(" IdleTimeout: %v", server.IdleTimeout)
// Graceful shutdown
go func() {
// Создаем явный IPv4 listener
logger.Debug("Создание IPv4 listener на 0.0.0.0:%s...", port)
listener, err := net.Listen("tcp4", "0.0.0.0:"+port)
if err != nil {
logger.Error("Ошибка создания IPv4 listener: %v", err)
log.Fatalf("Ошибка создания IPv4 listener: %v", err)
}
logger.Debug("IPv4 listener успешно создан: %v", listener.Addr())
// Создаем TLS listener
logger.Debug("Создание TLS listener...")
tlsListener := tls.NewListener(listener, tlsConfig)
logger.Debug("TLS listener успешно создан")
logger.Info("Сервер запущен на https://0.0.0.0:%s (IPv4)", port)
logger.Info("Простой веб-интерфейс доступен на https://localhost:%s/go-speech/front", port)
logger.Info("Справка доступна по пути https://localhost:%s/go-speech/help", port)
logger.Debug("Запуск server.Serve()...")
if err := server.Serve(tlsListener); err != nil && err != http.ErrServerClosed {
logger.Error("Ошибка запуска сервера: %v", err)
log.Fatalf("Ошибка запуска сервера: %v", err)
}
logger.Debug("server.Serve() завершился")
}()
// Ожидание сигнала для graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
logger.Debug("Ожидание сигнала для остановки (SIGINT/SIGTERM)...")
sig := <-quit
logger.Info("Получен сигнал: %v", sig)
logger.Info("Остановка сервера...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger.Debug("Graceful shutdown с таймаутом 5 секунд...")
if err := server.Shutdown(ctx); err != nil {
logger.Error("Ошибка при остановке сервера: %v", err)
log.Fatalf("Ошибка при остановке сервера: %v", err)
}
logger.Info("Сервер остановлен")
}
// loggingMiddleware добавляет логирование запросов
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
logger.Debug("Входящий запрос: %s %s от %s", r.Method, r.URL.Path, r.RemoteAddr)
logger.Debug(" User-Agent: %s", r.UserAgent())
logger.Debug(" Content-Type: %s", r.Header.Get("Content-Type"))
logger.Debug(" Content-Length: %s", r.Header.Get("Content-Length"))
next.ServeHTTP(w, r)
duration := time.Since(start)
logger.Info("%s %s %v", r.Method, r.URL.Path, duration)
logger.Debug("Запрос обработан за %v", duration)
})
}
// getEnv получает переменную окружения или возвращает значение по умолчанию
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// GetModelPath формирует путь к модели на основе выбранного голоса
func GetModelPath(modelDir, voice string) string {
// Если указан полный путь через MODEL_PATH, используем его
if modelPath := os.Getenv("MODEL_PATH"); modelPath != "" {
return modelPath
}
// Формируем путь к модели на основе голоса
modelFile := fmt.Sprintf("ru_RU-%s-medium.onnx", voice)
return filepath.Join(modelDir, modelFile)
}
// handleHelp рендерит README.md в HTML
func handleHelp(w http.ResponseWriter, r *http.Request) {
logger.Debug("GET /go-speech/help - отображение документации")
// Простой рендеринг markdown в HTML
html := markdownToHTML(readmeContent)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}
// markdownToHTML конвертирует markdown в простой HTML
func markdownToHTML(md string) string {
html := `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Speech - Документация</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.6;
color: #333;
background: #f5f5f5;
}
h1, h2, h3 { color: #667eea; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 8px; overflow-x: auto; }
pre code { background: none; padding: 0; }
a { color: #667eea; text-decoration: none; }
a:hover { text-decoration: underline; }
blockquote { border-left: 4px solid #667eea; padding-left: 20px; margin-left: 0; color: #666; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #667eea; color: white; }
</style>
</head>
<body>
`
// Простая конвертация markdown в HTML
lines := strings.Split(md, "\n")
inCodeBlock := false
inList := false
var codeBlock strings.Builder
for i, line := range lines {
line = strings.TrimRight(line, "\r")
trimmedLine := strings.TrimSpace(line)
// Обработка блоков кода
if strings.HasPrefix(trimmedLine, "```") {
if inCodeBlock {
// Закрываем блок кода
html += "<pre><code>" + template.HTMLEscapeString(codeBlock.String()) + "</code></pre>\n"
codeBlock.Reset()
inCodeBlock = false
} else {
// Открываем блок кода
inCodeBlock = true
}
if inList {
html += "</ul>\n"
inList = false
}
continue
}
if inCodeBlock {
codeBlock.WriteString(line + "\n")
continue
}
// Заголовки
if strings.HasPrefix(trimmedLine, "# ") {
if inList {
html += "</ul>\n"
inList = false
}
html += "<h1>" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "# ")) + "</h1>\n"
continue
}
if strings.HasPrefix(trimmedLine, "## ") {
if inList {
html += "</ul>\n"
inList = false
}
html += "<h2>" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "## ")) + "</h2>\n"
continue
}
if strings.HasPrefix(trimmedLine, "### ") {
if inList {
html += "</ul>\n"
inList = false
}
html += "<h3>" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "### ")) + "</h3>\n"
continue
}
// Списки
if strings.HasPrefix(trimmedLine, "- ") {
if !inList {
html += "<ul>\n"
inList = true
}
content := strings.TrimPrefix(trimmedLine, "- ")
// Обработка inline кода в списках
content = processInlineCode(content)
html += "<li>" + content + "</li>\n"
continue
}
// Закрываем список если он был открыт
if inList && trimmedLine == "" {
html += "</ul>\n"
inList = false
}
// Пустые строки
if trimmedLine == "" {
if i < len(lines)-1 {
html += "<br>\n"
}
continue
}
// Обычный текст
content := processInlineCode(template.HTMLEscapeString(trimmedLine))
html += "<p>" + content + "</p>\n"
}
// Закрываем открытые теги
if inList {
html += "</ul>\n"
}
html += `</body>
</html>`
return html
}
// processInlineCode обрабатывает inline код в markdown
func processInlineCode(text string) string {
// Простая обработка inline кода `code`
parts := strings.Split(text, "`")
result := strings.Builder{}
for i, part := range parts {
if i%2 == 0 {
result.WriteString(part)
} else {
result.WriteString("<code>")
result.WriteString(part)
result.WriteString("</code>")
}
}
return result.String()
}

BIN
piper.tar.gz Normal file

Binary file not shown.

16
podman-generate.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Usage: $0 <text> <output_file>"
exit 1
fi
if [ -z "$2" ]; then
echo "Usage: $0 <text> <output_file>"
exit 1
fi
curl -X POST https://localhost:8443/tts -H "Content-Type: application/json" \
-d "{\"text\": \"$1\"}" \
--insecure \
-o "$2"

7
podman-run.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/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

BIN
result.ogg Normal file

Binary file not shown.

452
static/index.html Normal file
View File

@@ -0,0 +1,452 @@
<!DOCTYPE html>
<html lang="ru">
<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;
}
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;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 30px;
text-align: center;
font-weight: 600;
font-size: 28px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
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;
}
select:focus {
outline: none;
border-color: #667eea;
}
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;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
.buttons {
display: flex;
gap: 12px;
margin-top: 24px;
}
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;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.audio-player {
margin-top: 24px;
display: none;
}
.audio-player.show {
display: block;
}
audio {
width: 100%;
margin-top: 12px;
}
.status {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
text-align: center;
display: none;
}
.status.show {
display: block;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status.loading {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.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;
}
.loader-overlay.show {
display: flex;
}
.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;
}
.container {
position: relative;
}
.form-disabled {
pointer-events: none;
opacity: 0.6;
}
</style>
</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>
<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>
</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');
let currentAudioUrl = null;
let currentBlob = null;
let isPlaying = false;
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 updateButtonsState() {
// Блокируем кнопки во время воспроизведения
speakBtn.disabled = isPlaying;
downloadBtn.disabled = isPlaying || !currentBlob;
}
async function generateSpeech(text, voice) {
try {
setLoading(true);
hideStatus();
showStatus('Генерация аудио...', 'loading');
const response = await fetch('/go-speech/api/v1/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: text, voice: voice })
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Ошибка генерации аудио');
}
const blob = await response.blob();
currentBlob = blob;
currentAudioUrl = URL.createObjectURL(blob);
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', () => {
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>
</html>

BIN
test.ogg Normal file

Binary file not shown.

205
tts/piper.go Normal file
View File

@@ -0,0 +1,205 @@
package tts
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"go-speech/internal/logger"
)
type PiperService struct {
piperPath string
modelDir string
ffmpegPath string
tempDir string
}
func NewPiperService(piperPath, modelDir, ffmpegPath string) *PiperService {
tempDir := filepath.Join(os.TempDir(), "go-speech")
os.MkdirAll(tempDir, 0755)
return &PiperService{
piperPath: piperPath,
modelDir: modelDir,
ffmpegPath: ffmpegPath,
tempDir: tempDir,
}
}
// GenerateAudio генерирует аудио файл из текста и возвращает путь к OGG файлу
func (s *PiperService) GenerateAudio(text string, voice string) (string, error) {
logger.Debug("=== Генерация аудио ===")
logger.Debug("Параметры:")
logger.Debug(" Текст: %s (длина: %d символов)", text, len(text))
logger.Debug(" Голос: %s", voice)
logger.Debug(" Piper путь: %s", s.piperPath)
logger.Debug(" Директория моделей: %s", s.modelDir)
logger.Debug(" FFmpeg путь: %s", s.ffmpegPath)
logger.Debug(" Временная директория: %s", s.tempDir)
// Формируем путь к модели на основе голоса
modelFile := fmt.Sprintf("ru_RU-%s-medium.onnx", voice)
modelPath := filepath.Join(s.modelDir, modelFile)
logger.Debug("Путь к модели: %s", modelPath)
// Создание уникального имени файла
timestamp := time.Now().UnixNano()
wavPath := filepath.Join(s.tempDir, fmt.Sprintf("speech_%d.wav", timestamp))
oggPath := filepath.Join(s.tempDir, fmt.Sprintf("speech_%d.ogg", timestamp))
logger.Debug("Временные файлы:")
logger.Debug(" WAV: %s", wavPath)
logger.Debug(" OGG: %s", oggPath)
// Проверка наличия Piper
logger.Debug("Проверка наличия Piper TTS...")
if _, err := os.Stat(s.piperPath); os.IsNotExist(err) {
logger.Error("Piper TTS не найден по пути: %s", s.piperPath)
return "", fmt.Errorf("Piper TTS не найден по пути: %s", s.piperPath)
}
logger.Debug("Piper TTS найден")
// Проверка наличия модели
logger.Debug("Проверка наличия модели...")
if _, err := os.Stat(modelPath); os.IsNotExist(err) {
logger.Error("Модель не найдена по пути: %s", modelPath)
return "", fmt.Errorf("Модель не найдена по пути: %s", modelPath)
}
logger.Debug("Модель найдена")
// Генерация WAV файла через Piper
logger.Debug("Запуск Piper TTS для генерации WAV...")
cmd := exec.Command(s.piperPath,
"--model", modelPath,
"--output_file", wavPath,
)
logger.Debug("Команда Piper: %s --model %s --output_file %s", s.piperPath, modelPath, wavPath)
// Получение stdin pipe для передачи текста
logger.Debug("Создание stdin pipe...")
stdin, err := cmd.StdinPipe()
if err != nil {
logger.Error("Ошибка создания stdin pipe: %v", err)
return "", fmt.Errorf("ошибка создания stdin pipe: %v", err)
}
// Запуск команды
logger.Debug("Запуск процесса Piper...")
if err := cmd.Start(); err != nil {
logger.Error("Ошибка запуска Piper: %v", err)
return "", fmt.Errorf("ошибка запуска Piper: %v", err)
}
logger.Debug("Процесс Piper запущен (PID: %d)", cmd.Process.Pid)
// Отправка текста в Piper через stdin
logger.Debug("Отправка текста в Piper через stdin...")
textToSend := text + "\n"
if _, err := stdin.Write([]byte(textToSend)); err != nil {
stdin.Close()
cmd.Wait()
logger.Error("Ошибка записи текста в Piper: %v", err)
return "", fmt.Errorf("ошибка записи текста в Piper: %v", err)
}
logger.Debug("Текст отправлен (%d байт)", len(textToSend))
stdin.Close()
// Ожидание завершения Piper
logger.Debug("Ожидание завершения Piper...")
startTime := time.Now()
if err := cmd.Wait(); err != nil {
logger.Error("Ошибка выполнения Piper: %v", err)
return "", fmt.Errorf("ошибка выполнения Piper: %v", err)
}
duration := time.Since(startTime)
logger.Debug("Piper завершился успешно за %v", duration)
// Проверка наличия созданного WAV файла
logger.Debug("Проверка наличия WAV файла...")
fileInfo, err := os.Stat(wavPath)
if os.IsNotExist(err) {
logger.Error("WAV файл не был создан")
return "", fmt.Errorf("WAV файл не был создан")
}
logger.Debug("WAV файл создан: %d байт", fileInfo.Size())
// Конвертация WAV в OGG через ffmpeg
logger.Debug("Начало конвертации WAV в OGG через ffmpeg...")
if err := s.convertToOGG(wavPath, oggPath); err != nil {
logger.Error("Ошибка конвертации в OGG: %v", err)
os.Remove(wavPath) // Удаляем WAV файл при ошибке
return "", fmt.Errorf("ошибка конвертации в OGG: %v", err)
}
// Проверка размера OGG файла
oggInfo, err := os.Stat(oggPath)
if err == nil {
logger.Debug("OGG файл создан: %d байт", oggInfo.Size())
logger.Debug("Сжатие: %.2f%% (WAV: %d байт -> OGG: %d байт)",
float64(fileInfo.Size()-oggInfo.Size())/float64(fileInfo.Size())*100,
fileInfo.Size(), oggInfo.Size())
}
// Удаляем временный WAV файл
logger.Debug("Удаление временного WAV файла...")
if err := os.Remove(wavPath); err != nil {
logger.Warn("Ошибка удаления WAV файла: %v", err)
} else {
logger.Debug("WAV файл удален")
}
logger.Debug("=== Генерация аудио завершена успешно ===")
return oggPath, nil
}
// convertToOGG конвертирует WAV файл в OGG используя ffmpeg
func (s *PiperService) convertToOGG(wavPath, oggPath string) error {
logger.Debug("=== Конвертация WAV в OGG ===")
logger.Debug("Входной файл: %s", wavPath)
logger.Debug("Выходной файл: %s", oggPath)
// Проверка наличия ffmpeg
logger.Debug("Проверка наличия ffmpeg...")
if _, err := os.Stat(s.ffmpegPath); os.IsNotExist(err) {
logger.Error("ffmpeg не найден по пути: %s", s.ffmpegPath)
return fmt.Errorf("ffmpeg не найден по пути: %s", s.ffmpegPath)
}
logger.Debug("ffmpeg найден: %s", s.ffmpegPath)
cmd := exec.Command(s.ffmpegPath,
"-i", wavPath,
"-acodec", "libvorbis",
"-qscale:a", "5",
"-y", // Перезаписать выходной файл если существует
oggPath,
)
logger.Debug("Команда ffmpeg: %s -i %s -acodec libvorbis -qscale:a 5 -y %s",
s.ffmpegPath, wavPath, oggPath)
logger.Debug("Запуск ffmpeg...")
startTime := time.Now()
output, err := cmd.CombinedOutput()
duration := time.Since(startTime)
if err != nil {
logger.Error("Ошибка выполнения ffmpeg: %v", err)
logger.Debug("Вывод ffmpeg: %s", string(output))
return fmt.Errorf("ошибка выполнения ffmpeg: %v, вывод: %s", err, string(output))
}
logger.Debug("ffmpeg завершился успешно за %v", duration)
if len(output) > 0 {
logger.Debug("Вывод ffmpeg: %s", string(output))
}
// Проверка наличия созданного OGG файла
logger.Debug("Проверка наличия OGG файла...")
if _, err := os.Stat(oggPath); os.IsNotExist(err) {
logger.Error("OGG файл не был создан")
return fmt.Errorf("OGG файл не был создан")
}
logger.Debug("OGG файл успешно создан")
logger.Debug("=== Конвертация завершена ===")
return nil
}