25 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
20 changed files with 1067 additions and 507 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
@@ -3,9 +3,6 @@ name: Release and Deploy Build
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
env: env:
VERSION_MAJOR: '1' VERSION_MAJOR: '1'
VERSION_MINOR: '1' VERSION_MINOR: '1'
@@ -16,17 +13,16 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: https://gitea.com/actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
- name: Use NodeJS v22.22.3 - name: Use NodeJS v22.22.3
uses: actions/setup-node@v6 uses: https://gitea.com/actions/setup-node@v4
with: with:
node-version: '22.22.3' node-version: '22.22.3'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
@@ -39,7 +35,7 @@ jobs:
id: release_version id: release_version
run: | run: |
package_version="$(node -p "require('./package.json').version")" package_version="$(node -p "require('./package.json').version")"
echo "tag=${package_version}" >> "$GITHUB_OUTPUT" echo "tag=${package_version}" >> "$GITEA_OUTPUT"
- name: Run Build - name: Run Build
run: npm run build run: npm run build
@@ -49,24 +45,23 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub release - name: Create Gitea Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 }} RELEASE_TAG: v${{ steps.release_version.outputs.tag }}
run: | run: |
git fetch --force --tags 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)" previous_tag="$(git describe --tags --abbrev=0 "$GITEA_SHA^" 2>/dev/null || true)"
if [ -n "$previous_tag" ]; then 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}..${GITEA_SHA}")"
commit_messages="$(git log --reverse --format='- %s' "${previous_tag}..${GITHUB_SHA}")" full_changelog="${GITEA_SERVER_URL}/${GITEA_REPOSITORY}/compare/${previous_tag}...${RELEASE_TAG}"
else
commit_messages="$(git log --reverse --format='- %s' "${GITHUB_SHA}")"
fi
full_changelog="https://github.com/${{ github.repository }}/compare/${previous_tag}...${RELEASE_TAG}"
else else
commit_messages="$(git log --reverse --format='- %s' "${GITHUB_SHA}")" commit_messages="$(git log --reverse --format='- %s' "${GITEA_SHA}")"
full_changelog="" full_changelog=""
fi fi
@@ -84,8 +79,14 @@ jobs:
fi fi
} > release-notes.md } > release-notes.md
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then 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") }))'
gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file release-notes.md
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 else
gh release create "$RELEASE_TAG" --target "${GITHUB_SHA}" --title "$RELEASE_TAG" --notes-file release-notes.md curl --fail-with-body --header "$auth_header" --header 'Content-Type: application/json' --request POST --data-binary @release.json "$api_url"
fi fi
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: https://gitea.com/actions/checkout@v4
- name: use NodeJS v22.22.3 - name: use NodeJS v22.22.3
uses: actions/setup-node@v4 uses: https://gitea.com/actions/setup-node@v4
with: with:
node-version: '22.22.3' node-version: '22.22.3'
-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'
+45 -63
View File
@@ -4,39 +4,34 @@
![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) ![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) ![GitHub Repo stars](https://img.shields.io/github/stars/FrauJulian/discord-audio-stream?style=social)
> **Designed for 24/7 Discord audio playback.** `discord-audio-stream` is a small TypeScript library for managed Discord voice playback through
> `discord-audio-stream` is a small TypeScript library for managed Discord voice playback through `@discordjs/voice` and ffmpeg.
> `@discordjs/voice` and ffmpeg.
Discord voice streams need careful lifecycle handling. This package manages the voice connection, ffmpeg process, It manages the Discord voice connection, ffmpeg process, raw PCM audio resource, optional renewal, and cleanup. Keep one
raw PCM resource creation, optional reconnect renewal, and predictable cleanup so your bot code stays small and readable. `AudioManager` per guild, usually in a `Map<string, AudioManager>`, and call `dispose()` when that manager will not be
reused.
> **Recommended best practice:** ## Support
> 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.
> **For large bots:** Create an [issue](https://git.lechner-systems.at/fraujulian/Discord-Audio-Stream/issues) on GitHub or contact
> 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
[`fraujulian`](https://discord.com/users/860206216893693973) on Discord. [`fraujulian`](https://discord.com/users/860206216893693973) on Discord.
## 📝 Usage ## Installation
### Installation Node.js `22.22.3` or newer is required.
**Node.js `22.22.3` or newer is required.**
Install the library and the required voice packages:
```bash ```bash
npm install discord-audio-stream @discordjs/voice prism-media @snazzah/davey opusscript npm install discord-audio-stream @discordjs/voice prism-media @snazzah/davey opusscript
``` ```
`ffmpeg` must be available either on the host PATH or through the optional `ffmpeg-static` package:
```bash
npm install ffmpeg-static
```
Use `ffmpeg.mode: 'native'` for PATH-based ffmpeg and `ffmpeg.mode: 'static'` for `ffmpeg-static`.
`libsodium-wrappers` is optional. Install it only when your runtime does not support `aes-256-gcm`: `libsodium-wrappers` is optional. Install it only when your runtime does not support `aes-256-gcm`:
```bash ```bash
@@ -44,15 +39,7 @@ node -e "console.log(require('node:crypto').getCiphers().includes('aes-256-gcm')
npm install libsodium-wrappers npm install libsodium-wrappers
``` ```
For bundled ffmpeg support, install `ffmpeg-static` and use `ffmpeg.mode: 'static'`: ## Basic Usage
```bash
npm install ffmpeg-static
```
If ffmpeg is already available on the host PATH, use `ffmpeg.mode: 'native'`.
### AudioManager Example
```ts ```ts
import { AudioManager } from 'discord-audio-stream'; import { AudioManager } from 'discord-audio-stream';
@@ -70,7 +57,6 @@ const manager = new AudioManager({
ffmpeg: { ffmpeg: {
mode: 'native', mode: 'native',
}, },
renewIntervalMs: 5_400_000,
}); });
await manager.start(); await manager.start();
@@ -89,9 +75,25 @@ await manager.start();
Relative file paths are resolved from `process.cwd()`. URL sources are validated before playback starts. 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.
## API
```ts ```ts
type AudioManagerOptions = { type AudioManagerOptions = {
@@ -148,11 +150,9 @@ manager.isPlaying;
manager.isConnected; manager.isConnected;
``` ```
## 🔊 Audio Options ## Audio Options
### Volume Inline volume has runtime cost in `@discordjs/voice`, so it is disabled by default.
Inline volume has a runtime cost in `@discordjs/voice`, so it is disabled by default.
```ts ```ts
const manager = new AudioManager({ const manager = new AudioManager({
@@ -170,33 +170,15 @@ manager.setVolume(25);
Calling `setVolume()` without `volume.enabled: true` throws `AudioManagerStateError`. Calling `setVolume()` without `volume.enabled: true` throws `AudioManagerStateError`.
### ffmpeg Modes Default ffmpeg output is raw Discord-compatible PCM: `s16le`, `48000 Hz`, `2 channels`.
- `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`.
You can override ffmpeg arguments through `ffmpeg.inputArgs` and `ffmpeg.outputArgs`. When you override them, you are 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`. responsible for keeping the output compatible with `StreamType.Raw`.
### Renewal
By default, the manager schedules a renewal after `5_400_000 ms` so long-running streams can reconnect periodically. 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: Set `renewIntervalMs: false` to disable it. `stop()` and `dispose()` always clear the renewal timer.
```ts ## Errors
const manager = new AudioManager({
connection,
source,
renewIntervalMs: false,
});
```
`stop()` and `dispose()` always clear the renewal timer.
## 🧯 Errors
The package exports these error classes: The package exports these error classes:
@@ -208,7 +190,7 @@ The package exports these error classes:
Configuration problems, such as a missing source or invalid URL, throw `AudioManagerConfigError`. Invalid lifecycle 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`. operations, such as calling `pause()` while nothing is playing, throw `AudioManagerStateError`.
## 🧑‍💻 Development ## Development
```bash ```bash
npm ci npm ci
@@ -216,10 +198,10 @@ npm run check
npm run build npm run build
``` ```
## 📋 Contributors ## Contributors
~ [**FrauJulian - Julian Lechner**](https://fraujulian.xyz/) - CODEOWNER ~ [**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)!
+395 -255
View File
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -43,9 +43,9 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/FrauJulian/Discord-Audio-Stream.git" "url": "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", "funding": "https://ko-fi.com/FrauJulian",
"keywords": [ "keywords": [
"discord", "discord",
@@ -72,26 +72,26 @@
"devDependencies": { "devDependencies": {
"@jest/globals": "^30.4.1", "@jest/globals": "^30.4.1",
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
"@types/node": "^25.9.1", "@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/eslint-plugin": "^8.61.1",
"@typescript-eslint/parser": "^8.60.0", "@typescript-eslint/parser": "^8.61.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "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-import": "^2.32.0",
"eslint-plugin-n": "^18.0.1", "eslint-plugin-n": "^18.1.0",
"eslint-plugin-promise": "^7.3.0", "eslint-plugin-promise": "^7.3.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^30.4.2", "jest": "^30.4.2",
"prettier": "^3.8.3", "prettier": "^3.8.4",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"peerDependencies": { "peerDependencies": {
"@discordjs/voice": "^0.19.2", "@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11", "@snazzah/davey": "^0.1.12",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"libsodium-wrappers": "^0.8.4", "libsodium-wrappers": "^0.8.4",
"opusscript": "^0.0.8", "opusscript": "^0.0.8",
@@ -105,6 +105,9 @@
"optional": true "optional": true
} }
}, },
"overrides": {
"esbuild": "^0.28.1"
},
"engines": { "engines": {
"node": ">=22.22.3" "node": ">=22.22.3"
}, },
+50 -11
View File
@@ -82,14 +82,27 @@ export default class AudioManager {
this.clearRenewTimer(); this.clearRenewTimer();
this.playbackState = 'connecting'; this.playbackState = 'connecting';
this.connection?.destroy(); this.connection?.destroy();
this.connection = joinVoiceChannel({ const connection = joinVoiceChannel({
guildId: this.connectionOptions.guildId, guildId: this.connectionOptions.guildId,
channelId: this.connectionOptions.channelId, channelId: this.connectionOptions.channelId,
adapterCreator: this.connectionOptions.adapterCreator, adapterCreator: this.connectionOptions.adapterCreator,
}); });
this.connection.subscribe(this.audioPlayer); this.connection = connection;
connection.subscribe(this.audioPlayer);
await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); try {
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.playbackState = 'ready';
this.scheduleRenewal(); this.scheduleRenewal();
} }
@@ -108,17 +121,35 @@ export default class AudioManager {
const resolvedSource = this.resolveSource(); const resolvedSource = this.resolveSource();
this.stopCurrentPlayback(); this.stopCurrentPlayback();
this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg); this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg);
this.resource = createAudioResource(this.ffmpeg.process.stdout, { const ffmpeg = this.ffmpeg;
inputType: StreamType.Raw, try {
inlineVolume: this.options.volume?.enabled === true, 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) { if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) {
this.setVolume(this.options.volume.initialPercent); this.setVolume(this.options.volume.initialPercent);
} }
this.audioPlayer.play(this.resource); try {
this.playbackState = 'playing'; this.audioPlayer.play(this.resource);
this.playbackState = 'playing';
} catch (error) {
this.stopCurrentPlayback();
this.playbackState = 'ready';
throw error;
}
} }
public async start(): Promise<void> { public async start(): Promise<void> {
@@ -169,7 +200,7 @@ export default class AudioManager {
throw new AudioManagerStateError('Volume control requires volume.enabled to be true.'); throw new AudioManagerStateError('Volume control requires volume.enabled to be true.');
} }
if (volumeInPercent < 0 || volumeInPercent > 100) { if (!Number.isFinite(volumeInPercent) || volumeInPercent < 0 || volumeInPercent > 100) {
throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.'); throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.');
} }
@@ -227,7 +258,15 @@ export default class AudioManager {
} }
this.renewTimer = setTimeout(() => { this.renewTimer = setTimeout(() => {
void this.start(); 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); }, renewIntervalMs);
if (typeof this.renewTimer.unref === 'function') { if (typeof this.renewTimer.unref === 'function') {
+51 -2
View File
@@ -3,7 +3,7 @@ import type { ChildProcessByStdio } from 'node:child_process';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import type { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import { AudioManagerConfigError } from './errors'; import { AudioManagerConfigError, FfmpegProcessError } from './errors';
import type { FfmpegOptions } from './types'; import type { FfmpegOptions } from './types';
const requireFromCurrentModule = createRequire(__filename); 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_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 DEFAULT_OUTPUT_ARGS = ['-vn', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'] as const;
const FORCE_KILL_TIMEOUT_MS = 2_000; const FORCE_KILL_TIMEOUT_MS = 2_000;
const STDERR_TAIL_BYTES = 4_096;
export type FfmpegProcessHandle = { export type FfmpegProcessHandle = {
process: ChildProcessByStdio<null, Readable, Readable>; process: ChildProcessByStdio<null, Readable, Readable>;
ready: Promise<void>;
stop(): void; stop(): void;
}; };
@@ -50,18 +52,65 @@ export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegP
...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS), ...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS),
]; ];
const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] }); const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const ready = waitForFfmpegOutput(childProcess);
childProcess.once('error', () => undefined);
childProcess.stderr.resume(); childProcess.stderr.resume();
return { return {
process: childProcess, process: childProcess,
ready,
stop: (): void => { stop: (): void => {
stopProcess(childProcess); 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 { function stopProcess(childProcess: ChildProcessByStdio<null, Readable, Readable>): void {
childProcess.stdout.destroy(); childProcess.stdout.destroy();
childProcess.stderr.destroy(); childProcess.stderr.destroy();
+294 -5
View File
@@ -1,11 +1,14 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import AudioManager from '../src/audio-manager'; import AudioManager from '../src/audio-manager';
import { AudioManagerConfigError, AudioManagerStateError } from '../src'; import { AudioManagerConfigError, AudioManagerStateError, FfmpegProcessError } from '../src';
import { startFfmpeg } from '../src/ffmpeg'; import { startFfmpeg } from '../src/ffmpeg';
import type { FfmpegProcessHandle } from '../src/ffmpeg';
import type { AudioSource, VoiceConnectionOptions } from '../src'; import type { AudioSource, VoiceConnectionOptions } from '../src';
import type { VoiceConnection } from '@discordjs/voice';
const mockAudioPlayer = { const mockAudioPlayer = {
play: jest.fn(), play: jest.fn(),
@@ -18,6 +21,13 @@ const mockConnection = {
disconnect: jest.fn(), disconnect: jest.fn(),
destroy: 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 = { const mockAudioResource = {
playStream: { playStream: {
destroy: jest.fn(), destroy: jest.fn(),
@@ -26,10 +36,11 @@ const mockAudioResource = {
setVolume: jest.fn(), setVolume: jest.fn(),
}, },
}; };
const mockFfmpegHandle = { const mockFfmpegHandle: FfmpegProcessHandle = {
process: { process: {
stdout: new PassThrough(), stdout: new PassThrough(),
}, } as unknown as FfmpegProcessHandle['process'],
ready: Promise.resolve(),
stop: jest.fn(), stop: jest.fn(),
}; };
@@ -45,8 +56,8 @@ jest.mock('@discordjs/voice', () => ({
}, },
createAudioPlayer: jest.fn(() => mockAudioPlayer), createAudioPlayer: jest.fn(() => mockAudioPlayer),
createAudioResource: jest.fn(() => mockAudioResource), createAudioResource: jest.fn(() => mockAudioResource),
entersState: jest.fn(() => Promise.resolve(mockConnection)), entersState: jest.fn(() => Promise.resolve(mockVoiceConnection)),
joinVoiceChannel: jest.fn(() => mockConnection), joinVoiceChannel: jest.fn(() => mockVoiceConnection),
})); }));
jest.mock('../src/ffmpeg', () => ({ jest.mock('../src/ffmpeg', () => ({
@@ -66,6 +77,26 @@ const liveStreamSource: AudioSource = {
const fileSourcePath = 'tests/audio.mp3'; const fileSourcePath = 'tests/audio.mp3';
const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath); 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', () => { describe('AudioManager', () => {
beforeEach(() => { beforeEach(() => {
@@ -99,6 +130,91 @@ describe('AudioManager', () => {
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); 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 () => { it('starts playback with the committed mp3 test file', async () => {
const manager = new AudioManager({ const manager = new AudioManager({
connection: connectionOptions, connection: connectionOptions,
@@ -144,6 +260,138 @@ describe('AudioManager', () => {
expect(manager.state).toBe('playing'); 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 () => { it('replaces active playback when a new source is played', async () => {
const manager = new AudioManager({ const manager = new AudioManager({
connection: connectionOptions, connection: connectionOptions,
@@ -233,6 +481,12 @@ describe('AudioManager', () => {
expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError); expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(101)).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', () => { it('rejects volume changes before any resource exists', () => {
@@ -281,6 +535,20 @@ describe('AudioManager', () => {
expect(manager.state).toBe('stopped'); 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 () => { it('restarts playback when the renewal timer fires', async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@@ -298,6 +566,27 @@ describe('AudioManager', () => {
expect(startSpy).toHaveBeenCalledTimes(1); 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 () => { it('clears existing renewal timer before reconnecting', async () => {
jest.useFakeTimers(); jest.useFakeTimers();
+92 -4
View File
@@ -1,6 +1,8 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { FfmpegProcessError } from '../src';
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg'; import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';
jest.mock('node:child_process', () => ({ jest.mock('node:child_process', () => ({
@@ -12,12 +14,16 @@ const mockSpawn = jest.mocked(spawn);
type MockChildProcess = { type MockChildProcess = {
stdout: { stdout: {
destroy: jest.Mock; destroy: jest.Mock;
once: jest.Mock;
off: jest.Mock;
}; };
stderr: { stderr: {
destroy: jest.Mock; destroy: jest.Mock;
on: jest.Mock;
resume: jest.Mock; resume: jest.Mock;
}; };
once: jest.Mock; once: jest.Mock;
off: jest.Mock;
removeAllListeners: jest.Mock; removeAllListeners: jest.Mock;
kill: jest.Mock; kill: jest.Mock;
killed: boolean; killed: boolean;
@@ -29,12 +35,16 @@ function createMockChildProcess(): MockChildProcess {
return { return {
stdout: { stdout: {
destroy: jest.fn(), destroy: jest.fn(),
once: jest.fn(),
off: jest.fn(),
}, },
stderr: { stderr: {
destroy: jest.fn(), destroy: jest.fn(),
on: jest.fn(),
resume: jest.fn(), resume: jest.fn(),
}, },
once: jest.fn(), once: jest.fn(),
off: jest.fn(),
removeAllListeners: jest.fn(), removeAllListeners: jest.fn(),
kill: jest.fn(), kill: jest.fn(),
killed: false, killed: false,
@@ -43,6 +53,26 @@ 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 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', () => { describe('ffmpeg helpers', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -63,7 +93,7 @@ describe('ffmpeg helpers', () => {
it('spawns ffmpeg with the default argument set', () => { it('spawns ffmpeg with the default argument set', () => {
const childProcess = createMockChildProcess(); const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess); mockSpawnReturn(childProcess);
startFfmpeg('https://synradiode.stream.laut.fm/synradiode'); startFfmpeg('https://synradiode.stream.laut.fm/synradiode');
@@ -88,12 +118,70 @@ describe('ffmpeg helpers', () => {
{ stdio: ['ignore', 'pipe', 'pipe'] }, { stdio: ['ignore', 'pipe', 'pipe'] },
); );
expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function)); 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); 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', () => { it('uses custom executable and argument overrides when provided', () => {
const childProcess = createMockChildProcess(); const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess); mockSpawnReturn(childProcess);
startFfmpeg('tests/audio.mp3', { startFfmpeg('tests/audio.mp3', {
executablePath: '/custom/ffmpeg', executablePath: '/custom/ffmpeg',
@@ -111,7 +199,7 @@ 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', () => {
jest.useFakeTimers(); jest.useFakeTimers();
const childProcess = createMockChildProcess(); const childProcess = createMockChildProcess();
mockSpawn.mockReturnValue(childProcess); mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3'); const handle = startFfmpeg('tests/audio.mp3');
handle.stop(); handle.stop();
@@ -129,7 +217,7 @@ describe('ffmpeg helpers', () => {
it('does not signal a process that already exited', () => { it('does not signal a process that already exited', () => {
const childProcess = createMockChildProcess(); const childProcess = createMockChildProcess();
childProcess.exitCode = 0; childProcess.exitCode = 0;
mockSpawn.mockReturnValue(childProcess); mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3'); const handle = startFfmpeg('tests/audio.mp3');
handle.stop(); handle.stop();