Compare commits

...
52 Commits
Author SHA1 Message Date
fraujulian 04eba4de45 ci(whoami): delete unnecessary task
Build Validation / build (push) Failing after 0s
2026-08-24 16:05:11 +02:00
fraujulian 7bd03b5e5f ci(release): verify npm authentication 2026-08-24 16:03:24 +02:00
fraujulian fefad30ad5 refactor(version): define release line statically 2026-08-24 16:02:24 +02:00
fraujulian 9b14d8e9f0 fix(version): preserve package formatting 2026-08-24 16:01:16 +02:00
fraujulian 2430f6e667 chore(license): align package metadata with AGPL 2026-08-24 15:59:12 +02:00
fraujulian afe67ebdc1 docs(errors): distinguish rejected operations
Build Validation / build (push) Successful in 41s
2026-08-24 15:27:44 +02:00
fraujulian c4cd464bfa docs(api): clarify setter behavior 2026-08-24 15:27:05 +02:00
fraujulian 08b103a26b docs(api): document option validation 2026-08-24 15:26:16 +02:00
fraujulian f83d78ab4e ci(node): pin Node.js 24.17 2026-08-24 15:25:19 +02:00
fraujulian 242de19cf2 build(node): require Node.js 24.17 2026-08-24 15:24:41 +02:00
fraujulian 36f546eaba docs(readme): refresh project badges 2026-08-24 15:23:41 +02:00
fraujulian dcaf0f2f05 ci(eol): set lineendings to auto
Build Validation / build (push) Successful in 37s
2026-08-24 14:59:36 +02:00
fraujulian 2e67987a44 docs: document Node 24 and lifecycle behavior
Build Validation / build (push) Failing after 33s
2026-08-24 14:13:55 +02:00
fraujulian 9d2d629a3e fix(voice): harden connection and playback lifecycle 2026-08-24 14:13:14 +02:00
fraujulian f48424e198 fix(ffmpeg): make process shutdown race-safe 2026-08-24 14:12:40 +02:00
fraujulian d750e5fe3f build(tooling): modernize Node and TypeScript setup 2026-08-24 14:12:04 +02:00
fraujulian 58d77b9771 refactor(errors): use native error causes 2026-08-24 14:11:23 +02:00
fraujulian 5fe9285bcb style(prettier): enforce LF line endings 2026-08-24 14:10:19 +02:00
fraujulian 0ec0479c4f ci(node): use Node.js 24 2026-08-24 14:09:44 +02:00
fraujulian 814ceb97e2 chore(gitea): remove unused issue templates 2026-08-24 14:09:05 +02:00
fraujulian 1a95376401 chore: add funding configuration 2026-07-27 15:59:54 +02:00
fraujulian f731845ea8 ci: migrate GitHub contributions to Gitea
Build Validation / build (push) Successful in 1m2s
2026-07-27 15:54:46 +02:00
fraujulian 40404781a9 docs: replace GitHub references with Gitea
Build Validation / build (push) Successful in 57s
2026-07-22 22:43:05 +02:00
fraujulian be478902db chore(package): update repository URLs 2026-07-22 22:43:04 +02:00
fraujulian 8c842b15a2 chore(package): exclude Gitea metadata 2026-07-22 22:43:02 +02:00
fraujulian 60a607a39b ci(gitea): migrate release workflow 2026-07-22 22:43:01 +02:00
fraujulian 4a23b46e54 ci(gitea): migrate validation workflow 2026-07-22 22:42:59 +02:00
fraujulian 641065dc58 chore(gitea): remove GitHub-only metadata 2026-07-22 22:42:58 +02:00
fraujulian 0b30982f04 chore(gitea): migrate issue templates 2026-07-22 22:41:53 +02:00
fraujulian 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
fraujulian 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
fraujulian 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
fraujulian 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] 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] 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] 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] 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
fraujulian 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
fraujulian 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] 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] 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
fraujulian 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
31 changed files with 2672 additions and 822 deletions
+21
View File
@@ -0,0 +1,21 @@
name: 🐛 Bug
description: 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
description: 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
description: 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
@@ -6,10 +6,6 @@ on:
permissions:
contents: write
env:
VERSION_MAJOR: '1'
VERSION_MINOR: '1'
jobs:
build:
runs-on: ubuntu-latest
@@ -21,10 +17,10 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Use NodeJS v22.22.3
- name: Use Node.js 24.17.0
uses: actions/setup-node@v6
with:
node-version: '22.22.3'
node-version: '24.17.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
@@ -32,14 +28,15 @@ jobs:
run: npm ci
- name: Set Release Version
id: version
run: npm run version:place
env:
VERSION_PATCH: ${{ gitea.run_number }}
- name: Export Release Version
id: release_version
run: |
package_version="$(node -p "require('./package.json').version")"
echo "tag=${package_version}" >> "$GITHUB_OUTPUT"
echo "tag=${package_version}" >> "$GITEA_OUTPUT"
- name: Run Build
run: npm run build
@@ -49,24 +46,29 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub release
- name: Create Gitea release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API_URL: ${{ gitea.api_url }}
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="$(gh api repos/${{ github.repository }}/releases --paginate --jq '.[] | select(.draft == false) | .tag_name' | grep -Fxv "$RELEASE_TAG" | head -n 1 || true)"
releases_url="${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/releases"
previous_tag="$(curl --fail --silent --show-error --header "Authorization: token ${GITEA_TOKEN}" "${releases_url}?limit=50" | jq -r --arg tag "$RELEASE_TAG" '[.[] | select(.draft == false and .tag_name != $tag)][0].tag_name // empty')"
if [ -n "$previous_tag" ]; then
if git rev-parse --verify --quiet "${previous_tag}^{commit}" >/dev/null; then
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${GITHUB_SHA}")"
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${GITEA_SHA}")"
else
commit_messages="$(git log --reverse --format='- %s' "${GITHUB_SHA}")"
commit_messages="$(git log --reverse --format='- %s' "${GITEA_SHA}")"
fi
full_changelog="https://github.com/${{ github.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
full_changelog="${GITEA_SERVER_URL}/${GITEA_REPOSITORY}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "${GITHUB_SHA}")"
commit_messages="$(git log --reverse --format='- %s' "${GITEA_SHA}")"
full_changelog=""
fi
@@ -84,8 +86,13 @@ jobs:
fi
} > release-notes.md
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file release-notes.md
jq -n --arg tag "$RELEASE_TAG" --arg target "$GITEA_SHA" --rawfile body release-notes.md \
'{tag_name: $tag, target_commitish: $target, name: $tag, body: $body, draft: false, prerelease: false}' > release.json
release_id="$(curl --silent --header "Authorization: token ${GITEA_TOKEN}" "${releases_url}/tags/${RELEASE_TAG}" | jq -r '.id // empty')"
if [ -n "$release_id" ]; then
curl --fail-with-body --request PATCH --header "Authorization: token ${GITEA_TOKEN}" --header 'Content-Type: application/json' --data @release.json "${releases_url}/${release_id}"
else
gh release create "$RELEASE_TAG" --target "${GITHUB_SHA}" --title "$RELEASE_TAG" --notes-file release-notes.md
curl --fail-with-body --request POST --header "Authorization: token ${GITEA_TOKEN}" --header 'Content-Type: application/json' --data @release.json "$releases_url"
fi
@@ -15,10 +15,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v22.22.3
- name: Use Node.js 24.17.0
uses: actions/setup-node@v4
with:
node-version: '22.22.3'
node-version: '24.17.0'
- name: Install Dependencies
run: npm ci
-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'
+106
View File
@@ -0,0 +1,106 @@
name: Redirect contributions to Gitea
on:
issues:
types: [opened, reopened]
pull_request_target:
types: [opened, reopened]
permissions:
issues: write
pull-requests: write
concurrency:
group: migrate-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number }}
env:
GITEA_API_URL: https://git.lechner-systems.at/api/v1
GITEA_REPOSITORY: FrauJulian/Discord-Audio-Stream
jobs:
migrate-issue:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- name: Migrate issue and close
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
number="$(jq -r '.issue.number' "$GITHUB_EVENT_PATH")"
title="$(jq -r '.issue.title' "$GITHUB_EVENT_PATH")"
body="$(jq -r '.issue.body // ""' "$GITHUB_EVENT_PATH")"
author="$(jq -r '.issue.user.login' "$GITHUB_EVENT_PATH")"
source_url="$(jq -r '.issue.html_url' "$GITHUB_EVENT_PATH")"
github_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
migrated_url="$(
curl --fail --silent --show-error \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Accept: application/vnd.github+json" \
"${github_api}/issues/${number}/comments?per_page=100" |
jq -r '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("Migrated to Gitea: ")))] | first | .body // "" | sub("^Migrated to Gitea: "; "")'
)"
if [ -z "$migrated_url" ]; then
migrated_body="${body}
---
Originally opened by @${author} on GitHub: ${source_url}"
jq -n \
--arg title "$title" \
--arg body "$migrated_body" \
'{title: $title, body: $body}' > gitea-request.json
curl --fail-with-body \
--request POST \
--header "Authorization: token ${GITEA_TOKEN}" \
--header "Content-Type: application/json" \
--data @gitea-request.json \
"${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/issues" > gitea-response.json
migrated_url="$(jq -r '.html_url' gitea-response.json)"
jq -n --arg body "Migrated to Gitea: ${migrated_url}" '{body: $body}' > github-comment.json
curl --fail-with-body \
--request POST \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Content-Type: application/json" \
--data @github-comment.json \
"${github_api}/issues/${number}/comments"
fi
curl --fail-with-body \
--request PATCH \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"state":"closed","state_reason":"not_planned"}' \
"${github_api}/issues/${number}"
redirect-pull-request:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- name: Redirect pull request and close
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
number="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")"
github_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
curl --fail-with-body \
--request POST \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"body":"Please open the Pull Request on Gitea"}' \
"${github_api}/issues/${number}/comments"
curl --fail-with-body \
--request PATCH \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"state":"closed"}' \
"${github_api}/pulls/${number}"
+1 -1
View File
@@ -1,4 +1,4 @@
.github
.gitea
.git
.idea
+69 -72
View File
@@ -1,58 +1,40 @@
# Discord Audio Stream
[![npm](https://img.shields.io/npm/dw/discord-audio-stream)](http://npmjs.org/package/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)
[![npm](https://img.shields.io/npm/dw/discord-audio-stream)](https://www.npmjs.com/package/discord-audio-stream)
![latest release](https://img.shields.io/gitea/v/release/FrauJulian/Discord-Audio-Stream?gitea_url=https%3A%2F%2Fgit.lechner-systems.at&color=blue)
![Github Repo stars](https://img.shields.io/github/stars/FrauJulian/Discord-Audio-Stream?github_url=https%3A%2F%2Fgithub.com&style=social)
> **Designed for 24/7 Discord audio playback.**
> `discord-audio-stream` is a small TypeScript library for managed Discord voice playback through
> `@discordjs/voice` and ffmpeg.
`discord-audio-stream` is a small TypeScript library for managed Discord voice playback through
`@discordjs/voice` and ffmpeg.
Discord voice streams need careful lifecycle handling. This package manages the voice connection, ffmpeg process,
raw PCM resource creation, optional reconnect renewal, and predictable cleanup so your bot code stays small and readable.
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 practice:**
> Keep one `AudioManager` per guild, usually in a `Map<string, AudioManager>`. Update the connection or source through
> `setConnection()` and `setSource()`, then call `start()`. When playback stops, call `stop()`. When the manager will not
> be reused, call `dispose()` to release timers, streams, ffmpeg, and the voice connection.
## Support
> **For large bots:**
> Queue many simultaneous starts, for example with [`p-queue`](https://www.npmjs.com/package/p-queue), so the host does
> not spawn too many ffmpeg processes in the same tick.
## 👋 Support
Please create an [issue](https://github.com/FrauJulian/Discord-Audio-Stream/issues) on GitHub or contact
Create an [issue](https://git.lechner-systems.at/FrauJulian/Discord-Audio-Stream/issues) on Gitea or contact
[`fraujulian`](https://discord.com/users/860206216893693973) on Discord.
## 📝 Usage
## Installation
### Installation
**Node.js `22.22.3` or newer is required.**
Install the library and the required voice packages:
Node.js `>=24.17.0` and `<25` is required.
```bash
npm install discord-audio-stream @discordjs/voice prism-media @snazzah/davey opusscript
npm install discord-audio-stream @discordjs/voice @discordjs/opus
```
`libsodium-wrappers` is optional. Install it only when your runtime does not support `aes-256-gcm`:
Use `opusscript@^0.0.8` only as a slower JavaScript fallback when `@discordjs/opus` cannot be installed.
```bash
node -e "console.log(require('node:crypto').getCiphers().includes('aes-256-gcm'))"
npm install libsodium-wrappers
```
For bundled ffmpeg support, install `ffmpeg-static` and use `ffmpeg.mode: 'static'`:
`ffmpeg` must be available either on the host PATH or through the optional `ffmpeg-static` package:
```bash
npm install ffmpeg-static
```
If ffmpeg is already available on the host PATH, use `ffmpeg.mode: 'native'`.
Use `ffmpeg.mode: 'native'` for PATH-based ffmpeg and `ffmpeg.mode: 'static'` for `ffmpeg-static`.
### AudioManager Example
## Basic Usage
```ts
import { AudioManager } from 'discord-audio-stream';
@@ -70,7 +52,6 @@ const manager = new AudioManager({
ffmpeg: {
mode: 'native',
},
renewIntervalMs: 5_400_000,
});
await manager.start();
@@ -89,9 +70,34 @@ await manager.start();
Relative file paths are resolved from `process.cwd()`. URL sources are validated before playback starts.
## ⚙️ API
For large bots, queue many simultaneous starts so the host does not spawn too many ffmpeg processes in the same tick.
### Constructor
## Lifecycle
```ts
manager.setConnection(connectionOptions);
manager.setSource(source);
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.
For scoped playback, `AudioManager` supports explicit resource management:
```ts
{
using manager = new AudioManager(options);
await manager.start();
} // disposed automatically
```
## API
```ts
type AudioManagerOptions = {
@@ -109,6 +115,7 @@ type AudioManagerOptions = {
source?: { type: 'url'; url: string } | { type: 'file'; path: string };
renewIntervalMs?: number | false;
connectTimeoutMs?: number;
onError?: (error: Error) => void;
volume?: {
enabled?: boolean;
initialPercent?: number;
@@ -116,21 +123,24 @@ type AudioManagerOptions = {
};
```
`connectTimeoutMs` and numeric `renewIntervalMs` values must be whole milliseconds from `1` through `2_147_483_647`.
`volume.initialPercent` must be a finite number from `0` through `100` and requires `volume.enabled: true`.
### Defaults
| Option | Default |
| ------------------ | ----------- |
| ------------------ | ---------- |
| `ffmpeg.mode` | `'native'` |
| `connectTimeoutMs` | `20_000` |
| `renewIntervalMs` | `5_400_000` |
| `renewIntervalMs` | `false` |
| `volume.enabled` | `false` |
### Methods
| Method | Description |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `setConnection(options)` | Replaces the voice connection target. |
| `setSource(source)` | Replaces the audio source. |
| `setConnection(options)` | Sets the target for the next `connect()` or `start()` call. |
| `setSource(source)` | Sets the source for the next `play()` or `start()` call. |
| `connect()` | Joins the configured Discord voice channel. |
| `play(source?)` | Starts playback on an existing connection. |
| `start()` | Connects and starts playback. |
@@ -139,6 +149,7 @@ type AudioManagerOptions = {
| `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. |
| `[Symbol.dispose]()` | Enables automatic cleanup with TypeScript's `using` declaration. |
### State
@@ -148,11 +159,9 @@ manager.isPlaying;
manager.isConnected;
```
## 🔊 Audio Options
## Audio Options
### Volume
Inline volume has a runtime cost in `@discordjs/voice`, so it is disabled by default.
Inline volume has runtime cost in `@discordjs/voice`, so it is disabled by default.
```ts
const manager = new AudioManager({
@@ -170,33 +179,16 @@ manager.setVolume(25);
Calling `setVolume()` without `volume.enabled: true` throws `AudioManagerStateError`.
### ffmpeg Modes
- `mode: 'native'` uses the `ffmpeg` executable from the host environment.
- `mode: 'static'` resolves the optional `ffmpeg-static` package.
- `executablePath` overrides both modes and is useful for Docker images or custom ffmpeg builds.
Default output is raw Discord-compatible PCM: `s16le`, `48000 Hz`, `2 channels`.
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`.
### Renewal
Connection renewal is disabled by default because `@discordjs/voice` handles recoverable disconnects. Set
`renewIntervalMs` only when an application has a measured need for periodic restarts. `stop()` and `dispose()` always
clear the renewal timer.
By default, the manager schedules a renewal after `5_400_000 ms` so long-running streams can reconnect periodically.
Set `renewIntervalMs: false` to disable this behavior:
```ts
const manager = new AudioManager({
connection,
source,
renewIntervalMs: false,
});
```
`stop()` and `dispose()` always clear the renewal timer.
## 🧯 Errors
## Errors
The package exports these error classes:
@@ -208,7 +200,12 @@ The package exports these error classes:
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
Use `onError` to observe asynchronous audio player and voice connection errors. The manager cleans up failed playback
and unrecoverable connections before invoking the callback.
`onError` does not handle rejected `connect()`, `play()`, or `start()` calls. Await and catch those calls explicitly.
## Development
```bash
npm ci
@@ -216,10 +213,10 @@ npm run check
npm run build
```
## 📋 Contributors
## 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)!
+1 -1
View File
@@ -14,7 +14,7 @@ If a vulnerability is fixed, the fix will generally be released only for the cur
## Reporting a Vulnerability
Do not open a public GitHub issue for security vulnerabilities.
Do not open a public Gitea issue for security vulnerabilities.
Report vulnerabilities privately by email to `fraujulian@lechner.top`.
+21 -2
View File
@@ -1,7 +1,9 @@
/** @type {import('eslint').Linter.FlatConfig[]} */
const prettierConfig = require('eslint-config-prettier/flat');
const config = [
{
ignores: ['dist/', 'node_modules/'],
ignores: ['coverage/', 'dist/', 'node_modules/'],
},
{
@@ -77,6 +79,23 @@ const config = [
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.eslint.json'],
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
},
},
{
files: ['**/*.{js,cjs}'],
languageOptions: {
@@ -88,4 +107,4 @@ const config = [
},
];
module.exports = config;
module.exports = [...config, prettierConfig];
-25
View File
@@ -1,25 +0,0 @@
/** @type {import('eslint').Linter.FlatConfig[]} */
const base = require('./eslint.config.cjs');
const typeAwareLayer = {
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: require('@typescript-eslint/parser'),
parserOptions: {
project: ['./tsconfig.eslint.json'],
tsconfigRootDir: __dirname,
},
},
plugins: {
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
},
rules: {
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': ['warn', { checksVoidReturn: false }],
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
},
};
module.exports = [...base, typeAwareLayer];
+1416 -440
View File
File diff suppressed because it is too large Load Diff
+31 -25
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "discord-audio-stream",
"version": "x.x.x",
"license": "LGPL-3.0-only",
"license": "AGPL-3.0-only",
"description": "A small Discord voice audio streaming library with managed ffmpeg playback.",
"exports": {
".": {
@@ -22,30 +22,27 @@
"files": [
"dist"
],
"directories": {
"lib": "src"
},
"sideEffects": false,
"scripts": {
"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 .",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit --declaration --isolatedDeclarations",
"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",
"check": "npm run version:check && npm run format:check && npm run lint && 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",
"build": "tsup",
"prepare": "husky"
},
"repository": {
"type": "git",
"url": "git+https://github.com/FrauJulian/Discord-Audio-Stream.git"
"url": "git+https://git.lechner-systems.at/FrauJulian/Discord-Audio-Stream.git"
},
"bugs": "https://github.com/FrauJulian/Discord-Audio-Stream/issues",
"bugs": "https://git.lechner-systems.at/FrauJulian/Discord-Audio-Stream/issues",
"funding": "https://ko-fi.com/FrauJulian",
"keywords": [
"discord",
@@ -70,43 +67,52 @@
]
},
"devDependencies": {
"@discordjs/opus": "^0.10.0",
"@jest/globals": "^30.4.1",
"@types/ejs": "^3.1.5",
"@types/node": "^25.9.1",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^9.39.4",
"@types/node": "^24.0.0",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.5",
"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": "^18.0.1",
"eslint-plugin-n": "^18.3.0",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-unused-imports": "^4.4.1",
"husky": "^9.1.7",
"jest": "^30.4.2",
"prettier": "^3.8.3",
"ts-jest": "^29.4.11",
"prettier": "^3.9.6",
"ts-jest": "^29.4.12",
"tsup": "^8.5.1",
"typescript": "^6.0.3"
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2"
},
"peerDependencies": {
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"ffmpeg-static": "^5.3.0",
"libsodium-wrappers": "^0.8.4",
"opusscript": "^0.0.8",
"prism-media": "^1.3.5"
"opusscript": "^0.0.8"
},
"peerDependenciesMeta": {
"@discordjs/opus": {
"optional": true
},
"ffmpeg-static": {
"optional": true
},
"libsodium-wrappers": {
"opusscript": {
"optional": true
}
},
"overrides": {
"@discordjs/node-pre-gyp": {
"tar": "^7.5.22"
},
"esbuild": "^0.28.2"
},
"engines": {
"node": ">=22.22.3"
"node": ">=24.17.0 <25"
},
"private": false,
"publishConfig": {
-11
View File
@@ -1,11 +0,0 @@
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);
+7 -10
View File
@@ -3,6 +3,8 @@ const fs = require('fs');
const lockFilePath = 'package-lock.json';
const packageFilePath = 'package.json';
const placeholderVersion = 'x.x.x';
const versionMajor = 1;
const versionMinor = 2;
const command = process.argv[2];
function readJson(path) {
@@ -24,7 +26,7 @@ function getLockFile() {
function writeVersions(version) {
const pkg = readJson(packageFilePath);
pkg.version = version;
writeJson(packageFilePath, pkg, 2);
writeJson(packageFilePath, pkg, 4);
const lock = getLockFile();
@@ -74,19 +76,14 @@ function fixPlaceholder() {
}
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 };
const patch = process.env.VERSION_PATCH;
for (const [name, value] of Object.entries(segments)) {
if (!/^\d+$/.test(value ?? '')) {
console.error(`Missing or invalid ${name} version segment: "${value ?? ''}"`);
if (!/^\d+$/.test(patch ?? '')) {
console.error(`Missing or invalid patch version segment: "${patch ?? ''}"`);
process.exit(1);
}
}
return `${major}.${minor}.${patch}`;
return `${versionMajor}.${versionMinor}.${patch}`;
}
if (command === 'check-placeholder') {
+186 -18
View File
@@ -4,6 +4,7 @@ import {
createAudioResource,
entersState,
joinVoiceChannel,
AudioPlayerStatus,
NoSubscriberBehavior,
StreamType,
VoiceConnectionStatus,
@@ -21,14 +22,30 @@ import type {
} from './types';
const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
const DEFAULT_RENEW_INTERVAL_MS = 5_400_000;
const DISCONNECT_RECOVERY_TIMEOUT_MS = 5_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
export default class AudioManager {
function assertValidTimerDelay(value: number, optionName: string): void {
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
throw new AudioManagerConfigError(
`${optionName} must be an integer between 1 and ${MAX_TIMER_DELAY_MS} milliseconds.`,
);
}
}
function assertValidVolumePercent(value: number): void {
if (!Number.isFinite(value) || value < 0 || value > 100) {
throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.');
}
}
export default class AudioManager implements Disposable {
private readonly audioPlayer: AudioPlayer;
private connection: VoiceConnection | undefined;
private resource: AudioResource | undefined;
private ffmpeg: FfmpegProcessHandle | undefined;
private connectAttempt: AbortController | undefined;
private renewTimer: NodeJS.Timeout | undefined;
private playbackState: PlaybackState = 'idle';
private connectionOptions: VoiceConnectionOptions | undefined;
@@ -37,9 +54,23 @@ export default class AudioManager {
Omit<AudioManagerOptions, 'connectTimeoutMs'>;
public constructor(options: AudioManagerOptions = {}) {
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
assertValidTimerDelay(connectTimeoutMs, 'connectTimeoutMs');
if (typeof options.renewIntervalMs === 'number') {
assertValidTimerDelay(options.renewIntervalMs, 'renewIntervalMs');
}
if (options.volume?.initialPercent !== undefined) {
if (options.volume.enabled !== true) {
throw new AudioManagerConfigError('volume.initialPercent requires volume.enabled to be true.');
}
assertValidVolumePercent(options.volume.initialPercent);
}
this.options = {
...options,
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
connectTimeoutMs,
};
this.connectionOptions = options.connection;
this.audioSource = options.source;
@@ -48,6 +79,15 @@ export default class AudioManager {
noSubscriber: NoSubscriberBehavior.Play,
},
});
this.audioPlayer.on('error', (error) => {
this.finishPlayback(error.resource);
this.reportError(error);
});
this.audioPlayer.on(AudioPlayerStatus.Idle, (oldState) => {
if ('resource' in oldState) {
this.finishPlayback(oldState.resource);
}
});
}
public get state(): PlaybackState {
@@ -59,7 +99,7 @@ export default class AudioManager {
}
public get isConnected(): boolean {
return Boolean(this.connection);
return this.connection?.state.status === VoiceConnectionStatus.Ready;
}
public setConnection(options: VoiceConnectionOptions): void {
@@ -79,17 +119,55 @@ export default class AudioManager {
throw new AudioManagerConfigError('Voice connection options are required before connecting.');
}
this.cancelConnectAttempt();
const attempt = new AbortController();
this.connectAttempt = attempt;
this.clearRenewTimer();
this.playbackState = 'connecting';
this.connection?.destroy();
this.connection = joinVoiceChannel({
const previousConnection = this.connection;
this.connection = undefined;
let connection: VoiceConnection | undefined;
try {
previousConnection?.destroy();
connection = joinVoiceChannel({
guildId: this.connectionOptions.guildId,
channelId: this.connectionOptions.channelId,
adapterCreator: this.connectionOptions.adapterCreator,
});
this.connection.subscribe(this.audioPlayer);
this.connection = connection;
this.observeConnection(connection);
connection.subscribe(this.audioPlayer);
await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs);
await entersState(
connection,
VoiceConnectionStatus.Ready,
AbortSignal.any([attempt.signal, AbortSignal.timeout(this.options.connectTimeoutMs)]),
);
} catch (error) {
if (this.connectAttempt !== attempt) {
throw new AudioManagerStateError('Voice connection was stopped before it became ready.', {
cause: error,
});
}
this.connectAttempt = undefined;
if (connection && connection.state.status !== VoiceConnectionStatus.Destroyed) {
connection.destroy();
}
if (this.connection === connection) {
this.connection = undefined;
}
this.playbackState = 'stopped';
throw error;
}
if (this.connectAttempt !== attempt || this.connection !== connection) {
throw new AudioManagerStateError('Voice connection was stopped before it became ready.');
}
this.connectAttempt = undefined;
this.playbackState = 'ready';
this.scheduleRenewal();
}
@@ -101,24 +179,47 @@ export default class AudioManager {
this.setSource(source);
}
if (!this.connection) {
if (!this.isConnected) {
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);
this.resource = createAudioResource(this.ffmpeg.process.stdout, {
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) {
throw error instanceof AudioManagerStateError
? error
: new AudioManagerStateError('Playback was stopped before ffmpeg became ready.', {
cause: error,
});
}
this.stopCurrentPlayback();
throw error;
}
try {
if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) {
this.setVolume(this.options.volume.initialPercent);
}
this.audioPlayer.play(this.resource);
this.playbackState = 'playing';
} catch (error) {
this.stopCurrentPlayback();
this.playbackState = 'ready';
throw error;
}
}
public async start(): Promise<void> {
@@ -153,6 +254,7 @@ export default class AudioManager {
return;
}
this.cancelConnectAttempt();
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
@@ -169,9 +271,7 @@ export default class AudioManager {
throw new AudioManagerStateError('Volume control requires volume.enabled to be true.');
}
if (volumeInPercent < 0 || volumeInPercent > 100) {
throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.');
}
assertValidVolumePercent(volumeInPercent);
if (!this.resource?.volume) {
throw new AudioManagerStateError('No audio resource with volume control is currently active.');
@@ -185,6 +285,7 @@ export default class AudioManager {
return;
}
this.cancelConnectAttempt();
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
@@ -195,6 +296,10 @@ export default class AudioManager {
this.playbackState = 'disposed';
}
public [Symbol.dispose](): void {
this.dispose();
}
private resolveSource(): ResolvedAudioSource {
if (!this.audioSource) {
throw new AudioManagerConfigError('Audio source is required before playback can start.');
@@ -207,7 +312,7 @@ export default class AudioManager {
source: this.audioSource,
};
} catch (error) {
throw new AudioManagerConfigError(`Invalid audio source URL. Cause: ${String(error)}`);
throw new AudioManagerConfigError('Invalid audio source URL.', { cause: error });
}
}
@@ -220,14 +325,27 @@ export default class AudioManager {
}
private scheduleRenewal(): void {
const renewIntervalMs = this.options.renewIntervalMs ?? DEFAULT_RENEW_INTERVAL_MS;
const renewIntervalMs = this.options.renewIntervalMs;
if (renewIntervalMs === false) {
if (renewIntervalMs === undefined || renewIntervalMs === false) {
return;
}
this.renewTimer = setTimeout(() => {
void this.start();
void this.start().catch((error: unknown) => {
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';
this.reportError(error);
});
}, renewIntervalMs);
if (typeof this.renewTimer.unref === 'function') {
@@ -235,6 +353,51 @@ export default class AudioManager {
}
}
private observeConnection(connection: VoiceConnection): void {
connection.on('error', (error) => {
this.reportError(error);
});
connection.on(VoiceConnectionStatus.Disconnected, () => this.handleDisconnectedConnection(connection));
}
private async handleDisconnectedConnection(connection: VoiceConnection): Promise<void> {
try {
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, DISCONNECT_RECOVERY_TIMEOUT_MS),
entersState(connection, VoiceConnectionStatus.Connecting, DISCONNECT_RECOVERY_TIMEOUT_MS),
]);
} catch (error) {
if (this.connection !== connection) {
return;
}
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection = undefined;
if (connection.state.status !== VoiceConnectionStatus.Destroyed) {
connection.destroy();
}
this.playbackState = 'stopped';
this.reportError(error);
}
}
private finishPlayback(resource: AudioResource): void {
if (this.resource !== resource) {
return;
}
this.stopCurrentPlayback();
if (this.playbackState !== 'disposed') {
this.playbackState = this.isConnected ? 'ready' : 'stopped';
}
}
private reportError(error: unknown): void {
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
}
private clearRenewTimer(): void {
if (this.renewTimer) {
clearTimeout(this.renewTimer);
@@ -242,6 +405,11 @@ export default class AudioManager {
}
}
private cancelConnectAttempt(): void {
this.connectAttempt?.abort();
this.connectAttempt = undefined;
}
private stopCurrentPlayback(): void {
this.resource?.playStream.destroy();
this.resource = undefined;
+4 -7
View File
@@ -1,6 +1,6 @@
export class AudioManagerError extends Error {
public constructor(message: string) {
super(message);
public constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
}
}
@@ -10,10 +10,7 @@ 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);
public constructor(message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause });
}
}
+95 -9
View File
@@ -3,7 +3,7 @@ import type { ChildProcessByStdio } from 'node:child_process';
import { createRequire } from 'node:module';
import type { Readable } from 'node:stream';
import { AudioManagerConfigError } from './errors';
import { AudioManagerConfigError, FfmpegProcessError } from './errors';
import type { FfmpegOptions } from './types';
const requireFromCurrentModule = createRequire(__filename);
@@ -11,9 +11,11 @@ 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>;
readonly process: ChildProcessByStdio<null, Readable, Readable>;
readonly ready: Promise<void>;
stop(): void;
};
@@ -34,7 +36,8 @@ export function resolveFfmpegExecutable(options: FfmpegOptions = {}): string {
}
} catch (error) {
throw new AudioManagerConfigError(
`Unable to resolve ffmpeg-static. Install it or pass ffmpeg.executablePath. Cause: ${String(error)}`,
'Unable to resolve ffmpeg-static. Install it or pass ffmpeg.executablePath.',
{ cause: error },
);
}
@@ -50,34 +53,117 @@ export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegP
...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS),
];
const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const abortController = new AbortController();
const ready = waitForFfmpegOutput(childProcess, abortController.signal);
childProcess.once('error', () => undefined);
childProcess.stderr.resume();
return {
process: childProcess,
ready,
stop: (): void => {
stopProcess(childProcess);
stopProcess(childProcess, abortController);
},
};
}
function stopProcess(childProcess: ChildProcessByStdio<null, Readable, Readable>): void {
function waitForFfmpegOutput(
childProcess: ChildProcessByStdio<null, Readable, Readable>,
signal: AbortSignal,
): Promise<void> {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let stderrTail = '';
let settled = false;
const appendStderr = (chunk: Buffer | string): void => {
stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES);
};
const cleanup = (): void => {
childProcess.off('exit', onExit);
childProcess.stdout.off('readable', onReadable);
childProcess.stderr.off('data', appendStderr);
signal.removeEventListener('abort', onAbort);
};
const settle = (complete: () => void): void => {
if (settled) {
return;
}
settled = true;
cleanup();
complete();
};
const fail = (message: string, cause?: unknown): void => {
settle(() => reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause)));
};
const onError = (error: Error): void => {
if (!settled) {
fail(`Unable to start ffmpeg. Cause: ${error.message}`, error);
}
};
const onExit = (code: number | null, exitSignal: NodeJS.Signals | null): void => {
fail(`ffmpeg exited before producing audio. Exit code: ${code ?? 'none'}, signal: ${exitSignal ?? 'none'}.`);
};
const onReadable = (): void => {
settle(() => resolve());
};
const onAbort = (): void => {
const reason: unknown = signal.reason;
settle(() =>
reject(
reason instanceof Error
? reason
: new FfmpegProcessError('ffmpeg was stopped before producing audio.', reason),
),
);
};
childProcess.stderr.on('data', appendStderr);
childProcess.on('error', onError);
childProcess.once('exit', onExit);
childProcess.stdout.once('readable', onReadable);
signal.addEventListener('abort', onAbort, { once: true });
return promise;
}
function addStderrTail(message: string, stderrTail: string): string {
const trimmedTail = stderrTail.trim();
return trimmedTail ? `${message} stderr: ${trimmedTail}` : message;
}
function stopProcess(
childProcess: ChildProcessByStdio<null, Readable, Readable>,
abortController: AbortController,
): void {
if (abortController.signal.aborted) {
return;
}
abortController.abort(new FfmpegProcessError('ffmpeg was stopped before producing audio.'));
childProcess.stdout.destroy();
childProcess.stderr.destroy();
childProcess.removeAllListeners();
if (childProcess.killed || childProcess.exitCode !== null || childProcess.signalCode !== null) {
if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
return;
}
childProcess.kill('SIGTERM');
const forceKillTimeout = setTimeout(() => {
if (!childProcess.killed && childProcess.exitCode === null && childProcess.signalCode === null) {
if (childProcess.exitCode === null && childProcess.signalCode === null) {
childProcess.kill('SIGKILL');
}
}, FORCE_KILL_TIMEOUT_MS);
childProcess.once('close', () => clearTimeout(forceKillTimeout));
forceKillTimeout.unref();
}
+10 -3
View File
@@ -112,7 +112,7 @@ export type VolumeOptions = {
/**
* Initial volume percentage applied when playback starts.
*
* Requires `enabled: true`.
* Must be a finite number from `0` through `100` and requires `enabled: true`.
*/
initialPercent?: number;
};
@@ -143,19 +143,26 @@ export type AudioManagerOptions = {
/**
* Milliseconds after which the manager reconnects and restarts playback.
*
* Set to `false` to disable renewal.
* Renewal is disabled unless an interval is provided.
* Numeric values must be integers from `1` through `2_147_483_647`.
*
* @defaultValue `5_400_000`
* @defaultValue `false`
*/
renewIntervalMs?: number | false;
/**
* Maximum milliseconds to wait for the Discord voice connection to become ready.
* Must be an integer from `1` through `2_147_483_647`.
*
* @defaultValue `20_000`
*/
connectTimeoutMs?: number;
/**
* Receives asynchronous audio player and voice connection errors.
*/
onError?: (error: Error) => void;
/**
* Optional inline volume configuration.
*/
+487 -5
View File
@@ -1,23 +1,59 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import {
AudioPlayerStatus,
createAudioPlayer,
createAudioResource,
entersState,
joinVoiceChannel,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { resolve } from 'node:path';
import { PassThrough } from 'node:stream';
import AudioManager from '../src/audio-manager';
import { AudioManagerConfigError, AudioManagerStateError } from '../src';
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';
type MockListener = (...args: unknown[]) => unknown;
const audioPlayerListeners = new Map<string, MockListener>();
const connectionListeners = new Map<string, MockListener>();
const secondConnectionListeners = new Map<string, MockListener>();
const mockAudioPlayer = {
on: jest.fn((event: string, listener: MockListener) => {
audioPlayerListeners.set(event, listener);
return mockAudioPlayer;
}),
play: jest.fn(),
pause: jest.fn(),
unpause: jest.fn(),
stop: jest.fn(),
};
const mockConnection = {
state: { status: 'ready' },
on: jest.fn((event: string, listener: MockListener) => {
connectionListeners.set(event, listener);
return mockConnection;
}),
subscribe: jest.fn(),
disconnect: jest.fn(),
destroy: jest.fn(),
};
const mockSecondConnection = {
state: { status: 'ready' },
on: jest.fn((event: string, listener: MockListener) => {
secondConnectionListeners.set(event, listener);
return 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(),
@@ -26,14 +62,18 @@ const mockAudioResource = {
setVolume: jest.fn(),
},
};
const mockFfmpegHandle = {
const mockFfmpegHandle: FfmpegProcessHandle = {
process: {
stdout: new PassThrough(),
},
} as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(),
};
jest.mock('@discordjs/voice', () => ({
AudioPlayerStatus: {
Idle: 'idle',
},
NoSubscriberBehavior: {
Play: 'play',
},
@@ -41,12 +81,16 @@ jest.mock('@discordjs/voice', () => ({
Raw: 'raw',
},
VoiceConnectionStatus: {
Connecting: 'connecting',
Destroyed: 'destroyed',
Disconnected: 'disconnected',
Ready: 'ready',
Signalling: 'signalling',
},
createAudioPlayer: jest.fn(() => mockAudioPlayer),
createAudioResource: jest.fn(() => mockAudioResource),
entersState: jest.fn(() => Promise.resolve(mockConnection)),
joinVoiceChannel: jest.fn(() => mockConnection),
entersState: jest.fn(() => Promise.resolve(mockVoiceConnection)),
joinVoiceChannel: jest.fn(() => mockVoiceConnection),
}));
jest.mock('../src/ffmpeg', () => ({
@@ -66,10 +110,36 @@ const liveStreamSource: AudioSource = {
const fileSourcePath = 'tests/audio.mp3';
const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath);
type MockedEntersStateReturn = ReturnType<typeof entersState>;
function createMockFfmpegHandle(): FfmpegProcessHandle {
return {
process: {
stdout: new PassThrough(),
} as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(),
};
}
function listenerFor(listeners: Map<string, MockListener>, event: string): MockListener {
const listener = listeners.get(event);
if (!listener) {
throw new Error(`No listener registered for ${event}.`);
}
return listener;
}
describe('AudioManager', () => {
beforeEach(() => {
jest.clearAllMocks();
audioPlayerListeners.clear();
connectionListeners.clear();
secondConnectionListeners.clear();
mockConnection.state.status = VoiceConnectionStatus.Ready;
mockSecondConnection.state.status = VoiceConnectionStatus.Ready;
});
afterEach(() => {
@@ -99,6 +169,170 @@ describe('AudioManager', () => {
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('does not schedule connection renewal by default', async () => {
jest.useFakeTimers();
const manager = new AudioManager({ connection: connectionOptions });
await manager.connect();
expect(jest.getTimerCount()).toBe(0);
manager.dispose();
});
it('reports voice connection errors without interrupting playback', async () => {
const connectionError = new Error('voice connection failed');
const onError = jest.fn();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
onError,
});
await manager.start();
listenerFor(connectionListeners, 'error')(connectionError);
expect(onError).toHaveBeenCalledWith(connectionError);
expect(manager.state).toBe('playing');
});
it('keeps playback while a disconnected voice connection recovers', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
await listenerFor(connectionListeners, VoiceConnectionStatus.Disconnected)();
expect(entersState).toHaveBeenCalledWith(mockVoiceConnection, VoiceConnectionStatus.Signalling, 5_000);
expect(entersState).toHaveBeenCalledWith(mockVoiceConnection, VoiceConnectionStatus.Connecting, 5_000);
expect(mockConnection.destroy).not.toHaveBeenCalled();
expect(manager.state).toBe('playing');
});
it('stops playback after an unrecoverable voice disconnect', async () => {
const disconnectError = new Error('voice connection did not recover');
const onError = jest.fn();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
onError,
});
await manager.start();
jest.mocked(entersState).mockRejectedValueOnce(disconnectError).mockRejectedValueOnce(disconnectError);
mockConnection.state.status = VoiceConnectionStatus.Disconnected;
await listenerFor(connectionListeners, VoiceConnectionStatus.Disconnected)();
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.stop).toHaveBeenCalledWith(true);
expect(mockConnection.destroy).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(disconnectError);
expect(manager.state).toBe('stopped');
expect(manager.isConnected).toBe(false);
});
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('restores the stopped state when voice setup throws synchronously', async () => {
const connectionError = new Error('voice setup failed');
jest.mocked(joinVoiceChannel).mockImplementationOnce(() => {
throw connectionError;
});
const manager = new AudioManager({ connection: connectionOptions, renewIntervalMs: false });
await expect(manager.connect()).rejects.toBe(connectionError);
expect(manager.state).toBe('stopped');
expect(manager.isConnected).toBe(false);
});
it('does not become ready when stopped during connection startup', async () => {
jest.useFakeTimers();
const ready = Promise.withResolvers<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 a stale connection attempt rejects', async () => {
const firstReady = Promise.withResolvers<VoiceConnection>();
const secondReady = Promise.withResolvers<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.reject(new Error('stale connection failed'));
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 = Promise.withResolvers<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.reject(new Error('disposed connection failed'));
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,
@@ -144,6 +378,174 @@ describe('AudioManager', () => {
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('cleans up and reports asynchronous audio player errors', async () => {
const playerError = Object.assign(new Error('audio stream failed'), { resource: mockAudioResource });
const onError = jest.fn();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
onError,
});
await manager.start();
listenerFor(audioPlayerListeners, 'error')(playerError);
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(playerError);
expect(manager.state).toBe('ready');
expect(manager.isPlaying).toBe(false);
});
it('returns to ready when the current audio resource becomes idle', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
listenerFor(audioPlayerListeners, AudioPlayerStatus.Idle)({ resource: mockAudioResource });
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
expect(manager.state).toBe('ready');
expect(manager.isPlaying).toBe(false);
});
it('does not report playback when stopped before ffmpeg is ready', async () => {
const ready = Promise.withResolvers<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 = Promise.withResolvers<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 = Promise.withResolvers<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,
@@ -213,6 +615,36 @@ describe('AudioManager', () => {
expect(mockAudioResource.volume.setVolume).toHaveBeenCalledWith(0.35);
});
it('rejects invalid initial volume before allocating audio resources', () => {
expect(
() =>
new AudioManager({
volume: {
enabled: true,
initialPercent: 101,
},
}),
).toThrow(AudioManagerConfigError);
expect(
() =>
new AudioManager({
volume: {
enabled: false,
initialPercent: 50,
},
}),
).toThrow(AudioManagerConfigError);
expect(createAudioPlayer).not.toHaveBeenCalled();
expect(createAudioResource).not.toHaveBeenCalled();
expect(startFfmpeg).not.toHaveBeenCalled();
});
it('rejects timer delays that Node.js cannot schedule safely', () => {
expect(() => new AudioManager({ connectTimeoutMs: 0 })).toThrow(AudioManagerConfigError);
expect(() => new AudioManager({ renewIntervalMs: 2_147_483_648 })).toThrow(AudioManagerConfigError);
});
it('rejects volume changes when inline volume is disabled', () => {
const manager = new AudioManager({ renewIntervalMs: false });
@@ -233,6 +665,12 @@ describe('AudioManager', () => {
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', () => {
@@ -281,6 +719,20 @@ describe('AudioManager', () => {
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();
@@ -298,6 +750,27 @@ describe('AudioManager', () => {
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();
@@ -328,6 +801,15 @@ describe('AudioManager', () => {
expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError);
});
it('supports explicit resource management', () => {
const manager = (() => {
using disposableManager = new AudioManager({ renewIntervalMs: false });
return disposableManager;
})();
expect(manager.state).toBe('disposed');
});
it('prevents use after disposal', async () => {
const manager = new AudioManager({
connection: connectionOptions,
+117 -10
View File
@@ -1,6 +1,8 @@
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', () => ({
@@ -12,13 +14,18 @@ const mockSpawn = jest.mocked(spawn);
type MockChildProcess = {
stdout: {
destroy: jest.Mock;
once: jest.Mock;
off: jest.Mock;
};
stderr: {
destroy: jest.Mock;
on: jest.Mock;
off: jest.Mock;
resume: jest.Mock;
};
on: jest.Mock;
once: jest.Mock;
removeAllListeners: jest.Mock;
off: jest.Mock;
kill: jest.Mock;
killed: boolean;
exitCode: number | null;
@@ -29,13 +36,18 @@ function createMockChildProcess(): MockChildProcess {
return {
stdout: {
destroy: jest.fn(),
once: jest.fn(),
off: jest.fn(),
},
stderr: {
destroy: jest.fn(),
on: jest.fn(),
off: jest.fn(),
resume: jest.fn(),
},
on: jest.fn(),
once: jest.fn(),
removeAllListeners: jest.fn(),
off: jest.fn(),
kill: jest.fn(),
killed: false,
exitCode: null,
@@ -43,6 +55,30 @@ function createMockChildProcess(): MockChildProcess {
};
}
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 getPersistentProcessHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.on.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();
@@ -63,7 +99,7 @@ describe('ffmpeg helpers', () => {
it('spawns ffmpeg with the default argument set', () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
startFfmpeg('https://synradiode.stream.laut.fm/synradiode');
@@ -87,13 +123,77 @@ describe('ffmpeg helpers', () => {
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function));
expect(childProcess.on).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 = getPersistentProcessHandler(childProcess, 'error');
const spawnError = new Error('spawn ENOENT');
errorHandler(spawnError);
await expect(handle.ready).rejects.toMatchObject({
cause: spawnError,
message: expect.stringContaining('spawn ENOENT'),
name: FfmpegProcessError.name,
});
});
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');
const errorHandler = getPersistentProcessHandler(childProcess, 'error');
readableHandler();
errorHandler(new Error('late process error'));
exitHandler(1, null);
await expect(handle.ready).resolves.toBeUndefined();
});
it('uses custom executable and argument overrides when provided', () => {
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
startFfmpeg('tests/audio.mp3', {
executablePath: '/custom/ffmpeg',
@@ -108,17 +208,22 @@ describe('ffmpeg helpers', () => {
);
});
it('stops a running child process and schedules a force kill fallback', () => {
it('stops a running child process and schedules a force kill fallback', async () => {
jest.useFakeTimers();
const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess);
childProcess.kill.mockImplementation(() => {
childProcess.killed = true;
return true;
});
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const readyExpectation = expect(handle.ready).rejects.toThrow('stopped before producing audio');
handle.stop();
await readyExpectation;
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);
@@ -126,14 +231,16 @@ describe('ffmpeg helpers', () => {
expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
});
it('does not signal a process that already exited', () => {
it('does not signal a process that already exited', async () => {
const childProcess = createMockChildProcess();
childProcess.exitCode = 0;
mockSpawn.mockReturnValue(childProcess);
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const readyExpectation = expect(handle.ready).rejects.toThrow('stopped before producing audio');
handle.stop();
await readyExpectation;
expect(childProcess.kill).not.toHaveBeenCalled();
});
});
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
+3
View File
@@ -1,4 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"skipLibCheck": true
},
"include": ["src/**/*.ts", "tests/**/*.ts", "tsup.config.ts"]
}
+5 -14
View File
@@ -1,31 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "node16",
"resolveJsonModule": true,
"target": "ES2024",
"lib": ["ES2024"],
"module": "Node20",
"types": [],
"rootDir": "./",
"outDir": "./dist",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"alwaysStrict": true,
"isolatedModules": true,
"erasableSyntaxOnly": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"skipLibCheck": true,
"noEmitOnError": true
},
"include": ["src"],
+2 -2
View File
@@ -1,8 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"outDir": "./dist-test"
"outDir": "./dist-test",
"skipLibCheck": true
},
"include": ["src/**/*.ts", "tests/**/*.ts", "tsup.config.ts"]
}
+6 -1
View File
@@ -3,7 +3,12 @@ import { defineConfig } from 'tsup';
export default defineConfig({
format: ['cjs', 'esm'],
entry: ['./src/index.ts'],
dts: false,
dts: {
compilerOptions: {
// tsup's declaration bundler still sets the removed baseUrl option internally.
ignoreDeprecations: '6.0',
},
},
shims: true,
skipNodeModulesBundle: true,
clean: true,