commit fccafad6de6d71269a85d2d7f889e7866f63340e Author: Anton Kuznetcov Date: Tue Nov 25 15:08:04 2025 +0600 v1.0.4 - init version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f98ac3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +*.md +!README.md +.git +.gitignore +*.log +tmp/ +*.swp +*.swo +*~ + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80c8ca3 --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fe57fd --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..77bd55d --- /dev/null +++ b/README.md @@ -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 +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 diff --git a/docker-run.sh b/docker-run.sh new file mode 100644 index 0000000..e7d5f13 --- /dev/null +++ b/docker-run.sh @@ -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 diff --git a/generate-certs.sh b/generate-certs.sh new file mode 100755 index 0000000..73803bd --- /dev/null +++ b/generate-certs.sh @@ -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" + diff --git a/go-speech.service b/go-speech.service new file mode 100644 index 0000000..24b9547 --- /dev/null +++ b/go-speech.service @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f4879ed --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go-speech + +go 1.25.0 diff --git a/handlers/tts.go b/handlers/tts.go new file mode 100644 index 0000000..71a8a19 --- /dev/null +++ b/handlers/tts.go @@ -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 + } +} diff --git a/healthz.ogg b/healthz.ogg new file mode 100644 index 0000000..791b226 Binary files /dev/null and b/healthz.ogg differ diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..9e40cea --- /dev/null +++ b/internal/logger/logger.go @@ -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) +} + diff --git a/main.go b/main.go new file mode 100644 index 0000000..e51b406 --- /dev/null +++ b/main.go @@ -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 := ` + + + + + Go Speech - Документация + + + +` + + // Простая конвертация 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 += "
" + template.HTMLEscapeString(codeBlock.String()) + "
\n" + codeBlock.Reset() + inCodeBlock = false + } else { + // Открываем блок кода + inCodeBlock = true + } + if inList { + html += "\n" + inList = false + } + continue + } + + if inCodeBlock { + codeBlock.WriteString(line + "\n") + continue + } + + // Заголовки + if strings.HasPrefix(trimmedLine, "# ") { + if inList { + html += "\n" + inList = false + } + html += "

" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "# ")) + "

\n" + continue + } + if strings.HasPrefix(trimmedLine, "## ") { + if inList { + html += "\n" + inList = false + } + html += "

" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "## ")) + "

\n" + continue + } + if strings.HasPrefix(trimmedLine, "### ") { + if inList { + html += "\n" + inList = false + } + html += "

" + template.HTMLEscapeString(strings.TrimPrefix(trimmedLine, "### ")) + "

\n" + continue + } + + // Списки + if strings.HasPrefix(trimmedLine, "- ") { + if !inList { + html += "\n" + inList = false + } + + // Пустые строки + if trimmedLine == "" { + if i < len(lines)-1 { + html += "
\n" + } + continue + } + + // Обычный текст + content := processInlineCode(template.HTMLEscapeString(trimmedLine)) + html += "

" + content + "

\n" + } + + // Закрываем открытые теги + if inList { + html += "\n" + } + + 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("") + result.WriteString(part) + result.WriteString("") + } + } + return result.String() +} diff --git a/piper.tar.gz b/piper.tar.gz new file mode 100644 index 0000000..9d4298b Binary files /dev/null and b/piper.tar.gz differ diff --git a/podman-generate.sh b/podman-generate.sh new file mode 100644 index 0000000..414e212 --- /dev/null +++ b/podman-generate.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ -z "$2" ]; then + echo "Usage: $0 " + exit 1 +fi + +curl -X POST https://localhost:8443/tts -H "Content-Type: application/json" \ + -d "{\"text\": \"$1\"}" \ + --insecure \ + -o "$2" \ No newline at end of file diff --git a/podman-run.sh b/podman-run.sh new file mode 100644 index 0000000..7e3e986 --- /dev/null +++ b/podman-run.sh @@ -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 diff --git a/result.ogg b/result.ogg new file mode 100644 index 0000000..04c7453 Binary files /dev/null and b/result.ogg differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..1d3c4b3 --- /dev/null +++ b/static/index.html @@ -0,0 +1,452 @@ + + + + + + Go Speech - TTS + + + +
+
+
+
+
Генерация аудио...
+
+
+ +

🎤 Go Speech TTS

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + + + + diff --git a/test.ogg b/test.ogg new file mode 100644 index 0000000..bbde457 Binary files /dev/null and b/test.ogg differ diff --git a/tts/piper.go b/tts/piper.go new file mode 100644 index 0000000..66a5af3 --- /dev/null +++ b/tts/piper.go @@ -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 +}