This commit is contained in:
2026-10-19 00:11:20 +07:00
parent f74b2a8a41
commit 0f948b316a
8 changed files with 176 additions and 3 deletions
+5 -2
View File
@@ -48,8 +48,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
на проекторе должны быть только материалы, а пульт — на компьютере.
Предпросмотр в блоке «Экран» показывает кадр окна показа с небольшой задержкой.
Звук воспроизводит окно показа; предпросмотр второго звука не создаёт.
Приложение использует аудиовыход ОС и не выбирает устройство самостоятельно.
Звук игры воспроизводит окно показа; предпросмотр второго звука не создаёт.
В блоке «Экран» можно выбрать конкретный аудиовыход или оставить устройство
по умолчанию в системе. Кнопка «Проверка звука» воспроизводит короткий сигнал
на выбранном выходе даже без открытого показа и при выключенном звуке игры.
Проверка не меняет громкость и воспроизведение игры.
## Подготовка игры при доступной сети
+20
View File
@@ -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) => {
+1
View File
@@ -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'),
+25
View File
@@ -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)
})
+53
View File
@@ -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<void> {
const media = document.querySelector('audio')
if (!media) return Promise.reject(new Error('Проверка звука недоступна'))
return new Promise<void>((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)
})
}
+52
View File
@@ -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'))
+19 -1
View File
@@ -58,6 +58,18 @@ const host = ref<HostInfo>({
})
const display = ref<number | null>(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<PreviewFrame | null>(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"
/>
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
<n-space align="center">
<n-button
data-testid="check-sound"
:disabled="isBusy || isCheckingSound"
:loading="isCheckingSound"
@click="checkSound"
>Проверка звука</n-button>
<n-button
v-if="!state?.outputOpen"
:disabled="display === null || isBusy"
+1
View File
@@ -155,6 +155,7 @@ export interface PlanStatus {
notice: string
}
export interface ScreenBridge {
checkSound: () => Promise<void>
selectAudioOutput: (id: string) => Promise<void>
prepareAudioOutput: () => Promise<void>
planStatus: () => Promise<PlanStatus>