v1.0.4 - init version
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal 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
76
Dockerfile
Normal 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
443
README.md
Normal 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
7
docker-run.sh
Normal 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
25
generate-certs.sh
Executable 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
18
go-speech.service
Normal 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
|
||||||
238
handlers/tts.go
Normal file
238
handlers/tts.go
Normal 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
BIN
healthz.ogg
Normal file
Binary file not shown.
70
internal/logger/logger.go
Normal file
70
internal/logger/logger.go
Normal 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
428
main.go
Normal 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
BIN
piper.tar.gz
Normal file
Binary file not shown.
16
podman-generate.sh
Normal file
16
podman-generate.sh
Normal 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
7
podman-run.sh
Normal 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
BIN
result.ogg
Normal file
Binary file not shown.
452
static/index.html
Normal file
452
static/index.html
Normal 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>
|
||||||
|
|
||||||
205
tts/piper.go
Normal file
205
tts/piper.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user