50 Commits
Author SHA1 Message Date
fraujulian 52c86cd76e chore: update issue templates and workflow files for Gitea compatibility
Build Validation / build (push) Successful in 39s
2026-07-21 13:39:44 +02:00
fraujulian 3047b9a9fe chore: rename GitHub workflow and issue template files
Build Validation / build (push) Successful in 1m28s
2026-07-21 12:53:12 +02:00
fraujulianandGitHub b17ff5b52e Merge pull request #76 from FrauJulian/dependabot/npm_and_yarn/types/node-26.0.1
Bump @types/node from 26.0.0 to 26.0.1
2026-07-14 23:32:59 +02:00
fraujulianandGitHub 16c153cfa7 Merge pull request #77 from FrauJulian/dependabot/npm_and_yarn/eslint-plugin-n-18.2.1
Bump eslint-plugin-n from 18.1.0 to 18.2.1
2026-07-14 23:32:40 +02:00
fraujulianandGitHub 7050c608bc Merge pull request #81 from FrauJulian/dependabot/npm_and_yarn/prettier-3.9.4
Bump prettier from 3.8.4 to 3.9.4
2026-07-14 23:32:11 +02:00
fraujulianandGitHub b2e1850df0 Merge pull request #82 from FrauJulian/dependabot/npm_and_yarn/typescript-eslint/parser-8.62.1
Bump @typescript-eslint/parser from 8.62.0 to 8.62.1
2026-07-14 23:31:53 +02:00
dependabot[bot]andGitHub 03aacbdba6 Bump @typescript-eslint/parser from 8.62.0 to 8.62.1
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.62.0 to 8.62.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 13:33:31 +00:00
dependabot[bot]andGitHub 6e64248a5b Bump prettier from 3.8.4 to 3.9.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.4 to 3.9.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 13:33:17 +00:00
dependabot[bot]andGitHub abc6a19cca Bump eslint-plugin-n from 18.1.0 to 18.2.1
Bumps [eslint-plugin-n](https://github.com/eslint-community/eslint-plugin-n) from 18.1.0 to 18.2.1.
- [Release notes](https://github.com/eslint-community/eslint-plugin-n/releases)
- [Changelog](https://github.com/eslint-community/eslint-plugin-n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/eslint-community/eslint-plugin-n/compare/v18.1.0...v18.2.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-n
  dependency-version: 18.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-26 13:33:32 +00:00
dependabot[bot]andGitHub 7b24d7de6f Bump @types/node from 26.0.0 to 26.0.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.0.0 to 26.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 13:34:10 +00:00
fraujulianandGitHub 74b45b7460 Merge pull request #73 from FrauJulian/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.62.0
Bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.62.0
2026-06-24 08:42:08 +02:00
fraujulianandGitHub 9ce21ec9ca Merge pull request #74 from FrauJulian/dependabot/npm_and_yarn/typescript-eslint/parser-8.62.0
Bump @typescript-eslint/parser from 8.61.1 to 8.62.0
2026-06-24 08:41:47 +02:00
dependabot[bot]andGitHub f625f27599 Bump @typescript-eslint/parser from 8.61.1 to 8.62.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.61.1 to 8.62.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.62.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 13:34:08 +00:00
dependabot[bot]andGitHub 298633267e Bump @typescript-eslint/eslint-plugin from 8.61.1 to 8.62.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.61.1 to 8.62.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.62.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 13:33:55 +00:00
fraujulian f73213964b chore: update dependencies 2026-06-22 12:52:25 +02:00
fraujulian 60871ccd60 improve: docs 2026-06-22 12:51:46 +02:00
fraujulianandGitHub 1f55657390 Merge pull request #72 from FrauJulian/66-67-68-69-70-71-multiple-bugs
improvements and bug fixes
2026-06-22 12:43:27 +02:00
fraujulian b4e92b9b7b improve: tests 2026-06-22 12:40:00 +02:00
fraujulian 215d67ce89 Reject non-finite volume values 2026-06-22 12:20:41 +02:00
fraujulian b29fda5ae9 Clean up failed voice connections 2026-06-22 12:18:35 +02:00
fraujulian d10dae1b53 Surface ffmpeg startup failures 2026-06-22 12:16:21 +02:00
fraujulian 1a77896c32 Add failed connection startup regression test 2026-06-22 12:12:48 +02:00
fraujulian a811feaede Handle renewal restart failures 2026-06-22 12:09:21 +02:00
fraujulian 24bbfc354f chore: updated issue templates 2026-06-22 11:37:05 +02:00
fraujulian faa48a0264 chore: update dependencies 2026-06-15 15:47:16 +02:00
fraujulian 9f4f70dcc0 fix: place version 2026-05-26 17:48:08 +02:00
fraujulian 907985b54e chore: added publish task 2026-05-26 17:44:18 +02:00
fraujulian 9998447c65 chore: updated ignore files 2026-05-26 17:43:58 +02:00
fraujulian e8fba67a1a chore(docs): updated md files 2026-05-26 17:41:05 +02:00
fraujulian b34afa0d8b chore: updated and improved dependencies 2026-05-26 17:33:56 +02:00
fraujulian 7f2eeaefb0 improve: tests 2026-05-26 17:33:11 +02:00
fraujulian 0fd3a41f1c chore: versioning 2026-05-26 17:10:26 +02:00
fraujulian 51d738a442 fix: rollout 2026-05-23 21:17:49 +02:00
fraujulian db7de9c04d fix: rollout 2026-05-23 21:12:11 +02:00
fraujulian eaf86f948b fix: rollout 2026-05-23 20:13:04 +02:00
fraujulian d4f92e1eac fix: rollout 2026-05-23 20:02:53 +02:00
fraujulian 9eace0838b fix: rollout 2026-05-23 19:56:12 +02:00
fraujulian 5cc7127a5a chore: updated dependencies 2026-05-23 19:48:54 +02:00
fraujulian 7923b917d8 chore: updated dependencies 2026-05-23 19:44:52 +02:00
fraujulian 78cbf3f233 refactor: husky 2026-05-23 19:38:05 +02:00
fraujulian 4cc9774ff5 feat: github release task 2026-05-23 19:37:12 +02:00
fraujulian 6962d87f24 feat & improvements & refactor: new structure, better garbage collection, better docs, better performance, added tests 2026-05-23 19:26:59 +02:00
fraujulianandGitHub 966db49e37 Add LICENSE file 2026-05-23 19:07:24 +02:00
fraujulianandGitHub 707822e43d Merge pull request #31 from FrauJulian/dependabot/npm_and_yarn/typescript-eslint/parser-8.50.1
Bump @typescript-eslint/parser from 8.50.0 to 8.50.1
2025-12-23 22:45:21 +01:00
fraujulianandGitHub 29f51c5ff0 Merge pull request #32 from FrauJulian/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.50.1
Bump @typescript-eslint/eslint-plugin from 8.50.0 to 8.50.1
2025-12-23 22:45:14 +01:00
dependabot[bot]andGitHub ee676212ab Bump @typescript-eslint/eslint-plugin from 8.50.0 to 8.50.1
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.50.0 to 8.50.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.50.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 13:20:33 +00:00
dependabot[bot]andGitHub 5496514dab Bump @typescript-eslint/parser from 8.50.0 to 8.50.1
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.50.0 to 8.50.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.50.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 13:20:21 +00:00
fraujulianandGitHub 1e08cddc61 Merge pull request #30 from FrauJulian/dependabot/npm_and_yarn/types/node-25.0.3
Bump @types/node from 25.0.2 to 25.0.3
2025-12-18 08:00:19 +01:00
dependabot[bot]andGitHub d07efd73a5 Bump @types/node from 25.0.2 to 25.0.3
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.0.2 to 25.0.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 13:25:52 +00:00
fraujulianandGitHub 4447437fd2 Update README for clarity and formatting 2025-12-17 08:20:50 +01:00
44 changed files with 4062 additions and 2657 deletions
+21
View File
@@ -0,0 +1,21 @@
name: 🐛 Bug
about: Report a bug or unexpected behavior
title: '🐛 [BUG]: '
body:
- type: textarea
id: information
attributes:
label: Description
description: Provide all information.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: criteria
attributes:
label: Acceptance Criteria
description: What are the expectations?
placeholder: 'How do you want it implemented? How should it be tested?'
validations:
required: false
+21
View File
@@ -0,0 +1,21 @@
name: 💡 Enhancement
about: Suggest a new feature
title: '💡 [Enhancement]: '
body:
- type: textarea
id: information
attributes:
label: Description
description: Provide all information.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: criteria
attributes:
label: Acceptance Criteria
description: What are the expectations?
placeholder: 'How do you want it implemented? How should it be tested?'
validations:
required: false
+21
View File
@@ -0,0 +1,21 @@
name: ⚙️ Improvement
about: Suggest an improvement to existing functionality
title: '⚙️ [Improvement]: '
body:
- type: textarea
id: information
attributes:
label: Description
description: Provide all information.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: criteria
attributes:
label: Acceptance Criteria
description: What are the expectations?
placeholder: 'How do you want it implemented? How should it be tested?'
validations:
required: false
+21
View File
@@ -0,0 +1,21 @@
name: 📝 Service Request
about: Request a service or support task
title: '📝 [Service Request]: '
body:
- type: textarea
id: information
attributes:
label: Description
description: Provide all information.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: criteria
attributes:
label: Acceptance Criteria
description: What are the expectations?
placeholder: 'How do you want it implemented? How should it be tested?'
validations:
required: false
+21
View File
@@ -0,0 +1,21 @@
name: 👮 Story
about: Describe a user story
title: '👮 [Story]: '
body:
- type: textarea
id: information
attributes:
label: Description
description: Provide all information.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: criteria
attributes:
label: Acceptance Criteria
description: What are the expectations?
placeholder: 'How do you want it implemented? How should it be tested?'
validations:
required: false
+92
View File
@@ -0,0 +1,92 @@
name: Release and Deploy Build
on:
workflow_dispatch:
env:
VERSION_MAJOR: '1'
VERSION_MINOR: '1'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Use NodeJS v22.22.3
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: '22.22.3'
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
run: npm ci
- name: Set Release Version
id: version
run: npm run version:place
- name: Export Release Version
id: release_version
run: |
package_version="$(node -p "require('./package.json').version")"
echo "tag=${package_version}" >> "$GITEA_OUTPUT"
- name: Run Build
run: npm run build
- name: Publish Library
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_SHA: ${{ gitea.sha }}
RELEASE_TAG: v${{ steps.release_version.outputs.tag }}
run: |
git fetch --force --tags
previous_tag="$(git describe --tags --abbrev=0 "$GITEA_SHA^" 2>/dev/null || true)"
if [ -n "$previous_tag" ]; then
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${GITEA_SHA}")"
full_changelog="${GITEA_SERVER_URL}/${GITEA_REPOSITORY}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "${GITEA_SHA}")"
full_changelog=""
fi
if [ -z "$commit_messages" ]; then
commit_messages='- No commits since the previous release'
fi
{
echo '## Changes'
echo
printf '%s\n' "$commit_messages"
echo
if [ -n "$full_changelog" ]; then
echo "**Full Changelog**: ${full_changelog}"
fi
} > release-notes.md
node -e 'const fs = require("node:fs"); fs.writeFileSync("release.json", JSON.stringify({ tag_name: process.env.RELEASE_TAG, target_commitish: process.env.GITEA_SHA, name: process.env.RELEASE_TAG, body: fs.readFileSync("release-notes.md", "utf8") }))'
api_url="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases"
auth_header="Authorization: token ${GITEA_TOKEN}"
if curl --fail --silent --header "$auth_header" --output current-release.json "${api_url}/tags/${RELEASE_TAG}"; then
release_id="$(node -p 'require("./current-release.json").id')"
curl --fail-with-body --header "$auth_header" --header 'Content-Type: application/json' --request PATCH --data-binary @release.json "${api_url}/${release_id}"
else
curl --fail-with-body --header "$auth_header" --header 'Content-Type: application/json' --request POST --data-binary @release.json "$api_url"
fi
+30
View File
@@ -0,0 +1,30 @@
name: Build Validation
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: https://gitea.com/actions/checkout@v4
- name: use NodeJS v22.22.3
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: '22.22.3'
- name: Install Dependencies
run: npm ci
- name: Run Checks
run: npm run check
- name: Run Build
run: npm run build
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: FrauJulian
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-42
View File
@@ -1,42 +0,0 @@
name: 🐛 Bug Report
description: Report a bug or unexpected behavior
title: '🐛 [BUG]: '
body:
- type: textarea
id: information
attributes:
label: Information
description: Provide all information about the issue.
placeholder: 'What happened? What did you expect to happen?'
validations:
required: false
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Step-by-step instructions to reproduce the issue.
placeholder: |
1. Go to ...
2. Click on ...
3. Observe ...
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs or Error Messages
description: Paste any relevant console output, stack traces, or screenshots.
placeholder: 'Console output, stacktrace, or screenshots here'
validations:
required: false
- type: input
id: occurrence
attributes:
label: Occurrence Version
description: Specify in which version the issue occurred
placeholder: 'v0.1.TTMMJJ-hhmm'
validations:
required: true
-29
View File
@@ -1,29 +0,0 @@
name: 💡 Enhancement
description: Suggest a new feature or improvement
title: '💡 [ENHANCEMENT]: '
body:
- type: textarea
id: description
attributes:
label: Description
description: Describe the new feature or improvement!
placeholder: 'What problem does it solve or what value does it add?'
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Solution
placeholder: 'Describe how the feature should work.'
validations:
required: false
- type: input
id: implementation
attributes:
label: Implementation Version
description: Specify in which version the enhancement should be released.
placeholder: 'v0.1'
validations:
required: true
-39
View File
@@ -1,39 +0,0 @@
name: ⚙️ Improvement
description: Suggest an improvement to existing functionality, code quality, or performance
title: '⚙️ [Improvement]: '
body:
- type: textarea
id: context
attributes:
label: Current Situation / Context
description: Describe the current behavior or implementation that could be improved.
placeholder: 'Currently, the system loads all users at once, which slows down response time.'
validations:
required: false
- type: textarea
id: proposal
attributes:
label: Proposed Improvement
description: Describe your proposed change or improvement in detail.
placeholder: 'Implement pagination for user loading to reduce memory usage and response time.'
validations:
required: false
- type: textarea
id: benefits
attributes:
label: Expected Benefits
description: Explain how this improvement will help (e.g., better performance, cleaner code, easier maintenance).
placeholder: 'Faster load times, improved scalability, and reduced database load.'
validations:
required: false
- type: input
id: implementation
attributes:
label: Implementation Version
description: Specify in which version the improvement should be released.
placeholder: 'v0.1'
validations:
required: true
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'
-39
View File
@@ -1,39 +0,0 @@
name: Release and Deploy Build
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v22.12.0
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
registry-url: 'https://registry.npmjs.org'
always-auth: true
- name: Create .npmrc
run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > ~/.npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install Dependencies
run: npm install
- name: run Rollout Build
run: npm run build
- name: Publish Library
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-24
View File
@@ -1,24 +0,0 @@
name: Build Validation
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v22.16.0
uses: actions/setup-node@v4
with:
node-version: '22.16.0'
- name: Install Dependencies
run: npm install
- name: run Rollout Build
run: npm run build
+13 -4
View File
@@ -1,4 +1,13 @@
.idea
.git
node_modules
dist
.idea/
.vscode/
node_modules/
dist/
coverage/
.npm/
.eslintcache
*.log
*.tsbuildinfo
.DS_Store
Thumbs.db
+1 -2
View File
@@ -1,3 +1,2 @@
#!/usr/bin/env sh
npm run format:check || exit 1
npm run lint || exit 1
npm run check || exit 1
+5 -1
View File
@@ -6,6 +6,8 @@
dist
node_modules
coverage
.npm
.husky
src
@@ -24,6 +26,8 @@ CODEOWNERS
tsconfig.json
tsconfig.eslint.json
tsup.config.ts
eslint.config.js
eslint.config.cjs
eslint.config.typeaware.cjs
package-lock.json
jest.config.cjs
tests
+9 -5
View File
@@ -1,5 +1,9 @@
node_modules
dist
.git
.idea
.vscode
node_modules/
dist/
coverage/
.git/
.idea/
.vscode/
package-lock.json
+1 -1
View File
@@ -9,5 +9,5 @@
"bracketSpacing": true,
"bracketSameLine": false,
"quoteProps": "as-needed",
"endOfLine": "crlf"
"endOfLine": "auto"
}
-1
View File
@@ -1 +0,0 @@
FrauJulian
+577 -420
View File
File diff suppressed because it is too large Load Diff
+173 -95
View File
@@ -1,129 +1,207 @@
# Discord Audio Stream
[![npm](https://img.shields.io/npm/dw/discord-audio-stream)](http://npmjs.org/package/discord-audio-stream)
![GitHub package.json version](https://img.shields.io/github/package-json/v/FrauJulian/discord-audio-stream)
![latest release](https://img.shields.io/badge/dynamic/json?label=release&query=$.name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FFrauJulian%2Fdiscord-audio-stream%2Freleases%2Flatest&color=blue)
![GitHub Repo stars](https://img.shields.io/github/stars/FrauJulian/discord-audio-stream?style=social)
**This module is designed to work with [discord.js/voice](https://www.npmjs.com/package/@discordjs/voice) v0.19. This
package doesn't support older
versions!**
`discord-audio-stream` is a small TypeScript library for managed Discord voice playback through
`@discordjs/voice` and ffmpeg.
> **Designed for 24/7 audio playing on discord.**
> Discord has many unwanted rate limits, especially in the audio area. This package does all the work and ensures that
> your music never stops playing due to ffmpeg or Discord, with as little effort as possible.
It manages the Discord voice connection, ffmpeg process, raw PCM audio resource, optional renewal, and cleanup. Keep one
`AudioManager` per guild, usually in a `Map<string, AudioManager>`, and call `dispose()` when that manager will not be
reused.
> **Recommended best Practise:**
> Create a global Map<GuildId, AudioManager> (Map<KEY, OBJ>) list. When the feature is used on a guild, add a new
> instance of AudioManager to the Map. When starting the connection or audio, overwrite the respective configuration with
> the override methods provided. To finally start, use the respective method. For each call and each change, retrieve the
> AudioManager object from the list and perform your actions. When the feature get stopped, first execute StopConnection
> and then Dispose to save as much power as possible. Garbage collection does the rest.
> **For large systems:** For large systems, it is recommended to queue each start with the
> packet [p-queue](https://www.npmjs.com/package/p-queue), to give ffmpeg enough time..
## Support
## 👋 Support
Create an [issue](https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream/issues) on GitHub or contact
[`fraujulian`](https://discord.com/users/860206216893693973) on Discord.
Please create an [issue](https://github.com/FrauJulian/DiscordAudioStreamNPM/issues) on github or write [
`fraujulian`](https://discord.com/users/860206216893693973) on discord!
## Installation
## 📝 Usage
### Installation
**Node.js 22.12.0 or newer is required.**
Node.js `22.22.3` or newer is required.
```bash
npm install discord-audio-stream
yarn add discord-audio-stream
pnpm add discord-audio-stream
bun add discord-audio-stream
npm install discord-audio-stream @discordjs/voice prism-media @snazzah/davey opusscript
```
#### Required Dependencies
`ffmpeg` must be available either on the host PATH or through the optional `ffmpeg-static` package:
- > You only need to install `libsodium-wrappers` if your system does not support `aes-256-gcm` (verify by running
> `require('node:crypto').getCiphers().includes('aes-256-gcm')`).
- `@snazzah/davey`
- `opusscript`
- `prism-media`
- **FFmpeg** (one of those)
- [`FFmpeg`](https://ffmpeg.org/) (installed and added to environment)
- `ffmpeg-static`
### AudioManager Instance
#### Options
**Declaration:**
```
AudioManager(ffmpegMode: string, renewInMs, number, connectionData: VoiceConnectionDataModel, audioData: VoiceAudioDataModel): IDisposable, IAudioManager
```bash
npm install ffmpeg-static
```
- `ffmpegMode`: 'Native' or 'Standalone'
- Native: [`FFmpeg`](https://ffmpeg.org/) installed on your system
- Standalone: FFmpeg-static as NodeJs library
- `renewInMs`: renewal time > default is 1,5h (5400000ms)
- `connectionData`: Options for voice connection. (type of **VoiceConnectionDataModel**)
- `audioData`: Options for audio player. (type of **VoiceAudioDataModel**)
Use `ffmpeg.mode: 'native'` for PATH-based ffmpeg and `ffmpeg.mode: 'static'` for `ffmpeg-static`.
#### Example
`libsodium-wrappers` is optional. Install it only when your runtime does not support `aes-256-gcm`:
```js
let audioManager = new AudioManager(
'Native',
5400000,
{
VoiceChannelId: 0, //voice channel id where to play music
GuildId: 0, //guild id
VoiceAdapter: 0, //guild VoiceAdapter
```bash
node -e "console.log(require('node:crypto').getCiphers().includes('aes-256-gcm'))"
npm install libsodium-wrappers
```
## Basic Usage
```ts
import { AudioManager } from 'discord-audio-stream';
const manager = new AudioManager({
connection: {
guildId: guild.id,
channelId: voiceChannel.id,
adapterCreator: guild.voiceAdapterCreator,
},
{
ResourceType: '', //resource type like link or file
Resource: '', //auto play link or file name
source: {
type: 'url',
url: 'https://example.com/live-stream.mp3',
},
);
ffmpeg: {
mode: 'native',
},
});
await manager.start();
```
### Fields and Methods of AudioManager
For a file source:
#### Fields
```ts
manager.setSource({
type: 'file',
path: 'audio/intro.mp3',
});
| Name | Type | Security | Description |
| --------- | -------- | --------- | ------------------------------------- |
| `Active` | **bool** | protected | To check if the connection is active. |
| `Playing` | **bool** | protected | To check if it is playing audio. |
await manager.start();
```
#### Methods
Relative file paths are resolved from `process.cwd()`. URL sources are validated before playback starts.
| Name | Parameters | Return type | Description |
| ----------------------------- | ------------------------------------------------------- | ------------- | ---------------------------------------- |
| `OverrideVoiceConnectionData` | `connectionData` (type of **VoiceConnectionDataModel**) | void | To override intern connectionData field. |
| `OverrideVoiceAudioDataModel` | `audioData` (type of **VoiceAudioDataModel**) | void | To override intern audioData field. |
| `CreateAndPlay` | | Promise<void> | Join channel and start playing audio. |
| `CreateConnection` | | void | Let it connect to voice channel. |
| `PlayAudio` | | Promise<void> | To start playing audio. |
| `PauseAudio` | | void | Pause the audio, if it is playing |
| `ResumeAudio` | | void | Resume the audio, if it is paused. |
| `SetVolume` | `volume` (type of number, 0 - 100 percent) | void | To set the audio volume. |
| `StopConnection` | | Promise<void> | Method to disconnect the voice channel. |
| `Dispose` | | void | Dispose all data in object. |
For large bots, queue many simultaneous starts so the host does not spawn too many ffmpeg processes in the same tick.
## 📝 Types
## Lifecycle
- [**VoiceConnection** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/VoiceConnection.ts#L166)
- [**AudioPlayer** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/audio/AudioPlayer.ts#L155)
- [**AudioResource** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/audio/AudioResource.ts#L44)
- [**DiscordGatewayAdapterCreator** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/util/adapter.ts#L50)
- [**VoiceConnectionDataModel** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L3)
- [**VoiceAudioDataModel** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L21)
- [**IDisposable** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L38)
- [**IAudioManager** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L45)
```ts
manager.setConnection(connectionOptions);
manager.setSource(source);
## 📋 Contributors:
await manager.start(); // connect() + play()
manager.pause();
manager.resume();
await manager.stop(); // stops playback and destroys the voice connection
manager.dispose(); // final cleanup; the manager cannot be reused
```
`connect()` joins the configured voice channel. `play(source?)` starts playback on an existing connection. Use `start()`
when you want both.
## API
```ts
type AudioManagerOptions = {
ffmpeg?: {
mode?: 'native' | 'static';
executablePath?: string;
inputArgs?: readonly string[];
outputArgs?: readonly string[];
};
connection?: {
guildId: string;
channelId: string;
adapterCreator: DiscordGatewayAdapterCreator;
};
source?: { type: 'url'; url: string } | { type: 'file'; path: string };
renewIntervalMs?: number | false;
connectTimeoutMs?: number;
volume?: {
enabled?: boolean;
initialPercent?: number;
};
};
```
### Defaults
| Option | Default |
| ------------------ | ----------- |
| `ffmpeg.mode` | `'native'` |
| `connectTimeoutMs` | `20_000` |
| `renewIntervalMs` | `5_400_000` |
| `volume.enabled` | `false` |
### Methods
| Method | Description |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `setConnection(options)` | Replaces the voice connection target. |
| `setSource(source)` | Replaces the audio source. |
| `connect()` | Joins the configured Discord voice channel. |
| `play(source?)` | Starts playback on an existing connection. |
| `start()` | Connects and starts playback. |
| `pause()` | Pauses active playback. |
| `resume()` | Resumes paused playback. |
| `stop()` | Stops playback, clears renewal, and destroys the voice connection. |
| `setVolume(percent)` | Sets volume from `0` to `100`; requires `volume.enabled: true`. |
| `dispose()` | Idempotently releases timers, ffmpeg, streams, player state, and voice connection. |
### State
```ts
manager.state; // 'idle' | 'connecting' | 'ready' | 'playing' | 'paused' | 'stopped' | 'disposed'
manager.isPlaying;
manager.isConnected;
```
## Audio Options
Inline volume has runtime cost in `@discordjs/voice`, so it is disabled by default.
```ts
const manager = new AudioManager({
connection,
source,
volume: {
enabled: true,
initialPercent: 50,
},
});
await manager.start();
manager.setVolume(25);
```
Calling `setVolume()` without `volume.enabled: true` throws `AudioManagerStateError`.
Default ffmpeg output is raw Discord-compatible PCM: `s16le`, `48000 Hz`, `2 channels`.
You can override ffmpeg arguments through `ffmpeg.inputArgs` and `ffmpeg.outputArgs`. When you override them, you are
responsible for keeping the output compatible with `StreamType.Raw`.
By default, the manager schedules a renewal after `5_400_000 ms` so long-running streams can reconnect periodically.
Set `renewIntervalMs: false` to disable it. `stop()` and `dispose()` always clear the renewal timer.
## Errors
The package exports these error classes:
- `AudioManagerError`
- `AudioManagerConfigError`
- `AudioManagerStateError`
- `FfmpegProcessError`
Configuration problems, such as a missing source or invalid URL, throw `AudioManagerConfigError`. Invalid lifecycle
operations, such as calling `pause()` while nothing is playing, throw `AudioManagerStateError`.
## Development
```bash
npm ci
npm run check
npm run build
```
## Contributors
~ [**FrauJulian - Julian Lechner**](https://fraujulian.xyz/) - CODEOWNER
## 🤝 Enjoy the package?
## Enjoy the package?
Give it a star on [github](https://github.com/FrauJulian/discord-audio-stream)!
Give it a star on [Gitea](https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream)!
+45 -8
View File
@@ -2,17 +2,54 @@
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
Security fixes are provided for the latest published release line.
| Version | Supported |
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
| ----------------------------------------- | --------- |
| Latest release | Yes |
| Older releases | No |
| Unreleased local forks or modified builds | No |
If a vulnerability is fixed, the fix will generally be released only for the current supported version unless a backport is clearly necessary.
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Do not open a public GitHub issue for security vulnerabilities.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
Report vulnerabilities privately by email to `fraujulian@lechner.top`.
Please include:
- A clear description of the issue and the expected security impact.
- Affected package version or commit, Node.js version, and runtime environment.
- Reproduction steps, proof of concept, or a minimal example.
- Any relevant logs, stack traces, or dependency details.
- Whether you believe the issue is already publicly known.
## What To Expect
- Initial acknowledgement is targeted within 7 days.
- After triage, you may be asked for more detail or validation.
- If the report is accepted, a fix will be prepared and released as soon as reasonably possible.
- Coordinated disclosure is preferred. Please wait for a fix or explicit approval before publishing details.
- If the report is out of scope or not reproducible, you will be told why.
## Scope
This policy covers vulnerabilities in the published `discord-audio-stream` package itself.
Examples that may be in scope:
- Input handling issues that could cause unsafe process execution behavior.
- Vulnerabilities caused by package logic, lifecycle management, or exposed API behavior.
- Security-relevant documentation mistakes that would realistically lead users into unsafe deployment.
Examples that are usually out of scope:
- Vulnerabilities in third-party dependencies that are not introduced by this package.
- Misconfiguration in a consuming bot, Discord server, host OS, or infrastructure.
- Denial of service caused solely by unsupported environments or intentionally extreme workloads.
## Disclosure
When a report is confirmed, the goal is to publish a fix and then disclose the issue responsibly through the repository release notes, commit history, or an advisory, depending on severity and impact.
+2 -2
View File
@@ -36,7 +36,7 @@ const config = [
'warn',
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'error',
/* Imports */
'import/first': 'error',
@@ -46,7 +46,7 @@ const config = [
'error',
{
devDependencies: [
'**/*.config.{js,cjs,mjs,ts}',
'**/*.config*.{js,cjs,mjs,ts}',
'**/scripts/**',
'**/*.test.{ts,js}',
'**/Testing.{ts,js}',
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('jest').Config} */
module.exports = {
clearMocks: true,
moduleFileExtensions: ['ts', 'js', 'json'],
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.ts'],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.test.json',
},
],
},
};
+1353 -1648
View File
File diff suppressed because it is too large Load Diff
+52 -31
View File
@@ -1,8 +1,9 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "discord-audio-stream",
"version": "0.7.DDDhhmm",
"license": "LGPL-2.1-only",
"description": "Discord library to make stream any audio easier.",
"version": "x.x.x",
"license": "LGPL-3.0-only",
"description": "A small Discord voice audio streaming library with managed ffmpeg playback.",
"exports": {
".": {
"require": {
@@ -25,29 +26,39 @@
"lib": "src"
},
"scripts": {
"update:dependencies": "npx npm-check-updates --upgrade",
"update:version": "node set-version.js",
"version:place": "node set-version.js set-ci-version",
"version:check": "node set-version.js check-placeholder",
"version:fix": "node set-version.js fix-placeholder",
"lint": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs --fix",
"lint:types": "eslint . --config eslint.config.typeaware.cjs --ext .ts,.tsx",
"format": "prettier --write .",
"format:check": "prettier --check .",
"build": "del-cli dist && npm run update:version && tsup",
"typecheck": "tsc --noEmit",
"test": "jest --runInBand",
"check": "npm run version:check && npm run format:check && npm run lint && npm run lint:types && npm run typecheck && npm run test",
"fix": "npm run version:fix && npm run format && npm run lint:fix",
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsup && tsc -p tsconfig.build.json && node scripts/copy-dts.cjs",
"prepare": "husky"
},
"repository": "https://github.com/FrauJulian/Discord-Audio-Stream",
"bugs": "https://github.com/FrauJulian/Discord-Audio-Stream/issues",
"repository": {
"type": "git",
"url": "https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream.git"
},
"bugs": "https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream/issues",
"funding": "https://ko-fi.com/FrauJulian",
"keywords": [
"discordjs",
"discord",
"discord.js",
"audio",
"voice",
"streaming",
"discordjs/voice",
"music",
"voice",
"audio",
"24/7"
],
"contributors": [
"FrauJulian - Lechner Julian <fraujulian@lechner.top>"
"Julian Lechner - FrauJulian <fraujulian@lechner.top>"
],
"lint-staged": {
"*.{ts,tsx,js,cjs,mjs}": [
@@ -58,37 +69,47 @@
"prettier --write"
]
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0"
},
"devDependencies": {
"@jest/globals": "^30.4.1",
"@types/ejs": "^3.1.5",
"@types/node": "^25.0.2",
"del-cli": "^7.0.0",
"eslint": "^9.39.2",
"@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.61.1",
"@typescript-eslint/parser": "^8.61.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-unused-imports": "^4.3.0",
"eslint-plugin-n": "^18.1.0",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-unused-imports": "^4.4.1",
"husky": "^9.1.7",
"npm-check-updates": "^19.2.0",
"prettier": "^3.7.4",
"ts-jest": "^29.4.6",
"jest": "^30.4.2",
"prettier": "^3.8.4",
"ts-jest": "^29.4.11",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
"typescript": "^6.0.3"
},
"peerDependencies": {
"@discordjs/voice": "^0.19.0",
"@snazzah/davey": "^0.1.8",
"libsodium-wrappers": "^0.7.15",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.12",
"ffmpeg-static": "^5.3.0",
"libsodium-wrappers": "^0.8.4",
"opusscript": "^0.0.8",
"prism-media": "^1.3.5"
},
"peerDependenciesMeta": {
"ffmpeg-static": {
"optional": true
},
"libsodium-wrappers": {
"optional": true
}
},
"overrides": {
"esbuild": "^0.28.1"
},
"engines": {
"node": ">=22.12.0"
"node": ">=22.22.3"
},
"private": false,
"publishConfig": {
+11
View File
@@ -0,0 +1,11 @@
const { copyFileSync, existsSync } = require('node:fs');
const { join } = require('node:path');
const declarationFile = join(__dirname, '..', 'dist', 'index.d.ts');
const esmDeclarationFile = join(__dirname, '..', 'dist', 'index.d.mts');
if (!existsSync(declarationFile)) {
throw new Error(`Missing declaration file: ${declarationFile}`);
}
copyFileSync(declarationFile, esmDeclarationFile);
+95 -13
View File
@@ -1,21 +1,103 @@
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8').toString());
const lockFilePath = 'package-lock.json';
const packageFilePath = 'package.json';
const placeholderVersion = 'x.x.x';
const command = process.argv[2];
const [major, minor] = pkg.version.split('.');
const prefix = `${major}.${minor}.`;
function readJson(path) {
return JSON.parse(fs.readFileSync(path, 'utf8').toString());
}
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now - start;
const dayOfYear = Math.floor(diff / (1000 * 60 * 60 * 24));
function writeJson(path, value, spaces) {
fs.writeFileSync(path, `${JSON.stringify(value, null, spaces)}\n`);
}
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
function getLockFile() {
if (!fs.existsSync(lockFilePath)) {
return null;
}
const newVersion = `${prefix}${String(dayOfYear).padStart(3, '0')}${hh}${mm}`;
return readJson(lockFilePath);
}
pkg.version = newVersion;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
function writeVersions(version) {
const pkg = readJson(packageFilePath);
pkg.version = version;
writeJson(packageFilePath, pkg, 2);
console.log('Version set to: ', newVersion);
const lock = getLockFile();
if (lock) {
lock.version = version;
if (lock.packages?.['']) {
lock.packages[''].version = version;
}
writeJson(lockFilePath, lock, 4);
}
}
function checkPlaceholder() {
const pkg = readJson(packageFilePath);
const lock = getLockFile();
const errors = [];
if (pkg.version !== placeholderVersion) {
errors.push(`package.json version must be "${placeholderVersion}", got "${pkg.version}"`);
}
if (lock && lock.version !== placeholderVersion) {
errors.push(`package-lock.json version must be "${placeholderVersion}", got "${lock.version}"`);
}
if (lock?.packages?.[''] && lock.packages[''].version !== placeholderVersion) {
errors.push(
`package-lock.json packages[""] version must be "${placeholderVersion}", got "${lock.packages[''].version}"`,
);
}
if (errors.length > 0) {
for (const error of errors) {
console.error(error);
}
process.exit(1);
}
console.log(`Version placeholder validated: ${placeholderVersion}`);
}
function fixPlaceholder() {
writeVersions(placeholderVersion);
console.log(`Version placeholder set to: ${placeholderVersion}`);
}
function getCiVersion() {
const major = process.env.VERSION_MAJOR;
const minor = process.env.VERSION_MINOR;
const patch = process.env.VERSION_PATCH ?? process.env.GITHUB_RUN_NUMBER;
const segments = { major, minor, patch };
for (const [name, value] of Object.entries(segments)) {
if (!/^\d+$/.test(value ?? '')) {
console.error(`Missing or invalid ${name} version segment: "${value ?? ''}"`);
process.exit(1);
}
}
return `${major}.${minor}.${patch}`;
}
if (command === 'check-placeholder') {
checkPlaceholder();
} else if (command === 'fix-placeholder') {
fixPlaceholder();
} else if (command === 'set-ci-version') {
const ciVersion = getCiVersion();
writeVersions(ciVersion);
console.log(`CI version set to: ${ciVersion}`);
} else {
console.error('Invalid command. Use one of: check-placeholder, fix-placeholder, set-ci-version');
process.exit(1);
}
+247 -148
View File
@@ -8,190 +8,289 @@ import {
StreamType,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { join } from 'node:path';
import type { IAudioManager, IDisposable, VoiceAudioDataModel, VoiceConnectionDataModel } from './types';
import { spawn } from 'node:child_process';
import type { Readable } from 'node:stream';
import { isAbsolute, resolve } from 'node:path';
export default class AudioManager implements IAudioManager, IDisposable {
private voiceConnection?: VoiceConnection | null;
private audioPlayer?: AudioPlayer | null;
private audioResource?: AudioResource | null;
import { AudioManagerConfigError, AudioManagerStateError } from './errors';
import { startFfmpeg, type FfmpegProcessHandle } from './ffmpeg';
import type {
AudioManagerOptions,
AudioSource,
PlaybackState,
ResolvedAudioSource,
VoiceConnectionOptions,
} from './types';
protected Active?: boolean | null;
protected Playing?: boolean | null;
const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
const DEFAULT_RENEW_INTERVAL_MS = 5_400_000;
private timeout?: NodeJS.Timeout | null;
private ffmpegProcess?: ReturnType<typeof spawn> | null;
private pcmStream?: Readable | null;
export default class AudioManager {
private readonly audioPlayer: AudioPlayer;
constructor(
private readonly ffmpegMode: 'Native' | 'Standalone',
private renewMs: number | null = null,
private connectionData: VoiceConnectionDataModel | null = null,
private audioData: VoiceAudioDataModel | null = null,
) {
this.renewMs = renewMs ? renewMs : 5400000;
}
public OverrideVoiceConnectionData(connectionData: VoiceConnectionDataModel): void {
this.connectionData = connectionData;
}
public OverrideVoiceAudioDataModel(audioData: VoiceAudioDataModel): void {
this.audioData = audioData;
}
public CreateConnection(): void {
this.voiceConnection = joinVoiceChannel({
channelId: this.connectionData!.VoiceChannelId,
guildId: this.connectionData!.GuildId,
adapterCreator: this.connectionData!.VoiceAdapter,
});
this.timeout = setTimeout(async (): Promise<void> => {
await this.StopConnection();
await this.CreateAndPlay();
}, this.renewMs!);
}
public async PlayAudio(): Promise<void> {
if (!this.audioData || !this.voiceConnection) return;
await entersState(this.voiceConnection!, VoiceConnectionStatus.Ready, 20_000);
if (this.ffmpegProcess) {
this.ffmpegProcess.kill('SIGKILL');
this.ffmpegProcess = null;
}
let source: string;
if (this.audioData!.ResourceType === 'File') {
source = join(__dirname, this.audioData!.Resource);
} else if (this.audioData!.ResourceType === 'Link') {
source = this.audioData!.Resource;
} else {
throw new TypeError('Invalid resource type.');
}
this.ffmpegProcess = spawn(
this.ffmpegMode === 'Native' ? 'ffmpeg' : require('ffmpeg-static'),
[
'-loglevel',
'error',
'-i',
source,
'-analyzeduration',
'0',
'-f',
's16le',
'-ar',
'48000',
'-ac',
'2',
'pipe:1',
],
{
stdio: ['ignore', 'pipe', 'pipe'],
},
);
this.pcmStream = this.ffmpegProcess!.stdout as Readable;
this.audioResource = createAudioResource(this.pcmStream, {
inputType: StreamType.Raw,
inlineVolume: true,
});
private connection: VoiceConnection | undefined;
private resource: AudioResource | undefined;
private ffmpeg: FfmpegProcessHandle | undefined;
private renewTimer: NodeJS.Timeout | undefined;
private playbackState: PlaybackState = 'idle';
private connectionOptions: VoiceConnectionOptions | undefined;
private audioSource: AudioSource | undefined;
private readonly options: Required<Pick<AudioManagerOptions, 'connectTimeoutMs'>> &
Omit<AudioManagerOptions, 'connectTimeoutMs'>;
public constructor(options: AudioManagerOptions = {}) {
this.options = {
...options,
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
};
this.connectionOptions = options.connection;
this.audioSource = options.source;
this.audioPlayer = createAudioPlayer({
behaviors: {
noSubscriber: NoSubscriberBehavior.Play,
},
});
this.voiceConnection!.subscribe(this.audioPlayer);
this.audioPlayer.play(this.audioResource);
this.Playing = true;
}
public PauseAudio(): void {
if (this.Playing) {
this.audioPlayer!.pause();
this.Playing = false;
} else {
throw new ReferenceError('Audio is not playing.');
}
public get state(): PlaybackState {
return this.playbackState;
}
public ResumeAudio(): void {
if (!this.Playing) {
this.audioPlayer!.unpause();
this.Playing = true;
} else {
throw new ReferenceError('Audio is playing.');
}
public get isPlaying(): boolean {
return this.playbackState === 'playing';
}
public async CreateAndPlay(): Promise<void> {
this.CreateConnection();
await this.PlayAudio();
public get isConnected(): boolean {
return Boolean(this.connection);
}
public async StopConnection(): Promise<void> {
this.voiceConnection?.disconnect();
this.voiceConnection?.destroy();
this.voiceConnection = null;
this.Active = false;
this.Playing = false;
public setConnection(options: VoiceConnectionOptions): void {
this.assertNotDisposed();
this.connectionOptions = options;
}
public SetVolume(volumeInPercent: number): void {
if (volumeInPercent < 0 || volumeInPercent > 100) throw new Error('Volume must be between 0 and 100.');
this.audioResource!.volume!.setVolume(volumeInPercent / 100);
public setSource(source: AudioSource): void {
this.assertNotDisposed();
this.audioSource = source;
}
public Dispose(): void {
public async connect(): Promise<void> {
this.assertNotDisposed();
if (!this.connectionOptions) {
throw new AudioManagerConfigError('Voice connection options are required before connecting.');
}
this.clearRenewTimer();
this.playbackState = 'connecting';
this.connection?.destroy();
const connection = joinVoiceChannel({
guildId: this.connectionOptions.guildId,
channelId: this.connectionOptions.channelId,
adapterCreator: this.connectionOptions.adapterCreator,
});
this.connection = connection;
connection.subscribe(this.audioPlayer);
try {
if (this.timeout) {
clearTimeout(this.timeout);
await entersState(connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs);
} catch (error) {
connection.destroy();
if (this.connection === connection) {
this.connection = undefined;
}
this.playbackState = 'stopped';
throw error;
}
if (this.connection !== connection) {
throw new AudioManagerStateError('Voice connection was stopped before it became ready.');
}
this.playbackState = 'ready';
this.scheduleRenewal();
}
if (this.audioPlayer) {
public async play(source?: AudioSource): Promise<void> {
this.assertNotDisposed();
if (source) {
this.setSource(source);
}
if (!this.connection) {
throw new AudioManagerStateError('A voice connection is required before audio can be played.');
}
const resolvedSource = this.resolveSource();
this.stopCurrentPlayback();
this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg);
const ffmpeg = this.ffmpeg;
try {
await ffmpeg.ready;
if (this.ffmpeg !== ffmpeg) {
throw new AudioManagerStateError('Playback was stopped before ffmpeg became ready.');
}
this.resource = createAudioResource(ffmpeg.process.stdout, {
inputType: StreamType.Raw,
inlineVolume: this.options.volume?.enabled === true,
});
} catch (error) {
if (this.ffmpeg === ffmpeg) {
this.stopCurrentPlayback();
}
throw error;
}
if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) {
this.setVolume(this.options.volume.initialPercent);
}
try {
this.audioPlayer.play(this.resource);
this.playbackState = 'playing';
} catch (error) {
this.stopCurrentPlayback();
this.playbackState = 'ready';
throw error;
}
}
public async start(): Promise<void> {
await this.connect();
await this.play();
}
public pause(): void {
this.assertNotDisposed();
if (this.playbackState !== 'playing') {
throw new AudioManagerStateError('Audio can only be paused while it is playing.');
}
this.audioPlayer.pause();
this.playbackState = 'paused';
}
public resume(): void {
this.assertNotDisposed();
if (this.playbackState !== 'paused') {
throw new AudioManagerStateError('Audio can only be resumed while it is paused.');
}
this.audioPlayer.unpause();
this.playbackState = 'playing';
}
public async stop(): Promise<void> {
if (this.playbackState === 'disposed') {
return;
}
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection?.disconnect();
this.connection?.destroy();
this.connection = undefined;
this.playbackState = 'stopped';
}
if (this.audioPlayer) {
this.audioPlayer!.stop();
public setVolume(volumeInPercent: number): void {
this.assertNotDisposed();
if (this.audioResource && this.audioResource.playStream) {
this.audioResource!.playStream.destroy();
if (this.options.volume?.enabled !== true) {
throw new AudioManagerStateError('Volume control requires volume.enabled to be true.');
}
if (!Number.isFinite(volumeInPercent) || volumeInPercent < 0 || volumeInPercent > 100) {
throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.');
}
if (!this.resource?.volume) {
throw new AudioManagerStateError('No audio resource with volume control is currently active.');
}
this.resource.volume.setVolume(volumeInPercent / 100);
}
public dispose(): void {
if (this.playbackState === 'disposed') {
return;
}
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection?.destroy();
this.connection = undefined;
this.connectionOptions = undefined;
this.audioSource = undefined;
this.playbackState = 'disposed';
}
private resolveSource(): ResolvedAudioSource {
if (!this.audioSource) {
throw new AudioManagerConfigError('Audio source is required before playback can start.');
}
if (this.audioSource.type === 'url') {
try {
return {
input: new URL(this.audioSource.url).toString(),
source: this.audioSource,
};
} catch (error) {
throw new AudioManagerConfigError(`Invalid audio source URL. Cause: ${String(error)}`);
}
}
if (this.voiceConnection) {
this.voiceConnection!.destroy();
return {
input: isAbsolute(this.audioSource.path)
? this.audioSource.path
: resolve(process.cwd(), this.audioSource.path),
source: this.audioSource,
};
}
if (this.ffmpegProcess) {
this.ffmpegProcess.kill('SIGKILL');
this.ffmpegProcess = null;
private scheduleRenewal(): void {
const renewIntervalMs = this.options.renewIntervalMs ?? DEFAULT_RENEW_INTERVAL_MS;
if (renewIntervalMs === false) {
return;
}
this.pcmStream?.destroy();
this.pcmStream = null;
this.renewTimer = setTimeout(() => {
void this.start().catch(() => {
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection?.disconnect();
this.connection?.destroy();
this.connection = undefined;
this.playbackState = 'stopped';
});
}, renewIntervalMs);
this.audioPlayer = null;
this.audioResource = null;
this.voiceConnection = null;
this.timeout = null;
this.Active = null;
this.Playing = null;
this.connectionData = null;
this.audioData = null;
this.renewMs = null;
} catch {}
if (typeof this.renewTimer.unref === 'function') {
this.renewTimer.unref();
}
}
private clearRenewTimer(): void {
if (this.renewTimer) {
clearTimeout(this.renewTimer);
this.renewTimer = undefined;
}
}
private stopCurrentPlayback(): void {
this.resource?.playStream.destroy();
this.resource = undefined;
this.ffmpeg?.stop();
this.ffmpeg = undefined;
}
private assertNotDisposed(): void {
if (this.playbackState === 'disposed') {
throw new AudioManagerStateError('AudioManager has been disposed.');
}
}
}
+19
View File
@@ -0,0 +1,19 @@
export class AudioManagerError extends Error {
public constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
export class AudioManagerConfigError extends AudioManagerError {}
export class AudioManagerStateError extends AudioManagerError {}
export class FfmpegProcessError extends AudioManagerError {
public constructor(
message: string,
public readonly cause?: unknown,
) {
super(message);
}
}
+132
View File
@@ -0,0 +1,132 @@
import { spawn } from 'node:child_process';
import type { ChildProcessByStdio } from 'node:child_process';
import { createRequire } from 'node:module';
import type { Readable } from 'node:stream';
import { AudioManagerConfigError, FfmpegProcessError } from './errors';
import type { FfmpegOptions } from './types';
const requireFromCurrentModule = createRequire(__filename);
const DEFAULT_INPUT_ARGS = ['-hide_banner', '-loglevel', 'error', '-nostdin'] as const;
const DEFAULT_OUTPUT_ARGS = ['-vn', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'] as const;
const FORCE_KILL_TIMEOUT_MS = 2_000;
const STDERR_TAIL_BYTES = 4_096;
export type FfmpegProcessHandle = {
process: ChildProcessByStdio<null, Readable, Readable>;
ready: Promise<void>;
stop(): void;
};
export function resolveFfmpegExecutable(options: FfmpegOptions = {}): string {
if (options.executablePath?.trim()) {
return options.executablePath;
}
if ((options.mode ?? 'native') === 'native') {
return 'ffmpeg';
}
try {
const executable = requireFromCurrentModule('ffmpeg-static') as unknown;
if (typeof executable === 'string' && executable.length > 0) {
return executable;
}
} catch (error) {
throw new AudioManagerConfigError(
`Unable to resolve ffmpeg-static. Install it or pass ffmpeg.executablePath. Cause: ${String(error)}`,
);
}
throw new AudioManagerConfigError('ffmpeg-static did not expose an executable path.');
}
export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegProcessHandle {
const executable = resolveFfmpegExecutable(options);
const args = [
...(options.inputArgs ?? DEFAULT_INPUT_ARGS),
'-i',
input,
...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS),
];
const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const ready = waitForFfmpegOutput(childProcess);
childProcess.stderr.resume();
return {
process: childProcess,
ready,
stop: (): void => {
stopProcess(childProcess);
},
};
}
function waitForFfmpegOutput(childProcess: ChildProcessByStdio<null, Readable, Readable>): Promise<void> {
let stderrTail = '';
const appendStderr = (chunk: Buffer | string): void => {
stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES);
};
childProcess.stderr.on('data', appendStderr);
return new Promise((resolve, reject) => {
const cleanup = (): void => {
childProcess.off('error', onError);
childProcess.off('exit', onExit);
childProcess.stdout.off('readable', onReadable);
};
const fail = (message: string, cause?: unknown): void => {
cleanup();
reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause));
};
const onError = (error: Error): void => {
fail(`Unable to start ffmpeg. Cause: ${error.message}`, error);
};
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
fail(`ffmpeg exited before producing audio. Exit code: ${code ?? 'none'}, signal: ${signal ?? 'none'}.`);
};
const onReadable = (): void => {
cleanup();
resolve();
};
childProcess.once('error', onError);
childProcess.once('exit', onExit);
childProcess.stdout.once('readable', onReadable);
});
}
function addStderrTail(message: string, stderrTail: string): string {
const trimmedTail = stderrTail.trim();
return trimmedTail ? `${message} stderr: ${trimmedTail}` : message;
}
function stopProcess(childProcess: ChildProcessByStdio<null, Readable, Readable>): void {
childProcess.stdout.destroy();
childProcess.stderr.destroy();
childProcess.removeAllListeners();
if (childProcess.killed || childProcess.exitCode !== null || childProcess.signalCode !== null) {
return;
}
childProcess.kill('SIGTERM');
const forceKillTimeout = setTimeout(() => {
if (!childProcess.killed && childProcess.exitCode === null && childProcess.signalCode === null) {
childProcess.kill('SIGKILL');
}
}, FORCE_KILL_TIMEOUT_MS);
forceKillTimeout.unref();
}
+10 -1
View File
@@ -1,2 +1,11 @@
export { default as AudioManager } from './audio-manager';
export type { VoiceAudioDataModel, VoiceConnectionDataModel } from './types';
export { AudioManagerConfigError, AudioManagerError, AudioManagerStateError, FfmpegProcessError } from './errors';
export type {
AudioManagerOptions,
AudioSource,
FfmpegMode,
FfmpegOptions,
PlaybackState,
VoiceConnectionOptions,
VolumeOptions,
} from './types';
-55
View File
@@ -1,55 +0,0 @@
import type { DiscordGatewayAdapterCreator } from '@discordjs/voice';
export type VoiceConnectionDataModel = {
/**
* The ID of the voice channel to connect to.
*/
VoiceChannelId: string;
/**
* The Id of the guild (server) to connect to.
*/
GuildId: string;
/**
* The adapter creator for the voice connection.
* Can be archived by the guild instance via the voiceAdapterCreator property.
*/
VoiceAdapter: DiscordGatewayAdapterCreator;
};
export type VoiceAudioDataModel = {
/**
* The unique identifier for the audio resource.
*/
ResourceType: 'Link' | 'File';
/**
* The URL or file path of the audio resource.
* any is to be assumed to require(filepath)
*/
Resource: string | any;
};
/**
* An interface which is implemented in audio manager
* for a proper Dispose functionality.
*/
export type IDisposable = {
Dispose(): void;
};
/**
* An interface to handle instances later on.
*/
export type IAudioManager = {
OverrideVoiceConnectionData(connectionData: VoiceConnectionDataModel): void;
OverrideVoiceAudioDataModel(audioData: VoiceAudioDataModel): void;
CreateAndPlay(): Promise<void>;
CreateConnection(): void;
PlayAudio(): Promise<void>;
PauseAudio(): void;
ResumeAudio(): void;
StopConnection(): Promise<void>;
SetVolume(volumeInPercent: number): void;
};
+178
View File
@@ -0,0 +1,178 @@
import type { DiscordGatewayAdapterCreator } from '@discordjs/voice';
/**
* Strategy used to resolve the ffmpeg executable.
*
* - `native` uses the `ffmpeg` binary available on the host PATH.
* - `static` resolves the optional `ffmpeg-static` package.
*/
export type FfmpegMode = 'native' | 'static';
/**
* Audio input consumed by ffmpeg.
*
* URL sources are validated through the built-in `URL` constructor before playback starts.
* File sources may be absolute or relative; relative paths are resolved from `process.cwd()`.
*/
export type AudioSource =
| {
/**
* Marks this source as a remote URL.
*/
type: 'url';
/**
* Fully qualified audio URL passed to ffmpeg.
*/
url: string;
}
| {
/**
* Marks this source as a local file path.
*/
type: 'file';
/**
* Absolute file path or path relative to `process.cwd()`.
*/
path: string;
};
/**
* Discord voice channel connection settings.
*/
export type VoiceConnectionOptions = {
/**
* Discord guild/server ID.
*/
guildId: string;
/**
* Discord voice channel ID where audio should be played.
*/
channelId: string;
/**
* Discord voice adapter creator, usually `guild.voiceAdapterCreator` from discord.js.
*/
adapterCreator: DiscordGatewayAdapterCreator;
};
/**
* Public playback lifecycle state exposed by `AudioManager.state`.
*/
export type PlaybackState = 'idle' | 'connecting' | 'ready' | 'playing' | 'paused' | 'stopped' | 'disposed';
/**
* ffmpeg executable and argument configuration.
*/
export type FfmpegOptions = {
/**
* ffmpeg resolution mode.
*
* @defaultValue `'native'`
*/
mode?: FfmpegMode;
/**
* Explicit ffmpeg executable path.
*
* When provided, this value takes precedence over `mode`.
*/
executablePath?: string;
/**
* Arguments placed before `-i <source>`.
*
* Override only when you need full control over ffmpeg input behavior.
*/
inputArgs?: readonly string[];
/**
* Arguments placed after `-i <source>`.
*
* If overridden, the output must remain compatible with `StreamType.Raw`.
*/
outputArgs?: readonly string[];
};
/**
* Optional inline volume control settings.
*/
export type VolumeOptions = {
/**
* Enables Discord voice inline volume support.
*
* Disabled by default because inline volume has runtime overhead.
*
* @defaultValue `false`
*/
enabled?: boolean;
/**
* Initial volume percentage applied when playback starts.
*
* Requires `enabled: true`.
*/
initialPercent?: number;
};
/**
* Constructor options for `AudioManager`.
*/
export type AudioManagerOptions = {
/**
* ffmpeg executable and argument configuration.
*/
ffmpeg?: FfmpegOptions;
/**
* Initial Discord voice connection settings.
*
* May also be supplied later through `setConnection()`.
*/
connection?: VoiceConnectionOptions;
/**
* Initial audio source.
*
* May also be supplied later through `setSource()` or `play(source)`.
*/
source?: AudioSource;
/**
* Milliseconds after which the manager reconnects and restarts playback.
*
* Set to `false` to disable renewal.
*
* @defaultValue `5_400_000`
*/
renewIntervalMs?: number | false;
/**
* Maximum milliseconds to wait for the Discord voice connection to become ready.
*
* @defaultValue `20_000`
*/
connectTimeoutMs?: number;
/**
* Optional inline volume configuration.
*/
volume?: VolumeOptions;
};
/**
* Internal normalized audio source passed to ffmpeg.
*/
export type ResolvedAudioSource = {
/**
* Validated URL or absolute file path passed as ffmpeg input.
*/
input: string;
/**
* Original source configuration.
*/
source: AudioSource;
};
+633
View File
@@ -0,0 +1,633 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice';
import { resolve } from 'node:path';
import { PassThrough } from 'node:stream';
import AudioManager from '../src/audio-manager';
import { AudioManagerConfigError, AudioManagerStateError, FfmpegProcessError } from '../src';
import { startFfmpeg } from '../src/ffmpeg';
import type { FfmpegProcessHandle } from '../src/ffmpeg';
import type { AudioSource, VoiceConnectionOptions } from '../src';
import type { VoiceConnection } from '@discordjs/voice';
const mockAudioPlayer = {
play: jest.fn(),
pause: jest.fn(),
unpause: jest.fn(),
stop: jest.fn(),
};
const mockConnection = {
subscribe: jest.fn(),
disconnect: jest.fn(),
destroy: jest.fn(),
};
const mockSecondConnection = {
subscribe: jest.fn(),
disconnect: jest.fn(),
destroy: jest.fn(),
};
const mockVoiceConnection = mockConnection as unknown as VoiceConnection;
const mockSecondVoiceConnection = mockSecondConnection as unknown as VoiceConnection;
const mockAudioResource = {
playStream: {
destroy: jest.fn(),
},
volume: {
setVolume: jest.fn(),
},
};
const mockFfmpegHandle: FfmpegProcessHandle = {
process: {
stdout: new PassThrough(),
} as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(),
};
jest.mock('@discordjs/voice', () => ({
NoSubscriberBehavior: {
Play: 'play',
},
StreamType: {
Raw: 'raw',
},
VoiceConnectionStatus: {
Ready: 'ready',
},
createAudioPlayer: jest.fn(() => mockAudioPlayer),
createAudioResource: jest.fn(() => mockAudioResource),
entersState: jest.fn(() => Promise.resolve(mockVoiceConnection)),
joinVoiceChannel: jest.fn(() => mockVoiceConnection),
}));
jest.mock('../src/ffmpeg', () => ({
startFfmpeg: jest.fn(() => mockFfmpegHandle),
}));
const connectionOptions: VoiceConnectionOptions = {
guildId: 'guild-id',
channelId: 'channel-id',
adapterCreator: jest.fn() as unknown as VoiceConnectionOptions['adapterCreator'],
};
const liveStreamSource: AudioSource = {
type: 'url',
url: 'https://synradiode.stream.laut.fm/synradiode',
};
const fileSourcePath = 'tests/audio.mp3';
const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath);
type MockedEntersStateReturn = ReturnType<typeof entersState>;
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
function createMockFfmpegHandle(): FfmpegProcessHandle {
return {
process: {
stdout: new PassThrough(),
} as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(),
};
}
describe('AudioManager', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('requires connection options before connecting', async () => {
const manager = new AudioManager({ renewIntervalMs: false });
await expect(manager.connect()).rejects.toThrow(AudioManagerConfigError);
});
it('connects and starts playback with resolved URL source', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
expect(manager.state).toBe('playing');
expect(manager.isConnected).toBe(true);
expect(manager.isPlaying).toBe(true);
expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer);
expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined);
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('cleans up when connection startup fails', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: 10_000,
});
jest.mocked(entersState).mockRejectedValueOnce(new Error('voice connection timeout'));
await expect(manager.connect()).rejects.toThrow('voice connection timeout');
expect(manager.state).toBe('stopped');
expect(manager.isConnected).toBe(false);
expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});
it('does not become ready when stopped during connection startup', async () => {
jest.useFakeTimers();
const ready = deferred<VoiceConnection>();
jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn);
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: 10_000,
});
const connectPromise = manager.connect();
await manager.stop();
ready.resolve(mockVoiceConnection);
await expect(connectPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('stopped');
expect(manager.isConnected).toBe(false);
expect(jest.getTimerCount()).toBe(0);
});
it('keeps the latest connection when concurrent connects resolve out of order', async () => {
const firstReady = deferred<VoiceConnection>();
const secondReady = deferred<VoiceConnection>();
jest.mocked(joinVoiceChannel)
.mockReturnValueOnce(mockVoiceConnection)
.mockReturnValueOnce(mockSecondVoiceConnection);
jest.mocked(entersState)
.mockReturnValueOnce(firstReady.promise as unknown as MockedEntersStateReturn)
.mockReturnValueOnce(secondReady.promise as unknown as MockedEntersStateReturn);
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
const firstConnect = manager.connect();
const secondConnect = manager.connect();
firstReady.resolve(mockVoiceConnection);
secondReady.resolve(mockSecondVoiceConnection);
await expect(firstConnect).rejects.toThrow(AudioManagerStateError);
await expect(secondConnect).resolves.toBeUndefined();
expect(manager.state).toBe('ready');
expect(manager.isConnected).toBe(true);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
expect(mockSecondConnection.destroy).not.toHaveBeenCalled();
});
it('does not become ready when disposed during connection startup', async () => {
const ready = deferred<VoiceConnection>();
jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn);
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
const connectPromise = manager.connect();
manager.dispose();
ready.resolve(mockVoiceConnection);
await expect(connectPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('disposed');
expect(manager.isConnected).toBe(false);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
});
it('starts playback with the committed mp3 test file', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: {
type: 'file',
path: fileSourcePath,
},
renewIntervalMs: false,
});
await manager.start();
expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined);
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('accepts the live stream URL as a valid source', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await manager.play();
expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined);
});
it('plays a source passed directly to play()', async () => {
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
await manager.connect();
await manager.play({
type: 'file',
path: fileSourcePath,
});
expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined);
expect(manager.state).toBe('playing');
});
it('does not report playback when ffmpeg startup fails', async () => {
const startupError = new FfmpegProcessError('Unable to start ffmpeg.');
jest.mocked(startFfmpeg).mockReturnValueOnce({
...mockFfmpegHandle,
ready: Promise.reject(startupError),
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toBe(startupError);
expect(manager.state).toBe('ready');
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
});
it('cleans up when audio resource creation fails', async () => {
const resourceError = new Error('resource failed');
jest.mocked(createAudioResource).mockImplementationOnce(() => {
throw resourceError;
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toBe(resourceError);
expect(manager.state).toBe('ready');
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
});
it('cleans up when the audio player rejects playback', async () => {
const playerError = new Error('player failed');
mockAudioPlayer.play.mockImplementationOnce(() => {
throw playerError;
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toBe(playerError);
expect(manager.state).toBe('ready');
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
});
it('does not report playback when stopped before ffmpeg is ready', async () => {
const ready = deferred<void>();
jest.mocked(startFfmpeg).mockReturnValueOnce({
...mockFfmpegHandle,
ready: ready.promise,
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
const playPromise = manager.play();
await manager.stop();
ready.resolve();
await expect(playPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('stopped');
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
});
it('does not report playback when disposed before ffmpeg is ready', async () => {
const ready = deferred<void>();
jest.mocked(startFfmpeg).mockReturnValueOnce({
...mockFfmpegHandle,
ready: ready.promise,
});
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
const playPromise = manager.play();
manager.dispose();
ready.resolve();
await expect(playPromise).rejects.toThrow(AudioManagerStateError);
expect(manager.state).toBe('disposed');
expect(mockAudioPlayer.play).not.toHaveBeenCalled();
});
it('keeps only the latest concurrent playback', async () => {
const firstReady = deferred<void>();
const firstHandle = {
...createMockFfmpegHandle(),
ready: firstReady.promise,
};
const secondHandle = createMockFfmpegHandle();
jest.mocked(startFfmpeg).mockReturnValueOnce(firstHandle).mockReturnValueOnce(secondHandle);
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
const firstPlay = manager.play();
const secondPlay = manager.play({ type: 'file', path: fileSourcePath });
firstReady.resolve();
await expect(firstPlay).rejects.toThrow(AudioManagerStateError);
await expect(secondPlay).resolves.toBeUndefined();
expect(manager.state).toBe('playing');
expect(mockAudioPlayer.play).toHaveBeenCalledTimes(1);
expect(firstHandle.stop).toHaveBeenCalledTimes(1);
expect(secondHandle.stop).not.toHaveBeenCalled();
});
it('replaces active playback when a new source is played', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
await manager.play({
type: 'file',
path: fileSourcePath,
});
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
expect(startFfmpeg).toHaveBeenLastCalledWith(resolvedFileSourcePath, undefined);
});
it('requires an active connection before play()', async () => {
const manager = new AudioManager({
source: liveStreamSource,
renewIntervalMs: false,
});
await expect(manager.play()).rejects.toThrow(AudioManagerStateError);
});
it('requires a source before play()', async () => {
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
});
it('rejects invalid URL sources', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: {
type: 'url',
url: 'not a url',
},
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
});
it('applies initial volume only when volume support is enabled', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
volume: {
enabled: true,
initialPercent: 35,
},
});
await manager.start();
expect(mockAudioResource.volume.setVolume).toHaveBeenCalledWith(0.35);
});
it('rejects volume changes when inline volume is disabled', () => {
const manager = new AudioManager({ renewIntervalMs: false });
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
});
it('rejects invalid volume percentages', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
volume: {
enabled: true,
},
});
await manager.start();
expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(Number.NaN)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(Number.POSITIVE_INFINITY)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(Number.NEGATIVE_INFINITY)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(0)).not.toThrow();
expect(() => manager.setVolume(42)).not.toThrow();
expect(() => manager.setVolume(100)).not.toThrow();
});
it('rejects volume changes before any resource exists', () => {
const manager = new AudioManager({
renewIntervalMs: false,
volume: {
enabled: true,
},
});
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
});
it('pauses and resumes only from valid playback states', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
expect(() => manager.pause()).toThrow(AudioManagerStateError);
await manager.start();
manager.pause();
manager.resume();
expect(mockAudioPlayer.pause).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.unpause).toHaveBeenCalledTimes(1);
expect(manager.state).toBe('playing');
});
it('stops playback and voice resources idempotently', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
await manager.stop();
await manager.stop();
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
expect(mockConnection.destroy).toHaveBeenCalled();
expect(manager.state).toBe('stopped');
});
it('rejects pause and resume after stop', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
await manager.stop();
expect(() => manager.pause()).toThrow(AudioManagerStateError);
expect(() => manager.resume()).toThrow(AudioManagerStateError);
});
it('restarts playback when the renewal timer fires', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: 10_000,
});
const startSpy = jest.spyOn(manager, 'start').mockResolvedValue(undefined);
await manager.connect();
jest.advanceTimersByTime(10_000);
await Promise.resolve();
expect(startSpy).toHaveBeenCalledTimes(1);
});
it('cleans up when renewal restart fails', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: 10_000,
});
const startSpy = jest.spyOn(manager, 'start').mockRejectedValue(new Error('renewal failed'));
await manager.connect();
jest.advanceTimersByTime(10_000);
await Promise.resolve();
expect(startSpy).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.stop).toHaveBeenCalledWith(true);
expect(mockConnection.destroy).toHaveBeenCalled();
expect(manager.state).toBe('stopped');
expect(jest.getTimerCount()).toBe(0);
});
it('clears existing renewal timer before reconnecting', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: 10_000,
});
await manager.connect();
await manager.connect();
expect(jest.getTimerCount()).toBe(1);
manager.dispose();
expect(jest.getTimerCount()).toBe(0);
});
it('blocks source and connection changes after disposal', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
manager.dispose();
expect(() => manager.setSource(liveStreamSource)).toThrow(AudioManagerStateError);
expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError);
});
it('prevents use after disposal', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
manager.dispose();
expect(() => manager.pause()).toThrow(AudioManagerStateError);
expect(manager.state).toBe('disposed');
});
});
BIN
View File
Binary file not shown.
+227
View File
@@ -0,0 +1,227 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { FfmpegProcessError } from '../src';
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';
jest.mock('node:child_process', () => ({
spawn: jest.fn(),
}));
const mockSpawn = jest.mocked(spawn);
type MockChildProcess = {
stdout: {
destroy: jest.Mock;
once: jest.Mock;
off: jest.Mock;
};
stderr: {
destroy: jest.Mock;
on: jest.Mock;
resume: jest.Mock;
};
once: jest.Mock;
off: jest.Mock;
removeAllListeners: jest.Mock;
kill: jest.Mock;
killed: boolean;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
};
function createMockChildProcess(): MockChildProcess {
return {
stdout: {
destroy: jest.fn(),
once: jest.fn(),
off: jest.fn(),
},
stderr: {
destroy: jest.fn(),
on: jest.fn(),
resume: jest.fn(),
},
once: jest.fn(),
off: jest.fn(),
removeAllListeners: jest.fn(),
kill: jest.fn(),
killed: false,
exitCode: null,
signalCode: null,
};
}
function mockSpawnReturn(childProcess: MockChildProcess): void {
mockSpawn.mockReturnValue(childProcess as unknown as ChildProcess);
}
function getProcessHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.once.mock.calls.find(([event]) => event === eventName)?.[1] as (...args: unknown[]) => void;
}
function getStdoutHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.stdout.once.mock.calls.find(([event]) => event === eventName)?.[1] as (
...args: unknown[]
) => void;
}
function getStderrHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.stderr.on.mock.calls.find(([event]) => event === eventName)?.[1] as (
...args: unknown[]
) => void;
}
describe('ffmpeg helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('prefers an explicit executable path', () => {
expect(resolveFfmpegExecutable({ executablePath: '/custom/ffmpeg' })).toBe('/custom/ffmpeg');
});
it('uses the native ffmpeg binary by default', () => {
expect(resolveFfmpegExecutable()).toBe('ffmpeg');
expect(resolveFfmpegExecutable({ mode: 'native' })).toBe('ffmpeg');
});
it('spawns ffmpeg with the default argument set', () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
startFfmpeg('https://synradiode.stream.laut.fm/synradiode');
expect(mockSpawn).toHaveBeenCalledWith(
'ffmpeg',
[
'-hide_banner',
'-loglevel',
'error',
'-nostdin',
'-i',
'https://synradiode.stream.laut.fm/synradiode',
'-vn',
'-f',
's16le',
'-ar',
'48000',
'-ac',
'2',
'pipe:1',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function));
expect(childProcess.once).toHaveBeenCalledWith('exit', expect.any(Function));
expect(childProcess.stdout.once).toHaveBeenCalledWith('readable', expect.any(Function));
expect(childProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function));
expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1);
});
it('rejects readiness when ffmpeg startup emits an error', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const errorHandler = getProcessHandler(childProcess, 'error');
errorHandler(new Error('spawn ENOENT'));
await expect(handle.ready).rejects.toThrow(FfmpegProcessError);
await expect(handle.ready).rejects.toThrow('spawn ENOENT');
});
it('includes stderr when ffmpeg exits before producing audio', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = getStderrHandler(childProcess, 'data');
const exitHandler = getProcessHandler(childProcess, 'exit');
stderrHandler('invalid input');
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('invalid input');
});
it('keeps the useful tail of long ffmpeg stderr output', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = getStderrHandler(childProcess, 'data');
const exitHandler = getProcessHandler(childProcess, 'exit');
stderrHandler(`${'x'.repeat(5_000)}final diagnostic`);
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('final diagnostic');
});
it('keeps readiness resolved when ffmpeg exits after producing audio', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const readableHandler = getStdoutHandler(childProcess, 'readable');
const exitHandler = getProcessHandler(childProcess, 'exit');
readableHandler();
exitHandler(1, null);
await expect(handle.ready).resolves.toBeUndefined();
});
it('uses custom executable and argument overrides when provided', () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
startFfmpeg('tests/audio.mp3', {
executablePath: '/custom/ffmpeg',
inputArgs: ['-re'],
outputArgs: ['-f', 'wav', 'pipe:1'],
});
expect(mockSpawn).toHaveBeenCalledWith(
'/custom/ffmpeg',
['-re', '-i', 'tests/audio.mp3', '-f', 'wav', 'pipe:1'],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
});
it('stops a running child process and schedules a force kill fallback', () => {
jest.useFakeTimers();
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
handle.stop();
expect(childProcess.stdout.destroy).toHaveBeenCalledTimes(1);
expect(childProcess.stderr.destroy).toHaveBeenCalledTimes(1);
expect(childProcess.removeAllListeners).toHaveBeenCalledTimes(1);
expect(childProcess.kill).toHaveBeenCalledWith('SIGTERM');
jest.advanceTimersByTime(2_000);
expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
});
it('does not signal a process that already exited', () => {
const childProcess = createMockChildProcess();
childProcess.exitCode = 0;
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
handle.stop();
expect(childProcess.kill).not.toHaveBeenCalled();
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts", "tsup.config.ts"]
}
+2 -6
View File
@@ -2,16 +2,12 @@
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node",
"module": "Node16",
"moduleResolution": "node16",
"resolveJsonModule": true,
"types": [],
"rootDir": "./",
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"outDir": "./dist-test"
},
"include": ["src/**/*.ts", "tests/**/*.ts", "tsup.config.ts"]
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from 'tsup';
export default defineConfig({
format: ['cjs', 'esm'],
entry: ['./src/index.ts'],
dts: true,
dts: false,
shims: true,
skipNodeModulesBundle: true,
clean: true,