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 { 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) => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}, 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'))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user