feat: Voice Gateway v8

This commit is contained in:
Elysia
2025-03-02 18:28:47 +07:00
parent 33b507fc6f
commit 756ec458bc
23 changed files with 740 additions and 334 deletions

View File

@@ -12,6 +12,7 @@ const proxy = new ProxyAgent({
const client = new Discord.Client({ const client = new Discord.Client({
ws: { ws: {
agent: proxy, // WebSocket Proxy agent: proxy, // WebSocket Proxy
// Do not use the `proxy` option if you don't need to use the WebSocket Proxy
}, },
http: { http: {
// API Proxy // API Proxy

View File

@@ -36,6 +36,7 @@ client.on('ready', async client => {
const audio = connection.receiver.createStream('user_id', { const audio = connection.receiver.createStream('user_id', {
mode: 'pcm', mode: 'pcm',
end: 'manual', end: 'manual',
paddingSilence: true,
}); });
audio.pipe(fs.createWriteStream('test.pcm')); audio.pipe(fs.createWriteStream('test.pcm'));

View File

@@ -27,19 +27,13 @@ client.on('ready', async client => {
const connectionStream = await connection.joinStreamConnection('user_id'); const connectionStream = await connection.joinStreamConnection('user_id');
const video = connectionStream.receiver.createVideoStream('user_id', { const video = connectionStream.receiver.createVideoStream('user_id', fs.createWriteStream('video.mkv')); // Output file using matroska container
portUdp: 50004, // Temporary port
output: fs.createWriteStream('video.mkv'), // Output file using matroska container
// If you want video with audio, set isEnableAudio to true
isEnableAudio: false,
});
video.stream.stderr.on('data', data => {
console.log(`FFmpeg: ${data}`);
});
video.on('ready', () => { video.on('ready', () => {
console.log('FFmpeg process ready!'); console.log('FFmpeg process ready!');
video.stream.stderr.on('data', data => {
console.log(`FFmpeg: ${data}`);
});
}); });
// After 15s // After 15s

View File

@@ -63,6 +63,7 @@
"tough-cookie": "^4.1.4", "tough-cookie": "^4.1.4",
"tree-kill": "^1.2.2", "tree-kill": "^1.2.2",
"undici": "^6.21.0", "undici": "^6.21.0",
"werift-rtp": "^0.8.4",
"ws": "^8.16.0" "ws": "^8.16.0"
}, },
"engines": { "engines": {

118
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
undici: undici:
specifier: ^6.21.0 specifier: ^6.21.0
version: 6.21.1 version: 6.21.1
werift-rtp:
specifier: ^0.8.4
version: 0.8.4
ws: ws:
specifier: ^8.16.0 specifier: ^8.16.0
version: 8.18.0 version: 8.18.0
@@ -353,12 +356,12 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@definitelytyped/header-parser@0.2.16': '@definitelytyped/header-parser@0.2.17':
resolution: {integrity: sha512-UFsgPft5bhZn07UNGz/9ck4AhdKgLFEOmi2DNr7gXcGL89zbe3u5oVafKUT8j1HOtSBjT8ZEQsXHKlbq+wwF/Q==} resolution: {integrity: sha512-U0juKFkTOcbkSfO83WSzMEJHYDwoBFiq0tf/JszulL3+7UoSiqunpGmxXS54bm3eGqy7GWjV8AqPQHdeoEaWBQ==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
'@definitelytyped/typescript-versions@0.1.6': '@definitelytyped/typescript-versions@0.1.7':
resolution: {integrity: sha512-gQpXFteIKrOw4ldmBZQfBrD3WobaIG1SwOr/3alXWkcYbkOWa2NRxQbiaYQ2IvYTGaZK26miJw0UOAFiuIs4gA==} resolution: {integrity: sha512-sBzBi1SBn79OkSr8V0H+FzR7QumHk23syPyRxod/VRBrSkgN9rCliIe+nqLoWRAKN8EeKbp00ketnJNLZhucdA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
'@definitelytyped/utils@0.1.8': '@definitelytyped/utils@0.1.8':
@@ -529,6 +532,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@minhducsun2002/leb128@1.0.0':
resolution: {integrity: sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -568,6 +574,13 @@ packages:
resolution: {integrity: sha512-0gRyXPH0hbmfZXwgvCH1z9H/kQwOlLeui86zKafZENpdjmuZznSkDFHRSUksr8Y5W/BxkqnR2WbNEfululh20Q==} resolution: {integrity: sha512-0gRyXPH0hbmfZXwgvCH1z9H/kQwOlLeui86zKafZENpdjmuZznSkDFHRSUksr8Y5W/BxkqnR2WbNEfululh20Q==}
engines: {node: '>=v14.0.0'} engines: {node: '>=v14.0.0'}
'@shinyoshiaki/binary-data@0.6.1':
resolution: {integrity: sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==}
engines: {node: '>=6'}
'@shinyoshiaki/jspack@0.0.6':
resolution: {integrity: sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==}
'@sinclair/typebox@0.24.51': '@sinclair/typebox@0.24.51':
resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==}
@@ -703,6 +716,9 @@ packages:
add-stream@1.0.0: add-stream@1.0.0:
resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==}
aes-js@3.1.2:
resolution: {integrity: sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==}
agent-base@6.0.2: agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
@@ -898,6 +914,9 @@ packages:
bare-events@2.5.4: bare-events@2.5.4:
resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bcrypt-pbkdf@1.0.2: bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
@@ -925,6 +944,9 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
builtin-modules@1.1.1: builtin-modules@1.1.1:
resolution: {integrity: sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==} resolution: {integrity: sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1736,6 +1758,9 @@ packages:
resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==} resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==}
deprecated: This package is no longer supported. deprecated: This package is no longer supported.
generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
gensync@1.0.0-beta.2: gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1938,6 +1963,9 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@4.0.6: ignore@4.0.6:
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -2078,6 +2106,13 @@ packages:
resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-plain-object@2.0.4:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
is-regex@1.1.4: is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2129,6 +2164,10 @@ packages:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
isobject@3.0.1:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
isstream@0.1.2: isstream@0.1.2:
resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
@@ -2655,6 +2694,9 @@ packages:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
mp4box@0.5.3:
resolution: {integrity: sha512-RIvyFZdPDIg3+mL6vUdPBSyQRrEfKO3ryAeJ4xJJV7HBHQUH3KfLlZRzfSpBHCd/HqR63HfbrWQI/CwXDvYENQ==}
ms@2.1.2: ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
@@ -3149,6 +3191,9 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rx.mini@1.4.0:
resolution: {integrity: sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==}
rxjs@6.6.7: rxjs@6.6.7:
resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
engines: {npm: '>=2.0.0'} engines: {npm: '>=2.0.0'}
@@ -3784,6 +3829,10 @@ packages:
webidl-conversions@3.0.1: webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
werift-rtp@0.8.4:
resolution: {integrity: sha512-n2FqQoSZnrS6ztMFkMMUee0ORh4JqdkEaaXwJ3NlemCoshcX3bfdKo4HukLwH2oBomfHFRIAvrqGRpo5JdYTzw==}
engines: {node: '>=10'}
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -4248,13 +4297,13 @@ snapshots:
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.9 '@jridgewell/trace-mapping': 0.3.9
'@definitelytyped/header-parser@0.2.16': '@definitelytyped/header-parser@0.2.17':
dependencies: dependencies:
'@definitelytyped/typescript-versions': 0.1.6 '@definitelytyped/typescript-versions': 0.1.7
'@definitelytyped/utils': 0.1.8 '@definitelytyped/utils': 0.1.8
semver: 7.6.3 semver: 7.6.3
'@definitelytyped/typescript-versions@0.1.6': {} '@definitelytyped/typescript-versions@0.1.7': {}
'@definitelytyped/utils@0.1.8': '@definitelytyped/utils@0.1.8':
dependencies: dependencies:
@@ -4553,6 +4602,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@minhducsun2002/leb128@1.0.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -4603,6 +4654,13 @@ snapshots:
'@sapphire/utilities@3.16.2': {} '@sapphire/utilities@3.16.2': {}
'@shinyoshiaki/binary-data@0.6.1':
dependencies:
generate-function: 2.3.1
is-plain-object: 2.0.4
'@shinyoshiaki/jspack@0.0.6': {}
'@sinclair/typebox@0.24.51': {} '@sinclair/typebox@0.24.51': {}
'@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.27.8': {}
@@ -4735,6 +4793,8 @@ snapshots:
add-stream@1.0.0: {} add-stream@1.0.0: {}
aes-js@3.1.2: {}
agent-base@6.0.2: agent-base@6.0.2:
dependencies: dependencies:
debug: 4.3.5(supports-color@9.4.0) debug: 4.3.5(supports-color@9.4.0)
@@ -4960,6 +5020,8 @@ snapshots:
bare-events@2.5.4: bare-events@2.5.4:
optional: true optional: true
base64-js@1.5.1: {}
bcrypt-pbkdf@1.0.2: bcrypt-pbkdf@1.0.2:
dependencies: dependencies:
tweetnacl: 0.14.5 tweetnacl: 0.14.5
@@ -4992,6 +5054,11 @@ snapshots:
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
builtin-modules@1.1.1: {} builtin-modules@1.1.1: {}
builtins@1.0.3: {} builtins@1.0.3: {}
@@ -5477,7 +5544,7 @@ snapshots:
dts-critic@3.3.11(typescript@5.5.4): dts-critic@3.3.11(typescript@5.5.4):
dependencies: dependencies:
'@definitelytyped/header-parser': 0.2.16 '@definitelytyped/header-parser': 0.2.17
command-exists: 1.2.9 command-exists: 1.2.9
rimraf: 3.0.2 rimraf: 3.0.2
semver: 6.3.1 semver: 6.3.1
@@ -5487,8 +5554,8 @@ snapshots:
dtslint@4.2.1(typescript@5.5.4): dtslint@4.2.1(typescript@5.5.4):
dependencies: dependencies:
'@definitelytyped/header-parser': 0.2.16 '@definitelytyped/header-parser': 0.2.17
'@definitelytyped/typescript-versions': 0.1.6 '@definitelytyped/typescript-versions': 0.1.7
'@definitelytyped/utils': 0.1.8 '@definitelytyped/utils': 0.1.8
dts-critic: 3.3.11(typescript@5.5.4) dts-critic: 3.3.11(typescript@5.5.4)
fs-extra: 6.0.1 fs-extra: 6.0.1
@@ -6012,6 +6079,10 @@ snapshots:
wide-align: 1.1.5 wide-align: 1.1.5
optional: true optional: true
generate-function@2.3.1:
dependencies:
is-property: 1.0.2
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
@@ -6228,6 +6299,8 @@ snapshots:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
optional: true optional: true
ieee754@1.2.1: {}
ignore@4.0.6: {} ignore@4.0.6: {}
ignore@5.3.1: {} ignore@5.3.1: {}
@@ -6353,6 +6426,12 @@ snapshots:
is-plain-obj@1.1.0: {} is-plain-obj@1.1.0: {}
is-plain-object@2.0.4:
dependencies:
isobject: 3.0.1
is-property@1.0.2: {}
is-regex@1.1.4: is-regex@1.1.4:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
@@ -6396,6 +6475,8 @@ snapshots:
isexe@3.1.1: {} isexe@3.1.1: {}
isobject@3.0.1: {}
isstream@0.1.2: {} isstream@0.1.2: {}
istanbul-lib-coverage@3.2.2: {} istanbul-lib-coverage@3.2.2: {}
@@ -7165,6 +7246,8 @@ snapshots:
modify-values@1.0.1: {} modify-values@1.0.1: {}
mp4box@0.5.3: {}
ms@2.1.2: {} ms@2.1.2: {}
ms@2.1.3: {} ms@2.1.3: {}
@@ -7630,6 +7713,8 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
rx.mini@1.4.0: {}
rxjs@6.6.7: rxjs@6.6.7:
dependencies: dependencies:
tslib: 1.14.1 tslib: 1.14.1
@@ -8322,6 +8407,19 @@ snapshots:
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
werift-rtp@0.8.4:
dependencies:
'@minhducsun2002/leb128': 1.0.0
'@shinyoshiaki/binary-data': 0.6.1
'@shinyoshiaki/jspack': 0.0.6
aes-js: 3.1.2
buffer: 6.0.3
debug: 4.3.5(supports-color@9.4.0)
mp4box: 0.5.3
rx.mini: 1.4.0
transitivePeerDependencies:
- supports-color
whatwg-url@5.0.0: whatwg-url@5.0.0:
dependencies: dependencies:
tr46: 0.0.3 tr46: 0.0.3

View File

@@ -23,12 +23,6 @@ class VoiceStateUpdate extends Action {
member = guild.members._add(data.member); member = guild.members._add(data.member);
} }
// Emit event
if (member?.user.id === client.user.id) {
client.emit('debug', `[VOICE] received voice state update: ${JSON.stringify(data)}`);
client.voice.onVoiceStateUpdate(data);
}
/** /**
* Emitted whenever a member changes voice state - e.g. joins/leaves a channel, mutes/unmutes. * Emitted whenever a member changes voice state - e.g. joins/leaves a channel, mutes/unmutes.
* @event Client#voiceStateUpdate * @event Client#voiceStateUpdate
@@ -43,14 +37,13 @@ class VoiceStateUpdate extends Action {
const newState = client.voiceStates._add(data); const newState = client.voiceStates._add(data);
client.emit(Events.VOICE_STATE_UPDATE, oldState, newState);
}
// Emit event // Emit event
if (data.user_id === client.user.id) { if (data.user_id === client.user?.id) {
client.emit('debug', `[VOICE] received voice state update: ${JSON.stringify(data)}`); client.emit('debug', `[VOICE] received voice state update: ${JSON.stringify(data)}`);
client.voice.onVoiceStateUpdate(data); client.voice.onVoiceStateUpdate(data);
} }
client.emit(Events.VOICE_STATE_UPDATE, oldState, newState);
}
} }
} }

View File

@@ -75,9 +75,9 @@ class VoiceConnection extends EventEmitter {
/** /**
* Our current video state * Our current video state
* @type {boolean} * @type {boolean | null}
*/ */
this.videoStatus = false; this.videoStatus = null;
/** /**
* The authentication data needed to connect to the voice server * The authentication data needed to connect to the voice server
@@ -98,7 +98,7 @@ class VoiceConnection extends EventEmitter {
* @event VoiceConnection#debug * @event VoiceConnection#debug
* @param {string} message The debug message * @param {string} message The debug message
*/ */
this.emit('debug', `audio player - ${m}`); this.emit('debug', `media player - ${m}`);
}); });
this.player.on('error', e => { this.player.on('error', e => {
@@ -233,24 +233,39 @@ class VoiceConnection extends EventEmitter {
* @param {boolean} value Video on or off * @param {boolean} value Video on or off
*/ */
setVideoStatus(value) { setVideoStatus(value) {
if (this.status !== VoiceStatus.CONNECTED) return;
if (value === this.videoStatus) return; if (value === this.videoStatus) return;
if (this.status !== VoiceStatus.CONNECTED) return;
this.videoStatus = value; this.videoStatus = value;
if (!value) {
this.sockets.ws this.sockets.ws
.sendPacket({ .sendPacket({
op: VoiceOpcodes.SOURCES, op: VoiceOpcodes.SOURCES,
d: { d: {
audio_ssrc: this.authentication.ssrc, audio_ssrc: this.authentication.ssrc,
video_ssrc: value ? this.authentication.ssrc + 1 : 0, video_ssrc: 0,
rtx_ssrc: value ? this.authentication.ssrc + 2 : 0, rtx_ssrc: 0,
streams: [],
},
})
.catch(e => {
this.emit('debug', e);
});
} else {
this.sockets.ws
.sendPacket({
op: VoiceOpcodes.SOURCES,
d: {
audio_ssrc: this.authentication.ssrc,
video_ssrc: this.authentication.ssrc + 1,
rtx_ssrc: this.authentication.ssrc + 2,
streams: [ streams: [
{ {
type: 'video', type: 'video',
rid: '100', rid: '100',
ssrc: value ? this.authentication.ssrc + 1 : 0, ssrc: this.authentication.ssrc + 1,
active: true, active: true,
quality: 100, quality: 100,
rtx_ssrc: value ? this.authentication.ssrc + 2 : 0, rtx_ssrc: this.authentication.ssrc + 2,
max_bitrate: 8000000, max_bitrate: 8000000,
max_framerate: 60, max_framerate: 60,
max_resolution: { max_resolution: {
@@ -266,6 +281,7 @@ class VoiceConnection extends EventEmitter {
this.emit('debug', e); this.emit('debug', e);
}); });
} }
}
/** /**
* The voice state of this connection * The voice state of this connection
@@ -926,9 +942,9 @@ class StreamConnection extends VoiceConnection {
/** /**
* Stream state * Stream state
* @type {boolean} * @type {boolean | null}
*/ */
this.isPaused = false; this.isPaused = null;
/** /**
* Viewer IDs * Viewer IDs

View File

@@ -28,7 +28,7 @@ class AnnexBDispatcher extends VideoDispatcher {
this._nalFunctions = nalFunctions; this._nalFunctions = nalFunctions;
} }
codecCallback(frame) { _codecCallback(frame) {
let accessUnit = frame; let accessUnit = frame;
let offset = 0; let offset = 0;
@@ -43,7 +43,7 @@ class AnnexBDispatcher extends VideoDispatcher {
this._playChunk(Buffer.concat([this.createPayloadExtension(), nalu]), isLastNal); this._playChunk(Buffer.concat([this.createPayloadExtension(), nalu]), isLastNal);
} else { } else {
const [naluHeader, naluData] = this._nalFunctions.splitHeader(nalu); const [naluHeader, naluData] = this._nalFunctions.splitHeader(nalu);
const dataFragments = this.partitionVideoData(naluData); const dataFragments = this.partitionMtu(naluData);
// Send as Fragmentation Unit A (FU-A): // Send as Fragmentation Unit A (FU-A):
for (let fragmentIndex = 0; fragmentIndex < dataFragments.length; fragmentIndex++) { for (let fragmentIndex = 0; fragmentIndex < dataFragments.length; fragmentIndex++) {
const data = dataFragments[fragmentIndex]; const data = dataFragments[fragmentIndex];

View File

@@ -5,6 +5,8 @@ const Util = require('../../../util/Util');
const Silence = require('../util/Silence'); const Silence = require('../util/Silence');
const VolumeInterface = require('../util/VolumeInterface'); const VolumeInterface = require('../util/VolumeInterface');
const CHANNELS = 2;
/** /**
* @external WritableStream * @external WritableStream
* @see {@link https://nodejs.org/api/stream.html#stream_class_stream_writable} * @see {@link https://nodejs.org/api/stream.html#stream_class_stream_writable}
@@ -37,6 +39,22 @@ class AudioDispatcher extends BaseDispatcher {
if (typeof plp !== 'undefined') this.setPLP(plp); if (typeof plp !== 'undefined') this.setPLP(plp);
} }
get TIMESTAMP_INC() {
return 480 * CHANNELS;
}
get FRAME_LENGTH() {
return 20;
}
/**
* Get the type of the dispatcher
* @returns {'audio'}
*/
getTypeDispatcher() {
return 'audio';
}
/** /**
* Set the bitrate of the current Opus encoder if using a compatible Opus stream. * Set the bitrate of the current Opus encoder if using a compatible Opus stream.
* @param {number} value New bitrate, in kbps * @param {number} value New bitrate, in kbps
@@ -103,6 +121,17 @@ class AudioDispatcher extends BaseDispatcher {
return true; return true;
} }
/**
* Sync with another video dispatcher to ensure that the audio and video are played at the same time.
* @param {VideoDispatcher} otherDispatcher The video dispatcher to sync with
*/
setSyncVideoDispatcher(otherDispatcher) {
if (otherDispatcher.getTypeDispatcher() !== 'video') {
throw new Error('Dispatcher must be a video dispatcher');
}
this._syncDispatcher = otherDispatcher;
}
// Volume stubs for docs // Volume stubs for docs
/* eslint-disable no-empty-function*/ /* eslint-disable no-empty-function*/
get volumeDecibels() {} get volumeDecibels() {}

View File

@@ -6,12 +6,10 @@ const { Writable } = require('node:stream');
const { setTimeout } = require('node:timers'); const { setTimeout } = require('node:timers');
const secretbox = require('../util/Secretbox'); const secretbox = require('../util/Secretbox');
const CHANNELS = 2;
const MAX_UINT_16 = 2 ** 16 - 1; const MAX_UINT_16 = 2 ** 16 - 1;
const MAX_UINT_32 = 2 ** 32 - 1; const MAX_UINT_32 = 2 ** 32 - 1;
const extensions = [{ id: 5, len: 2, val: 0 }]; const extensions = [{ id: 5, length: 2, value: 0 }];
/** /**
* @external WritableStream * @external WritableStream
@@ -28,7 +26,7 @@ class BaseDispatcher extends Writable {
}); });
this.streams = streams; this.streams = streams;
/** /**
* The Audio Player that controls this dispatcher * The Player that controls this dispatcher
* @type {MediaPlayer} * @type {MediaPlayer}
*/ */
this.player = player; this.player = player;
@@ -52,14 +50,6 @@ class BaseDispatcher extends Writable {
this.sequence = 0; this.sequence = 0;
this.timestamp = 0; this.timestamp = 0;
/**
* Video FPS
* @type {number}
*/
this.fps = 0;
this.mtu = 1200;
const streamError = (type, err) => { const streamError = (type, err) => {
/** /**
* Emitted when the dispatcher encounters an error. * Emitted when the dispatcher encounters an error.
@@ -86,6 +76,10 @@ class BaseDispatcher extends Writable {
}); });
} }
getTypeDispatcher() {
return 'base';
}
resetNonceBuffer() { resetNonceBuffer() {
this._nonceBuffer = this._nonceBuffer =
this.player.voiceConnection.authentication.mode === 'aead_aes256_gcm_rtpsize' this.player.voiceConnection.authentication.mode === 'aead_aes256_gcm_rtpsize'
@@ -93,25 +87,6 @@ class BaseDispatcher extends Writable {
: Buffer.alloc(24); : Buffer.alloc(24);
} }
get TIMESTAMP_INC() {
return this.extensionEnabled ? 90000 / this.fps : 480 * CHANNELS;
}
get FRAME_LENGTH() {
return this.extensionEnabled ? 1000 / this.fps : 20;
}
partitionVideoData(data) {
const out = [];
const dataLength = data.length;
for (let i = 0; i < dataLength; i += this.mtu) {
out.push(data.slice(i, i + this.mtu));
}
return out;
}
getNewSequence() { getNewSequence() {
const currentSeq = this.sequence; const currentSeq = this.sequence;
this.sequence++; this.sequence++;
@@ -128,8 +103,20 @@ class BaseDispatcher extends Writable {
this.emit('start'); this.emit('start');
this.startTime = performance.now(); this.startTime = performance.now();
} }
if (this.extensionEnabled) { if (this._syncDispatcher && !this._syncDispatcher.startTime) {
this.codecCallback(chunk); this.pause();
const cb = () => {
this.resume();
clearTimeout(timeout);
};
this._syncDispatcher.once('start', cb);
let timeout = setTimeout(() => {
this.removeListener('start', cb);
this.resume();
}, 10_000).unref();
}
if (this.getTypeDispatcher() === 'video') {
this._codecCallback(chunk);
} else { } else {
this._playChunk(chunk); this._playChunk(chunk);
} }
@@ -166,8 +153,7 @@ class BaseDispatcher extends Writable {
this.streams.ffmpeg.pause(); this.streams.ffmpeg.pause();
this.streams.video.unpipe(this); this.streams.video.unpipe(this);
} }
if (!this.extensionEnabled) { if (this.getTypeDispatcher() === 'audio') {
// Audio
if (silence) { if (silence) {
this.streams.silence.pipe(this); this.streams.silence.pipe(this);
this._silence = true; this._silence = true;
@@ -201,7 +187,7 @@ class BaseDispatcher extends Writable {
*/ */
resume() { resume() {
if (!this.pausedSince) return; if (!this.pausedSince) return;
if (!this.extensionEnabled) this.streams.silence.unpipe(this); if (this.getTypeDispatcher() === 'audio') this.streams.silence.unpipe(this);
if (this.streams.opus) this.streams.opus.pipe(this); if (this.streams.opus) this.streams.opus.pipe(this);
if (this.streams.video) { if (this.streams.video) {
this.streams.ffmpeg.resume(); this.streams.ffmpeg.resume();
@@ -246,14 +232,15 @@ class BaseDispatcher extends Writable {
callback(); callback();
} }
_playChunk(chunk, isLastPacket) { _playChunk(chunk, isLastPacket = false) {
if ( if (
(this.player.dispatcher !== this && this.player.videoDispatcher !== this) || (this.player.dispatcher !== this && this.player.videoDispatcher !== this) ||
!this.player.voiceConnection.authentication.secret_key !this.player.voiceConnection.authentication.secret_key
) { ) {
return; return;
} }
this[this.extensionEnabled ? '_sendVideoPacket' : '_sendPacket'](this._createPacket(chunk, isLastPacket)); const packet = this._createPacket(chunk, isLastPacket);
if (packet) this._sendPacket(packet);
} }
/** /**
@@ -277,8 +264,9 @@ class BaseDispatcher extends Writable {
} }
/** /**
* Creates a single extension of type playout-delay * Creates a one-byte extension header & a single extension of type playout-delay
* Discord seems to send this extension on every video packet * @see https://docs.discord.sex/topics/voice-connections#sending-and-receiving-voice
* Discord expects a playout delay RTP extension header on every video packet.
* @see https://webrtc.googlesource.com/src/+/refs/heads/main/docs/native-code/rtp-hdrext/playout-delay * @see https://webrtc.googlesource.com/src/+/refs/heads/main/docs/native-code/rtp-hdrext/playout-delay
* @returns {Buffer} playout-delay extension <Buffer 51 00 00 00> * @returns {Buffer} playout-delay extension <Buffer 51 00 00 00>
*/ */
@@ -289,6 +277,9 @@ class BaseDispatcher extends Writable {
* EXTENSION DATA - each extension payload is 32 bits * EXTENSION DATA - each extension payload is 32 bits
*/ */
const data = Buffer.alloc(4); const data = Buffer.alloc(4);
// https://webrtc.googlesource.com/src/+/refs/heads/main/docs/native-code/rtp-hdrext/playout-delay
if (ext.id === 5) {
/** /**
* 0 1 2 3 4 5 6 7 * 0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+
@@ -296,16 +287,18 @@ class BaseDispatcher extends Writable {
+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+
where len = actual length - 1 where len = actual length - 1
*/ /
data[0] = (ext.id & 0b00001111) << 4; data[0] = (ext.id & 0b00001111) << 4;
data[0] |= (ext.len - 1) & 0b00001111; data[0] |= (ext.len - 1) & 0b00001111;
/** Specific to type playout-delay /** Specific to type playout-delay
* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| MIN delay | MAX delay | | MIN delay | MAX delay |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*/ */
data.writeUIntBE(ext.val, 1, 2); // Not quite but its 0 anyway data.writeUIntBE(ext.value, 1, 2); // Not quite but its 0 anyway
}
extensionsData.push(data); extensionsData.push(data);
} }
return Buffer.concat(extensionsData); return Buffer.concat(extensionsData);
@@ -352,24 +345,50 @@ class BaseDispatcher extends Writable {
} }
} }
_createPacket(buffer, isLastPacket = false) { _createPacket(buffer, isLastPacket) {
// Header /*
const rtpHeader = this.extensionEnabled // Packet is raw rtp from ffmpeg
? Buffer.concat([Buffer.alloc(12), this.createHeaderExtension()]) const rtp = webrtc.RtpPacket.deSerialize(buffer);
: Buffer.alloc(12); // RTP_HEADER_SIZE if (!rtp.payload) {
rtpHeader[0] = this.extensionEnabled ? 0x90 : 0x80; // Version + Flags (1 byte) console.log('no payload', rtp);
rtpHeader[1] = this.payloadType; // Payload Type (1 byte) return null;
if (this.extensionEnabled) {
if (isLastPacket) {
rtpHeader[1] |= 0x80;
} }
// Header
// https://docs.discord.sex/topics/voice-connections#rtp-packet-structure
let rtpHeader = buffer.slice(0, 12); // RTP_HEADER_SIZE
rtpHeader[0] = 0x80; // Version + Flags (1 byte)
rtpHeader[1] = this.payloadType; // Payload Type (1 byte)
if (this.extensionEnabled) {
rtpHeader = Buffer.concat([rtpHeader, this.createHeaderExtension()]);
rtpHeader[0] |= 1 << 4; // 0x90
}
rtpHeader.writeUIntBE(this.getNewSequence(), 2, 2);
rtpHeader.writeUIntBE(this.timestamp, 4, 4);
rtpHeader.writeUIntBE(
this.player.voiceConnection.authentication.ssrc + Number(this.getTypeDispatcher() === 'video'),
8,
4,
);
*/
// Header
let rtpHeader = Buffer.alloc(12); // RTP_HEADER_SIZE
rtpHeader[0] = 0x80; // Version + Flags (1 byte)
rtpHeader[1] = this.payloadType; // Payload Type (1 byte)
if (this.extensionEnabled) {
rtpHeader = Buffer.concat([rtpHeader, this.createHeaderExtension()]);
rtpHeader[0] |= 1 << 4; // 0x90
}
if (this.getTypeDispatcher() === 'video' && isLastPacket) {
rtpHeader[1] |= 1 << 7; // Marker bit
} }
rtpHeader.writeUIntBE(this.getNewSequence(), 2, 2); rtpHeader.writeUIntBE(this.getNewSequence(), 2, 2);
rtpHeader.writeUIntBE(this.timestamp, 4, 4); rtpHeader.writeUIntBE(this.timestamp, 4, 4);
rtpHeader.writeUIntBE(this.player.voiceConnection.authentication.ssrc + this.extensionEnabled, 8, 4); rtpHeader.writeUIntBE(
this.player.voiceConnection.authentication.ssrc + Number(this.getTypeDispatcher() === 'video'),
8,
4,
);
return Buffer.concat([rtpHeader, ...this._encrypt(buffer, rtpHeader)]); return Buffer.concat([rtpHeader, ...this._encrypt(buffer, rtpHeader)]);
} }
@@ -379,28 +398,24 @@ class BaseDispatcher extends Writable {
* @event BaseDispatcher#debug * @event BaseDispatcher#debug
* @param {string} info The debug info * @param {string} info The debug info
*/ */
if (this.getTypeDispatcher() === 'audio') {
this._setSpeaking(this.player.isScreenSharing ? 1 << 1 : 1 << 0); // 1 << 0 = SPEAKING, 1 << 1 = SOUND SHARE this._setSpeaking(this.player.isScreenSharing ? 1 << 1 : 1 << 0); // 1 << 0 = SPEAKING, 1 << 1 = SOUND SHARE
} else if (this.getTypeDispatcher() === 'video') {
this._setVideoStatus(true);
this._setStreamStatus(false);
}
if (!this.player.voiceConnection.sockets.udp) { if (!this.player.voiceConnection.sockets.udp) {
this.emit('debug', 'Failed to send a packet - no UDP socket'); this.emit('debug', 'Failed to send a packet - no UDP socket');
return; return;
} }
this.player.voiceConnection.sockets.udp.send(packet).catch(e => { this.player.voiceConnection.sockets.udp.send(packet).catch(e => {
this._setSpeaking(0); if (this.getTypeDispatcher() === 'audio') {
this.emit('debug', `Failed to send a packet - ${e}`); this._setSpeaking(this._setSpeaking(0));
}); } else if (this.getTypeDispatcher() === 'video') {
}
_sendVideoPacket(packet) {
this._setVideoStatus(true);
this._setStreamStatus(false);
if (!this.player.voiceConnection.sockets.udp) {
this.emit('debug', 'Failed to send a video packet - no UDP socket');
return;
}
this.player.voiceConnection.sockets.udp.send(packet).catch(e => {
this._setVideoStatus(false); this._setVideoStatus(false);
this._setStreamStatus(true); this._setStreamStatus(true);
this.emit('debug', `Failed to send a video packet - ${e}`); }
this.emit('debug', `Failed to send a packet - ${e}`);
}); });
} }

View File

@@ -26,25 +26,25 @@ class VP8Dispatcher extends VideoDispatcher {
super(player, highWaterMark, streams, fps, Util.getPayloadType('VP8')); super(player, highWaterMark, streams, fps, Util.getPayloadType('VP8'));
} }
makeChunk(buffer, isFirstFrame) { makeChunk(buffer, isFirstPacket) {
// Vp8 payload descriptor // Vp8 payload descriptor
const payloadDescriptorBuf = Buffer.alloc(2); const payloadDescriptorBuf = Buffer.alloc(2);
payloadDescriptorBuf[0] = isFirstFrame ? 0x90 : 0x80; // Mark S bit, indicates start of frame: payloadDescriptorBuf[0] |= 0b00010000; payloadDescriptorBuf[0] = 0x80;
payloadDescriptorBuf[1] = 0x80; payloadDescriptorBuf[1] = 0x80;
if (isFirstPacket) {
payloadDescriptorBuf[0] |= 1 << 4; // Mark S bit, indicates start of frame
}
// Vp8 pictureid payload extension // Vp8 pictureid payload extension
const pictureIdBuf = Buffer.alloc(2); const pictureIdBuf = Buffer.alloc(2);
pictureIdBuf.writeUintBE(this.count, 0, 2); pictureIdBuf.writeUintBE(this.count, 0, 2);
pictureIdBuf[0] |= 0x80; pictureIdBuf[0] |= 0x80;
return Buffer.concat([payloadDescriptorBuf, pictureIdBuf, buffer]); return Buffer.concat([this.createPayloadExtension(), payloadDescriptorBuf, pictureIdBuf, buffer]);
} }
codecCallback(chunk) { _codecCallback(chunk) {
const chunkSplit = this.partitionVideoData(chunk); const chunkSplit = this.partitionMtu(chunk).map((c, i) => this.makeChunk(c, i === 0));
for (let i = 0; i < chunkSplit.length; i++) { for (let i = 0; i < chunkSplit.length; i++) {
this._playChunk( this._playChunk(chunkSplit[i], i + 1 === chunkSplit.length);
Buffer.concat([this.createPayloadExtension(), this.makeChunk(chunkSplit[i], i == 0)]),
i + 1 === chunkSplit.length,
);
} }
} }
} }

View File

@@ -16,7 +16,40 @@ const BaseDispatcher = require('./BaseDispatcher');
class VideoDispatcher extends BaseDispatcher { class VideoDispatcher extends BaseDispatcher {
constructor(player, highWaterMark = 12, streams, fps, payloadType) { constructor(player, highWaterMark = 12, streams, fps, payloadType) {
super(player, highWaterMark, payloadType, true, streams); super(player, highWaterMark, payloadType, true, streams);
/**
* Video FPS
* @type {number}
*/
this.fps = fps; this.fps = fps;
this.mtu = 1200;
}
get TIMESTAMP_INC() {
return 90000 / this.fps;
}
get FRAME_LENGTH() {
return 1000 / this.fps;
}
/**
* Get the type of the dispatcher
* @returns {'video'}
*/
getTypeDispatcher() {
return 'video';
}
partitionMtu(data) {
const out = [];
const dataLength = data.length;
for (let i = 0; i < dataLength; i += this.mtu) {
out.push(data.slice(i, i + this.mtu));
}
return out;
} }
/** /**
@@ -26,6 +59,10 @@ class VideoDispatcher extends BaseDispatcher {
setFPSSource(value) { setFPSSource(value) {
this.fps = value; this.fps = value;
} }
_codecCallback() {
throw new Error('The _codecCallback method must be implemented');
}
} }
module.exports = VideoDispatcher; module.exports = VideoDispatcher;

View File

@@ -26,6 +26,8 @@ class VoiceWebSocket extends EventEmitter {
*/ */
this.attempts = 0; this.attempts = 0;
this._sequenceNumber = -1;
this.dead = false; this.dead = false;
this.connection.on('closing', this.shutdown.bind(this)); this.connection.on('closing', this.shutdown.bind(this));
} }
@@ -75,7 +77,7 @@ class VoiceWebSocket extends EventEmitter {
* The actual WebSocket used to connect to the Voice WebSocket Server. * The actual WebSocket used to connect to the Voice WebSocket Server.
* @type {WebSocket} * @type {WebSocket}
*/ */
this.ws = WebSocket.create(`wss://${this.connection.authentication.endpoint}/`, { v: 7 }); this.ws = WebSocket.create(`wss://${this.connection.authentication.endpoint}/`, { v: 8 });
this.emit('debug', `[WS] connecting, ${this.attempts} attempts, ${this.ws.url}`); this.emit('debug', `[WS] connecting, ${this.attempts} attempts, ${this.ws.url}`);
this.ws.onopen = this.onOpen.bind(this); this.ws.onopen = this.onOpen.bind(this);
this.ws.onmessage = this.onMessage.bind(this); this.ws.onmessage = this.onMessage.bind(this);
@@ -144,9 +146,10 @@ class VoiceWebSocket extends EventEmitter {
/** /**
* Called whenever the connection to the WebSocket server is lost. * Called whenever the connection to the WebSocket server is lost.
* @param {CloseEvent} event The WebSocket close event
*/ */
onClose() { onClose(event) {
this.emit('debug', `[WS] closed`); this.emit('debug', `[WS] closed with code ${event.code} and reason: ${event.reason}`);
if (!this.dead) setTimeout(this.connect.bind(this), this.attempts * 1000).unref(); if (!this.dead) setTimeout(this.connect.bind(this), this.attempts * 1000).unref();
} }
@@ -165,6 +168,7 @@ class VoiceWebSocket extends EventEmitter {
*/ */
onPacket(packet) { onPacket(packet) {
this.emit('debug', `[WS] << ${JSON.stringify(packet)}`); this.emit('debug', `[WS] << ${JSON.stringify(packet)}`);
if (packet.seq) this._sequenceNumber = packet.seq;
switch (packet.op) { switch (packet.op) {
case VoiceOpcodes.HELLO: case VoiceOpcodes.HELLO:
this.setHeartbeat(packet.d.heartbeat_interval); this.setHeartbeat(packet.d.heartbeat_interval);
@@ -266,7 +270,13 @@ class VoiceWebSocket extends EventEmitter {
* Sends a heartbeat packet. * Sends a heartbeat packet.
*/ */
sendHeartbeat() { sendHeartbeat() {
this.sendPacket({ op: VoiceOpcodes.HEARTBEAT, d: Math.floor(Math.random() * 10e10) }).catch(() => { this.sendPacket({
op: VoiceOpcodes.HEARTBEAT,
d: {
t: Date.now(),
seq_ack: this._sequenceNumber,
},
}).catch(() => {
this.emit('warn', 'Tried to send heartbeat, but connection is not open'); this.emit('warn', 'Tried to send heartbeat, but connection is not open');
this.clearHeartbeat(); this.clearHeartbeat();
}); });

View File

@@ -20,7 +20,7 @@ Please use the @dank074/discord-video-stream library for the best support.
const EventEmitter = require('events'); const EventEmitter = require('events');
const { Readable: ReadableStream } = require('stream'); const { Readable: ReadableStream } = require('stream');
const prism = require('prism-media'); const prism = require('prism-media');
const { H264NalSplitter } = require('./processing/AnnexBNalSplitter'); const { H264NalSplitter, H265NalSplitter } = require('./processing/AnnexBNalSplitter');
const { IvfTransformer } = require('./processing/IvfSplitter'); const { IvfTransformer } = require('./processing/IvfSplitter');
const { H264Dispatcher } = require('../dispatcher/AnnexBDispatcher'); const { H264Dispatcher } = require('../dispatcher/AnnexBDispatcher');
const AudioDispatcher = require('../dispatcher/AudioDispatcher'); const AudioDispatcher = require('../dispatcher/AudioDispatcher');
@@ -62,6 +62,19 @@ const FFMPEG_H264_ARGUMENTS = options => [
'h264_metadata=aud=insert', 'h264_metadata=aud=insert',
]; ];
const FFMPEG_H265_ARGUMENTS = options => [
'-c:v',
'libx265',
'-f',
'hevc',
'-preset',
options?.presetH265 || 'faster',
'-profile:v',
'main',
'-bf',
'0',
];
/** /**
* Player for a Voice Connection. * Player for a Voice Connection.
* @private * @private
@@ -188,15 +201,19 @@ class MediaPlayer extends EventEmitter {
} }
// Get stream type // Get stream type
if (this.voiceConnection.videoCodec == 'VP8') { if (this.voiceConnection.videoCodec === 'VP8') {
args.push(...FFMPEG_VP8_ARGUMENTS); args.push(...FFMPEG_VP8_ARGUMENTS);
// Remove '-speed', '5' bc bad quality // Remove '-speed', '5' bc bad quality
} }
if (this.voiceConnection.videoCodec == 'H264') { if (this.voiceConnection.videoCodec === 'H264') {
args.push(...FFMPEG_H264_ARGUMENTS(options)); args.push(...FFMPEG_H264_ARGUMENTS(options));
} }
if (this.voiceConnection.videoCodec === 'H265') {
args.push(...FFMPEG_H265_ARGUMENTS(options));
}
args.push('-force_key_frames', '00:02'); args.push('-force_key_frames', '00:02');
if (options?.inputFFmpegArgs) { if (options?.inputFFmpegArgs) {
@@ -229,7 +246,7 @@ class MediaPlayer extends EventEmitter {
return this.playAnnexBVideo(ffmpeg, options, streams, 'H264'); return this.playAnnexBVideo(ffmpeg, options, streams, 'H264');
} }
default: { default: {
throw new Error('Invalid codec (Supported: VP8, H264)'); throw new Error('Invalid codec (Supported: VP8, H264, H265)');
} }
} }
} }
@@ -247,7 +264,12 @@ class MediaPlayer extends EventEmitter {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
playAnnexBVideo(stream, options, streams, type) { playAnnexBVideo(stream, options, streams, type) {
this.destroyVideoDispatcher(); this.destroyVideoDispatcher();
const videoStream = new H264NalSplitter(); let videoStream;
if (type === 'H264') {
videoStream = new H264NalSplitter();
} else if (type === 'H265') {
videoStream = new H265NalSplitter();
}
stream.pipe(videoStream); stream.pipe(videoStream);
streams.video = videoStream; streams.video = videoStream;
const dispatcher = this.createVideoDispatcher(options, streams); const dispatcher = this.createVideoDispatcher(options, streams);

View File

@@ -0,0 +1,37 @@
'use strict';
const { Buffer } = require('node:buffer');
const { Transform } = require('node:stream');
class PCMInsertSilence extends Transform {
constructor(options) {
super(options);
// 48Khz, 2 channels, 16-bit (2 bytes per channel)
this.sampleRate = 48000;
this.channels = 2;
// 4 bytes per frame (2 channels * 2 bytes)
this.bytesPerFrame = this.channels * 2;
this.lastChunkTime = Date.now();
this.silenceThresholdMs = 50;
}
_transform(chunk, encoding, callback) {
const now = Date.now();
const gap = now - this.lastChunkTime;
if (gap >= this.silenceThresholdMs) {
const missingFrames = Math.floor((gap / 1000) * this.sampleRate);
const silenceBuffer = Buffer.alloc(missingFrames * this.bytesPerFrame, 0);
this.push(silenceBuffer);
}
this.lastChunkTime = now;
this.push(chunk);
callback();
}
}
module.exports = {
PCMInsertSilence,
};

View File

@@ -3,10 +3,11 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const { Buffer } = require('node:buffer'); const { Buffer } = require('node:buffer');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { nextTick } = require('node:process');
const { setTimeout } = require('node:timers'); const { setTimeout } = require('node:timers');
const FFmpegHandler = require('./FFmpegHandler'); const { RtpPacket } = require('werift-rtp');
const Recorder = require('./Recorder');
const Speaking = require('../../../util/Speaking'); const Speaking = require('../../../util/Speaking');
const Util = require('../../../util/Util');
const secretbox = require('../util/Secretbox'); const secretbox = require('../util/Secretbox');
const { SILENCE_FRAME } = require('../util/Silence'); const { SILENCE_FRAME } = require('../util/Silence');
@@ -14,7 +15,7 @@ const { SILENCE_FRAME } = require('../util/Silence');
// https://github.com/discordjs/discord.js/issues/3524#issuecomment-540373200 // https://github.com/discordjs/discord.js/issues/3524#issuecomment-540373200
const DISCORD_SPEAKING_DELAY = 250; const DISCORD_SPEAKING_DELAY = 250;
const HEADER_EXTENSION_BYTE = Buffer.from([0xbe, 0xde]); // Unused: const HEADER_EXTENSION_BYTE = Buffer.from([0xbe, 0xde]);
const UNPADDED_NONCE_LENGTH = 4; const UNPADDED_NONCE_LENGTH = 4;
const AUTH_TAG_LENGTH = 16; const AUTH_TAG_LENGTH = 16;
@@ -57,16 +58,21 @@ class PacketHandler extends EventEmitter {
return stream; return stream;
} }
makeVideoStream(user, portUdp, codec, output, isEnableAudio = false) { makeVideoStream(user, output) {
if (this.videoStreams.has(user)) return this.videoStreams.get(user); if (this.videoStreams.has(user)) return this.videoStreams.get(user);
const stream = new FFmpegHandler(this, user, codec, portUdp, output, isEnableAudio); const stream = new Recorder(this, {
userId: user,
output,
portUdpH264: 65506,
portUdpOpus: 65510,
});
stream.on('ready', () => { stream.on('ready', () => {
this.videoStreams.set(user, stream); this.videoStreams.set(user, stream);
}); });
return stream; return stream;
} }
parseBuffer(buffer, shouldReturnTuple = false) { parseBuffer(buffer) {
const { secret_key, mode } = this.receiver.connection.authentication; const { secret_key, mode } = this.receiver.connection.authentication;
// Open packet // Open packet
if (!secret_key) return new Error('secret_key cannot be null or undefined'); if (!secret_key) return new Error('secret_key cannot be null or undefined');
@@ -115,30 +121,21 @@ class PacketHandler extends EventEmitter {
} }
} }
if (shouldReturnTuple) { /*
return [header, packet];
}
// Strip decrypted RTP Header Extension if present // Strip decrypted RTP Header Extension if present
if (buffer.slice(12, 14).compare(HEADER_EXTENSION_BYTE) === 0) { if (buffer.slice(12, 14).compare(HEADER_EXTENSION_BYTE) === 0) {
const headerExtensionLength = buffer.slice(14).readUInt16BE(); const headerExtensionLength = buffer.slice(14).readUInt16BE();
packet = packet.subarray(4 * headerExtensionLength); packet = packet.subarray(4 * headerExtensionLength);
} }
*/
return packet; return RtpPacket.deSerialize(Buffer.concat([header, packet]));
} }
audioReceiver(buffer) { audioReceiver(ssrc, userStat, opusPacket) {
const ssrc = buffer.readUInt32BE(8);
const userStat = this.connection.ssrcMap.get(ssrc);
if (!userStat) return;
let opusPacket;
const streamInfo = this.streams.get(userStat.userId); const streamInfo = this.streams.get(userStat.userId);
// If the user is in video, we need to check if the packet is just silence // If the user is in video, we need to check if the packet is just silence
if (userStat.hasVideo) { if (userStat.hasVideo) {
opusPacket = this.parseBuffer(buffer);
if (opusPacket instanceof Error) { if (opusPacket instanceof Error) {
// Only emit an error if we were actively receiving packets from this user // Only emit an error if we were actively receiving packets from this user
if (streamInfo) { if (streamInfo) {
@@ -146,7 +143,14 @@ class PacketHandler extends EventEmitter {
} }
return; return;
} }
if (SILENCE_FRAME.equals(opusPacket)) { // Check payload type
if (opusPacket.header.payloadType !== Util.getPayloadType('opus')) {
return;
}
if (!opusPacket.payload) {
return;
}
if (SILENCE_FRAME.equals(opusPacket.payload)) {
// If this is a silence frame, pretend we never received it // If this is a silence frame, pretend we never received it
return; return;
} }
@@ -176,65 +180,68 @@ class PacketHandler extends EventEmitter {
if (streamInfo) { if (streamInfo) {
const { stream } = streamInfo; const { stream } = streamInfo;
if (!opusPacket) {
opusPacket = this.parseBuffer(buffer);
if (opusPacket instanceof Error) { if (opusPacket instanceof Error) {
this.emit('error', opusPacket); this.emit('error', opusPacket);
return; return;
} }
if (opusPacket.header.payloadType !== Util.getPayloadType('opus')) {
return;
} }
if (opusPacket === null) { stream.push(opusPacket.payload);
// ! null marks EOF for stream
nextTick(() => this.destroy());
}
stream.push(opusPacket);
} }
} }
audioReceiverForStream(buffer) { audioReceiverForStream(ssrc, userStat, packet) {
const ssrc = buffer.readUInt32BE(8);
const userStat = this.connection.ssrcMap.get(ssrc); // Audio_ssrc
if (!userStat) return;
const streamInfo = this.videoStreams.get(userStat.userId); const streamInfo = this.videoStreams.get(userStat.userId);
if (!streamInfo) return; if (!streamInfo) return;
const packet = this.parseBuffer(buffer, true);
if (packet instanceof Error) { if (packet instanceof Error) {
return; return;
} }
if (streamInfo.isEnableAudio) { if (packet.header.payloadType !== Util.getPayloadType('opus')) {
streamInfo.sendPayloadToFFmpeg(Buffer.concat(packet), true); return;
} }
streamInfo.feed(packet);
} }
videoReceiver(buffer) { /**
const ssrc = buffer.readUInt32BE(8); * Test
const userStat = this.connection.ssrcMap.get(ssrc - 1); // Video_ssrc * @param {number} ssrc ssrc
* @param {Object} userStat { userId, hasVideo }
if (!userStat) return; * @param {RtpPacket} packet RtpPacket
* @returns {void}
*/
videoReceiver(ssrc, userStat, packet) {
const streamInfo = this.videoStreams.get(userStat.userId); const streamInfo = this.videoStreams.get(userStat.userId);
// If the user is in video, we need to check if the packet is just silence // If the user is in video, we need to check if the packet is just silence
if (userStat.hasVideo) { if (userStat.hasVideo) {
const packet = this.parseBuffer(buffer, true);
if (packet instanceof Error) { if (packet instanceof Error) {
return; return;
} }
let [header, videoPacket] = packet;
if (SILENCE_FRAME.equals(videoPacket)) { if (packet.header.payloadType === Util.getPayloadType('opus')) {
// If this is a silence frame, pretend we never received it
return; return;
} }
this.receiver.emit('videoData', ssrc - 1, userStat, header, videoPacket);
if (streamInfo) { if (streamInfo) {
streamInfo.sendPayloadToFFmpeg(Buffer.concat(packet)); streamInfo.feed(packet);
} }
} }
} }
push(buffer) { push(buffer) {
this.audioReceiver(buffer); const ssrc = buffer.readUInt32BE(8);
this.videoReceiver(buffer); let userStat, packet;
this.audioReceiverForStream(buffer); if (this.connection.ssrcMap.has(ssrc)) {
userStat = this.connection.ssrcMap.get(ssrc); // Audio_ssrc
packet = this.parseBuffer(buffer);
this.audioReceiver(ssrc, userStat, packet);
this.audioReceiverForStream(ssrc, userStat, packet);
} else if (this.connection.ssrcMap.has(ssrc - 1)) {
userStat = this.connection.ssrcMap.get(ssrc - 1); // Video_ssrc
packet = this.parseBuffer(buffer);
this.videoReceiver(ssrc, userStat, packet);
}
if (userStat && !(packet instanceof Error)) this.receiver.emit('receiverData', userStat, packet);
} }
// When udp connection is closed (STREAM_DELETE), destroy all streams (Memory leak) // When udp connection is closed (STREAM_DELETE), destroy all streams (Memory leak)

View File

@@ -4,6 +4,7 @@ const EventEmitter = require('events');
const prism = require('prism-media'); const prism = require('prism-media');
const PacketHandler = require('./PacketHandler'); const PacketHandler = require('./PacketHandler');
const { Error } = require('../../../errors'); const { Error } = require('../../../errors');
const { PCMInsertSilence } = require('../player/processing/PCMInsertSilence');
/** /**
* Receives audio packets from a voice connection. * Receives audio packets from a voice connection.
@@ -33,6 +34,8 @@ class VoiceReceiver extends EventEmitter {
* audio * audio
* @property {string} [end='silence'] When the stream should be destroyed. If `silence`, this will be when the user * @property {string} [end='silence'] When the stream should be destroyed. If `silence`, this will be when the user
* stops talking. Otherwise, if `manual`, this should be handled by you. * stops talking. Otherwise, if `manual`, this should be handled by you.
* @property {boolean} [paddingSilence=false] Whether to add silence padding
* If 'end' is set to 'silence', this property automatically defaults to `false`
*/ */
/** /**
@@ -42,10 +45,22 @@ class VoiceReceiver extends EventEmitter {
* @param {ReceiveStreamOptions} options Options. * @param {ReceiveStreamOptions} options Options.
* @returns {ReadableStream} * @returns {ReadableStream}
*/ */
createStream(user, { mode = 'opus', end = 'silence' } = {}) { createStream(user, { mode = 'opus', end = 'silence', paddingSilence = false } = {}) {
user = this.connection.client.users.resolve(user); user = this.connection.client.users.resolve(user);
if (end === 'silence') paddingSilence = false;
if (!user) throw new Error('VOICE_USER_MISSING'); if (!user) throw new Error('VOICE_USER_MISSING');
const stream = this.packets.makeStream(user.id, end); const stream = this.packets.makeStream(user.id, end); // Opus stream
if (paddingSilence) {
const decoder = new prism.opus.Decoder({ channels: 2, rate: 48000, frameSize: 960 });
const pcmTransformer = new PCMInsertSilence();
stream.pipe(decoder).pipe(pcmTransformer);
if (mode === 'opus') {
const encoder = new prism.opus.Encoder({ channels: 2, rate: 48000, frameSize: 960 });
pcmTransformer.pipe(encoder);
return encoder;
}
return pcmTransformer;
} else {
if (mode === 'pcm') { if (mode === 'pcm') {
const decoder = new prism.opus.Decoder({ channels: 2, rate: 48000, frameSize: 960 }); const decoder = new prism.opus.Decoder({ channels: 2, rate: 48000, frameSize: 960 });
stream.pipe(decoder); stream.pipe(decoder);
@@ -53,38 +68,28 @@ class VoiceReceiver extends EventEmitter {
} }
return stream; return stream;
} }
}
/**
* Options passed to `VoiceReceiver#createVideoStream`.
* @typedef {Object} ReceiveVideoStreamOptions
* @property {number} portUdp The UDP port to use for the video stream (local stream).
* @property {WritableStream|string} output Output stream or file path to write the video stream to.
* @property {boolean} [isEnableAudio=false] Enable audio for the video stream.
* <info>If you intend to record the stream with audio, make sure that `portUdp` and `portUdp + 2` are not in use.</info>
*/
/** /**
* Creates a new video receiving stream. If a stream already exists for a user, then that stream will be returned * Creates a new video receiving stream. If a stream already exists for a user, then that stream will be returned
* rather than generating a new one. * rather than generating a new one.
* <info>Proof of concept - Requires a very good internet connection</info> * <info>Proof of concept - Requires a very good internet connection</info>
* @param {UserResolvable} user The user to start listening to. * @param {UserResolvable} user The user to start listening to.
* @param {ReceiveVideoStreamOptions} options Options. * @param {WritableStream|string} output Output stream or file path to write the video stream to.
* @returns {FFmpegHandler} The video stream for the specified user. * @returns {Recorder} The video stream for the specified user.
*/ */
createVideoStream(user, { portUdp, output, isEnableAudio = false } = {}) { createVideoStream(user, output) {
user = this.connection.client.users.resolve(user); user = this.connection.client.users.resolve(user);
if (!user) throw new Error('VOICE_USER_MISSING'); if (!user) throw new Error('VOICE_USER_MISSING');
const stream = this.packets.makeVideoStream(user.id, portUdp, 'H264', output, isEnableAudio); const stream = this.packets.makeVideoStream(user.id, output);
return stream; return stream;
} }
/** /**
* Emitted whenever there is a video data (Raw) * Emitted whenever there is a data packet received.
* @event VoiceReceiver#videoData * @event VoiceReceiver#receiverData
* @param {number} ssrc SSRC
* @param {{ userId: Snowflake, hasVideo: boolean }} ssrcData SSRC Data * @param {{ userId: Snowflake, hasVideo: boolean }} ssrcData SSRC Data
* @param {Buffer} header The unencrypted RTP header contains 12 bytes, Buffer<0xbe, 0xde> and the extension size * @param {RtpPacket} header RTP Packet
* @param {Buffer} packetDecrypt Decrypted contains the extension, if any, the video packet
*/ */
} }

View File

@@ -7,15 +7,17 @@ const { Buffer } = require('node:buffer');
const { Writable } = require('stream'); const { Writable } = require('stream');
const find = require('find-process'); const find = require('find-process');
const kill = require('tree-kill'); const kill = require('tree-kill');
const { RtpPacket } = require('werift-rtp');
const Util = require('../../../util/Util'); const Util = require('../../../util/Util');
const { randomPorts } = require('../util/Function');
const { StreamOutput } = require('../util/Socket'); const { StreamOutput } = require('../util/Socket');
/** /**
* Represents a FFmpeg handler * Represents a FFmpeg handler
* @extends {EventEmitter} * @extends {EventEmitter}
*/ */
class FFmpegHandler extends EventEmitter { class Recorder extends EventEmitter {
constructor(receiver, userId, codec, portUdp, output, isEnableAudio) { constructor(receiver, { userId, portUdpH264, portUdpOpus, output } = {}) {
super(); super();
Object.defineProperty(this, 'receiver', { value: receiver }); Object.defineProperty(this, 'receiver', { value: receiver });
@@ -26,27 +28,18 @@ class FFmpegHandler extends EventEmitter {
*/ */
this.userId = userId; this.userId = userId;
/** this.portUdpH264 = portUdpH264;
* If the audio is enabled this.portUdpH265 = null;
* @type {boolean} this.portUdpOpus = portUdpOpus;
*/
this.isEnableAudio = isEnableAudio;
/** this.promise = null;
* The codec of the stream
* @type {VideoCodec}
*/
this.codec = codec;
/** if (!portUdpH264 || !portUdpOpus) {
* The UDP port to listen to this.promise = randomPorts(6, 'udp4').then(ports => {
* @type {number} ports = ports.filter(port => port % 2 === 0);
*/ this.portUdpH264 ??= ports[0];
this.portUdp = portUdp; this.portUdpOpus ??= ports[1];
});
const isStream = output instanceof Writable;
if (isStream) {
this.outputStream = StreamOutput(output);
} }
/** /**
@@ -55,35 +48,54 @@ class FFmpegHandler extends EventEmitter {
*/ */
this.output = output; this.output = output;
const sdpData = Util.getSDPCodecName(portUdp, this.isEnableAudio);
/** /**
* The FFmpeg process is ready or not * The FFmpeg process is ready or not
* @type {boolean} * @type {boolean}
*/ */
this.ready = false; this.ready = false;
this.socket = createSocket('udp4');
this.init(output);
}
async init(output) {
await this.promise;
const sdpData = Util.getSDPCodecName(this.portUdpH264, this.portUdpH265, this.portUdpOpus);
const isStream = output instanceof Writable;
if (isStream) {
this.outputStream = StreamOutput(output);
}
const stream = spawn('ffmpeg', [ const stream = spawn('ffmpeg', [
'-reorder_queue_size', '-reorder_queue_size',
'50', '500',
'-thread_queue_size',
'500',
'-err_detect', '-err_detect',
'ignore_err', 'ignore_err',
'-flags2', '-flags2',
'+export_mvs', '+export_mvs',
'-fflags', '-fflags',
'+genpts', '+genpts+discardcorrupt',
'-fflags',
'+discardcorrupt',
'-use_wallclock_as_timestamps', '-use_wallclock_as_timestamps',
'1', '1',
'-f',
'sdp',
'-analyzeduration',
'1M',
'-probesize',
'1M',
'-protocol_whitelist', '-protocol_whitelist',
'file,udp,rtp,pipe,fd', 'file,udp,rtp,pipe,fd',
'-i', '-i',
'-', // Read from stdin '-', // Read from stdin
'-buffer_size', '-buffer_size',
'1000000', '4M',
'-max_delay', '-max_delay',
'500000', '500000', // 500ms
'-rtbufsize',
'4M',
'-c',
'copy',
'-y', '-y',
'-f', '-f',
'matroska', 'matroska',
@@ -102,33 +114,36 @@ class FFmpegHandler extends EventEmitter {
this.ready = true; this.ready = true;
this.emit('ready'); this.emit('ready');
}); });
this.socket = createSocket('udp4');
this.socketAudio = createSocket('udp4');
} }
/** /**
* Send a payload to FFmpeg via UDP * Send a payload to FFmpeg via UDP
* @param {Buffer} payload The payload * @param {RtpPacket|string|Buffer} payload The payload
* @param {boolean} isAudio If the payload is audio
* @param {*} callback Callback * @param {*} callback Callback
*/ */
sendPayloadToFFmpeg( feed(
payload, payload,
isAudio = false,
callback = e => { callback = e => {
if (e) { if (e) {
console.error('Error sending packet:', e); console.error('Error sending packet:', e);
} }
}, },
) { ) {
const message = Buffer.from(payload); if (!(payload instanceof RtpPacket)) {
if (isAudio && !this.isEnableAudio) { payload = RtpPacket.deSerialize(Buffer.isBuffer(payload) ? payload : Buffer.from(payload));
}
const message = payload.serialize();
// Get port from payloadType
let port;
if (payload.header.payloadType === Util.getPayloadType('opus')) {
port = this.portUdpOpus;
} else if (payload.header.payloadType === Util.getPayloadType('H264')) {
port = this.portUdpH264;
} else if (payload.header.payloadType === Util.getPayloadType('H265')) {
port = this.portUdpH265;
} else {
return; return;
} }
if (isAudio) { this.socket.send(message, 0, message.length, port, '127.0.0.1', callback);
this.socketAudio.send(message, 0, message.length, this.portUdp + 2, '127.0.0.1', callback);
} else {
this.socket.send(message, 0, message.length, this.portUdp, '127.0.0.1', callback);
}
} }
destroy() { destroy() {
@@ -138,21 +153,21 @@ class FFmpegHandler extends EventEmitter {
let process = list.find(o => o.pid === ffmpegPid || o.ppid === ffmpegPid || o.cmd.includes(args)); let process = list.find(o => o.pid === ffmpegPid || o.ppid === ffmpegPid || o.cmd.includes(args));
if (process) { if (process) {
kill(process.pid); kill(process.pid);
this.receiver.videoStreams.delete(this.userId); this.receiver?.videoStreams?.delete(this.userId);
this.emit('closed'); this.emit('closed');
} }
}); });
} }
/** /**
* Emitted when the FFmpegHandler becomes ready to start working. * Emitted when the Recorder becomes ready to start working.
* @event FFmpegHandler#ready * @event Recorder#ready
*/ */
/** /**
* Emitted when the FFmpegHandler is closed. * Emitted when the Recorder is closed.
* @event FFmpegHandler#closed * @event Recorder#closed
*/ */
} }
module.exports = FFmpegHandler; module.exports = Recorder;

View File

@@ -1,5 +1,103 @@
'use strict'; 'use strict';
const dgram = require('dgram');
const { setImmediate } = require('node:timers');
/**
* @typedef {Object} InterfaceAddresses
* @property {string} [udp4] - IPv4 address
* @property {string} [udp6] - IPv6 address
*/
/**
* Get the interface address for a given socket type.
* @param {"udp4"|"udp6"} type - The socket type.
* @param {InterfaceAddresses} [interfaceAddresses] - The interface addresses mapping.
* @returns {string|undefined} The interface address if available.
*/
function interfaceAddress(type, interfaceAddresses) {
return interfaceAddresses ? interfaceAddresses[type] : undefined;
}
/**
* Get a random available port.
* @param {"udp4"|"udp6"} [protocol="udp4"] - The socket type.
* @param {InterfaceAddresses} [interfaceAddresses] - The interface addresses mapping.
* @returns {Promise<number>} The assigned random port.
*/
async function randomPort(protocol = 'udp4', interfaceAddresses) {
const socket = dgram.createSocket(protocol);
setImmediate(() =>
socket.bind({
port: 0,
address: interfaceAddress(protocol, interfaceAddresses),
}),
);
await new Promise((resolve, reject) => {
socket.once('error', reject);
socket.once('listening', resolve);
});
const port = socket.address()?.port;
await new Promise(resolve => socket.close(resolve));
return port;
}
/**
* Get multiple random available ports.
* @param {number} num - Number of ports to find.
* @param {"udp4"|"udp6"} [protocol="udp4"] - The socket type.
* @param {InterfaceAddresses} [interfaceAddresses] - The interface addresses mapping.
* @returns {Promise<number[]>} An array of assigned random ports.
*/
async function randomPorts(num, protocol = 'udp4', interfaceAddresses) {
return Promise.all(Array.from({ length: num }).map(() => randomPort(protocol, interfaceAddresses)));
}
/**
* Find an available port within a given range.
* @param {number} min - The minimum port number.
* @param {number} max - The maximum port number.
* @param {"udp4"|"udp6"} [protocol="udp4"] - The socket type.
* @param {InterfaceAddresses} [interfaceAddresses] - The interface addresses mapping.
* @returns {Promise<number>} The available port within range.
* @throws {Error} If no port is found within the range.
*/
async function findPort(min, max, protocol = 'udp4', interfaceAddresses) {
let port;
for (let i = min; i <= max; i++) {
const socket = dgram.createSocket(protocol);
setImmediate(() =>
socket.bind({
port: i,
address: interfaceAddress(protocol, interfaceAddresses),
}),
);
const error = await new Promise(resolve => {
socket.once('error', resolve);
socket.once('listening', () => resolve(null));
});
await new Promise(resolve => socket.close(resolve));
if (error) continue;
const addressInfo = socket.address();
if (addressInfo && addressInfo.port >= min && addressInfo.port <= max) {
port = addressInfo.port;
break;
}
}
if (!port) throw new Error('port not found');
return port;
}
function parseStreamKey(key) { function parseStreamKey(key) {
const Arr = key.split(':'); const Arr = key.split(':');
const type = Arr[0]; const type = Arr[0];
@@ -10,5 +108,9 @@ function parseStreamKey(key) {
} }
module.exports = { module.exports = {
randomPort,
randomPorts,
findPort,
interfaceAddress,
parseStreamKey, parseStreamKey,
}; };

View File

@@ -168,3 +168,4 @@ exports.DiscordAuthWebsocket = require('./util/RemoteAuth');
exports.PurchasedFlags = require('./util/PurchasedFlags'); exports.PurchasedFlags = require('./util/PurchasedFlags');
exports.Poll = require('./structures/Poll').Poll; exports.Poll = require('./structures/Poll').Poll;
exports.PollAnswer = require('./structures/PollAnswer').PollAnswer; exports.PollAnswer = require('./structures/PollAnswer').PollAnswer;
exports.Recorder = require('./client/voice/receiver/Recorder');

View File

@@ -38,6 +38,11 @@
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/ReactionType} * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/ReactionType}
*/ */
/**
* @external RtpPacket
* @see {@link https://github.com/shinyoshiaki/werift-webrtc/blob/develop/doc/classes/RtpPacket.md}
*/
/** /**
* @external TeamMemberRole * @external TeamMemberRole
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/TeamMemberRole} * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/TeamMemberRole}

View File

@@ -42,7 +42,7 @@ const payloadTypes = [
payload_type: 103, payload_type: 103,
rtx_payload_type: 104, rtx_payload_type: 104,
encode: false, encode: false,
decode: false, // Working but very glitchy decode: false,
}, },
{ {
name: 'H264', name: 'H264',
@@ -988,27 +988,43 @@ class Util extends null {
return payloadTypes; return payloadTypes;
} }
/**
* Get the payload type of the codec
* @param {'opus' | 'H264' | 'H265' | 'VP8' | 'VP9' | 'AV1'} codecName - Codec name
* @returns {number}
*/
static getPayloadType(codecName) { static getPayloadType(codecName) {
return payloadTypes.find(p => p.name === codecName).payload_type; return payloadTypes.find(p => p.name === codecName).payload_type;
} }
static getSDPCodecName(portUdp, isEnableAudio) { static getSDPCodecName(portUdpH264, portUdpH265, portUdpOpus) {
const payloadTypeH264 = Util.getPayloadType('H264');
const payloadTypeH265 = Util.getPayloadType('H265');
const payloadTypeOpus = Util.getPayloadType('opus');
let sdpData = `v=0 let sdpData = `v=0
o=- 0 0 IN IP4 0.0.0.0 o=- 0 0 IN IP4 0.0.0.0
s=- s=-
c=IN IP4 0.0.0.0 c=IN IP4 0.0.0.0
t=0 0 t=0 0
a=tool:libavformat 61.1.100 a=tool:libavformat 61.1.100
m=video ${portUdp} RTP/AVP 105 m=video ${portUdpH264} RTP/AVP ${payloadTypeH264}
a=rtpmap:105 H264/90000 c=IN IP4 127.0.0.1
a=fmtp:105 profile-level-id=42e01f;sprop-parameter-sets=Z0IAH6tAoAt2AtwEBAaQeJEV,aM4JyA==;packetization-mode=1 b=AS:1000
a=rtpmap:${payloadTypeH264} H264/90000
a=fmtp:${payloadTypeH264} profile-level-id=42e01f;sprop-parameter-sets=Z0IAH6tAoAt2AtwEBAaQeJEV,aM4JyA==;packetization-mode=1
${ ${
isEnableAudio portUdpH265
? `m=audio ${portUdp + 2} RTP/AVP 120 ? `m=video ${portUdpH265} RTP/AVP ${payloadTypeH265}
a=rtpmap:120 opus/48000/2 c=IN IP4 127.0.0.1
a=fmtp:120 minptime=10;useinbandfec=1` b=AS:1000
a=rtpmap:${payloadTypeH265} H265/90000`
: '' : ''
} }
m=audio ${portUdpOpus} RTP/AVP ${payloadTypeOpus}
c=IN IP4 127.0.0.1
b=AS:96
a=rtpmap:${payloadTypeOpus} opus/48000/2
a=fmtp:${payloadTypeOpus} minptime=10;useinbandfec=1
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01

57
typings/index.d.ts vendored
View File

@@ -20,6 +20,7 @@ import {
underscore, underscore,
userMention, userMention,
} from '@discordjs/builders'; } from '@discordjs/builders';
import { RtpPacket } from 'werift-rtp';
import { Collection } from '@discordjs/collection'; import { Collection } from '@discordjs/collection';
import { import {
APIActionRowComponent, APIActionRowComponent,
@@ -983,8 +984,6 @@ export class BaseDispatcher extends Writable {
public count: number; public count: number;
public sequence: number; public sequence: number;
public timestamp: number; public timestamp: number;
public mtu: number;
public fps: number;
public payloadType: number; public payloadType: number;
public extensionEnabled: boolean; public extensionEnabled: boolean;
@@ -1010,10 +1009,14 @@ export class AudioDispatcher extends VolumeMixin(BaseDispatcher) {
constructor(player: object, options?: StreamOptions, streams?: object); constructor(player: object, options?: StreamOptions, streams?: object);
public readonly bitrateEditable: boolean; public readonly bitrateEditable: boolean;
public getTypeDispatcher(): 'audio';
public setBitrate(value: number | 'auto'): boolean; public setBitrate(value: number | 'auto'): boolean;
public setFEC(enabled: boolean): boolean; public setFEC(enabled: boolean): boolean;
public setPLP(value: number): boolean; public setPLP(value: number): boolean;
public setSyncVideoDispatcher(otherDispatcher: VideoDispatcher): void;
public on(event: 'volumeChange', listener: (oldVolume: number, newVolume: number) => void): this; public on(event: 'volumeChange', listener: (oldVolume: number, newVolume: number) => void): this;
public on(event: string, listener: (...args: any[]) => void): this; public on(event: string, listener: (...args: any[]) => void): this;
@@ -1024,6 +1027,11 @@ export class AudioDispatcher extends VolumeMixin(BaseDispatcher) {
export class VideoDispatcher extends BaseDispatcher { export class VideoDispatcher extends BaseDispatcher {
constructor(player: object, options?: StreamOptions, streams?: object, fps?: number); constructor(player: object, options?: StreamOptions, streams?: object, fps?: number);
public mtu: number;
public fps: number;
public getTypeDispatcher(): 'video';
public setFPSSource(value: number): void; public setFPSSource(value: number): void;
} }
@@ -1133,60 +1141,53 @@ export class StreamConnectionReadonly extends VoiceConnection {
public override playVideo(): VideoDispatcher; public override playVideo(): VideoDispatcher;
} }
export class FFmpegHandler extends EventEmitter { export class Recorder<Ready extends boolean = boolean, T = any> extends EventEmitter {
public codec: 'H264'; constructor(receiver: T, options: { ffmpegArgs: string[]; channels: number; frameDuration: number });
public portUdp: number; private promise: Promise<void>;
public ready: boolean; public readonly receiver: T;
public stream: ChildProcessWithoutNullStreams; public portUdpH264: number;
public portUdpOpus: number;
public ready: Ready;
public stream: If<Ready, ChildProcessWithoutNullStreams>;
public socket: Socket; public socket: Socket;
public socketAudio: Socket;
public output: Writable | string; public output: Writable | string;
public isEnableAudio: boolean;
public userId: Snowflake; public userId: Snowflake;
public sendPayloadToFFmpeg(payload: Buffer, isAudio?: boolean): void; public feed(payload: RtpPacket | BufferResolvable): void;
public on(event: 'ready' | 'closed', listener: () => void): this; public on(event: 'ready' | 'closed', listener: (recorder: Recorder<true, T>) => void): this;
public once(event: 'ready' | 'closed', listener: () => void): this; public once(event: 'ready' | 'closed', listener: (recorder: Recorder<true, T>) => void): this;
public destroy(): void; public destroy(): void;
} }
export class VoiceReceiver extends EventEmitter { export class VoiceReceiver extends EventEmitter {
constructor(connection: VoiceConnection); constructor(connection: VoiceConnection);
public createStream(user: UserResolvable, options?: { mode?: 'opus' | 'pcm'; end?: 'silence' | 'manual' }): Readable; public createStream(
public createVideoStream(
user: UserResolvable, user: UserResolvable,
options?: { options?: { mode?: 'opus' | 'pcm'; end?: 'silence' | 'manual'; paddingSilence?: boolean },
portUdp: number; ): Readable;
output: Writable | string; public createVideoStream(user: UserResolvable, output: Writable | string): Recorder<false, any>;
isEnableAudio: boolean;
},
): FFmpegHandler;
public on(event: 'debug', listener: (error: Error | string) => void): this; public on(event: 'debug', listener: (error: Error | string) => void): this;
public on( public on(
event: 'videoData', event: 'receiverData',
listener: ( listener: (
ssrc: number,
ssrcData: { ssrcData: {
userId: Snowflake; userId: Snowflake;
hasVideo: boolean; hasVideo: boolean;
}, },
headerRaw: Buffer, packet: RtpPacket,
packetDecrypt: Buffer,
) => void, ) => void,
): this; ): this;
public on(event: string, listener: (...args: any[]) => void): this; public on(event: string, listener: (...args: any[]) => void): this;
public once(event: 'debug', listener: (error: Error | string) => void): this; public once(event: 'debug', listener: (error: Error | string) => void): this;
public once( public once(
event: 'videoData', event: 'receiverData',
listener: ( listener: (
ssrc: number,
ssrcData: { ssrcData: {
userId: Snowflake; userId: Snowflake;
hasVideo: boolean; hasVideo: boolean;
}, },
headerRaw: Buffer, packet: RtpPacket,
packetDecrypt: Buffer,
) => void, ) => void,
): this; ): this;
public once(event: string, listener: (...args: any[]) => void): this; public once(event: string, listener: (...args: any[]) => void): this;