import { DEFAULT_AUDIO_FORMAT, type AudioFormat, type Playback, type StreamingPlayback, } from "./utils.js" import { listDevices, findDeviceByLabel, streamTone } from "./utils.js" export class Player { #deviceId?: string #format: Required static async create({ label, format }: { label?: string; format?: AudioFormat } = {}) { const devices = await listDevices() const playbackDevices = devices.filter((d) => d.type === "playback") if (playbackDevices.length === 0) { throw new Error("No playback devices found") } let deviceId: string | undefined if (label) { const device = await findDeviceByLabel(label, "playback") deviceId = device.id } return new Player({ deviceId, format }) } constructor({ deviceId, format }: { deviceId?: string; format?: AudioFormat }) { this.#deviceId = deviceId this.#format = { format: format?.format ?? DEFAULT_AUDIO_FORMAT.format, sampleRate: format?.sampleRate ?? DEFAULT_AUDIO_FORMAT.sampleRate, channels: format?.channels ?? DEFAULT_AUDIO_FORMAT.channels, } } #commonArgs(): string[] { const args = [] if (this.#deviceId) { args.push("-D", this.#deviceId) } return args } async play(filePath: string, { repeat }: { repeat?: boolean } = {}): Promise { const args = [...this.#commonArgs(), filePath] let proc = Bun.spawn(["aplay", ...args], { stdout: "pipe", stderr: "pipe" }) const handle: Playback = { isPlaying: true, stop: async () => { if (!handle.isPlaying) return handle.isPlaying = false proc.kill() await proc.exited }, finished: async () => { await proc.exited }, } const loop = async () => { while (handle.isPlaying) { await proc.exited if (!handle.isPlaying) break if (repeat) { proc = Bun.spawn(["aplay", ...args], { stdout: "pipe", stderr: "pipe" }) } else { handle.isPlaying = false break } } } loop() return handle } async playTone(frequencies: number[], duration: number): Promise { if (duration !== Infinity && duration <= 0) { throw new Error("Duration must be greater than 0 or Infinity") } const args = [ ...this.#commonArgs(), "-f", this.#format.format, "-r", this.#format.sampleRate.toString(), "-c", this.#format.channels.toString(), "-t", "raw", "-", ] const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe", }) // Start streaming tone in background streamTone(proc.stdin, frequencies, duration, this.#format) const handle: Playback = { isPlaying: true, stop: async () => { if (!handle.isPlaying) return handle.isPlaying = false try { proc.stdin.end() } catch (e) {} proc.kill() await proc.exited }, finished: async () => { await proc.exited }, } proc.exited.then(() => { handle.isPlaying = false }) return handle } playStream(): StreamingPlayback { const args = [ ...this.#commonArgs(), "-f", this.#format.format, "-r", this.#format.sampleRate.toString(), "-c", this.#format.channels.toString(), "-t", "raw", "-", ] const proc = Bun.spawn(["aplay", ...args], { stdin: "pipe", stdout: "pipe", stderr: "pipe", }) let bufferFinishTime = performance.now() const format = this.#format const bytesPerSecond = format.sampleRate * format.channels * 2 const handle: StreamingPlayback = { isPlaying: true, write: (chunk: Uint8Array) => { if (handle.isPlaying) { proc.stdin.write(chunk) const chunkDurationMs = (chunk.byteLength / bytesPerSecond) * 1000 bufferFinishTime = Math.max(performance.now(), bufferFinishTime) + chunkDurationMs } }, get bufferEmptyFor() { const now = performance.now() if (now > bufferFinishTime) { return now - bufferFinishTime } return 0 }, stop: async () => { if (!handle.isPlaying) return handle.isPlaying = false try { proc.stdin.end() } catch (e) {} proc.kill() await proc.exited }, } proc.exited.then(() => { handle.isPlaying = false }) return handle } }