d3ro-voice/apps/desktop/tests/main/services/audio-capture-redteam-r2-7.test.ts

230 lines
8.1 KiB
TypeScript

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