diff --git a/.env.example b/.env.example
index 4f90252e..1419d84e 100644
--- a/.env.example
+++ b/.env.example
@@ -109,8 +109,8 @@ owner.list="" # Список разработчиков, ук
ffmpeg.path="ffmpeg" # Путь до исполняемого файла ffmpeg
duration.optimization=15 # Время от конца для блокировки смены потока
-audio.fade=10 # Длительность плавного включения/выключения
-audio.swap.fade=3 # Длительность плавного включения/выключения при переключении трека или применения фильтра
+audio.fade=5 # Длительность плавного включения/выключения
+audio.swap.fade=2 # Длительность плавного включения/выключения при переключении трека или применения фильтра
audio.volume=50 # Громкость аудио
########################################################################################################
@@ -120,16 +120,17 @@ audio.volume=50 # Громкость аудио
########################################################################################################
cache=off # Глобальный параметр кеширования
cache.file=off # Кеширование в файлы (аудио, данные треков, логи), при отключении все будет хранится в памяти
-cache.dir="../build/.cache" # Куда все будет кешироваться
+cache.dir="build/.cache" # Куда все будет кешироваться
########################################################################################################
### APIs Sub System ###
### Здесь можно задать кол-во выдаваемых объектов ###
########################################################################################################
-APIs.limit.search=8 #Кол-во треков в поиске
-APIs.limit.author=8 #Кол-во треков при запросе автора
-APIs.limit.playlist=70 #Кол-во треков при получении плейлиста
-APIs.limit.album=70 #Кол-во треков при получении альбома
+APIs.limit.search=8 # Кол-во треков в поиске
+APIs.limit.author=8 # Кол-во треков при запросе автора
+APIs.limit.playlist=70 # Кол-во треков при получении плейлиста
+APIs.limit.album=70 # Кол-во треков при получении альбома
+APIs.limit.related=10 # Кол-во треков при похожих треков
########################################################################################################
### Имена или индикаторы emoji внутри системы discord ###
@@ -179,7 +180,6 @@ button.autoplay="♾"
button.queue="📑"
button.lyrics="💽"
button.filters="🎛"
-button.replay="🔄"
button.stop="🟥"
########################################################################################################
diff --git a/.github/images/src.png b/.github/images/src.png
index ab49c895..1f65f8e5 100644
Binary files a/.github/images/src.png and b/.github/images/src.png differ
diff --git a/README.md b/README.md
index 06e1f14f..89a31233 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
🌟 Discord Music Bot 💫
-
Невероятный бот с собственным голосовым/аудио движком, масштабируемой архитектурой, множеством фильтров и поддержкой 6 музыкальных платформ.
-
Качество аудио превосходит lavalink, не верите? Послушайте сами! Работает без просадок даже на ARM!
+
Невероятный бот с собственным голосовым/аудио движком, масштабируемой архитектурой, множеством фильтров и поддержкой 6 музыкальных платформ.
+
Качество аудио превосходит lavalink и использует E2EE 🔐, не верите? Послушайте сами! Работает без просадок даже на ARM!
@@ -23,9 +23,6 @@
-
-
-
@@ -42,28 +39,30 @@
- 👤 [`SNIPPIK`](https://github.com/SNIPPIK)
- 💡 [`GHOST-OF-THE-ABYSS`](https://github.com/GHOST-OF-THE-ABYSS) — идеи и предложения
-📢 Об ошибках и недочётах просим сообщать в [Issues](https://github.com/SNIPPIK/UnTitles/issues)
+📢 Об ошибках и недочётах просим сообщать в [`Issues`](https://github.com/SNIPPIK/UnTitles/issues) или [`Discord`](https://discord.gg/qMf2Sv3)
🚫 Бот не работает 24/7 — он может быть недоступен!
[](https://discord.com/oauth2/authorize?client_id=623170593268957214)
[](https://discord.gg/qMf2Sv3)
-> [!WARNING]
+> [!WARNING]
> ⚠️ WatKLOK (UnTitles) — это сложный технический проект, который поддерживается исключительно 1 автором `SNIPPIK`
-> Некорректное использование, удаление авторства или присвоение приведут к закрытию публичного репозитория
-
+> Некорректное использование, удаление авторства или присвоение приведут к закрытию публичного репозитория
+>
+> Audio issues
+> Если ваш интернет не стабилен потери будут в любом случае.
+> Полностью устранить `packet lost` не возможно, из-за протокола `UDP` и прочих ограничений `discord`
-> [!IMPORTANT]
-> Если нет ответа от YouTube — установите `ytdlp-nodejs`. Настоятельно рекомендуется включить кеширование
-> `main` — стабильная, но редко обновляемая ветка
-> `beta` — новейшие фиксы и функции, может быть нестабильной
+> [!TIP]
+> Рекомендую включить систему кэширования в `.env`, в таком случае можно включать треки даже при полной блокировке платформы
+> Но голосовой системе просто не дозволено терять аудио пакеты даже при критической нагрузке!
---
### ⚠️ Требования к железу | Данные с Ryzen 7 5700x3D | 1 плеер
- CPU: 0-0.1%
-- Ram: ~80 MB, все зависит от кол-ва треков, нагрузки на платформы, а именно youtube!
-- Disk: ~50 MB, для кеширования хватает 200 GB (1.5к треков ~1.2 GB)
+- RAM: `~80 MB`, все зависит от кол-ва треков, нагрузки на платформы, кеша discord!
+- Disk: `~50 MB`, для кеширования хватает `200 GB` (1.5к треков ~1.2 GB)
---
@@ -77,10 +76,12 @@ setInterval(() => {
}, 200);
```
#### 🔊 Голосовой движок
-- Реализация [Voice Gateway Version 8](https://discord.com/developers/docs/topics/voice-connections) [`(WebSocket + UDP + SRTP + Opus + Sodium)`](src/core/voice) + [End-to-End Encryption (DAVE Protocol)](https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol)
+- Реализация [**Voice Gateway Version 8**](https://discord.com/developers/docs/topics/voice-connections) [`(WebSocket + UDP + SRTP + Opus + Sodium)`](src/core/voice) + [**End-to-End Encryption (E2EE 🔐)**](https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol)
- Полная реализация **SRTP**: `aead_aes256_gcm`, `xchacha20_poly1305` (через библиотеки)
+- Лучший аудио плеер по сравнению с **open source** решениями
- Не требует никаких opus encoders/decoders, имеет свой opus encoder по методу парсинга!
-- Адаптивная система отправки аудио пакетов, свой `Jitter Buffer`!
+- Свой `jitter buffer`, очень схож с WebRTP по логике!
+- Авто калибровка задержки голосового подключения, для компенсации задержек сети
- Требуется **FFmpeg**, он отвечает за аудио и фильтры!
- Поддерживаются: Autoplay, Repeat, Shuffle, Replay и другие функции.
- Работает даже при сильном **event loop lag**!
@@ -88,20 +89,20 @@ setInterval(() => {
- Есть возможность переиспользовать аудио без конвертации если оно длительностью менее 8 мин
- Плавный **fade-in/fade-out** переход между треками, даже при **skip**, **seek** и **тп**.
- Есть система плавного перехода от одного аудио к другому `Hot audio swap`
-- 16+ фильтров, можно добавить свои без сложного копания в коде [filters](src/core/player/filters.json)
-- Есть поддержка длинных видео, Live видео пока сыровато.
+- 16+ фильтров, можно добавить свои без сложного копания в коде [**filters**](src/core/player/filters.json)
+- Есть поддержка длинных видео, Live, пока сыровато.
- Присутствует явная синхронизация аудио потока, без аудио фильтров!
#### 🌐 Платформы
- Поддерживаются: `YouTube`, `Spotify`, `VK`, `Yandex-Music`, `SoundCloud`, `Deezer`
- Аудио: `YouTube`, `VK`, `Yandex-Music` **(MP3 + Lossless)**, `SoundCloud`
-- Точный поиск при отсутствии аудио, через время и названия по слогам
-- Есть поиск на других платформах при отсутствии аудио!
+- Есть поиск аудио на других платформах, даже если платформа не хочет отдавать аудио!
+- Полностью `fallback` система, нет трека на 1 платформе найдется на другой!
- Есть поддержка `related`(**похожих треков**), включение похожих треков
-- Платформы работают в отдельном **worker** (потоке) для производительности
+- Платформы работают в отдельном **worker** (потоке) для лучшей производительности
- Все подробно расписано, есть примеры и куча интерфейсов для типизации
- Легкое расширение и добавление новых платформ через `Динамический загрузчик - Handler`
#### 🌍 Локализация
-- Доступные языки: `English`, `Русский` ([файл с языками](src/structures/locale/languages.json))
+- Доступные языки: `English`, `Русский` ([**файл с языками**](src/structures/locale/languages.json))
- Можно добавить любой язык поддерживаемый discord
---
@@ -116,13 +117,13 @@ setInterval(() => {
#### 💡 Адаптивный цикл
- Не боится **event loop** и **drift**, он просто учитывает их не как проблему, а как параметры!
- Цикл может срабатывать на опережение от 0 до 2 ms для обработки объектов в цикле!
-- Аудио отправка построена именно на нем!
+- Работает на своих вычислениях, а не на `newTime - oldTime`, вычисления задержки функции, расхождения после выполнения и прочее
- Точность цикла `±0.05 ms` при `process.hrtime` + `performance.now`
#### ⚙️ Внутренние инструменты
-- [`SetArray`](src/structures/tools/SetArray.ts) - 2 в одном Array и Set в один класс
+- [`SetArray`](src/structures/array/index.set.ts) - 2 в одном Array и Set в один класс
- [`Cycle`](src/structures/tools/Cycle.ts) - Управляет системой обновления сообщений и отправкой аудио пакетов
-- [`TypedEmitter`](src/structures/tools/TypedEmitter.ts) - типизированный `EventEmitterAsyncResource`
+- [`TypedEmitter`](src/structures/tools/TypedEmitter.ts) - Пользовательский объект на основе `object`
- [`SimpleWorker`](src/structures/tools/SimpleWorker.ts) - Класс для работы с потоками
---
@@ -144,6 +145,7 @@ setInterval(() => {
| `/remove` | ✅ | value | Удаление трека |
| `/seek` | ❌ | 00:00, int | Перемотка времени трека |
| `/skip` | ✅ | (back, to, next) | Пропуск треков |
+| `/repeat` | ✅ | type | Тип повтора |
| `/queue` | ✅ | {destroy, list} | Управление очередью |
| `/avatar` | ✅ | {user} | Аватар пользователя |
| `/voice` | ✅ | (join, leave, tribune) | Голосовой канал |
@@ -151,8 +153,8 @@ setInterval(() => {
---
## 🚀 Быстрый старт
-> Необходимы Node.js или Bun, а также установленный FFmpeg
-> Вся конфигурация прописана в `.env`
+> Необходим Node.js, а также установленный FFmpeg
+> Все параметры прописаны в `.env`, не забудьте скопировать его в `.build` и настроить его под себя
```shell
# Клонируем
git clone https://github.com/SNIPPIK/UnTitles
@@ -164,18 +166,12 @@ npm install
# Запуск через Node.js
# настройка переменных окружения в build/.env
npm run build && npm run start
-
-# Запуск через Bun (пока не работает)
-# настройка переменных окружения в ./env
-npm i dotenv
-bun run start-bun
```
---
-[](https://www.typescriptlang.org/)
-[](https://bun.com/)
+[](https://www.typescriptlang.org/)
[](https://nodejs.org/en)
-[](https://discord.js.org/)
+[](https://discord.js.org/)
[](https://www.npmjs.com/package/ws)
[](https://ffmpeg.org/)
---
diff --git a/package-lock.json b/package-lock.json
index e642b4f4..df77daad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,40 +1,40 @@
{
"name": "untitles",
- "version": "0.3.2",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "untitles",
- "version": "0.3.2",
+ "version": "0.4.0",
"license": "BSD-3-Clause + custom restriction",
"dependencies": {
- "discord-api-types": "^0.38.26",
- "discord.js": "^14.22.1",
+ "discord-api-types": "^0.38.36",
+ "discord.js": "14.25.1",
"ws": "^8.18.3"
},
"devDependencies": {
- "@types/node": "^24.5.2",
+ "@types/node": "^24.10.1",
"tsconfig-paths": "^4.2.0",
- "typescript": "^5.9.2"
+ "typescript": "^5.9.3"
},
"engines": {
- "node": ">=23.0.0"
+ "node": ">=24.0.0"
},
"optionalDependencies": {
- "@snazzah/davey": "^0.1.6"
+ "@snazzah/davey": "^0.1.8"
}
},
"node_modules/@discordjs/builders": {
- "version": "1.11.3",
- "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.3.tgz",
- "integrity": "sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==",
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz",
+ "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.1",
"@discordjs/util": "^1.1.1",
"@sapphire/shapeshift": "^4.0.0",
- "discord-api-types": "^0.38.16",
+ "discord-api-types": "^0.38.31",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
@@ -56,12 +56,12 @@
}
},
"node_modules/@discordjs/formatters": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz",
- "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
+ "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"dependencies": {
- "discord-api-types": "^0.38.1"
+ "discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
@@ -106,10 +106,13 @@
}
},
"node_modules/@discordjs/util": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz",
- "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
+ "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
+ "dependencies": {
+ "discord-api-types": "^0.38.33"
+ },
"engines": {
"node": ">=18"
},
@@ -153,20 +156,20 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
- "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
+ "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.0.4",
+ "@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
- "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -174,9 +177,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
- "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+ "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -184,15 +187,15 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "0.2.12",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
- "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
+ "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==",
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.4.3",
- "@emnapi/runtime": "^1.4.3",
- "@tybys/wasm-util": "^0.10.0"
+ "@emnapi/core": "^1.5.0",
+ "@emnapi/runtime": "^1.5.0",
+ "@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@sapphire/async-queue": {
@@ -229,9 +232,9 @@
}
},
"node_modules/@snazzah/davey": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.6.tgz",
- "integrity": "sha512-wKJDQ7iobl3rvuQDXLC2yZdpuVxPvMnbyjyPpkcETqPfqNVrdyX9zSdV74dnkpx7aLpINEmKh8ZEIlCIJA2h1w==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.8.tgz",
+ "integrity": "sha512-f4NkCtU/4r9DUohp636qkZkxCF19v8Xq6W/1m4IKY2emZm+M2IdhWIv4GQxog2bQBWLNRRLD4DNM/UXEUb8W/A==",
"license": "MIT",
"optional": true,
"engines": {
@@ -241,26 +244,26 @@
"url": "https://github.com/sponsors/Snazzah"
},
"optionalDependencies": {
- "@snazzah/davey-android-arm-eabi": "0.1.6",
- "@snazzah/davey-android-arm64": "0.1.6",
- "@snazzah/davey-darwin-arm64": "0.1.6",
- "@snazzah/davey-darwin-x64": "0.1.6",
- "@snazzah/davey-freebsd-x64": "0.1.6",
- "@snazzah/davey-linux-arm-gnueabihf": "0.1.6",
- "@snazzah/davey-linux-arm64-gnu": "0.1.6",
- "@snazzah/davey-linux-arm64-musl": "0.1.6",
- "@snazzah/davey-linux-x64-gnu": "0.1.6",
- "@snazzah/davey-linux-x64-musl": "0.1.6",
- "@snazzah/davey-wasm32-wasi": "0.1.6",
- "@snazzah/davey-win32-arm64-msvc": "0.1.6",
- "@snazzah/davey-win32-ia32-msvc": "0.1.6",
- "@snazzah/davey-win32-x64-msvc": "0.1.6"
+ "@snazzah/davey-android-arm-eabi": "0.1.8",
+ "@snazzah/davey-android-arm64": "0.1.8",
+ "@snazzah/davey-darwin-arm64": "0.1.8",
+ "@snazzah/davey-darwin-x64": "0.1.8",
+ "@snazzah/davey-freebsd-x64": "0.1.8",
+ "@snazzah/davey-linux-arm-gnueabihf": "0.1.8",
+ "@snazzah/davey-linux-arm64-gnu": "0.1.8",
+ "@snazzah/davey-linux-arm64-musl": "0.1.8",
+ "@snazzah/davey-linux-x64-gnu": "0.1.8",
+ "@snazzah/davey-linux-x64-musl": "0.1.8",
+ "@snazzah/davey-wasm32-wasi": "0.1.8",
+ "@snazzah/davey-win32-arm64-msvc": "0.1.8",
+ "@snazzah/davey-win32-ia32-msvc": "0.1.8",
+ "@snazzah/davey-win32-x64-msvc": "0.1.8"
}
},
"node_modules/@snazzah/davey-android-arm-eabi": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.6.tgz",
- "integrity": "sha512-6Fso+kxvvIcmUdTgU4etHjvEZUwGwvIk+SUYxKTRZKz/S62pZvcFeZfbofpQC5ZIlt/rdp7l+4IM62J7PUduxQ==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.8.tgz",
+ "integrity": "sha512-kRrrU2AldUEG8+Qqj+d5bofm1xzHEX2e657TFHOzKXObU7igqINFL61opcb30cMHcnMTGthV3bFFlh6ky0xrqw==",
"cpu": [
"arm"
],
@@ -274,9 +277,9 @@
}
},
"node_modules/@snazzah/davey-android-arm64": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.6.tgz",
- "integrity": "sha512-5ZGLumjewJAmGAcHqSHb2+KZSSufdNY++/GouzqdQXfhs2bSNBPuHpNn94u6//5UK0o73udJ6B1H/uLOLfEBLQ==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.8.tgz",
+ "integrity": "sha512-ttGznhY/vy0aUI+wwvt9Ati0U4gujKKjVwBOaOY6Ug+DKaPIc3Sah73XMXoX9QB9TyUBkH7lxvhpI+VneIcg4w==",
"cpu": [
"arm64"
],
@@ -290,9 +293,9 @@
}
},
"node_modules/@snazzah/davey-darwin-arm64": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.6.tgz",
- "integrity": "sha512-0k6gOm29bcznz4ND1gfJVKeCxfyFw/EtfhPQvQ2PPJToSIaSvVqfYIlj/v9ogWW/lzuPI4EbLP0b6hnZkKidbQ==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.8.tgz",
+ "integrity": "sha512-c75j0DuAWZ20XgaTVCXzks2Gl9L0+dC0ID1NSdvoOqBRCphjHBUSf1te2hdWJKI3Dq1uPaJ00JL6fYUkpb+Wxw==",
"cpu": [
"arm64"
],
@@ -306,9 +309,9 @@
}
},
"node_modules/@snazzah/davey-darwin-x64": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.6.tgz",
- "integrity": "sha512-y9UuymB5JTi9LSwjsCZDf/mjI6nAum1+uYX2h4xdO+VUxXQSAR4B2mr3lCI7l9KwYqW7JVDN5wETithAkXcTYA==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.8.tgz",
+ "integrity": "sha512-/SAvTmkEc73R4JSAfwVGMcVzwtjvmduaZHWnF49fCO1iFGR688U8k4W1obM9JkaNoLg4LPvU4HGMuDvfxh/gUA==",
"cpu": [
"x64"
],
@@ -322,9 +325,9 @@
}
},
"node_modules/@snazzah/davey-freebsd-x64": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.6.tgz",
- "integrity": "sha512-G0XzHi+pZqTZ5Zr7Z66J6oGOG07+Obw7f0CwD9nAJcSFlKnd8wYzTjL+krHfQxmLHnuA5w/9df+M9oDJDcGcJw==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.8.tgz",
+ "integrity": "sha512-91gPSY0gcbq/o7yqpBfWyrck7yZ4nciH3LJlk55tpC+NExedWUtH2HvsqVqqoS8TmNu+1msT0+5nJsq65flWPQ==",
"cpu": [
"x64"
],
@@ -338,9 +341,9 @@
}
},
"node_modules/@snazzah/davey-linux-arm-gnueabihf": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.6.tgz",
- "integrity": "sha512-RaxTzO8iJfDvj4a8OcXRwcP+2WfaCcno28ZWFMTI0pHEviG3MfLH5COAIvtMQvg0XfC+HgFC4YA1d29S8Dhvbg==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.8.tgz",
+ "integrity": "sha512-nJQOsykF5XFngsxxv6zKQX0bPfqE2YotlcPajHKVR7COSbrYgFXZ+Cck+nILiDqpCU+4fk7ae+8Knx181qbWYg==",
"cpu": [
"arm"
],
@@ -354,9 +357,9 @@
}
},
"node_modules/@snazzah/davey-linux-arm64-gnu": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.6.tgz",
- "integrity": "sha512-2BIJSWs4rHq4U9A7B6WtF1LzwYJrbFUz5SQVmwqwQXKJ8cm81iizqclDGWr3zFGiVPTXLZ/+G3wnQNDB54oABQ==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.8.tgz",
+ "integrity": "sha512-IaALezD2F3Qt2n1GL5Wm2aQEjeiJI6gzSEhEHYiqyZUM/LygCyRXSDkoPAvHGuu3Xwy5m58Z20pfukxA49xbGQ==",
"cpu": [
"arm64"
],
@@ -370,9 +373,9 @@
}
},
"node_modules/@snazzah/davey-linux-arm64-musl": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.6.tgz",
- "integrity": "sha512-N1egO+HT8cvSdIGCzJNRVH3ZhxCIYKVYxEkfzVZaBx26snN8NF737YTVRldl84w//3tdgohyl27yrn+dMkWS2Q==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.8.tgz",
+ "integrity": "sha512-OhEwQmZ/ntiqHoSAxRe1qVfELdKMREqwlf52vhPMFVa4XNyT6jNYXGanTMjYX2+jhH9SlJ7cqsADm1VFvjRnGA==",
"cpu": [
"arm64"
],
@@ -386,9 +389,9 @@
}
},
"node_modules/@snazzah/davey-linux-x64-gnu": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.6.tgz",
- "integrity": "sha512-RAC96Y//HHoMP+1MUf4rOkBq5Nx6GCiOGeGsNXt7r02lbIthoFEPYFQdbfc9jYA79k67gpzmCa0N5ws7ZLVU5A==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.8.tgz",
+ "integrity": "sha512-yghgG7iXZUHy734Cq3PcgrbRnLhhB233JNTX5VPRxRqdwFAg2MzAJ2iSWpP12K6hSqKq9hw0sdt8CNpr0mEXjQ==",
"cpu": [
"x64"
],
@@ -402,9 +405,9 @@
}
},
"node_modules/@snazzah/davey-linux-x64-musl": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.6.tgz",
- "integrity": "sha512-nBvxJTKlQFP9UsQ7ah78L+rGdcwLWKDR8z/knut/M+UZLe37vaponJAbY3F5ZqGAcfqJbwUi/CXR77t9E+TDmw==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.8.tgz",
+ "integrity": "sha512-WwCiAge27ZOEu7NRx5NFjpCAkGcUGHGtoROBY4ElRYE0Tp3DfuzWU06qSu6JBQPlzhTTBN29X2/kGP8iRUwqnQ==",
"cpu": [
"x64"
],
@@ -418,25 +421,25 @@
}
},
"node_modules/@snazzah/davey-wasm32-wasi": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.6.tgz",
- "integrity": "sha512-hvpZH6a4mYZiXv6vdZaFwjPProgFtb3k4BoMvEEJZDXsEPuIDgp+d2BX5Q9nVazdnJa/6JR/XCuObzugPWp0Ew==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.8.tgz",
+ "integrity": "sha512-VAUY6eMhiS4HkHDh0SCjVbwf2SUR/cHs0IzS02p4lYwuUaqPFQnraxMNZ6R2MNOK8NmtPbCpWvi7kYMxMJuL2A==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
- "@napi-rs/wasm-runtime": "^0.2.11"
+ "@napi-rs/wasm-runtime": "^1.0.7"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@snazzah/davey-win32-arm64-msvc": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.6.tgz",
- "integrity": "sha512-iuxYXXa0Z8eAEZotAlMYUc5DCy3VonRXQMm8/w2EvM/ZzGBI7SMap0GhPf6HjArEW32ETarTLh1s/Yi/jhFPDQ==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.8.tgz",
+ "integrity": "sha512-4ZcEhyfuRH+ni4pHBrgm+2zumwgaac5+OGOKHEGezJmUd3omn/iKpcj9Z0+Y9qQ8UGslzBrgesW46ENXoOfv2Q==",
"cpu": [
"arm64"
],
@@ -450,9 +453,9 @@
}
},
"node_modules/@snazzah/davey-win32-ia32-msvc": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.6.tgz",
- "integrity": "sha512-QEubcCIBR+ZZoQzRzJuOuKcH2IaF2pFXU+t48ITHG1o2WL4NAnvc3IpfVQGhbkr+DlydZ6fKNMMEemd1pRZzRA==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.8.tgz",
+ "integrity": "sha512-znL43C+hxocxLgk/zbgCukOSNW14HYQE5G6fNeWtANHVCstkTN7hrJVbZfBnuKD+Jw1RbCs4gxLsYja1n1iZnA==",
"cpu": [
"ia32"
],
@@ -466,9 +469,9 @@
}
},
"node_modules/@snazzah/davey-win32-x64-msvc": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.6.tgz",
- "integrity": "sha512-8tR3o+amQOHJL8QzSwuSCCave+jm3SC1m1OKSh9Coy4wN/XoJN0XQUxqzA7ineSClAMW3yIO0ShFmMlMIXsC0A==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.8.tgz",
+ "integrity": "sha512-JKIco1miwtM4NgVwU/H9TJdUaSlJ+kdtydy3+tiV9cmJv0u1SM2NpwjV85H44xAQWW2zcRBHP0ZDriciHw09qQ==",
"cpu": [
"x64"
],
@@ -482,9 +485,9 @@
}
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.0",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
- "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -492,12 +495,12 @@
}
},
"node_modules/@types/node": {
- "version": "24.5.2",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
- "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
+ "version": "24.10.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
+ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"license": "MIT",
"dependencies": {
- "undici-types": "~7.12.0"
+ "undici-types": "~7.16.0"
}
},
"node_modules/@types/ws": {
@@ -510,9 +513,9 @@
}
},
"node_modules/@vladfrangu/async_event_emitter": {
- "version": "2.4.6",
- "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz",
- "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==",
+ "version": "2.4.7",
+ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
+ "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
@@ -520,28 +523,28 @@
}
},
"node_modules/discord-api-types": {
- "version": "0.38.26",
- "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.26.tgz",
- "integrity": "sha512-xpmPviHjIJ6dFu1eNwNDIGQ3N6qmPUUYFVAx/YZ64h7ZgPkTcKjnciD8bZe8Vbeji7yS5uYljyciunpq0J5NSw==",
+ "version": "0.38.36",
+ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.36.tgz",
+ "integrity": "sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
- "version": "14.22.1",
- "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.22.1.tgz",
- "integrity": "sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==",
+ "version": "14.25.1",
+ "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
+ "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"license": "Apache-2.0",
"dependencies": {
- "@discordjs/builders": "^1.11.2",
+ "@discordjs/builders": "^1.13.0",
"@discordjs/collection": "1.5.3",
- "@discordjs/formatters": "^0.6.1",
+ "@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
- "@discordjs/util": "^1.1.1",
+ "@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
- "discord-api-types": "^0.38.16",
+ "discord-api-types": "^0.38.33",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
@@ -640,9 +643,9 @@
"license": "0BSD"
},
"node_modules/typescript": {
- "version": "5.9.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
- "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -663,9 +666,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.12.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
- "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/ws": {
diff --git a/package.json b/package.json
index f1a67949..4931de7f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "untitles",
- "version": "0.3.2",
+ "version": "0.4.0",
"homepage": "https://github.com/SNIPPIK/UnTitles",
"bugs": "https://github.com/SNIPPIK/UnTitles/issues",
"author": {
@@ -12,25 +12,26 @@
"engineStrict": true,
"types": "src/index.ts",
"engines": {
- "node": ">=23.0.0"
+ "node": ">=24.0.0"
},
"dependencies": {
- "discord-api-types": "^0.38.26",
- "discord.js": "^14.22.1",
+ "discord-api-types": "^0.38.36",
+ "discord.js": "14.25.1",
"ws": "^8.18.3"
},
"optionalDependencies": {
- "@snazzah/davey": "^0.1.6"
+ "@snazzah/davey": "^0.1.8"
},
"devDependencies": {
- "@types/node": "^24.5.2",
+ "@types/node": "^24.10.1",
"tsconfig-paths": "^4.2.0",
- "typescript": "^5.9.2"
+ "typescript": "^5.9.3"
},
"scripts": {
+ "start": "cd build && node -r tsconfig-paths/register src --ShardManager --optimize_for_size --expose-gc --experimental-require-module --no-compilation-cache",
"start-bun": "bun src",
- "start": "cd build && node -r tsconfig-paths/register src --ShardManager --optimize_for_size --expose-gc",
- "auto": "npm run build && npm run start",
- "build": "tsc"
+ "build": "tsc",
+
+ "dev:start": "npm run build && cd build && node -r tsconfig-paths/register src --optimize_for_size --expose-gc"
}
}
diff --git a/src/core/audio/opus.ts b/src/core/audio/opus.ts
index b14cd119..53c8659c 100644
--- a/src/core/audio/opus.ts
+++ b/src/core/audio/opus.ts
@@ -1,12 +1,39 @@
-import { Writable, Transform, TransformOptions, WritableOptions } from "node:stream";
+import { Transform, TransformOptions, Writable, WritableOptions } from "node:stream";
import { TypedEmitter } from "#structures";
/**
* @author SNIPPIK
- * @description Заголовок для поиска opus
- * @const OGG_MAGIC
+ * @description Вспомогательная функция для создания буфера из строки
+ * @const fromString
+ * @private
*/
-const OGG_MAGIC = Buffer.from("OggS");
+const fromString = (str: string): Buffer => Buffer.from(str);
+
+/**
+ * @author SNIPPIK
+ * @description Константы и сигнатуры Ogg/Opus
+ * @const OGG_CONSTANTS
+ * @private
+ */
+const OGG_CONSTANTS = {
+ // Магическая сигнатура "OggS"
+ CAPTURE_PATTERN: fromString("OggS"),
+ // Заголовки Opus
+ OPUS_HEAD: fromString("OpusHead"),
+ OPUS_TAGS: fromString("OpusTags"),
+ // Размеры фиксированных частей заголовка
+ PAGE_HEADER_SIZE: 27,
+ // Смещения в заголовке Ogg
+ OFFSET_VERSION: 4,
+ OFFSET_TYPE: 5,
+ OFFSET_GRANULE: 6,
+ OFFSET_SERIAL: 14,
+ OFFSET_SEQ: 18,
+ OFFSET_CRC: 22,
+ OFFSET_SEGMENTS_COUNT: 26,
+ OFFSET_SEGMENT_TABLE: 27,
+};
+
/**
* @author SNIPPIK
@@ -20,24 +47,10 @@ export const SILENT_FRAME = Buffer.from([0xF8, 0xFF, 0xFE]);
* @author SNIPPIK
* @description Длительность opus фрейма в ms
* @const OPUS_FRAME_SIZE
+ * @public
*/
export const OPUS_FRAME_SIZE = 20;
-/**
- * @author SNIPPIK
- * @description Размер opus аудио пакета
- * @const OPUS_FRAME_LENGTH
- */
-const OPUS_FRAME_LENGTH = 255;
-
-/**
- * @author SNIPPIK
- * @description Пустой фрейм для предотвращения чтения null
- * @const EMPTY_FRAME
- */
-const EMPTY_FRAME = Buffer.alloc(0);
-
-
/**
* @author SNIPPIK
* @description Базовый класс декодера, ищет opus фрагменты в ogg потоке
@@ -45,117 +58,139 @@ const EMPTY_FRAME = Buffer.alloc(0);
* @extends TypedEmitter
* @private
*/
-class BaseEncoder extends TypedEmitter {
- /**
- * @description Отправлен ли 1 аудио пакет
- * @private
- */
- private _first = true;
+class OggOpusParser extends TypedEmitter {
+ /** Остаток данных от предыдущего фрейма, который не удалось обработать */
+ private _remainder: Buffer | null = null;
- /**
- * @description Временный буфер, для общения между функциями
- * @private
- */
- public _buffer: Buffer = EMPTY_FRAME;
+ /** Серийный номер битового потока, к которому мы "привязались" */
+ private _bitstreamSerial: number | null = null;
/**
* @description Функция ищущая актуальный для взятия фрагмент
- * @private
+ * @public
*/
public parseAvailablePages = (chunk: Buffer) => {
- this._buffer = Buffer.concat([this._buffer, chunk]);
+ // Объединяем входящий фрейм с остатком от предыдущего (если есть)
+ let buffer = this._remainder ? Buffer.concat([this._remainder, chunk]) : chunk;
- // Начинаем обработку буфера с начала
- const size = this._buffer.length;
let offset = 0;
+ const totalLength = buffer.length;
- // Основной цикл обработки страниц в OGG-потоке
- // Цикл продолжается, пока доступно хотя бы 27 байт — минимальный размер заголовка страницы
- while (offset + 27 <= size) {
- // Проверяем, соответствует ли текущая позиция сигнатуре "OggS" (OGG_MAGIC)
- // Это "магическая строка", которая всегда должна быть в начале страницы
- if (!this._buffer.subarray(offset, offset + 4).equals(OGG_MAGIC)) {
- // Если не совпадает, пытаемся найти ближайшую следующую сигнатуру OGG
- const next = this._buffer.indexOf(OGG_MAGIC, offset + 1);
+ // 2. Цикл обработки страниц Ogg внутри буфера
+ while (true) {
+ // Проверка: хватает ли данных хотя бы на минимальный заголовок страницы (27 байт)
+ if (totalLength - offset < OGG_CONSTANTS.PAGE_HEADER_SIZE) {
+ break;
+ }
- // Если ничего не найдено — выходим из цикла, т.к. Не можем синхронизироваться
- if (next === -1) break;
+ // Проверка сигнатуры "OggS"
+ // Мы используем subarray для сравнения без копирования памяти
+ if (!buffer.subarray(offset, offset + 4).equals(OGG_CONSTANTS.CAPTURE_PATTERN)) {
+ // Критическая ошибка: потеряна синхронизация или неверный формат.
+ // В продакшене можно попробовать найти следующее вхождение "OggS" (resync),
+ // но для простоты выбрасываем ошибку.
+ this.emit("error", new Error("OggS capture pattern not found. Stream might be corrupted."));
+ return;
+ }
- // Перемещаемся к найденной сигнатуре и пробуем снова
- offset = next;
- continue;
+ // Читаем версию структуры (должна быть 0)
+ const version = buffer.readUInt8(offset + OGG_CONSTANTS.OFFSET_VERSION);
+ if (version !== 0) {
+ this.emit("error", new Error(`Unsupported Ogg stream structure version: ${version}`));
+ return;
+ }
+
+ // Получаем количество сегментов в этой странице
+ const pageSegmentsCount = buffer.readUInt8(offset + OGG_CONSTANTS.OFFSET_SEGMENTS_COUNT);
+
+ // Проверка: хватает ли данных на таблицу сегментов
+ // Заголовок (27) + Таблица сегментов (N байт)
+ if (totalLength - offset < OGG_CONSTANTS.PAGE_HEADER_SIZE + pageSegmentsCount) {
+ break;
}
- // Проверяем, доступен ли весь заголовок страницы (27 байт)
- // Бывает, что заголовок ещё не весь пришёл — тогда ждём следующих данных
- else if (offset + 27 > this._buffer.length) break;
+ // Читаем таблицу сегментов (Lacing values) для расчета размера данных
+ let pageDataSize = 0;
+ const segmentTableStart = offset + OGG_CONSTANTS.PAGE_HEADER_SIZE;
- // Байты [offset + 26] содержит количество сегментов (Lacing Table Entries)
- // Каждая запись определяет длину одного Opus-пакета (фрагмента)
- const pageSegments = this._buffer.readUInt8(offset + 26);
- const headerLength = 27 + pageSegments;
+ for (let i = 0; i < pageSegmentsCount; i++) {
+ pageDataSize += buffer.readUInt8(segmentTableStart + i);
+ }
- // Проверяем, пришла ли вся сегментная таблица
- // Если нет — выходим, ждём следующих данных
- if (offset + headerLength > size) break;
+ // Полный размер страницы = Заголовок + Таблица сегментов + Сами данные
+ const totalPageSize = OGG_CONSTANTS.PAGE_HEADER_SIZE + pageSegmentsCount + pageDataSize;
- // Проверяем, получена ли вся страница
- // Если нет — выход из цикла до прихода полной страницы
- const segmentTable = this._buffer.subarray(offset + 27, offset + 27 + pageSegments);
- const totalSegmentLength = segmentTable.reduce((sum, val) => sum + val, 0);
- const fullPageLength = headerLength + totalSegmentLength;
+ // Проверка: загружена ли вся страница целиком?
+ if (totalLength - offset < totalPageSize) {
+ break;
+ }
- // Извлекаем содержимое страницы — начиная с конца таблицы и до конца страницы
- if (offset + fullPageLength > size) break;
+ // --- ОБРАБОТКА СТРАНИЦЫ ---
- // Передаём таблицу сегментов и payload в обработчике, который выделяет Opus-пакеты
- const payload = this._buffer.subarray(offset + headerLength, offset + fullPageLength);
- this.extractPackets(segmentTable, payload);
+ // Проверяем Serial Number. Если это первая страница, запоминаем его.
+ const serial = buffer.readUInt32BE(offset + OGG_CONSTANTS.OFFSET_SERIAL);
- // Смещаем offset на конец текущей страницы и продолжаем со следующей
- offset += fullPageLength;
+ if (this._bitstreamSerial === null) this._bitstreamSerial = serial;
+ else if (this._bitstreamSerial !== serial) {
+ // Это страница из другого логического потока (мультиплексирование), пропускаем её
+ offset += totalPageSize;
+ continue;
+ }
+
+ // Извлекаем пакеты данных
+ let dataStart = segmentTableStart + pageSegmentsCount;
+ let packetSize = 0;
+
+ // Итерируемся по таблице сегментов снова, чтобы собрать пакеты
+ for (let i = 0; i < pageSegmentsCount; i++) {
+ const segmentSize = buffer.readUInt8(segmentTableStart + i);
+ packetSize += segmentSize;
+
+ // Если размер сегмента < 255, это конец логического пакета
+ if (segmentSize < 255) {
+ const packet = buffer.subarray(dataStart, dataStart + packetSize);
+ this.extractPackets(packet);
+
+ // Сдвигаем указатель данных на следующий пакет
+ dataStart += packetSize;
+ packetSize = 0;
+ }
+ }
+
+ // Сдвигаем глобальный offset на размер обработанной страницы
+ offset += totalPageSize;
}
- // После выхода из цикла: обрезаем буфер, удаляя обработанные байты
- // Это важно, чтобы избежать переполнения и сохранить только "хвост", который ещё не разобран
- this._buffer = this._buffer.subarray(offset);
+ // Сохраняем необработанный остаток для следующего вызова
+ if (offset < totalLength) {
+ // Копируем остаток в новый буфер, чтобы не удерживать ссылку на огромный старый chunk
+ this._remainder = Buffer.from(buffer.subarray(offset));
+ }
+
+ // Если нет остатка, тогда просто удаляем его
+ // Ключевая оптимизация, при серьезной нагрузке спасает от OggS capture pattern not found
+ else this._remainder = null;
};
/**
- * @description Функция выделяющая opus пакет для отправки и передается через событие frame
- * @param segmentTable - Буфер сегментов
- * @param payload - Данные для корректного поиска сегмента
+ * @description Обработка и маршрутизация извлеченного пакета
+ * @param packet - Аудио данные
* @private
*/
- private extractPackets = (segmentTable: Buffer, payload: Buffer) => {
- let currentPacket: Buffer[] = [], payloadOffset = 0;
-
- // Проверяем все фреймы
- for (const segmentLength of segmentTable) {
- currentPacket.push(payload.subarray(payloadOffset, payloadOffset + segmentLength));
- payloadOffset += segmentLength;
-
- // Если сегмент меньше 255 — пакет окончен
- if (segmentLength < OPUS_FRAME_LENGTH) {
- const packet = Buffer.concat(currentPacket);
- currentPacket = [];
-
- // Если найден заголовок
- // 19 - Head frame
- if (isOpusHead(packet)) continue;
-
- // Если найден тег
- // 296 - Tags frame
- else if (isOpusTags(packet)) continue;
-
- // Если не отправлен 1 opus frame
- else if (this._first) {
- this.emit("frame", SILENT_FRAME);
- this._first = false;
- }
-
- this.emit("frame", packet);
- }
+ private extractPackets(packet: Buffer): void {
+ const signature = packet.subarray(0, 8);
+
+ // Проверяем сигнатуру является ли это заголовок
+ if (signature.equals(OGG_CONSTANTS.OPUS_HEAD)) {
+ // Мы не пушим OpusHead в readable аудио данных, обычно это метаданные.
+ this.emit("head", packet);
+ } else if (signature.equals(OGG_CONSTANTS.OPUS_TAGS)) {
+ // Теги комментариев
+ this.emit("tags", packet);
+ } else {
+ // Это аудио данные. Отправляем дальше.
+ // Обычно первый пакет после заголовков должен быть отправлен.
+ this.emit("frame", packet);
}
};
@@ -164,12 +199,13 @@ class BaseEncoder extends TypedEmitter {
* @public
*/
public destroy() {
- // Отправляем пустой пакет последним
this.emit("frame", SILENT_FRAME);
- this._first = null;
- this._buffer = null;
+ this._remainder = null;
+ this._bitstreamSerial = null;
+ // Освобождаем emitter
+ this.removeAllListeners();
super.destroy();
};
}
@@ -187,7 +223,7 @@ export class BufferedEncoder extends Writable {
* @description Базовый класс декодера
* @private
*/
- public encoder = new BaseEncoder();
+ public parser = new OggOpusParser();
/**
* @description Создаем класс
@@ -196,15 +232,18 @@ export class BufferedEncoder extends Writable {
*/
public constructor(options: WritableOptions = { autoDestroy: true }) {
super(options);
- this.encoder.on("frame", this.emit.bind(this, "frame"));
+ this.parser.on("frame", this.emit.bind(this, "frame"));
+ this.parser.on("head", (frame) => this.emit("head", frame));
+ this.parser.on("tags", (frame) => this.emit("tags", frame));
+ this.parser.on("error", (err) => this.emit("error", err));
};
/**
* @description Функция для работы чтения
* @protected
*/
- public _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
- this.encoder.parseAvailablePages(chunk);
+ public async _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
+ this.parser.parseAvailablePages(chunk);
return callback();
};
@@ -214,11 +253,11 @@ export class BufferedEncoder extends Writable {
* @public
*/
public _destroy(error: Error) {
- this.encoder.destroy();
- this.encoder = null;
+ this.parser.destroy();
+ this.parser = null;
- super.destroy(error);
this.removeAllListeners();
+ super.destroy(error);
};
}
@@ -235,7 +274,7 @@ export class PipeEncoder extends Transform {
* @description Базовый класс декодера
* @private
*/
- private encoder = new BaseEncoder();
+ private parser = new OggOpusParser();
/**
* @description Создаем класс
@@ -244,15 +283,18 @@ export class PipeEncoder extends Transform {
*/
public constructor(options: TransformOptions = { autoDestroy: true }) {
super(Object.assign(options, { readableObjectMode: true }));
- this.encoder.on("frame", this.push.bind(this));
+ this.parser.on("frame", this.push.bind(this));
+ this.parser.on("head", (frame) => this.emit("head", frame));
+ this.parser.on("tags", (frame) => this.emit("tags", frame));
+ this.parser.on("error", (err) => this.emit("error", err));
};
/**
* @description При получении данных через pipe или write, модифицируем их для одобрения со стороны discord
* @public
*/
- public _transform = (chunk: Buffer, _: any, done: () => any) => {
- this.encoder.parseAvailablePages(chunk);
+ public _transform = async (chunk: Buffer, _: any, done: () => any) => {
+ this.parser.parseAvailablePages(chunk);
return done();
};
@@ -262,51 +304,19 @@ export class PipeEncoder extends Transform {
* @public
*/
public _destroy(error: Error) {
- this.encoder.destroy();
- this.encoder = null;
+ this.parser.destroy();
+ this.parser = null;
- super.destroy(error);
this.removeAllListeners();
+ super.destroy(error);
};
}
-
-/**
- * @author SNIPPIK
- * @description По строковый расчет opusHead
- * @param packet
- */
-function isOpusHead(packet: Buffer): boolean {
- // "OpusHead" в ASCII: 0x4F 0x70 0x75 0x73 0x48 0x65 0x61 0x64
- return (
- packet.length >= 8 &&
- packet[4] === 0x48 && // 'H'
- packet[5] === 0x65 && // 'e'
- packet[6] === 0x61 && // 'a'
- packet[7] === 0x64 // 'd'
- );
-}
-
-/**
- * @author SNIPPIK
- * @description По строковый расчет opusTags
- * @param packet
- */
-function isOpusTags(packet: Buffer): boolean {
- // "OpusTags" в ASCII: 0x4F 0x70 0x75 0x73 0x54 0x61 0x67 0x73
- return (
- packet.length >= 8 &&
- packet[4] === 0x54 && // 'T'
- packet[5] === 0x61 && // 'a'
- packet[6] === 0x67 && // 'g'
- packet[7] === 0x73 // 's'
- );
-}
-
/**
* @author SNIPPIK
* @description События для типизации декодера
* @interface EncoderEvents
+ * @private
*/
interface EncoderEvents {
/**
@@ -326,4 +336,10 @@ interface EncoderEvents {
* @param frame - Основной фрагмент opus потока
*/
"frame": (frame: Buffer) => void;
+
+ /**
+ * @description Получение ошибки при конвертировании аудио
+ * @param error - ошибка
+ */
+ "error": (error: Error) => void;
}
\ No newline at end of file
diff --git a/src/core/audio/process.ts b/src/core/audio/process.ts
index c03bf0db..934d6018 100644
--- a/src/core/audio/process.ts
+++ b/src/core/audio/process.ts
@@ -43,7 +43,6 @@ export class Process {
*/
public constructor(args: string[], name: string = ffmpeg_path) {
const index_resource = args.indexOf("-i");
- const index_seek = args.indexOf("-ss");
// Проверяем на наличие ссылки в пути
if (index_resource !== -1) {
@@ -53,14 +52,7 @@ export class Process {
if (isLink) args.unshift("-reconnect", "1", "-reconnect_delay_max", "5", "-reconnect_on_network_error", "1");
}
- // Проверяем на наличие пропуска времени
- if (index_seek !== -1) {
- const seek = parseInt(args.at(index_seek + 1));
-
- // Если указано не число
- if (isNaN(seek) || !seek) args.splice(index_seek, 2);
- }
-
+ // Добавляем аргументы отключения видео и логирования
args.unshift("-vn", "-loglevel", "error", "-hide_banner");
this._process = spawn(name, args, {
env: { PATH: process.env.PATH },
@@ -68,26 +60,33 @@ export class Process {
shell: false
});
+ // Добавляем события к процессу
for (let event of ["end", "error", "exit"]) {
- this._process.once(event, this.destroy);
+ if (this._process) this._process.once(event, this.destroy);
}
};
/**
* @description Удаляем и отключаемся от процесса
+ * @returns void
* @private
*/
public destroy = () => {
if (this._process) {
+ // Отключаем все точки данных и удаляем их
for (const std of [this._process.stdout, this._process.stderr, this._process.stdin]) {
std.removeAllListeners();
std.destroy();
}
+ // Отключаем события
this._process.removeAllListeners();
+ // Убиваем процесс
this._process.kill("SIGKILL");
- this._process = null;
}
+
+ // Удаляем данные процесса
+ this._process = null;
};
}
diff --git a/src/core/audio/resource.ts b/src/core/audio/resource.ts
index 113e0fa4..7f4071c5 100644
--- a/src/core/audio/resource.ts
+++ b/src/core/audio/resource.ts
@@ -1,7 +1,9 @@
import { BufferedEncoder, OPUS_FRAME_SIZE, PipeEncoder } from "./opus";
import { Logger } from "#structures/logger";
import { TypedEmitter } from "#structures";
+import type { Track } from "#core/queue";
import { Process } from "./process";
+import { db } from "#app/db";
/**
* @author SNIPPIK
@@ -45,8 +47,7 @@ class AudioBuffer {
* @public
*/
public set position(position) {
- if (position > this.size || position < 0) return;
- this._position = position;
+ this._position = Math.max(0, Math.min(position, this.size));
};
/**
@@ -71,10 +72,11 @@ class AudioBuffer {
* @public
*/
public clear = () => {
- // Удаляем буферы
+ this._chunks = [];
this._chunks.length = 0;
- this._position = null;
this._chunks = null;
+ this._position = null;
+ this._position = null;
};
}
@@ -90,19 +92,35 @@ abstract class BaseAudioResource extends TypedEmitter {
* @description Можно ли читать поток
* @protected
*/
- protected _readable = false;
+ protected _readable: boolean;
+
+ /**
+ * @description Последнее заданное значение затухания
+ * @protected
+ */
+ protected _afade = 0;
/**
- * @description Параметр seek, для вычисления времени проигрывания
+ * @description Модификатор скорости фильтров
* @protected
*/
- protected _seek = 0;
+ protected _afade_modificator = 1;
+
+ /**
+ * @description Модификатор скорости высчитанный из фильтров
+ * @public
+ */
+ public get speed() {
+ return this._afade_modificator;
+ };
/**
* @description Если чтение возможно
* @public
*/
- public abstract get readable(): boolean;
+ public get readable(): boolean {
+ return this._readable;
+ };
/**
* @description Duration в секундах с учётом текущей позиции в буфере и seek-а (предыдущего смещения)
@@ -125,14 +143,80 @@ abstract class BaseAudioResource extends TypedEmitter {
*/
public abstract get packets(): number;
+ /**
+ * @description Создание аргументов для FFmpeg
+ * @protected
+ */
+ protected get arguments(): string[] {
+ const { seek, track } = this.options;
+ const args = [
+ "-ss", `${seek ?? 0}`,
+ "-i", track.link,
+ ];
+
+ if (track.isBuffered) args.unshift("-accurate_seek");
+
+ return [
+ "-vn",
+ ...args,
+ ...this.filters,
+
+ // Указываем формат аудио (ogg/opus)
+ "-acodec", "libopus",
+ "-frame_duration", "20",
+ "-f", "opus",
+ "pipe:1"
+ ];
+ };
+
+ /**
+ * @description Собираем фильтры для ffmpeg
+ * @protected
+ */
+ protected get filters(): string[] {
+ const { volume, filters, track, seek } = this.options;
+ const afade = [
+ `volume=${volume / 150}`
+ ];
+
+ // Если есть используемые фильтры
+ if (filters) afade.unshift(filters);
+
+ // Добавляем стартовое время приглушения
+ afade.push(`afade=t=in:st=0:d=${this._afade}`);
+
+ // Если можно использовать приглушение
+ if (track.time.total > 0) {
+ afade.push(
+ `afade=t=out:st=${Math.max(track.time.total, seek - track.time.total - db.queues.options.fade)}:d=${db.queues.options.fade}`
+ );
+ }
+
+ // Отдаем готовые фильтры
+ return [
+ "-af", afade.join(",")
+ ];
+ };
+
/**
* @description Создаем класс и задаем параметры
* @constructor
* @protected
*/
- protected constructor(protected input_data: AudioResourceOptions) {
+ protected constructor(public options: AudioResourceOptions) {
super();
- this._seek = (input_data.options.seek * 1e3) / OPUS_FRAME_SIZE;
+ // Ищем модификатор скорости (asetrate, tempo)
+ let modificator: number = 1.0;
+
+ try {
+ // Иначе проверяем текущие фильтры
+ if (options.filters) modificator = Math.max(1.0, getSpeedMultiplier(options.filters));
+ } catch (error) {
+ console.log(error);
+ }
+
+ this._afade_modificator = modificator;
+ this._afade = !this.options.swapped ? db.queues.options.fade : db.queues.options.swapFade;
};
/**
@@ -146,7 +230,7 @@ abstract class BaseAudioResource extends TypedEmitter {
const path = options.events.path ? options.input[options.events.path] : options.input;
// Запускаем прослушивание события
- path["once"](event, (err: any) => {
+ path["once"](event, (err: Error) => {
if (event === "error") this.emit("error", new Error(`AudioResource get ${err}`));
options.events.destroy_callback(options.input);
});
@@ -173,8 +257,7 @@ abstract class BaseAudioResource extends TypedEmitter {
super.destroy();
this._readable = null;
- this._seek = null;
- this.input_data = null;
+ this.options = null;
};
}
@@ -193,8 +276,7 @@ abstract class BaseAudioResource extends TypedEmitter {
export class BufferedAudioResource extends BaseAudioResource {
/**
* @description Список аудио буферов, для временного хранения
- * @protected
- * @readonly
+ * @private
*/
private _buffer = new AudioBuffer();
@@ -214,8 +296,8 @@ export class BufferedAudioResource extends BaseAudioResource {
public get duration() {
if (!this._buffer || !this._buffer?.position) return 0;
- const time = (this._buffer.position + this._seek) * OPUS_FRAME_SIZE;
- return Math.abs(time / 1e3);
+ const time = this._buffer.position * OPUS_FRAME_SIZE;
+ return time / 1e3 + this.options.seek;
};
/**
@@ -245,7 +327,15 @@ export class BufferedAudioResource extends BaseAudioResource {
* @public
*/
public set seek(seek: number) {
- this._buffer.position = (seek * 1e3 + OPUS_FRAME_SIZE) / OPUS_FRAME_SIZE;
+ const index = (seek * 1e3) / OPUS_FRAME_SIZE;
+
+ // Если указано неподходящие значение
+ if (index > this._buffer.size || index < this._buffer.size) {
+ this._buffer.position = 0;
+ return;
+ }
+
+ this._buffer.position = index;
};
/**
@@ -255,8 +345,6 @@ export class BufferedAudioResource extends BaseAudioResource {
*/
public constructor(config: AudioResourceOptions) {
super(config);
-
- const { path, options } = config;
const decoder = new BufferedEncoder({
highWaterMark: 512 * 5 // Буфер на ~1:14
});
@@ -280,10 +368,8 @@ export class BufferedAudioResource extends BaseAudioResource {
decode: (input) => {
input.on("frame", (packet: Buffer) => {
if (this._buffer) {
- setImmediate(() => {
- // Сообщаем что поток можно начать читать
- if (!this._buffer.position) this.emit("readable");
- });
+ // Сообщаем что поток можно начать читать
+ if (!this._readable) setImmediate(() => { this.emit("readable");});
// Если создал класс буфера, начинаем кеширование пакетов
if (packet) this._buffer.packet = packet;
@@ -295,22 +381,7 @@ export class BufferedAudioResource extends BaseAudioResource {
// Процесс (FFmpeg)
this.input({
// Создание потока
- input: new Process([
- // Пропуск времени
- "-ss", `${options.seek ?? 0}`,
-
- // Файл или ссылка на ресурс
- "-i", path,
-
- // Подключаем фильтры
- "-af", options.filters,
-
- // Указываем формат аудио (ogg/opus)
- "-acodec", "libopus",
- "-frame_duration", "20",
- "-f", "opus",
- "pipe:"
- ]),
+ input: new Process(this.arguments),
// Управление событиями
events: {
@@ -326,7 +397,7 @@ export class BufferedAudioResource extends BaseAudioResource {
},
// Начало кодирования
- decode: (input: Process) => {
+ decode: (input) => {
input.stdout.pipe(decoder);
},
});
@@ -336,10 +407,10 @@ export class BufferedAudioResource extends BaseAudioResource {
* @description Обновление потока, без потерь
* @public
*/
- public refresh = () => {
- this._seek = 0;
+ /*public refresh = () => {
this._buffer.position = 0;
- };
+ this._seek = 0;
+ };*/
/**
* @description Удаляем ненужные данные
@@ -347,7 +418,7 @@ export class BufferedAudioResource extends BaseAudioResource {
*/
public destroy = () => {
super.destroy();
- this._buffer.clear();
+ this._buffer?.clear();
this._buffer = null;
};
}
@@ -378,14 +449,6 @@ export class PipeAudioResource extends BaseAudioResource {
*/
private played = 0;
- /**
- * @description Если чтение возможно
- * @public
- */
- public get readable(): boolean {
- return this.encoder.readable;
- };
-
/**
* @description Выдаем фрагмент потока
* @help (время пакета 20ms)
@@ -418,8 +481,8 @@ export class PipeAudioResource extends BaseAudioResource {
public get duration() {
if (!this.played) return 0;
- const time = (this.played + this._seek) * OPUS_FRAME_SIZE;
- return Math.abs(time / 1e3);
+ const time = this.played * OPUS_FRAME_SIZE;
+ return time / 1e3 + this.options.seek;
};
/**
@@ -428,7 +491,7 @@ export class PipeAudioResource extends BaseAudioResource {
* @public
*/
public set seek(seek: number) {
- let steps = (seek * 1e3 + OPUS_FRAME_SIZE) / OPUS_FRAME_SIZE;
+ let steps = ((seek * 1e3) / OPUS_FRAME_SIZE);
// Если диапазон слишком мал или большой
if (steps >= 0 || steps > this.packets) return;
@@ -447,7 +510,6 @@ export class PipeAudioResource extends BaseAudioResource {
*/
public constructor(config: AudioResourceOptions) {
super(config);
- const {path, options} = config;
// Расшифровщик
this.input({
@@ -476,22 +538,7 @@ export class PipeAudioResource extends BaseAudioResource {
// Процесс (FFmpeg)
this.input({
// Создание потока
- input: new Process([
- // Пропуск времени
- "-ss", `${options.seek ?? 0}`,
-
- // Файл или ссылка на ресурс
- "-i", path,
-
- // Подключаем фильтры
- "-af", options.filters,
-
- // Указываем формат аудио (ogg/opus)
- "-acodec", "libopus",
- "-frame_duration", "20",
- "-f", "opus",
- "pipe:"
- ]),
+ input: new Process(this.arguments),
// Управление событиями
events: {
@@ -517,31 +564,130 @@ export class PipeAudioResource extends BaseAudioResource {
*/
public destroy = () => {
super.destroy();
- this.played = null;
this.encoder = null;
};
}
+/**
+ * @author SNIPPIK
+ * @description Регулярное выражение для захвата числового множителя из строки 'asetrate=48000*X'.
+ * @example "asetrate=48000*1.2" -> "1.2"
+ * @const ASSETRATE_MULTIPLIER_PATTERN
+ * @private
+ */
+const ASSETRATE_MULTIPLIER_PATTERN = /^asetrate=48000\*([\d\.]+)(?:,.*)?$/;
+
+/**
+ * @author SNIPPIK
+ * @description Регулярное выражение для захвата числового множителя из строки 'atempo=X'.
+ * @example "atempo=2" -> "2"
+ * @const ATEMPO_MULTIPLIER_PATTERN
+ * @private
+ */
+const ATEMPO_MULTIPLIER_PATTERN = /^atempo=([\d\.]+)(?:,.*)?$/;
+
+/**
+ * @author SNIPPIK
+ * @description Извлекает числовой множитель (rate) из фильтра asetrate.
+ * @param filtersString Строка фильтров FFmpeg.
+ * @returns Извлеченное значение как строка, или null.
+ * @function extractAsetrateMultiplier
+ * @private
+ */
+function extractAsetrateMultiplier(filtersString: string): string | null {
+ const match = filtersString.match(ASSETRATE_MULTIPLIER_PATTERN);
+ return match ? match[1] : null;
+}
+
+/**
+ * @author SNIPPIK
+ * @description Извлекает числовой множитель (rate) из фильтра atempo.
+ * @param filtersString Строка фильтров FFmpeg.
+ * @returns Извлеченное значение как строка, или null.
+ * @function extractAtempoMultiplier
+ * @private
+ */
+function extractAtempoMultiplier(filtersString: string): string | null {
+ const match = filtersString.match(ATEMPO_MULTIPLIER_PATTERN);
+ return match ? match[1] : null;
+}
+
+/**
+ * @author SNIPPIK
+ * @description Центральная функция для получения множителя скорости (Speed Multiplier)
+ * из строки фильтров, проверяя сначала asetrate, затем atempo.
+ * @param filtersString Строка фильтров FFmpeg.
+ * @returns Числовой множитель скорости или 1.0, если не найден.
+ * @function getSpeedMultiplier
+ * @private
+ */
+function getSpeedMultiplier(filtersString: string): number {
+ if (!filtersString) return 1.0;
+
+ // Извлекаем множитель asetrate
+ const asetrateStr = extractAsetrateMultiplier(filtersString);
+
+ // Конвертируем в число. Если не найдено, используем 1.0 (нет изменения)
+ const asetrateMultiplier = asetrateStr ? parseFloat(asetrateStr) : 1.0;
+
+ // Извлекаем множитель atempo
+ const atempoStr = extractAtempoMultiplier(filtersString);
+
+ // Конвертируем в число. Если не найдено, используем 1.0 (нет изменения)
+ const atempoMultiplier = atempoStr ? parseFloat(atempoStr) : 1.0;
+
+ // Общий множитель - это произведение (умножение) двух эффектов.
+ const totalMultiplier = asetrateMultiplier * atempoMultiplier;
+
+ // Проверка на NaN и возврат результата.
+ return isNaN(totalMultiplier) ? 1.0 : totalMultiplier;
+}
/**
* @author SNIPPIK
* @description Параметры для создания класса AudioResource
* @interface AudioResourceOptions
+ * @private
*/
interface AudioResourceOptions {
- path: string;
- options: {
- seek?: number;
- filters?: string;
- }
+ /**
+ * @description Трек который надо включить
+ * @public
+ */
+ track: Track;
+
+ /**
+ * @description Громкость аудио потока
+ * @public
+ */
+ volume: number;
+
+ /**
+ * @description Время пропуска, с этой временной точки включится аудио
+ * @public
+ */
+ seek?: number;
+
+ /**
+ * @description Фильтры ffmpeg для включения через filter_complex
+ * @public
+ */
+ filters: string;
+
+ /**
+ * @description Смена аудио потока?
+ * @public
+ */
+ swapped: boolean;
}
/**
* @author SNIPPIK
* @description События аудио потока
* @interface AudioResourceEvents
+ * @private
*/
interface AudioResourceEvents {
/**
@@ -573,6 +719,7 @@ interface AudioResourceEvents {
* @author SNIPPIK
* @description Параметры для функции совмещения потоков
* @interface AudioResourceInput
+ * @private
*/
interface AudioResourceInput {
/**
@@ -596,12 +743,12 @@ interface AudioResourceInput {
* @description Если надо конкретно откуда-то отслеживать события
* @readonly
*/
- path?: string
+ path?: string;
};
/**
* @description Как начать передавать данные из потока
* @readonly
*/
- readonly decode: (input: T) => void;
+ readonly decode?: (input: T) => void;
}
\ No newline at end of file
diff --git a/src/core/player/controllers/filters.ts b/src/core/player/controllers/filters.ts
index 5be13609..1b9e08f0 100644
--- a/src/core/player/controllers/filters.ts
+++ b/src/core/player/controllers/filters.ts
@@ -1,6 +1,5 @@
import type { LocalizationMap } from "discord-api-types/v10";
import { SetArray } from "#structures";
-import { db } from "#app/db";
/**
* @author SNIPPIK
@@ -10,39 +9,84 @@ import { db } from "#app/db";
*/
export class ControllerFilters extends SetArray {
/**
- * @description Сжимаем фильтры для работы ffmpeg
- * @returns string
+ * @description Скомпилированные фильтры, заранее подготовленные
+ * @private
+ */
+ private _filters: string = null;
+
+ /**
+ * @description Выдаем скомпилированные фильтры
+ * @public
+ */
+ public get filters() {
+ return this._filters;
+ };
+
+ /**
+ * @description Добавляем фильтр/ы из списка
+ * @param item - Фильтр
* @public
*/
- public toString = (time: number, volume: number, isSwap = false) => {
- const { fade, optimization, swapFade } = db.queues.options;
- const filters: string[] = [`volume=${volume / 150}`];
- const fade_int = isSwap ? swapFade : fade;
- const live = time === 0;
-
- // Если есть приглушение аудио
- if (fade_int) {
- filters.push(`afade=t=in:st=0:d=${fade_int}`);
-
- // Если есть время трека
- if (typeof time === "number" && time >= optimization && !live) {
- const start = time - (fade + 5);
-
- if (start > 0) filters.push(`afade=t=out:st=${start}:d=${fade + 5}`);
- }
+ public add(item: T) {
+ super.add(item);
+ this._filters = this.parseFilters();
+ return this;
+ };
+
+ /**
+ * @description Удаляем фильтр/ы из списка
+ * @param item - Фильтр
+ * @public
+ */
+ public delete(item: T) {
+ super.delete(item);
+ this._filters = this.parseFilters();
+ return true;
+ };
+
+ /**
+ * @description Подготавливаем фильтры в строчку
+ * @private
+ */
+ private parseFilters = () => {
+ const filters: string[] = [];
+
+ // Добавляем пользовательские фильтры
+ for (const { filter, args, argument } of this) {
+ if (!filter || typeof filter !== "string") continue;
+ const argString = args ? `${filter}${argument ?? ""}` : filter;
+ filters.push(argString.trim());
}
- // Если трек не live
- if (!live) {
- // Берем данные из всех фильтров
- for (const enabled of this) {
- const {filter, args, argument} = enabled;
+ return filters.join(",");
+ };
+
+ /**
+ * @description Проверяем совместимость фильтров
+ * @param filter - Сам фильтр
+ * @public
+ */
+ public hasUnsupported = (filter: T): null | [string, string] => {
+ // Делаем проверку на совместимость
+ // Проверяем, не конфликтует ли новый фильтр с уже включёнными
+ for (const enabledFilter of this) {
+ // Новый фильтр несовместим с уже включённым?
+ if (filter.unsupported && filter.unsupported?.includes(enabledFilter.name)) return [filter.name, enabledFilter.name];
- filters.push(args ? `${filter}${argument ?? ""}` : filter);
- }
+ // Уже включённый фильтр несовместим с новым?
+ else if (enabledFilter.unsupported.includes(filter.name)) return [enabledFilter.name, filter.name];
}
- return filters.join(",")
+ return null;
+ };
+
+ /**
+ * @description Чистим фильтры включая подготовленные
+ * @public
+ */
+ public clear() {
+ this._filters = null;
+ super.clear();
};
}
diff --git a/src/core/player/filters.json b/src/core/player/filters.json
index 0b45a23f..bb9f26bf 100644
--- a/src/core/player/filters.json
+++ b/src/core/player/filters.json
@@ -1,162 +1,19 @@
[
- {
- "name": "stable",
- "locale": {
- "en-US": "Normalizes and stabilizes the volume for consistent loudness.",
- "ru": "Нормализует и стабилизирует громкость для постоянного уровня звука."
- },
- "unsupported": [],
- "filter": "loudnorm",
- "args": false
- },
- {
- "name": "nightcore",
- "locale": {
- "en-US": "Speeds up playback by 20% and raises pitch for a nightcore effect.",
- "ru": "Ускоряет воспроизведение на 20% и повышает тональность для эффекта nightcore."
- },
- "unsupported": ["vaporwave", "demonic", "reverse", "phaser", "vinyl"],
- "filter": "asetrate=48000*1.20,aresample=128000",
- "args": false
- },
- {
- "name": "vaporwave",
- "locale": {
- "en-US": "Slows down playback and lowers pitch, creating a vaporwave atmosphere.",
- "ru": "Замедляет воспроизведение и понижает тональность, создавая атмосферу vaporwave."
- },
- "unsupported": ["nightcore", "demonic", "speed", "treble", "reverse", "vinyl"],
- "filter": "asetrate=48000*0.8,aresample=48000,atempo=1.1",
- "args": false
- },
- {
- "name": "demonic",
- "locale": {
- "en-US": "Deep, distorted effect with echoes — makes a track sound demonic.",
- "ru": "Глубокий и искажённый эффект с эхо — превращает трек в демонический."
- },
- "unsupported": ["stable", "nightcore", "vaporwave", "bass", "speed", "vinyl"],
- "filter": "loudnorm=I=-16:TP=-1.5:LRA=11,asetrate=48000*1.20,aresample=128000,bass=g=5,aecho=0.8:0.9:1000:0.3",
- "args": false
- },
- {
- "name": "vinyl",
- "locale": {
- "en-US": "Adds vinyl record hiss, crackle, and a retro lo-fi effect.",
- "ru": "Добавляет шипение, потрескивания и ретро lo-fi эффект винила."
- },
- "unsupported": ["nightcore", "demonic", "vaporwave"],
- "filter": "acrusher=bits=8:mode=log:mix=0.7,highpass=f=200,lowpass=f=5000",
- "args": false
- },
- {
- "name": "speed",
- "locale": {
- "en-US": "Changes playback speed (1.0 = normal, up to 3x faster).",
- "ru": "Изменяет скорость воспроизведения (1.0 = нормальная, до 3х быстрее)."
- },
- "unsupported": [],
- "filter": "atempo=",
- "args": [1, 3]
- },
- {
- "name": "bass",
- "locale": {
- "en-US": "Boosts low frequencies for deeper and punchier bass.",
- "ru": "Усиливает низкие частоты для более глубокого и мощного баса."
- },
- "unsupported": ["demonic"],
- "filter": "bass=g=",
- "args": [1, 30]
- },
- {
- "name": "sub_boost",
- "locale": {
- "en-US": "Emphasizes sub-bass frequencies for a deeper rumble.",
- "ru": "Подчёркивает саб-бас частоты для более глубокого звучания."
- },
- "unsupported": ["demonic"],
- "filter": "asubboost",
- "args": false
- },
- {
- "name": "mono",
- "locale": {
- "en-US": "Mixes both stereo channels into a single mono output.",
- "ru": "Объединяет оба стерео канала в один моно сигнал."
- },
- "unsupported": ["8d"],
- "filter": "pan=mono|c0=.5*c0+.5*c1",
- "args": false
- },
- {
- "name": "treble",
- "locale": {
- "en-US": "Boosts high frequencies up to 20 kHz for brighter sound.",
- "ru": "Усиливает высокие частоты до 20 kHz для более яркого звука."
- },
- "unsupported": [],
- "filter": "treble=g=5",
- "args": false
- },
- {
- "name": "reverse",
- "locale": {
- "en-US": "Plays the audio track backwards.",
- "ru": "Воспроизводит аудио трек в обратном направлении."
- },
- "unsupported": [],
- "filter": "areverse",
- "args": false
- },
- {
- "name": "flanger",
- "locale": {
- "en-US": "Creates a sweeping, ‘flying’ sound with time-delayed signal mixing.",
- "ru": "Создаёт переливающийся «летящий» звук через смешивание с задержкой."
- },
- "unsupported": [],
- "filter": "flanger",
- "args": false
- },
- {
- "name": "haas",
- "locale": {
- "en-US": "Haas effect (precedence) widens stereo image using channel delay.",
- "ru": "Эффект Хааса (предшествования) расширяет стерео за счёт задержки каналов."
- },
- "unsupported": ["mono"],
- "filter": "haas",
- "args": false
- },
- {
- "name": "echo",
- "locale": {
- "en-US": "Adds multiple repeating echoes to the audio.",
- "ru": "Добавляет многократные повторяющиеся эхо к звуку."
- },
- "unsupported": [],
- "filter": "aecho=0.8:0.9:1000:0.3",
- "args": false
- },
- {
- "name": "8d",
- "locale": {
- "en-US": "Simulates 3D spatial audio by rotating sound around the listener.",
- "ru": "Имитирует 3D-пространство, вращая звук вокруг слушателя."
- },
- "unsupported": ["mono"],
- "filter": "apulsator=hz=0.09",
- "args": false
- },
- {
- "name": "phaser",
- "locale": {
- "en-US": "Applies a sweeping phase effect, creating a ‘shimmering’ sound.",
- "ru": "Накладывает фазовый сдвиг, создавая «мерцающий» эффект звучания."
- },
- "unsupported": [],
- "filter": "aphaser=in_gain=0.4",
- "args": false
- }
+ { "name": "normalize", "locale": { "en-US": "Normalizes volume for consistent loudness.", "ru": "Нормализует громкость для равномерного звучания." }, "filter": "dynaudnorm=p=0.9:s=5", "args": false },
+ { "name": "nightcore", "locale": { "en-US": "Increases speed by 20 % and raises pitch.", "ru": "Ускоряет воспроизведение на 20 % и повышает тональность." }, "unsupported": ["vaporwave","demonic","reverse","phaser","vinyl","normalize"], "filter": "asetrate=48000*1.25,aresample=128000", "args": false },
+ { "name": "vaporwave", "locale": { "en-US": "Slows down playback and lowers pitch.", "ru": "Замедляет воспроизведение и понижает тональность." }, "unsupported": ["nightcore","demonic","speed","treble","reverse","vinyl","normalize"], "filter": "asetrate=48000*0.8,aresample=48000,atempo=1.1", "args": false },
+ { "name": "demonic", "locale": { "en-US": "Deepens voice with distortion and echo.", "ru": "Понижает голос с добавлением искажения и эха." }, "unsupported": ["normalize","nightcore","vaporwave","bass","speed","vinyl"], "filter": "loudnorm=I=-16:TP=-1.5:LRA=11,asetrate=48000*1.20,aresample=128000,bass=g=5,aecho=0.8:0.9:1000:0.3", "args": false },
+ { "name": "vinyl", "locale": { "en-US": "Adds vinyl record noise and slight distortion.", "ru": "Добавляет шум и потрескивания виниловой пластинки." }, "unsupported": ["nightcore","demonic","vaporwave","normalize"], "filter": "acrusher=bits=8:mode=log:mix=0.7,highpass=f=200,lowpass=f=5000", "args": false },
+ { "name": "speed", "locale": { "en-US": "Adjusts playback speed (0.5× – 3×).", "ru": "Изменяет скорость воспроизведения (0.5× – 3×)." }, "filter": "atempo=", "args": [0.5, 3] },
+ { "name": "bass", "locale": { "en-US": "Boosts low frequencies.", "ru": "Усиливает низкие частоты." }, "unsupported": ["demonic"], "filter": "bass=g=", "args": [1, 30] },
+ { "name": "subboost", "locale": { "en-US": "Enhances sub-bass frequencies (30–60 Hz).", "ru": "Усиливает суб-бас (30–60 Гц)." }, "unsupported": ["demonic"], "filter": "asubboost", "args": false },
+ { "name": "mono", "locale": { "en-US": "Converts stereo to mono.", "ru": "Преобразует стерео в моно." }, "unsupported": ["8d","stereo_swap"], "filter": "pan=mono|c0=.5*c0+.5*c1", "args": false },
+ { "name": "stereo_swap", "locale": { "en-US": "Swaps left and right channels.", "ru": "Меняет левый и правый каналы местами." }, "unsupported": ["mono","8d"], "filter": "pan=stereo|c0=c1|c1=c0", "args": false },
+ { "name": "treble", "locale": { "en-US": "Boosts high frequencies.", "ru": "Усиливает высокие частоты." }, "filter": "treble=g=", "args": [-10, 15] },
+ { "name": "reverse", "locale": { "en-US": "Plays audio in reverse.", "ru": "Воспроизводит аудио в обратном направлении." }, "filter": "areverse", "args": false },
+ { "name": "flanger", "locale": { "en-US": "Applies flanger effect.", "ru": "Добавляет эффект фленджера." }, "filter": "flanger", "args": false },
+ { "name": "haas", "locale": { "en-US": "Widens stereo image using Haas effect.", "ru": "Расширяет стереобазу эффектом Хааса." }, "unsupported": ["mono"], "filter": "haas", "args": false },
+ { "name": "echo", "locale": { "en-US": "Adds repeating echo.", "ru": "Добавляет повторяющееся эхо." }, "filter": "aecho=0.8:0.9:1000:0.3", "args": false },
+ { "name": "8d", "locale": { "en-US": "Creates rotating surround effect.", "ru": "Создаёт эффект вращающегося звука вокруг слушателя." }, "unsupported": ["mono","stereo_swap"], "filter": "apulsator=hz=0.09", "args": false },
+ { "name": "phaser", "locale": { "en-US": "Applies phaser effect.", "ru": "Добавляет эффект фазера." }, "filter": "aphaser=in_gain=0.4", "args": false }
]
\ No newline at end of file
diff --git a/src/core/player/index.ts b/src/core/player/index.ts
index b0941343..dfee2886 100644
--- a/src/core/player/index.ts
+++ b/src/core/player/index.ts
@@ -14,24 +14,32 @@ export interface AudioPlayerEvents {
* @description Событие при котором плеер начинает завершение текущего трека
* @param player - Текущий плеер
* @param seek - Время пропуска если оно есть
+ * @returns void
+ * @readonly
*/
readonly "player/ended": (player: AudioPlayer, seek: number) => void;
/**
* @description Событие при котором плеер ожидает новый трек
* @param player - Текущий плеер
+ * @returns void
+ * @readonly
*/
readonly "player/wait": (player: AudioPlayer) => void;
/**
* @description Событие при котором плеер встает на паузу и ожидает дальнейших действий
* @param player - Текущий плеер
+ * @returns void
+ * @readonly
*/
readonly "player/pause": (player: AudioPlayer) => void;
/**
* @description Событие при котором плеер начинает проигрывание
* @param player - Текущий плеер
+ * @returns void
+ * @readonly
*/
readonly "player/playing": (player: AudioPlayer) => void;
@@ -41,6 +49,8 @@ export interface AudioPlayerEvents {
* @param err - Ошибка в формате string
* @param skip - Если надо пропустить трек
* @param position - Позиция трека в очереди
+ * @returns void
+ * @readonly
*/
readonly "player/error": (player: AudioPlayer, err: string, track?: {skip: boolean, position: number}) => void;
}
\ No newline at end of file
diff --git a/src/core/player/modules/audio.ts b/src/core/player/modules/audio.ts
index 202cc380..72dd61e5 100644
--- a/src/core/player/modules/audio.ts
+++ b/src/core/player/modules/audio.ts
@@ -1,6 +1,23 @@
import { BufferedAudioResource, PipeAudioResource } from "#core/audio";
+import { Logger } from "#structures";
import { db } from "#app/db";
+/**
+ * @author SNIPPIK
+ * @description Время ожидания потока live трека
+ * @const TIMEOUT_STREAM_PIPE
+ * @private
+ */
+const TIMEOUT_STREAM_PIPE = 15e3;
+
+/**
+ * @author SNIPPIK
+ * @description Время ожидания потока трека
+ * @const TIMEOUT_STREAM_BUFFERED
+ * @private
+ */
+const TIMEOUT_STREAM_BUFFERED = 10e3;
+
/**
* @author SNIPPIK
* @description Класс для управления включенным потоком, хранит в себе все данные потока
@@ -15,37 +32,34 @@ export class PlayerAudio {
* @description Поток, расшифровывает ogg/opus в чистый opus он же sl16e
* @private
*/
- private _audio: T;
+ private _audio: T | null;
/**
* @description Поток, находящийся в ожидании загрузки и проигрывания
* @private
*/
- private _pre_audio: T;
+ private _pre_audio: T | null;
/**
* @description Таймер чтения аудио потока, для авто удаления
* @private
*/
- private _timeout: NodeJS.Timeout | null = null;
+ private _timeout: NodeJS.Timeout | null;
/**
* @description Громкость аудио, по умолчанию берется параметр из db/env
- * @protected
+ * @private
*/
private _volume = db.queues.options.volume;
/**
* @description Изменяем значение громкости у аудио
- * @param vol - Громкость допустимый диапазон (10-200)
+ * @param volume - Громкость допустимый диапазон (10-200)
* @public
*/
- public set volume(vol: number) {
- if (vol > 200) vol = 200;
- else if (vol < 10) vol = 10;
-
+ public set volume(volume: number) {
// Меняем параметр
- this._volume = vol;
+ this._volume = volume > 200 ? 200 : volume < 1 ? 10 : volume;
};
/**
@@ -80,41 +94,46 @@ export class PlayerAudio {
*/
public set preload(stream: T) {
// Если уже есть пред-загруженное аудио
- if (this._pre_audio) this._pre_audio?.destroy();
+ if (this._pre_audio) {
+ clearTimeout(this._timeout);
+ this._pre_audio.destroy();
+ }
// Записываем аудио в пред-загруженные
this._pre_audio = stream;
- // Если аудио поток не ответил в течении указанного времени
- this._timeout = setTimeout(() => {
- // Отправляем данные событию для отображения ошибки
- stream.emit("error", new Error("Timeout: the stream has been exceeded!"));
- }, 10e3);
-
// Отслеживаем аудио поток на ошибки
- (stream as BufferedAudioResource).once("error", async () => {
+ (stream as BufferedAudioResource).once("error", (error) => {
// Удаляем таймер
clearTimeout(this._timeout);
// Уничтожаем новый аудио поток
stream.destroy();
this._pre_audio = null;
+
+ Logger.log("ERROR", error);
});
// Отслеживаем аудио поток на готовность к чтению
- (stream as BufferedAudioResource).once("readable", async () => {
- const oldStream = this._audio;
-
+ (stream as BufferedAudioResource).once("readable", () => {
// Удаляем таймер
clearTimeout(this._timeout);
+ // Если есть активный поток
+ if (this._audio) {
+ this._audio.destroy();
+ }
+
// Перезаписываем текущий поток
this._audio = stream;
this._pre_audio = null;
-
- // Если есть активный поток
- if (oldStream) oldStream.destroy();
});
+
+ // Установка таймера ожидания
+ const waitTime = stream.options.track.isBuffered ? TIMEOUT_STREAM_BUFFERED : TIMEOUT_STREAM_PIPE;
+ this._timeout = setTimeout(() => {
+ stream.emit("error", Error("Timeout: the stream has been exceeded!"));
+ }, waitTime);
};
/**
diff --git a/src/core/player/modules/progress.ts b/src/core/player/modules/progress.ts
index 0bc881fa..c95a1ddd 100644
--- a/src/core/player/modules/progress.ts
+++ b/src/core/player/modules/progress.ts
@@ -1,5 +1,46 @@
-import { RestServerSide } from "#handler/rest";
+import type { RestAPIS_Names } from "#handler/rest/index.decorator";
import { env } from "#app/env";
+import { db } from "#app/db";
+
+/**
+ * @author SNIPPIK
+ * @description Функция для отложенной загрузки кнопок
+ * @function initButtons
+ * @private
+ */
+function initButtons() {
+ buttons = db.api.platforms.array.reduce((acc, api) => {
+ const platform = api.name.toLowerCase();
+ const inEnv = env.get(`progress.button.${platform}`, null);
+
+ if (inEnv) acc[`button_${platform}`] = inEnv;
+ return acc;
+ }, {
+ button: env.get("progress.button"),
+ });
+}
+
+/**
+ * @author SNIPPIK
+ * @description Доступные элементы для создания прогресс бара
+ * @type Elements
+ * @private
+ */
+type Elements = "left" | "center" | "right";
+
+/**
+ * @author SNIPPIK
+ * @description Получение списка для создания прогресс бара
+ * @param type - Тип элемента
+ * @private
+ */
+function initElement(type: "empty" | "not_empty") {
+ const keys = ["left", "center", "right"];
+ return keys.reduce((acc, key) => {
+ acc[key] = env.get(`progress.${type}.${key}`);
+ return acc;
+ }, {} as Record);
+}
/**
* @author SNIPPIK
@@ -10,56 +51,20 @@ const emoji = {
/**
* @description Пустой прогресс бар
*/
- empty: {
- left: env.get("progress.empty.left"),
- center: env.get("progress.empty.center"),
- right: env.get("progress.empty.right")
- },
+ empty: initElement("empty"),
/**
* @description Не пустой прогресс бар
*/
- upped: {
- left: env.get("progress.not_empty.left"),
- center: env.get("progress.not_empty.center"),
- right: env.get("progress.not_empty.right")
- },
-
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button: env.get("progress.button"),
-
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button_vk: env.get("progress.button.vk"),
+ upped: initElement("not_empty")
+};
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button_yandex: env.get("progress.button.yandex"),
-
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button_youtube: env.get("progress.button.youtube"),
-
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button_spotify: env.get("progress.button.spotify"),
-
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button_soundcloud: env.get("progress.button.soundcloud"),
-
- /**
- * @description Разделение прогресс бара, поддерживает платформы
- */
- button_deezer: env.get("progress.button.deezer")
-}
+/**
+ * @author SNIPPIK
+ * @description Все найденные кнопки платформ
+ * @private
+ */
+let buttons: { [key: string]: string; } = null;
/**
* @author SNIPPIK
@@ -74,7 +79,7 @@ export class PlayerProgress {
* @constructor
* @public
*/
- public constructor(private readonly size: number = 15) {};
+ public constructor(private size: number = 15) {};
/**
* @description Получаем готовый прогресс бар
@@ -82,8 +87,10 @@ export class PlayerProgress {
* @public
*/
public bar = ({ duration, platform }: PlayerProgressInput): string => {
+ if (!buttons) initButtons();
+
const { current, total } = duration;
- const button = emoji[`button_${platform.toLowerCase()}`] || emoji.button;
+ const button = buttons[`button_${platform.toLowerCase()}`] ?? buttons["button"];
// Если live-трек
if (total === 0) {
@@ -116,16 +123,18 @@ export class PlayerProgress {
* @author SNIPPIK
* @description Данные для создания прогресс бара
* @interface PlayerProgressInput
+ * @private
*/
interface PlayerProgressInput {
/**
* @description Название платформы
- * @readonly
+ * @public
*/
- platform: RestServerSide.API["name"];
+ platform: RestAPIS_Names;
/**
* @description Данные о времени трека
+ * @public
*/
duration: {
// Текущее время
diff --git a/src/core/player/structures/player.ts b/src/core/player/structures/player.ts
index f75cb204..166c0a3e 100644
--- a/src/core/player/structures/player.ts
+++ b/src/core/player/structures/player.ts
@@ -1,6 +1,6 @@
-import { BufferedAudioResource, PipeAudioResource, SILENT_FRAME } from "#core/audio";
+import { type AudioFilter, type AudioPlayerEvents, ControllerFilters } from "#core/player";
+import { BufferedAudioResource, OPUS_FRAME_SIZE, PipeAudioResource, SILENT_FRAME } from "#core/audio";
import { ControllerTracks, ControllerVoice, RepeatType, Track } from "#core/queue";
-import { AudioFilter, AudioPlayerEvents, ControllerFilters } from "#core/player";
import { PlayerProgress } from "../modules/progress";
import { Logger, TypedEmitter } from "#structures";
import type { VoiceConnection } from "#core/voice";
@@ -17,26 +17,19 @@ const Progress = new PlayerProgress();
/**
* @author SNIPPIK
- * @description Безопасное время для буферизации трека
- * @const PLAYER_BUFFERED_TIME
- * @public
- */
-export const PLAYER_BUFFERED_TIME = 500;
-
-/**
- * @author SNIPPIK
- * @description Безопасное время между аудио потоками
+ * @description Безопасное время между отправкой аудио пакетов
* @const PLAYER_PAUSE_OFFSET
* @private
*/
-const PLAYER_PAUSE_OFFSET = 2500;
+const PLAYER_PAUSE_OFFSET = 3000;
/**
* @author SNIPPIK
- * @description Поддерживающиеся потоковые аудио стримы
- * @type AudioPlayerAudio
+ * @description Безопасное время между аудио потоками
+ * @const PLAYER_TIMEOUT_OFFSET
+ * @private
*/
-type AudioPlayerAudio = BufferedAudioResource | PipeAudioResource;
+const PLAYER_TIMEOUT_OFFSET = 2000;
/**
* @author SNIPPIK
@@ -48,8 +41,13 @@ type AudioPlayerAudio = BufferedAudioResource | PipeAudioResource;
* # Особенности
* - Плеер не даст загрузить новый трек если прошлый не загружен! Через 10 сек можно будет загрузить новый!
* - Поддерживает hot swap, не ломает jitter buffer (AudioPlayerTimeout)
+ * - Умеет накладывать аудио на аудио, через ffmpeg
+ * - Высокая надежность, практически невозможно сломать
*/
export class AudioPlayer extends TypedEmitter {
+ public _stepCounter: number = 1; // Требуется для подстройки под голосовое соединение
+ public _counter: number = 0; // Требуется для подстройки под голосовое соединение
+
/**
* @description Текущий статус плеера, при создании он должен быть в ожидании
* @private
@@ -74,7 +72,7 @@ export class AudioPlayer extends TypedEmitter {
* @readonly
* @private
*/
- protected _audio = new PlayerAudio();
+ protected _audio = new PlayerAudio();
/**
* @description Делаем tracks параметр публичным для использования вне класса
@@ -147,6 +145,14 @@ export class AudioPlayer extends TypedEmitter {
return `\n\`\`${current.duration()}\`\` ${bar} \`\`${time.split}\`\``;
};
+ /**
+ * @description Задержка плеера между отправкой аудио пакетов
+ * @public
+ */
+ public get latency() {
+ return this._stepCounter * OPUS_FRAME_SIZE;
+ };
+
/**
* @description Проверяем играет ли плеер
* @return boolean
@@ -157,18 +163,46 @@ export class AudioPlayer extends TypedEmitter {
if (this._status === "player/wait" || this._status === "player/pause") return false;
// Если голосовое состояние не позволяет отправлять пакеты
- else if (!this._voice.connection && !this._voice.connection?.ready) return false;
+ else if (!this._voice.connection?.isReadyToSend) return false;
+
+ const currentAudio = this._audio.current;
// Если поток не читается, переходим в состояние ожидания
- else if (!this._audio.current && !this._audio.current.packets || !this._audio.current?.readable) {
+ if (!currentAudio && currentAudio.packets > 0 || !currentAudio.readable) {
this.status = "player/wait";
- this.disableCycle();
+ this.cycle = false;
return false;
}
return true;
};
+ /**
+ * @description Включение/Отключение плеера в цикла
+ * @private
+ */
+ private set cycle(isActive: boolean) {
+ // Подключаем плеер к циклу
+ if (isActive) {
+ // Если нет плеера в цикле
+ if (!db.queues.cycles.players.has(this)) {
+ db.queues.cycles.players.add(this);
+
+ Logger.log("DEBUG", `[AudioPlayer/${this.id}] pushed in cycle`);
+ }
+ }
+
+ // Отключаем плеер от цикла
+ else if (!isActive) {
+ // Если есть плеер в цикле
+ if (db.queues.cycles.players.has(this)) {
+ db.queues.cycles.players.delete(this);
+
+ Logger.log("DEBUG", `[AudioPlayer/${this.id}] removed from cycle`);
+ }
+ }
+ };
+
/**
* @description Задаем параметры плеера перед началом работы
* @param _tracks - Ссылка на треки из очереди
@@ -178,19 +212,19 @@ export class AudioPlayer extends TypedEmitter {
* @public
*/
public constructor(
- /**
- * @description Уникальный идентификатор сервера, для привязки плеера к серверу
- * @protected
- * @abstract
- */
+ /** Ссылка на класс с треками */
protected _tracks: ControllerTracks