fix(voice): rebuild audio engines on route change, cleaner output
This commit is contained in:
parent
5ac1089c5c
commit
00557f9078
2 changed files with 86 additions and 23 deletions
|
|
@ -51,16 +51,26 @@ let playFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 2400
|
|||
let captureFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 24000, channels: 1, interleaved: true)!
|
||||
|
||||
// Speaker: its own engine, pulling PCM16 from the stdin-fed queue.
|
||||
let outputEngine = AVAudioEngine()
|
||||
let source = AVAudioSourceNode(format: playFormat) { _, _, frameCount, audioBufferList -> OSStatus in
|
||||
let samples = queue.pop(frames: Int(frameCount))
|
||||
let out = UnsafeMutableAudioBufferListPointer(audioBufferList)[0].mData!.assumingMemoryBound(to: Float.self)
|
||||
for i in 0..<Int(frameCount) { out[i] = Float(samples[i]) / 32768.0 }
|
||||
return noErr
|
||||
var outputEngine: AVAudioEngine?
|
||||
|
||||
func startOutput() {
|
||||
outputEngine?.stop()
|
||||
let engine = AVAudioEngine()
|
||||
outputEngine = engine
|
||||
let source = AVAudioSourceNode(format: playFormat) { _, _, frameCount, audioBufferList -> OSStatus in
|
||||
let samples = queue.pop(frames: Int(frameCount))
|
||||
let out = UnsafeMutableAudioBufferListPointer(audioBufferList)[0].mData!.assumingMemoryBound(to: Float.self)
|
||||
for i in 0..<Int(frameCount) { out[i] = Float(samples[i]) / 32768.0 }
|
||||
return noErr
|
||||
}
|
||||
engine.attach(source)
|
||||
engine.connect(source, to: engine.mainMixerNode, format: playFormat)
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
log("output engine failed: \(error)")
|
||||
}
|
||||
}
|
||||
outputEngine.attach(source)
|
||||
outputEngine.connect(source, to: outputEngine.mainMixerNode, format: playFormat)
|
||||
try outputEngine.start()
|
||||
|
||||
// Microphone: take channel 0 of whatever the hardware provides, resample to
|
||||
// 24kHz PCM16 for stdout. Channel extraction is manual because hardware
|
||||
|
|
@ -145,7 +155,29 @@ func startInput(voiceProcessing: Bool) {
|
|||
// Voice processing is opt-in (--aec): it is only needed on speakers, and on
|
||||
// some machines (observed with Bluetooth headsets active) the VP engine binds
|
||||
// to the wrong capture device entirely, delivering noise instead of the mic.
|
||||
startInput(voiceProcessing: CommandLine.arguments.contains("--aec"))
|
||||
let wantAEC = CommandLine.arguments.contains("--aec")
|
||||
|
||||
// Mic first: activating the mic flips Bluetooth headsets from music mode to
|
||||
// headset mode, reconfiguring the output device. Starting output afterwards
|
||||
// (and rebuilding on any route change below) keeps playback on the live device.
|
||||
startInput(voiceProcessing: wantAEC)
|
||||
startOutput()
|
||||
|
||||
// Device switches (Bluetooth profile flips, headphones plugged/unplugged,
|
||||
// default device changes) stop engines silently. Rebuild both, debounced.
|
||||
var rebuildScheduled = false
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange, object: nil, queue: .main
|
||||
) { _ in
|
||||
if rebuildScheduled { return }
|
||||
rebuildScheduled = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
rebuildScheduled = false
|
||||
log("audio route changed — rebuilding engines")
|
||||
startInput(voiceProcessing: wantAEC)
|
||||
startOutput()
|
||||
}
|
||||
}
|
||||
|
||||
signal(SIGUSR1, SIG_IGN)
|
||||
let flushSignal = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main)
|
||||
|
|
|
|||
|
|
@ -95,12 +95,45 @@ async function requireSession() {
|
|||
if (activeSessionID) return activeSessionID
|
||||
const created = await client.session.create({ location: { directory: args.directory! } })
|
||||
activeSessionID = created.id
|
||||
console.log(`\n[voice] created session ${activeSessionID}`)
|
||||
printLine(dim(` [session] created ${activeSessionID}`))
|
||||
return created.id
|
||||
}
|
||||
|
||||
const truncate = (text: string, max = 2000) => (text.length > max ? text.slice(0, max) + "…" : text)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal output: assistant text streams; everything else must not collide
|
||||
// with the open streaming line.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tty = process.stdout.isTTY
|
||||
const dim = (text: string) => (tty ? `\x1b[2m${text}\x1b[0m` : text)
|
||||
const cyan = (text: string) => (tty ? `\x1b[1;36m${text}\x1b[0m` : text)
|
||||
const green = (text: string) => (tty ? `\x1b[1;32m${text}\x1b[0m` : text)
|
||||
|
||||
let assistantStreaming = false
|
||||
|
||||
function printLine(line: string) {
|
||||
if (assistantStreaming) {
|
||||
process.stdout.write("\n")
|
||||
assistantStreaming = false
|
||||
}
|
||||
console.log(line)
|
||||
}
|
||||
|
||||
function printAssistantDelta(text: string) {
|
||||
if (!assistantStreaming) {
|
||||
process.stdout.write(green("● assistant ") )
|
||||
assistantStreaming = true
|
||||
}
|
||||
process.stdout.write(text)
|
||||
}
|
||||
|
||||
function printAssistantDone() {
|
||||
if (assistantStreaming) process.stdout.write("\n")
|
||||
assistantStreaming = false
|
||||
}
|
||||
|
||||
const toolHandlers: Record<string, (input: Record<string, unknown>) => Promise<unknown>> = {
|
||||
list_sessions: async (input) => {
|
||||
const limit = typeof input["limit"] === "number" ? input["limit"] : 10
|
||||
|
|
@ -128,7 +161,7 @@ const toolHandlers: Record<string, (input: Record<string, unknown>) => Promise<u
|
|||
const sessionID = await requireSession()
|
||||
lastPromptAt = Date.now()
|
||||
await client.session.prompt({ sessionID, text })
|
||||
console.log(`\n[voice] prompted ${sessionID}: ${text}`)
|
||||
printLine(dim(` [prompt] ${truncate(text, 120)}`))
|
||||
return { sessionID, admitted: true, hint: "Work runs in the background. Use check_session or wait_for_reply." }
|
||||
},
|
||||
check_session: async () => {
|
||||
|
|
@ -366,7 +399,7 @@ async function handleFunctionCall(item: RealtimeItem) {
|
|||
const output = handler
|
||||
? await handler(JSON.parse(item.arguments ?? "{}")).catch((error) => ({ error: String(error) }))
|
||||
: { error: `unknown tool ${item.name}` }
|
||||
console.log(`\n[tool] ${item.name} -> ${truncate(JSON.stringify(output), 300)}`)
|
||||
printLine(dim(` [${item.name}] ${truncate(JSON.stringify(output), 200)}`))
|
||||
send({
|
||||
type: "conversation.item.create",
|
||||
item: { type: "function_call_output", call_id: item.call_id, output: JSON.stringify(output) },
|
||||
|
|
@ -385,7 +418,7 @@ if (!args.text && process.stdin.isTTY) {
|
|||
if (!assistantSpeaking()) return
|
||||
send({ type: "response.cancel" })
|
||||
flushPlayback()
|
||||
console.log("\n[voice] interrupted")
|
||||
printLine(dim(" [interrupted]"))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -444,14 +477,12 @@ ws.addEventListener("message", (event) => {
|
|||
createResponse()
|
||||
break
|
||||
case "response.output_text.delta":
|
||||
process.stdout.write(data.delta ?? "")
|
||||
printAssistantDelta(data.delta ?? "")
|
||||
break
|
||||
case "response.done": {
|
||||
printAssistantDone()
|
||||
const calledFunction = data.response?.output?.some((item) => item.type === "function_call") ?? false
|
||||
if (args.text && !calledFunction && inflightTools === 0) {
|
||||
process.stdout.write("\n")
|
||||
shutdown()
|
||||
}
|
||||
if (args.text && !calledFunction && inflightTools === 0) shutdown()
|
||||
break
|
||||
}
|
||||
case "input_audio_buffer.speech_started":
|
||||
|
|
@ -461,22 +492,22 @@ ws.addEventListener("message", (event) => {
|
|||
if (fullDuplex) flushPlayback()
|
||||
break
|
||||
case "conversation.item.input_audio_transcription.completed":
|
||||
console.log(`\nYou: ${data.transcript ?? ""}`)
|
||||
printLine(cyan("● you ") + (data.transcript ?? "").trim())
|
||||
break
|
||||
case "response.output_audio.delta":
|
||||
if (data.delta) playAudio(data.delta)
|
||||
break
|
||||
case "response.output_audio_transcript.delta":
|
||||
process.stdout.write(data.delta ?? "")
|
||||
printAssistantDelta(data.delta ?? "")
|
||||
break
|
||||
case "response.output_audio_transcript.done":
|
||||
process.stdout.write("\n")
|
||||
printAssistantDone()
|
||||
break
|
||||
case "response.output_item.done":
|
||||
if (data.item?.type === "function_call") void handleFunctionCall(data.item)
|
||||
break
|
||||
case "error":
|
||||
console.error(`\n[realtime error] ${data.error?.code}: ${data.error?.message}`)
|
||||
printLine(`[realtime error] ${data.error?.code}: ${data.error?.message}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue