phone/src/buzz/player.ts
2025-11-17 13:01:24 -08:00

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
}
}