fix
This commit is contained in:
+5
-2
@@ -48,8 +48,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
|
|||||||
на проекторе должны быть только материалы, а пульт — на компьютере.
|
на проекторе должны быть только материалы, а пульт — на компьютере.
|
||||||
Предпросмотр в блоке «Экран» показывает кадр окна показа с небольшой задержкой.
|
Предпросмотр в блоке «Экран» показывает кадр окна показа с небольшой задержкой.
|
||||||
|
|
||||||
Звук воспроизводит окно показа; предпросмотр второго звука не создаёт.
|
Звук игры воспроизводит окно показа; предпросмотр второго звука не создаёт.
|
||||||
Приложение использует аудиовыход ОС и не выбирает устройство самостоятельно.
|
В блоке «Экран» можно выбрать конкретный аудиовыход или оставить устройство
|
||||||
|
по умолчанию в системе. Кнопка «Проверка звука» воспроизводит короткий сигнал
|
||||||
|
на выбранном выходе даже без открытого показа и при выключенном звуке игры.
|
||||||
|
Проверка не меняет громкость и воспроизведение игры.
|
||||||
|
|
||||||
## Подготовка игры при доступной сети
|
## Подготовка игры при доступной сети
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { PackageStore } from './package-store.js'
|
|||||||
import { packageMediaResponse } from './package-media.js'
|
import { packageMediaResponse } from './package-media.js'
|
||||||
import { prepareFetchHeaders } from './response-headers.js'
|
import { prepareFetchHeaders } from './response-headers.js'
|
||||||
import { fetchNetwork } from './network-fetch.js'
|
import { fetchNetwork } from './network-fetch.js'
|
||||||
|
import { playSoundCheck, soundCheckWav } from './sound-check.js'
|
||||||
import type { HostInfo } from '../src/types/screen.js'
|
import type { HostInfo } from '../src/types/screen.js'
|
||||||
|
|
||||||
// Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11.
|
// Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11.
|
||||||
@@ -421,6 +422,8 @@ app
|
|||||||
'X-Content-Type-Options': 'nosniff',
|
'X-Content-Type-Options': 'nosniff',
|
||||||
}
|
}
|
||||||
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
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('/packages/')) return packageMediaResponse(packages, request)
|
||||||
if (url.pathname.startsWith('/media/')) {
|
if (url.pathname.startsWith('/media/')) {
|
||||||
const media =
|
const media =
|
||||||
@@ -554,8 +557,25 @@ app
|
|||||||
control.on('unmaximize', scheduleSave)
|
control.on('unmaximize', scheduleSave)
|
||||||
control.on('close', saveWindowSettings)
|
control.on('close', saveWindowSettings)
|
||||||
secure(control)
|
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) => {
|
ipcMain.handle('select-audio-output', (event, id: unknown) => {
|
||||||
requireControl(event)
|
requireControl(event)
|
||||||
|
if (checkingSound) throw new Error('Подождите завершения проверки звука')
|
||||||
return audioOutput.select(id)
|
return audioOutput.select(id)
|
||||||
})
|
})
|
||||||
ipcMain.handle('prepare-audio-output', async (event) => {
|
ipcMain.handle('prepare-audio-output', async (event) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('screenBridge', {
|
contextBridge.exposeInMainWorld('screenBridge', {
|
||||||
|
checkSound: () => ipcRenderer.invoke('check-sound'),
|
||||||
selectAudioOutput: (id: string) => ipcRenderer.invoke('select-audio-output', id),
|
selectAudioOutput: (id: string) => ipcRenderer.invoke('select-audio-output', id),
|
||||||
prepareAudioOutput: () => ipcRenderer.invoke('prepare-audio-output'),
|
prepareAudioOutput: () => ipcRenderer.invoke('prepare-audio-output'),
|
||||||
planStatus: () => ipcRenderer.invoke('plan-status'),
|
planStatus: () => ipcRenderer.invoke('plan-status'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -258,6 +258,58 @@ app.whenReady().then(async () => {
|
|||||||
return s.sequence >= sequence && (!c || c.status !== 'pending') && s
|
return s.sequence >= sequence && (!c || c.status !== 'pending') && s
|
||||||
}, JSON.stringify(intent))
|
}, 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('typeof window.require'), 'undefined')
|
||||||
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
||||||
if (process.argv.includes('--intro-waveform'))
|
if (process.argv.includes('--intro-waveform'))
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const host = ref<HostInfo>({
|
|||||||
})
|
})
|
||||||
const display = ref<number | null>(null)
|
const display = ref<number | null>(null)
|
||||||
const isBusy = ref(false)
|
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 preview = ref<PreviewFrame | null>(null)
|
||||||
const previewURL = computed(() =>
|
const previewURL = computed(() =>
|
||||||
preview.value?.session === state.value?.session &&
|
preview.value?.session === state.value?.session &&
|
||||||
@@ -292,12 +304,18 @@ onUnmounted(() => {
|
|||||||
{ label: 'По умолчанию в системе', value: '' },
|
{ label: 'По умолчанию в системе', value: '' },
|
||||||
...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })),
|
...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })),
|
||||||
]"
|
]"
|
||||||
:disabled="isBusy"
|
:disabled="isBusy || isCheckingSound"
|
||||||
:input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }"
|
:input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }"
|
||||||
@update:value="selectAudioOutput"
|
@update:value="selectAudioOutput"
|
||||||
/>
|
/>
|
||||||
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
|
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
|
||||||
<n-space align="center">
|
<n-space align="center">
|
||||||
|
<n-button
|
||||||
|
data-testid="check-sound"
|
||||||
|
:disabled="isBusy || isCheckingSound"
|
||||||
|
:loading="isCheckingSound"
|
||||||
|
@click="checkSound"
|
||||||
|
>Проверка звука</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="!state?.outputOpen"
|
v-if="!state?.outputOpen"
|
||||||
:disabled="display === null || isBusy"
|
:disabled="display === null || isBusy"
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export interface PlanStatus {
|
|||||||
notice: string
|
notice: string
|
||||||
}
|
}
|
||||||
export interface ScreenBridge {
|
export interface ScreenBridge {
|
||||||
|
checkSound: () => Promise<void>
|
||||||
selectAudioOutput: (id: string) => Promise<void>
|
selectAudioOutput: (id: string) => Promise<void>
|
||||||
prepareAudioOutput: () => Promise<void>
|
prepareAudioOutput: () => Promise<void>
|
||||||
planStatus: () => Promise<PlanStatus>
|
planStatus: () => Promise<PlanStatus>
|
||||||
|
|||||||
Reference in New Issue
Block a user