adaptorex
Version:
Connect all your live interactive storytelling devices and software
284 lines (252 loc) • 7.38 kB
JavaScript
/**
*
* simple sound plugin based on the npm package play-sound.
* Adds PowerShell mediaplayer support for Windows.
*
* @requires play-sound
* @requires plugin
*
* @module sound/sound
* @copyright Lasse Marburg 2026
* @license MIT
*/
/**
*
*/
var player = null
const { execFile, ChildProcess } = require("child_process")
const path = require("path")
const plugin = require("../plugin.js")
const file = require("../../file.js")
var schema = require("./schema.json")
class Sound extends plugin.Plugin {
constructor() {
super(schema)
this.autoconnect = true
}
async setup(config, game) {
await super.setup(config, game)
this.addTemplate("play", (payload, action) => {
const title = `sound ${action.name}`
let footer = { text: "" }
let subtitle
const body = []
if (payload.loop) {
if (payload.loop < 0) {
footer["text"] = "Repeat infinite "
} else if (payload.loop != 1) {
footer["text"] = `Repeat ${payload.loop}x `
}
}
for (let track of payload.tracks) {
body.push({ text: `${track}` })
}
if (payload.next) {
footer["text"] += `then: ${payload.next}`
footer["next"] = payload.next
}
if (footer.text != "") {
body.push(footer)
}
return { title, subtitle, body }
})
// @todo play a silent testsound to check if player is available
this.connected = true
return this.schema
}
async connect() {
const selected_player = this.settings?.player
if (
selected_player &&
selected_player !== "auto" &&
selected_player !== "powershell"
) {
// Settings explicitly set a player
player = require("play-sound")({ player: selected_player })
this.log.info(`Using ${selected_player} for audio playback`)
} else if (
process.platform === "win32" &&
(!selected_player ||
selected_player === "auto" ||
selected_player === "powershell")
) {
// Windows: no preference, auto, or powershell selected
const powerShellPlayer = new PowerShellPlayer()
if (await powerShellPlayer.test()) {
this.log.info(
"Using PowerShell mediaplayer for audio playback on Windows"
)
player = powerShellPlayer
} else {
if (selected_player === "powershell") {
// Selected powershell but it's not available
this.log.warn(
"PowerShell mediaplayer selected but unavailable, falling back to auto-detect"
)
}
player = require("play-sound")({})
this.log.info(`Using ${player.player} for audio playback`)
}
} else {
// Non-Windows auto detect
player = require("play-sound")({})
this.log.info(`Using ${player.player} for audio playback`)
}
}
update(settings) {
if (this.settings.player !== settings.player) {
this.settings = settings
this.connect()
}
}
async play(data, session) {
let playback = new Playback(data, session, this.game)
await playback.start()
return playback.cancel.bind(playback)
}
}
class Playback {
/**
* @type {ChildProcess}
* child process executing the current audio playback
*/
process
constructor(properties, session, game) {
Object.assign(this, properties)
this.session = session
this.game = game
}
async start() {
this.donePlayingCallback = this.session.getCallback(
this.donePlaying.bind(this)
)
if (this.loop < 0) {
this.loop = 99999
}
if (!this.loop) {
this.loop = 1
}
this.play_index = 0
await this.playNext(this.tracks[0])
}
playNext(track) {
return new Promise((resolve, reject) => {
if (this.status == "canceled") {
return
}
if (!track || typeof track !== "string" || !track.trim()) {
throw new adaptor.InvalidError(`Invalid audio track: "${track}"`)
}
this.current_audiofile = this.game.getFilePath(track)
if (!file.exists(this.current_audiofile)) {
throw new adaptor.NotFoundError(
`Could not find audiofile ${this.current_audiofile}`
)
}
this.process = player.play(this.current_audiofile, async (err) => {
if (this.status == "canceled") {
return
}
this.donePlayingCallback(err)
})
if (!(this.process instanceof ChildProcess)) {
return reject(
new Error(
`Could not play audio ${this.current_audiofile}. Unable to spawn process with audioplayer: ${player.player}`
)
)
}
this.process.on("error", (err) => {
return reject(
new Error(
`Could not play audio ${this.current_audiofile} with audioplayer: ${player.player}\n${err.message}`
)
)
})
this.process.on("spawn", () => {
this.session.log.info(
`Play audio ${this.current_audiofile} with ${player.player}`
)
return resolve()
})
})
}
async donePlaying(err) {
if (err) {
// err.code === 1 means unsupported format from PowerShell mediaplayer
if (err.code === 1) {
throw new adaptor.InvalidError(
`Unsupported audio format: ${path.extname(this.current_audiofile)}`
)
}
throw new Error(err)
}
this.session.log("Done playing " + this.tracks[this.play_index])
this.play_index++
if (this.play_index >= this.tracks.length) {
this.play_index = 0
this.loop--
if (this.loop <= 0) {
this.session.log.debug("Played last track in playlist")
if (this.next) {
this.session.next(this.next)
}
return
}
}
try {
await this.playNext(this.tracks[this.play_index])
} catch (error) {
throw error
}
}
cancel() {
this.status = "canceled"
if (this.process) {
this.process.kill()
}
}
}
class PowerShellPlayer {
constructor() {
this.player = "PowerShellMediaPlayer"
}
/**
* Tests whether PowerShell's presentationCore MediaPlayer is available.
* @returns {Promise<boolean>}
*/
test() {
return new Promise((resolve) => {
const ps = [
"Add-Type -AssemblyName presentationCore;",
"New-Object system.windows.media.mediaplayer;",
"exit 0"
].join(" ")
execFile("powershell", ["-Command", ps], (err) => {
resolve(!err)
})
})
}
/**
* Play a file silently on Windows using PowerShell's MediaPlayer.
* Returns the ChildProcess so it can be killed.
* @param {string} filePath - Absolute path to the audio file
* @param {function} cb - Callback(err)
* @returns {ChildProcess}
*/
play(filePath, cb) {
const abs = path.resolve(filePath).replace(/'/g, "''")
const ps = [
"Add-Type -AssemblyName presentationCore;",
"$p = New-Object system.windows.media.mediaplayer;",
`$p.open('${abs}');`,
"$p.Play();",
"$timeout = 25; $i = 0;",
"while ($p.HasAudio -eq $false -and $i -lt $timeout) { Start-Sleep -m 100; $i++ };",
"if ($p.HasAudio -eq $false) { exit 1 };",
"Start-Sleep -s $p.NaturalDuration.TimeSpan.TotalSeconds"
].join(" ")
return execFile("powershell", ["-Command", ps], cb)
}
}
module.exports.Plugin = Sound