fix(voice): stop mic-test ref stealing, unblock action queue, keep tail audio, and move context/LLM routing behind ports and pure policies
This commit is contained in:
parent
2cd462333f
commit
2f94d24c99
13 changed files with 1672 additions and 353 deletions
|
|
@ -0,0 +1,230 @@
|
|||
// tests/main/services/audio-capture-redteam-r2-7.test.ts
|
||||
// AudioCaptureService / 마이크 테스트 IPC 회귀 테스트:
|
||||
// - kill 된 옛 SoX 의 늦은 'close' 가 새 캡처를 오류로 끝내지 않는다
|
||||
// - 마이크 테스트는 자기가 잡은 참조만 놓는다 (회의·자막의 캡처를 빼앗지 않는다)
|
||||
// - 정지 시 SoX 를 곧바로 죽이지 않고 꼬리 오디오와 잔여 프레임을 흘려보낸다
|
||||
// - SoX 버퍼를 줄여(--buffer 1024) 청크 지연·손실을 줄인다
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn(() => 'default'),
|
||||
configSet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/utils/paths', () => ({
|
||||
getSoxPath: () => 'sox',
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({
|
||||
getMainWindow: () => null,
|
||||
}))
|
||||
|
||||
class FakeProcess extends EventEmitter {
|
||||
stdout = new EventEmitter()
|
||||
stderr = new EventEmitter()
|
||||
kill = vi.fn()
|
||||
}
|
||||
|
||||
const spawned = vi.hoisted(() => ({ list: [] as unknown[], args: [] as string[][] }))
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>()
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn((_cmd: string, args: string[]) => {
|
||||
const proc = new FakeProcess()
|
||||
spawned.list.push(proc)
|
||||
spawned.args.push(args)
|
||||
return proc
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
type Module = typeof import('../../../src/main/services/AudioCaptureService')
|
||||
let mod: Module
|
||||
|
||||
const FRAME = 1920 // 60ms
|
||||
|
||||
function proc(i: number): FakeProcess {
|
||||
return spawned.list[i] as FakeProcess
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.useRealTimers()
|
||||
spawned.list = []
|
||||
spawned.args = []
|
||||
mod = await import('../../../src/main/services/AudioCaptureService')
|
||||
mod.resetAudioCaptureServiceForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('SoX 프로세스 수명', () => {
|
||||
it("정지 후 곧바로 재시작하면 옛 SoX 의 늦은 'close' 가 새 캡처를 죽이지 않는다", async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
const stopped: string[] = []
|
||||
audio.on('stopped', ({ reason }) => stopped.push(reason))
|
||||
|
||||
await audio.start()
|
||||
await audio.stop()
|
||||
await audio.start()
|
||||
expect(spawned.list).toHaveLength(2)
|
||||
|
||||
// 옛 프로세스의 'close' 가 kill 몇 ms 뒤 도착
|
||||
proc(0).emit('close', null)
|
||||
proc(0).emit('error', new Error('late'))
|
||||
|
||||
expect(audio.state).toBe('capturing')
|
||||
expect(proc(1).kill).not.toHaveBeenCalled()
|
||||
expect(stopped).toEqual(['manual'])
|
||||
})
|
||||
|
||||
it('SoX 인자에 --buffer 1024 가 들어간다', async () => {
|
||||
await mod.getAudioCaptureService().start()
|
||||
const args = spawned.args[0]
|
||||
const i = args.indexOf('--buffer')
|
||||
expect(i).toBeGreaterThanOrEqual(0)
|
||||
expect(args[i + 1]).toBe(String(mod.SOX_BUFFER_BYTES))
|
||||
expect(mod.SOX_BUFFER_BYTES).toBe(1024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('정지 시 꼬리 오디오', () => {
|
||||
it('정지 중 도착한 청크와 60ms 미만 잔여 바이트를 흘려보낸 뒤 SoX 를 끝낸다', async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
const received: number[] = []
|
||||
audio.on('audio-data', ({ buffer }) => received.push(buffer.length))
|
||||
|
||||
await audio.start()
|
||||
proc(0).stdout.emit('data', Buffer.alloc(FRAME + 100)) // 1 프레임 + 잔여 100
|
||||
|
||||
const stopping = audio.stop()
|
||||
expect(proc(0).kill).not.toHaveBeenCalled()
|
||||
// SoX 버퍼에 남아 있던 끝부분이 정지 직후 도착
|
||||
proc(0).stdout.emit('data', Buffer.alloc(FRAME))
|
||||
await stopping
|
||||
|
||||
expect(proc(0).kill).toHaveBeenCalledTimes(1)
|
||||
// 1920 (첫 프레임) + 1920 (잔여 100 + 꼬리 1820) + 잔여 100 (마지막 부분 프레임)
|
||||
expect(received).toEqual([FRAME, FRAME, 100])
|
||||
expect(received.reduce((a, b) => a + b, 0)).toBe(FRAME * 2 + 100)
|
||||
expect(audio.state).toBe('idle')
|
||||
})
|
||||
|
||||
it('꼬리 수집 중 새 소비자가 오면 정지를 취소하고 같은 SoX 로 이어 간다', async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
const stopped: string[] = []
|
||||
audio.on('stopped', ({ reason }) => stopped.push(reason))
|
||||
|
||||
await audio.start()
|
||||
const stopping = audio.stop()
|
||||
await audio.start()
|
||||
await stopping
|
||||
|
||||
expect(spawned.list).toHaveLength(1)
|
||||
expect(proc(0).kill).not.toHaveBeenCalled()
|
||||
expect(audio.state).toBe('capturing')
|
||||
expect(stopped).toEqual([])
|
||||
|
||||
await audio.stop()
|
||||
expect(proc(0).kill).toHaveBeenCalledTimes(1)
|
||||
expect(stopped).toEqual(['manual'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('캡처 임대(lease)', () => {
|
||||
it('release 는 멱등이고 다른 소비자의 참조를 깎지 않는다', async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
await audio.start() // 회의·자막이 잡은 참조
|
||||
const lease = await audio.acquire()
|
||||
|
||||
await lease.release()
|
||||
await lease.release()
|
||||
|
||||
expect(audio.state).toBe('capturing')
|
||||
expect(proc(0).kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('캡처가 죽었다 다시 시작됐으면 옛 임대의 release 는 새 캡처를 멈추지 않는다', async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
const lease = await audio.acquire()
|
||||
proc(0).emit('close', 1) // 장치 분리
|
||||
expect(audio.state).toBe('error')
|
||||
|
||||
await audio.start() // 다른 소비자가 새로 시작
|
||||
await lease.release()
|
||||
|
||||
expect(audio.state).toBe('capturing')
|
||||
expect(proc(1).kill).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('마이크 테스트 IPC', () => {
|
||||
function handler(channel: string): () => Promise<unknown> {
|
||||
const call = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === channel)
|
||||
if (!call) throw new Error(`handler not registered: ${channel}`)
|
||||
return call[1] as unknown as () => Promise<unknown>
|
||||
}
|
||||
|
||||
it('회의 캡처 중에 마이크 테스트를 해도 회의 캡처는 끊기지 않는다', async () => {
|
||||
vi.mocked(ipcMain.handle).mockClear()
|
||||
const { registerAudioHandlers } = await import('../../../src/main/ipc/audio-handlers')
|
||||
registerAudioHandlers()
|
||||
const audio = mod.getAudioCaptureService()
|
||||
|
||||
// 회의(자막) 가 캡처를 잡고 있다
|
||||
await audio.start()
|
||||
const meetingFrames: number[] = []
|
||||
audio.on('audio-data', ({ buffer }) => meetingFrames.push(buffer.length))
|
||||
|
||||
// 설정 창에서 TEST → 곧바로 STOP
|
||||
await handler(IPC_CHANNELS.AUDIO.TEST_DEVICE)()
|
||||
await handler(IPC_CHANNELS.AUDIO.STOP_TEST)()
|
||||
await new Promise((r) => setTimeout(r, mod.TAIL_FLUSH_MS + 20))
|
||||
|
||||
// 옛 SoX 를 죽이거나 새로 띄우지 않았고 회의 오디오가 계속 들어온다
|
||||
expect(spawned.list).toHaveLength(1)
|
||||
expect(proc(0).kill).not.toHaveBeenCalled()
|
||||
expect(audio.state).toBe('capturing')
|
||||
proc(0).stdout.emit('data', Buffer.alloc(FRAME))
|
||||
expect(meetingFrames).toEqual([FRAME])
|
||||
})
|
||||
|
||||
it('테스트가 돌고 있지 않을 때의 STOP 은 다른 소비자의 참조를 놓지 않는다', async () => {
|
||||
vi.mocked(ipcMain.handle).mockClear()
|
||||
const { registerAudioHandlers } = await import('../../../src/main/ipc/audio-handlers')
|
||||
registerAudioHandlers()
|
||||
const audio = mod.getAudioCaptureService()
|
||||
await audio.start()
|
||||
|
||||
await handler(IPC_CHANNELS.AUDIO.STOP_TEST)()
|
||||
await new Promise((r) => setTimeout(r, mod.TAIL_FLUSH_MS + 20))
|
||||
|
||||
expect(audio.state).toBe('capturing')
|
||||
expect(proc(0).kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('테스트 단독이면 STOP 으로 캡처가 끝난다', async () => {
|
||||
vi.mocked(ipcMain.handle).mockClear()
|
||||
const { registerAudioHandlers } = await import('../../../src/main/ipc/audio-handlers')
|
||||
registerAudioHandlers()
|
||||
const audio = mod.getAudioCaptureService()
|
||||
|
||||
await handler(IPC_CHANNELS.AUDIO.TEST_DEVICE)()
|
||||
expect(audio.state).toBe('capturing')
|
||||
await handler(IPC_CHANNELS.AUDIO.STOP_TEST)()
|
||||
await vi.waitFor(() => expect(audio.state).toBe('idle'))
|
||||
expect(proc(0).kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue