192 lines
4.5 KiB
TypeScript
192 lines
4.5 KiB
TypeScript
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<AudioFormat>
|
|
|
|
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<Playback> {
|
|
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<Playback> {
|
|
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
|
|
}
|
|
}
|