From 0f948b316a61e9f0f8ae58f8867ce638f384af6d Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Mon, 19 Oct 2026 00:11:20 +0700 Subject: [PATCH] fix --- docs/USER_GUIDE.md | 7 +++-- electron/main.ts | 20 +++++++++++++ electron/preload.ts | 1 + electron/sound-check.test.ts | 25 ++++++++++++++++ electron/sound-check.ts | 53 ++++++++++++++++++++++++++++++++++ scripts/smoke.cjs | 52 +++++++++++++++++++++++++++++++++ src/components/ControlPage.vue | 20 ++++++++++++- src/types/screen.ts | 1 + 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 electron/sound-check.test.ts create mode 100644 electron/sound-check.ts diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1e74829..85a64c3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -48,8 +48,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова на проекторе должны быть только материалы, а пульт — на компьютере. Предпросмотр в блоке «Экран» показывает кадр окна показа с небольшой задержкой. -Звук воспроизводит окно показа; предпросмотр второго звука не создаёт. -Приложение использует аудиовыход ОС и не выбирает устройство самостоятельно. +Звук игры воспроизводит окно показа; предпросмотр второго звука не создаёт. +В блоке «Экран» можно выбрать конкретный аудиовыход или оставить устройство +по умолчанию в системе. Кнопка «Проверка звука» воспроизводит короткий сигнал +на выбранном выходе даже без открытого показа и при выключенном звуке игры. +Проверка не меняет громкость и воспроизведение игры. ## Подготовка игры при доступной сети diff --git a/electron/main.ts b/electron/main.ts index ee51693..ed96173 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,6 +26,7 @@ import { PackageStore } from './package-store.js' import { packageMediaResponse } from './package-media.js' import { prepareFetchHeaders } from './response-headers.js' import { fetchNetwork } from './network-fetch.js' +import { playSoundCheck, soundCheckWav } from './sound-check.js' import type { HostInfo } from '../src/types/screen.js' // Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11. @@ -421,6 +422,8 @@ app 'X-Content-Type-Options': 'nosniff', } if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 }) + if (url.pathname === '/sound-check.wav') + return new Response(soundCheckWav, { headers: { ...headers, 'Content-Type': 'audio/wav' } }) if (url.pathname.startsWith('/packages/')) return packageMediaResponse(packages, request) if (url.pathname.startsWith('/media/')) { const media = @@ -554,8 +557,25 @@ app control.on('unmaximize', scheduleSave) control.on('close', saveWindowSettings) secure(control) + let checkingSound = false + ipcMain.handle('check-sound', async (event) => { + requireControl(event) + if (checkingSound) throw new Error('Проверка звука уже выполняется') + checkingSound = true + try { + await audioOutput.prepare(async (id) => { + const window = audioDevicesWindow + if (!window || window.isDestroyed()) throw new Error('Проверка звука недоступна') + await setAudioSink(window, id) + await window.webContents.executeJavaScript(`(${playSoundCheck.toString()})()`) + }) + } finally { + checkingSound = false + } + }) ipcMain.handle('select-audio-output', (event, id: unknown) => { requireControl(event) + if (checkingSound) throw new Error('Подождите завершения проверки звука') return audioOutput.select(id) }) ipcMain.handle('prepare-audio-output', async (event) => { diff --git a/electron/preload.ts b/electron/preload.ts index 3c743f0..5def55b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron' import type { Command, Report, SceneReport, State } from '../src/types/screen.js' contextBridge.exposeInMainWorld('screenBridge', { + checkSound: () => ipcRenderer.invoke('check-sound'), selectAudioOutput: (id: string) => ipcRenderer.invoke('select-audio-output', id), prepareAudioOutput: () => ipcRenderer.invoke('prepare-audio-output'), planStatus: () => ipcRenderer.invoke('plan-status'), diff --git a/electron/sound-check.test.ts b/electron/sound-check.test.ts new file mode 100644 index 0000000..888ba88 --- /dev/null +++ b/electron/sound-check.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { soundCheckWav } from './sound-check.js' + +test('проверочный WAV содержит два коротких тона с паузой и умеренной амплитудой', () => { + assert.equal(soundCheckWav.toString('ascii', 0, 4), 'RIFF') + assert.equal(soundCheckWav.toString('ascii', 8, 16), 'WAVEfmt ') + assert.equal(soundCheckWav.readUInt32LE(4), soundCheckWav.length - 8) + assert.equal(soundCheckWav.readUInt16LE(20), 1) + assert.equal(soundCheckWav.readUInt16LE(22), 1) + assert.equal(soundCheckWav.readUInt16LE(34), 16) + const rate = soundCheckWav.readUInt32LE(24) + assert.equal(soundCheckWav.readUInt32LE(40) / (rate * 2), 1) + let first = 0 + let second = 0 + for (let index = 0; index < rate; index++) { + const amplitude = Math.abs(soundCheckWav.readInt16LE(44 + index * 2)) + assert.ok(amplitude <= 6553) + if (index < rate * 0.05 || (index >= rate * 0.45 && index < rate * 0.55) || index >= rate * 0.95) + assert.equal(amplitude, 0) + if (index < rate * 0.5) first += amplitude + else second += amplitude + } + assert.ok(first > 0 && second > 0) +}) diff --git a/electron/sound-check.ts b/electron/sound-check.ts new file mode 100644 index 0000000..f681787 --- /dev/null +++ b/electron/sound-check.ts @@ -0,0 +1,53 @@ +const sampleRate = 16000 +export const soundCheckWav = Buffer.alloc(44 + sampleRate * 2) +soundCheckWav.write('RIFF', 0) +soundCheckWav.writeUInt32LE(soundCheckWav.length - 8, 4) +soundCheckWav.write('WAVEfmt ', 8) +soundCheckWav.writeUInt32LE(16, 16) +soundCheckWav.writeUInt16LE(1, 20) +soundCheckWav.writeUInt16LE(1, 22) +soundCheckWav.writeUInt32LE(sampleRate, 24) +soundCheckWav.writeUInt32LE(sampleRate * 2, 28) +soundCheckWav.writeUInt16LE(2, 32) +soundCheckWav.writeUInt16LE(16, 34) +soundCheckWav.write('data', 36) +soundCheckWav.writeUInt32LE(sampleRate * 2, 40) +for (let sample = 0; sample < sampleRate; sample++) { + const time = sample / sampleRate + const start = time < 0.5 ? 0.05 : 0.55 + const elapsed = time - start + if (elapsed < 0 || elapsed >= 0.4) continue + const envelope = Math.min(1, elapsed / 0.02, (0.4 - elapsed) / 0.02) + const frequency = time < 0.5 ? 440 : 660 + soundCheckWav.writeInt16LE(Math.round(6553 * envelope * Math.sin(2 * Math.PI * frequency * elapsed)), 44 + sample * 2) +} + +// Runs in the audio devices renderer; keep this function independent of module imports. +export function playSoundCheck(): Promise { + const media = document.querySelector('audio') + if (!media) return Promise.reject(new Error('Проверка звука недоступна')) + return new Promise((resolve, reject) => { + let finished = false + const finish = (error?: Error) => { + if (finished) return + finished = true + clearTimeout(timeout) + media.removeEventListener('ended', ended) + media.removeEventListener('error', failed) + media.pause() + media.removeAttribute('src') + media.load() + if (error) reject(error) + else resolve() + } + const ended = () => finish() + const failed = () => finish(new Error('Не удалось воспроизвести проверочный сигнал')) + const timeout = setTimeout(() => finish(new Error('Превышено время проверки звука')), 3000) + media.addEventListener('ended', ended) + media.addEventListener('error', failed) + media.muted = false + media.volume = 1 + media.src = 'screen-app://app/sound-check.wav' + void media.play().catch(failed) + }) +} diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 6e984e8..d82270f 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -258,6 +258,58 @@ app.whenReady().then(async () => { return s.sequence >= sequence && (!c || c.status !== 'pending') && s }, JSON.stringify(intent)) } + if (process.argv.includes('--sound-check')) { + const devices = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('audio-devices=1')) + const inspectAudio = (source) => devices.webContents.executeJavaScript(source) + await command({ action: 'mute', enabled: true }) + await command({ action: 'volume', channel: 'main', volume: 37 }) + await command({ action: 'volume', channel: 'music', volume: 29 }) + const before = await state() + await poll(() => evaluate('!!document.querySelector("[data-testid=check-sound]:not([disabled])")'), 'sound check enabled') + await evaluate('document.querySelector("[data-testid=check-sound]").click()') + await poll(() => inspectAudio('!document.querySelector("audio").paused'), 'test signal playing without output') + assert.equal(await evaluate('document.querySelector("[data-testid=check-sound]").disabled'), true) + assert.equal(await evaluate('!!document.querySelector("#audio-output .n-base-selection--disabled")'), true) + await assert.rejects(evaluate('window.screenBridge.checkSound()'), /уже выполняется/) + await assert.rejects(evaluate('window.screenBridge.selectAudioOutput("")'), /проверки звука/) + assert.equal(await inspectAudio('document.querySelector("audio").muted'), false) + await poll(() => evaluate('!document.querySelector("[data-testid=check-sound]").disabled'), 'test signal completed') + assert.equal(await inspectAudio('document.querySelector("audio").getAttribute("src")'), null) + assert.equal((await state()).outputOpen, false) + assert.equal((await state()).muted, true) + assert.deepEqual((await state()).channels, before.channels) + const audioHost = await evaluate('window.screenBridge.host()') + if (audioHost.audioOutputs.length) { + const id = audioHost.audioOutputs[0].id + await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(id)})`) + await evaluate('window.screenBridge.checkSound()') + assert.equal(await inspectAudio('document.querySelector("audio").sinkId'), id) + } + await evaluate(`window.screenBridge.show(${targetDisplay.id})`) + await poll(async () => (await state()).channels.main.status === 'stopped', 'output prepared') + await command({ action: 'play', channel: 'main' }) + const playing = (await state()).channels.main + await evaluate('window.screenBridge.checkSound()') + const after = (await state()).channels.main + assert.equal(after.desired, 'playing') + assert.equal(after.operation, playing.operation) + assert.equal(after.volume, playing.volume) + assert.deepEqual(after.source, playing.source) + assert.equal((await state()).muted, true) + const outputWindow = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('output=1')) + await assert.rejects(outputWindow.webContents.executeJavaScript('window.screenBridge.checkSound()'), /Недоступно/) + await inspectAudio('window.originalSetSinkId = HTMLMediaElement.prototype.setSinkId; HTMLMediaElement.prototype.setSinkId = async () => { throw new Error("TEST_MISSING_DEVICE") }; void 0') + await assert.rejects(evaluate('window.screenBridge.checkSound()'), /TEST_MISSING_DEVICE/) + await inspectAudio('HTMLMediaElement.prototype.setSinkId = window.originalSetSinkId; void 0') + await inspectAudio('window.originalPlay = HTMLMediaElement.prototype.play; HTMLMediaElement.prototype.play = () => Promise.resolve(); void 0') + await assert.rejects(evaluate('window.screenBridge.checkSound()'), /Превышено время проверки/) + assert.equal(await inspectAudio('document.querySelector("audio").getAttribute("src")'), null) + await inspectAudio('HTMLMediaElement.prototype.play = window.originalPlay; void 0') + await evaluate('window.screenBridge.checkSound()') + console.log('SCREEN_SOUND_CHECK_PASS') + app.exit(0) + return + } assert.equal(await evaluate('typeof window.require'), 'undefined') assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) if (process.argv.includes('--intro-waveform')) diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index 9ea74ad..c3d6523 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -58,6 +58,18 @@ const host = ref({ }) const display = ref(null) const isBusy = ref(false) +const isCheckingSound = ref(false) +async function checkSound() { + if (!bridge || isCheckingSound.value) return + isCheckingSound.value = true + try { + await bridge.checkSound() + } catch (error) { + message.error(error instanceof Error ? error.message : 'Не удалось проверить звук') + } finally { + isCheckingSound.value = false + } +} const preview = ref(null) const previewURL = computed(() => preview.value?.session === state.value?.session && @@ -292,12 +304,18 @@ onUnmounted(() => { { label: 'По умолчанию в системе', value: '' }, ...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })), ]" - :disabled="isBusy" + :disabled="isBusy || isCheckingSound" :input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }" @update:value="selectAudioOutput" /> {{ host.audioOutputNotice }} + Проверка звука Promise selectAudioOutput: (id: string) => Promise prepareAudioOutput: () => Promise planStatus: () => Promise