diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..5fa860c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,74 @@ +--- +name: Immerse Jam +version: 1 +token_ownership: existing-runtime-canonical +register: product +locales: [zh-CN, en] +colors: + ground: '#0d0c11' + surface: '#14131a' + panel: '#1b1a22' + text: '#eeecf3' + secondary: '#aba7b9' + accent: '#ff5c7a' +typography: + body: Noto Sans SC, HarmonyOS Sans SC, PingFang SC, Microsoft YaHei UI, system-ui + display: Bricolage Grotesque, Noto Sans SC, HarmonyOS Sans SC, Microsoft YaHei UI + mono: JetBrains Mono, Cascadia Mono, Consolas, monospace +spacing: + settings_navigation: 220px + settings_gap: 24px +rounded: + control: 7px + panel: 14px +elevation: + page: none + popup: var(--shadow-pop) +motion: + fast: 120ms + medium: 200ms +--- + +# Immerse Jam design context + +A local music practice and recording workspace. Preserve the existing rehearsal-room visual language and its user-selected light/dark theme and accent. Settings are an instrument panel: fixed categories, readable controls, and visible paths/status. SV2 reference screenshots inform grouping and order, not product-specific audio/synthesis features. + +The approved 2026-11-05 scope is S1, S2, S4, S5, S6, S7; S3 custom key bindings is excluded. + +## Runtime ownership and mapping + +Model B: existing CSS is canonical. src/styles/rehearsal-design.css is generated from docs/design/prototype.html; do not hand-edit that generated file. src/styles/themes.css maps original palette tokens. src/styles/rehearsal.css integrates light/dark/accent semantics. New settings styles live in src/styles/settings.css in the integration layer and consume the existing semantic properties. + +| Role | Runtime source | Consumers | +| ----------------- | -------------------------------------- | --------------------------------------- | +| Ground and panels | --ground, --surface-1, --surface-2 | settings page, navigation, fields | +| Text hierarchy | --text, --text-2, --text-3 | headings, descriptions, path captions | +| Action/focus | --accent, --accent-soft, --accent-line | selected category, buttons, focus ring | +| Status | --ok, --warn, --danger | runtime state, errors, cleanup feedback | +| Type | --font-ui, --font-display, --font-mono | settings labels, headings, paths | +| Shape | --r-s, --r-l | controls and panels | +| Motion | --t-fast, --t-med | hover and popup transitions | + +The exact dark/rose values above mirror the existing runtime seed. Other accents and the light theme retain their existing mappings; settings never hard-code these seed colors. + +## Layout and components + +Settings use a full-window page with a single Back action in the fixed heading and a persistent category rail. Only the content pane scrolls. At narrow widths, a shared Select replaces the rail. Search results group matched settings by category and keep the clear action available. Storage paths wrap; long model sources are folded behind details. + +Use the existing Select owner, ModalFrame/Radix focus management for the settings page and confirmations, app notify status for action results, and Lucide icons. Body text stays at least the existing 14px body role. Paths and byte counts use the mono role. Focus is visible and active categories expose aria-current. Busy and failure states keep their controls in place. + +The distinctive element is useful technical information: actual device state, readable data paths and explicit resource categories. No decorative hero, replacement logo, marketing slogan, or telemetry is introduced. + +## Follow-up interaction and guide direction + +Native desktop drag regions are disabled beneath managed modals and explicitly excluded on their surfaces and controls. Verify actual Windows mouse input after zoom/restore, rather than relying solely on DOM-injected clicks. Runtime preparation logs appear only when non-empty and are folded behind a disclosure. + +The onboarding guide uses a compact step rail, persistent footer actions and restrained code-native workflow illustrations (import, stems, loop recording and export). Illustrations are labelled as examples and do not pretend to be live controls. The existing palette/type tokens remain canonical. #66CCFF is the first waveform swatch; existing saved colors and the original default remain intact. + +Native playback settings expose real Windows systems and enumerated devices, processing vs device sample rate, explicit unavailability and safe rollback. Capture input remains the Windows recording device selected in the recording section. No shortcut editor is introduced. + +## Motion and verification + +Honor prefers-reduced-motion. Tooltips appear after two seconds of pointer hover and on keyboard focus; Escape dismisses them. All new text uses the shared Chinese/English dictionary. Preserve the unrelated existing music and recording screen contracts. + +Check the built settings page in both themes, English/Chinese, narrow and short windows, and after restart. Storage cleanup needs synthetic filesystem regression tests; test profiles, screenshots and all caches stay on the selected development drive. diff --git a/UX-CONTRACT.md b/UX-CONTRACT.md new file mode 100644 index 0000000..7b2e71d --- /dev/null +++ b/UX-CONTRACT.md @@ -0,0 +1,39 @@ +# Settings UX contract + +Applies to the settings workspace introduced 2026-11-05. Existing product flows remain governed by docs/ARCHITECTURE.md, shared project schema, and their tests. + +## Scope and ownership + +Approved: S1, S2, S4, S5, S6, S7 and the subsequent Windows native playback follow-up. S3 remains excluded: no shortcut editor or changes to existing key bindings. No path migration, MIDI hardware input or CPU/GPU tuning is introduced. + +| Capability | Canonical owner | Source of truth | Allowed variants | Verification | +| ----------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- | +| Select/Listbox | src/shared/Select.tsx | DESIGN.md and this contract | desktop native where platform geometry is accepted; authored menu=true for app-owned popup | keyboard and popup | +| Form | labelled shared controls in settings | This contract and validated preferences | local validation and inline error; no native validation bubbles | invalid values and persistence | +| Scrollbar | src/styles/settings.css global baseline | DESIGN.md | content-pane geometry | narrow layout and computed styles | +| Toast | useWorkspace.notify / .toast role=status | This contract | success and error | failure/retry result | +| Dialog | ModalFrame / Radix managedFocus | This contract | full-window settings, local cleanup confirmation | Escape, focus trap/restoration | +| Tooltip | src/shared/HelpTooltips.tsx | This contract | delayed pointer / immediate focus | delay, Escape, disabled preference | +| Storage lifecycle | desktop/settings-storage.cjs | Approved cleanup scope and this contract | opaque preview token, bounded cleanup | path, race, busy, expired token tests | + +No date picker or table selection is introduced. Settings search is transient local state; it is not a shareable URL. + +## Flow decisions + +- Open settings from any route without unmounting the player or losing the current project. Model management opens the model category directly. +- General appearance changes take effect immediately and persist. Restoring a category resets only that category, not projects, devices, key bindings or data paths. +- A fresh empty library receives the guide once; it is skippable and may be reopened. Existing preference/profile data migrates without forcing a repeated guide. +- Audio defaults are separate from an explicit project recording override. The user may apply defaults to the current project. Device switching saves only after routing succeeds and is disabled while recording or calibrating. Compatibility capture calibration retains its existing input/output pair key; native playback adds backend, output device and processing rate to the route key. Explicit project corrections retain precedence. +- Settings use one Back action; operating-system window controls retain their normal meaning. Managed modal surfaces and controls are not draggable. Underlying page headers lose their drag regions while a managed modal is open; only the settings title region can drag the window. Native pointer testing must cover zoom changes and maximize/restore. +- Windows playback exposes compatibility output, WASAPI shared/exclusive/low-latency shared, DirectSound and installed x64 ASIO drivers. Unavailable drivers are not selectable. Processing rates are 44.1, 48, 88.2 and 96 kHz; shared-device mix rate is reported separately. Driver support determines whether a selection opens successfully. +- Engine changes preserve the project and playback position, stop output before rebuilding the processing context, and commit preferences only after the new route succeeds. Failure restores the previous engine and saved settings; failed recovery requests output selection. Active playback, recording or calibration prevents switching. Native errors do not clear unrelated analysis tasks. +- Native playback sends only the final stereo mix through a bounded PCM bridge. It silences the browser output and releases the device when idle. Recording input continues to use the selected Windows/browser capture device; native playback does not imply native ASIO input. +- Showing settings, checking environment state, or changing selected installation mode never installs anything. Installation/repair is a separate explicit action. Preparation logs are cleaned and folded behind a disclosure; an empty log never creates a strip. +- Storage statistics include actual locations, incomplete scans and unknown free space. Cache, installed models and original media are different categories. +- Cleanup begins with a preview describing file count, size and age rule. The backend returns an opaque five-minute token, checks current busy state and revalidates every candidate. It never follows links, accepts caller-provided paths, recursively removes unknown content, or deletes resumable downloads. Partial results are reported accurately. +- Diagnostics stay local: open an allowlisted application folder or copy a bounded status summary. No log upload, private music upload or telemetry. +- First-run guide, search, errors, status and confirmations support zh-CN/en, visible focus, keyboard use and theme changes. The settings heading and Back action remain visible throughout scrolling. The guide retains visible step progress, Skip and replay, and labels its illustrations as examples. + +## Recovery + +Failed saves retain the previous value. Failed storage scans expose retry. Changed/stale cleanup candidates survive and are reported as skipped. An active task prevents maintenance cleanup. Returning from settings restores focus to its trigger; ongoing music playback is preserved. diff --git a/assets/licenses/NAudio-LICENSE.txt b/assets/licenses/NAudio-LICENSE.txt new file mode 100644 index 0000000..8022176 --- /dev/null +++ b/assets/licenses/NAudio-LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2020 Mark Heath + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/assets/licenses/THIRD_PARTY_NOTICES.txt b/assets/licenses/THIRD_PARTY_NOTICES.txt index abd3b9f..60c5f85 100644 --- a/assets/licenses/THIRD_PARTY_NOTICES.txt +++ b/assets/licenses/THIRD_PARTY_NOTICES.txt @@ -172,6 +172,12 @@ git+https://github.com/radix-ui/primitives.git @radix-ui/react-use-is-hydrated 0.1.3 — MIT git+https://github.com/radix-ui/primitives.git +NAudio 2.2.1 — MIT (native Windows audio output) +https://github.com/naudio/NAudio/tree/v2.2.1 + +Microsoft .NET 10 portable runtime — license and third-party notices bundled in native-audio/licenses +https://github.com/dotnet/runtime + Bricolage Grotesque — SIL OFL 1.1 https://github.com/google/fonts/tree/main/ofl/bricolagegrotesque diff --git a/backend/analysis.py b/backend/analysis.py index 20d7e7c..8b33b03 100644 --- a/backend/analysis.py +++ b/backend/analysis.py @@ -14,6 +14,7 @@ def decode(source, destination, sr=44100): try: subprocess.run([ffmpeg,'-nostdin','-v','error','-y','-i',str(source),'-vn','-t','14401','-ac','2','-ar',str(sr),'-c:a','pcm_f32le','-rf64','auto',str(temp)],check=True,creationflags=0x08000000 if os.name=='nt' else 0) info=sf.info(temp) + if not math.isfinite(info.duration) or info.duration<=0:raise ValueError('音频中没有可读取的采样') if info.duration>14400:raise ValueError('音频最长四小时,请先分段处理') os.replace(temp,destination);return info finally:temp.unlink(missing_ok=True) diff --git a/desktop/application.cjs b/desktop/application.cjs index 1860367..1c62c94 100644 --- a/desktop/application.cjs +++ b/desktop/application.cjs @@ -1,6 +1,15 @@ const { audioToolPath, ensureAudioTools } = require('./audio-tools.cjs'); const { text: t, nativeDialogs } = require('./locale.cjs'); -const { app, BrowserWindow, ipcMain, dialog, protocol, net, shell } = require('electron'); +const { + app, + BrowserWindow, + ipcMain, + dialog, + protocol, + net, + shell, + clipboard, +} = require('electron'); const fs = require('node:fs'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -28,7 +37,10 @@ let win, peerFiles, takes, installing = false, - pending = false; + pending = false, + modelTransfers = 0, + maintenance, + nativeAudio; const base = process.env.IMMERSE_DATA_DIR || (development @@ -135,6 +147,24 @@ async function importFiles(files) { function handle(name, fn) { ipcMain.handle('jam:' + name, async (event, data) => { if (event.sender !== win.webContents) throw new Error('无效调用来源'); + if ( + maintenance?.cleaning && + ![ + 'settings-storage', + 'settings-diagnostics', + 'ui-language', + 'ui-scale', + 'library', + 'models', + 'runtime-status', + 'window', + 'audio-output-info', + 'audio-output-start', + 'audio-output-stop', + 'audio-output-write', + ].includes(name) + ) + throw new Error('正在清理临时内容,请稍后重试'); return fn(data || {}); }); } @@ -184,6 +214,32 @@ app backgroundThrottling: false, }, }); + const { + NativeAudio, + hostCommand, + validate: validateAudioOutput, + } = require('./native-audio.cjs'); + nativeAudio = new NativeAudio({ + ...hostCommand(root, app.isPackaged, process.resourcesPath), + emit, + }); + handle('audio-output-info', () => nativeAudio.info()); + handle('audio-output-start', (data) => { + if ( + takes.sessions.size && + JSON.stringify(validateAudioOutput(data)) !== JSON.stringify(nativeAudio.lastConfig) + ) + throw new Error('请先停止播放、录音或校准再切换音频系统'); + return nativeAudio.start(data); + }); + handle('audio-output-stop', ({ streamId }) => nativeAudio.stop(streamId)); + handle('audio-output-write', (data) => nativeAudio.write(data)); + win.webContents.on('render-process-gone', () => { + void nativeAudio.stop(); + }); + win.on('closed', () => { + void nativeAudio.stop(); + }); win.removeMenu(); const emitWindowState = () => { if (!win.isDestroyed()) @@ -397,6 +453,38 @@ app } }); const updates = new (require('./updates.cjs').Updates)(runtimeBase, app.getVersion()); + maintenance = require('./settings-ipc.cjs')({ + handle, + app, + shell, + clipboard, + getWindow: () => win, + roots: { + library: base, + runtime: runtimeBase, + profile: app.getPath('userData'), + logs: app.getPath('logs'), + }, + isBusy: () => + !!( + installing || + pending || + jobs.active || + modelTransfers || + updates.downloading || + takes.sessions.size + ), + getState: () => { + const status = runtimeStatus(); + return { + ready: status.ready, + runtimeMode: status.mode, + installing, + processing: !!jobs.active, + modelDownloads: modelTransfers, + }; + }, + }); handle('update-check', () => updates.check()); handle('update-download', () => updates.download((event) => emit({ ...event, scope: 'update' })), @@ -447,6 +535,7 @@ app }); handle('models', () => models.list()); handle('model-download', async ({ id }) => { + modelTransfers++; try { emit({ scope: 'model-download', @@ -467,6 +556,8 @@ app } catch (error) { emit({ scope: 'model-download', modelId: id, type: 'error', message: error.message }); throw error; + } finally { + modelTransfers--; } }); handle('model-source-open', ({ id, file }) => { @@ -708,7 +799,11 @@ app dialog.showErrorBox('Immerse Jam', error.message || String(error)); app.exit(1); }); +app.on('before-quit', () => { + void nativeAudio?.stop(); +}); app.on('window-all-closed', () => { + void nativeAudio?.stop(); takes?.close(); jobs?.close(); transfer?.stop(); diff --git a/desktop/installation.cjs b/desktop/installation.cjs index e401b2e..0fa3a0a 100644 --- a/desktop/installation.cjs +++ b/desktop/installation.cjs @@ -145,6 +145,7 @@ async function prepare(input, services = {}) { if (mode === 'cuda' && !services.runtime) { const result = await require('./gpu-health.cjs').probeGpu(python); if (!result.ready) { + if (result.detail) log({ message: 'GPU readiness check failed: ' + result.detail }); if (input.mode === 'cuda') throw new Error('NVIDIA 驱动不兼容,请重新安装并选择 CPU 或自动模式'); mode = 'cpu'; diff --git a/desktop/native-audio.cjs b/desktop/native-audio.cjs new file mode 100644 index 0000000..0fadfba --- /dev/null +++ b/desktop/native-audio.cjs @@ -0,0 +1,267 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawn } = require('node:child_process'); +const SYSTEMS = ['wasapi', 'wasapi-exclusive', 'wasapi-low-latency', 'directsound', 'asio']; +const RATES = [44100, 48000, 88200, 96000]; +function validate(config) { + if ( + !config || + !SYSTEMS.includes(config.backend) || + !RATES.includes(config.sampleRate) || + typeof config.deviceId !== 'string' || + !config.deviceId || + config.deviceId.length > 2048 || + config.deviceId.includes('\0') + ) + throw new Error('音频系统、设备或采样率无效'); + return { backend: config.backend, deviceId: config.deviceId, sampleRate: config.sampleRate }; +} +class NativeAudio { + constructor({ + executable, + assembly, + emit = () => {}, + spawnProcess = spawn, + exists = fs.existsSync, + platform = process.platform, + }) { + this.executable = executable; + this.assembly = assembly; + this.emit = emit; + this.spawn = spawnProcess; + this.exists = exists; + this.platform = platform; + this.session = null; + this.queue = Promise.resolve(); + } + get available() { + return ( + this.platform === 'win32' && + this.exists(this.executable) && + (!this.assembly || this.exists(this.assembly)) + ); + } + async info() { + if (!this.available) + return { + available: false, + devices: [], + errors: [], + reason: '原生音频组件尚未构建或此平台不支持', + }; + const result = await this.run(['--list']); + return { ...result, available: true }; + } + run(args) { + return new Promise((resolve, reject) => { + const child = this.spawn(this.executable, this.assembly ? [this.assembly, ...args] : args, { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = '', + error = '', + settled = false; + const finish = (problem, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (problem) reject(problem); + else resolve(value); + }; + const timer = setTimeout(() => { + child.kill(); + finish(new Error('原生音频组件响应超时')); + }, 8000); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (data) => { + output += data; + if (output.length > 131072) { + child.kill(); + finish(new Error('原生音频组件返回数据过大')); + } + }); + child.stderr.on('data', (data) => { + error = (error + data).slice(-4000); + }); + child.once('error', (problem) => finish(problem)); + child.once('close', (code) => { + try { + const data = JSON.parse(output.trim()); + if (code !== 0 || data.type === 'error') + throw new Error(data.message || error || '原生音频组件启动失败'); + finish(null, data); + } catch (problem) { + finish(problem); + } + }); + }); + } + start(input) { + const config = validate(input); + const work = this.queue.then(async () => { + if (!this.available) throw new Error('原生音频组件尚未构建或此平台不支持'); + await this.closeSession(); + const id = crypto.randomUUID(); + const child = this.spawn( + this.executable, + [ + ...(this.assembly ? [this.assembly] : []), + '--play', + Buffer.from(JSON.stringify(config)).toString('base64'), + ], + { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + const session = { id, child, config, stopping: false, failure: '', lines: '' }; + this.session = session; + const ready = await new Promise((resolve, reject) => { + let settled = false; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(result); + }; + const failed = (text) => { + session.failure = text; + if (!session.stopping && !session.notified) { + session.notified = true; + this.emit({ scope: 'native-audio', type: 'error', streamId: id, message: text }); + } + finish(new Error(text)); + }; + const timer = setTimeout(() => { + failed('原生音频组件响应超时'); + child.kill(); + }, 8000); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (bytes) => { + session.lines += bytes.toString('utf8'); + if (session.lines.length > 131072) { + failed('原生音频组件返回数据过大'); + child.kill(); + return; + } + let line; + while ((line = session.lines.indexOf('\n')) >= 0) { + const text = session.lines.slice(0, line).trim(); + session.lines = session.lines.slice(line + 1); + if (!text) continue; + try { + const value = JSON.parse(text); + if (value.type === 'ready') finish(null, value); + else if (value.type === 'error') failed(value.message || '原生音频输出中断'); + } catch { + failed('原生音频组件返回数据无效'); + child.kill(); + } + } + }); + child.stderr.on('data', (data) => { + session.failure = (session.failure + data).slice(-4000); + }); + child.stdin.on('error', (error) => { + if (!session.stopping) failed(error.message); + }); + child.once('error', (error) => failed(error.message)); + child.once('close', () => { + if (!session.stopping) failed(session.failure || '原生音频输出已断开'); + }); + }).catch(async (error) => { + await this.closeSession(id); + throw error; + }); + this.lastConfig = config; + return { ...ready, streamId: id }; + }); + this.queue = work.catch(() => {}); + return work; + } + async write({ streamId, samples } = {}) { + const session = this.session; + if (!session || session.id !== streamId || session.stopping) + return { accepted: false, stale: true }; + let bytes; + if (samples instanceof ArrayBuffer) bytes = Buffer.from(samples); + else if (ArrayBuffer.isView(samples)) + bytes = Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength); + else throw new Error('无效音频数据'); + if (!bytes.length || bytes.length > 32768 || bytes.length % 8) + throw new Error('无效音频数据长度'); + for (let offset = 0; offset < bytes.length; offset += 4) { + const value = bytes.readFloatLE(offset); + if (!Number.isFinite(value) || Math.abs(value) > 1.01) throw new Error('无效音频采样'); + } + if (session.child.stdin.writableLength > (session.config.sampleRate * 8) / 4) + throw new Error('音频输出积压,请增大缓冲或改用兼容模式'); + if (!session.child.stdin.write(bytes)) + await new Promise((resolve, reject) => { + const stream = session.child.stdin; + const done = (error) => { + clearTimeout(timer); + stream.off('drain', drained); + stream.off('error', done); + stream.off('close', closed); + if (error) reject(error); + else resolve(); + }; + const drained = () => done(), + closed = () => done(new Error('原生音频输出已断开')); + const timer = setTimeout( + () => done(new Error('音频输出积压,请增大缓冲或改用兼容模式')), + 2000, + ); + stream.once('drain', drained); + stream.once('error', done); + stream.once('close', closed); + }); + return { accepted: true }; + } + stop(id) { + const work = this.queue.then(() => this.closeSession(id)); + this.queue = work.catch(() => {}); + return work; + } + async closeSession(id) { + const session = this.session; + if (!session || (id && id !== session.id)) return; + this.session = null; + session.stopping = true; + const child = session.child; + if (child.exitCode !== null && child.exitCode !== undefined) return; + await new Promise((resolve) => { + let timer; + const done = () => { + clearTimeout(timer); + resolve(); + }; + child.once('close', done); + child.stdin.end(); + timer = setTimeout(() => { + child.kill(); + done(); + }, 1000); + }); + } +} +function hostCommand(root, packaged, resources) { + let base = path.join(resources || root, 'native-audio'); + if (!packaged) { + const parent = path.join(root, '.runtime', 'native-audio'); + let revision = 'unbuilt'; + try { + revision = JSON.parse(fs.readFileSync(path.join(parent, 'current.json'), 'utf8')).revision; + } catch {} + if (!/^[a-f0-9]{20}$/.test(revision)) revision = 'unbuilt'; + base = path.join(parent, 'versions', revision); + } + return { + executable: path.join(base, 'dotnet.exe'), + assembly: path.join(base, 'ImmerseAudioHost.dll'), + }; +} +module.exports = { NativeAudio, validate, hostCommand, SYSTEMS, RATES }; diff --git a/desktop/native-audio/ImmerseAudioHost.csproj b/desktop/native-audio/ImmerseAudioHost.csproj new file mode 100644 index 0000000..e714bd9 --- /dev/null +++ b/desktop/native-audio/ImmerseAudioHost.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0-windows + enable + enable + ImmerseAudioHost + win-x64 + false + false + false + none + + + + + + + diff --git a/desktop/native-audio/NAudio-LICENSE.txt b/desktop/native-audio/NAudio-LICENSE.txt new file mode 100644 index 0000000..8022176 --- /dev/null +++ b/desktop/native-audio/NAudio-LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2020 Mark Heath + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/desktop/native-audio/NuGet.config b/desktop/native-audio/NuGet.config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/desktop/native-audio/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/desktop/native-audio/Program.cs b/desktop/native-audio/Program.cs new file mode 100644 index 0000000..a75c928 --- /dev/null +++ b/desktop/native-audio/Program.cs @@ -0,0 +1,216 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace Immerse.AudioHost; + +sealed class PcmQueue : IWaveProvider +{ + readonly object gate = new(); + readonly byte[] buffer; + int head, length; + public WaveFormat WaveFormat { get; } + public int BufferedFrames { get { lock (gate) return length / 8; } } + public PcmQueue(int rate) + { + WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(rate, 2); + buffer = new byte[rate * 8 / 2]; + } + public void Write(byte[] source, int count) + { + if (count % 8 != 0) throw new InvalidDataException("PCM frame alignment"); + lock (gate) + { + if (count > buffer.Length - length) throw new IOException("Audio output queue overflow"); + for (int i = 0; i < count; i++) buffer[(head + length + i) % buffer.Length] = source[i]; + length += count; + } + } + public int Read(byte[] target, int offset, int count) + { + lock (gate) + { + int copy = Math.Min(count, length); + for (int i = 0; i < copy; i++) target[offset + i] = buffer[(head + i) % buffer.Length]; + head = (head + copy) % buffer.Length; + length -= copy; + Array.Clear(target, offset + copy, count - copy); + return count; + } + } +} +record Configuration(string Backend, string DeviceId, int SampleRate); +static class Program +{ + static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; + static readonly object MessageGate = new(); + static void Send(object value) { lock (MessageGate) { Console.WriteLine(JsonSerializer.Serialize(value, Json)); Console.Out.Flush(); } } + [DllImport("user32.dll")] static extern bool PeekMessage(out Message message, nint window, uint min, uint max, uint remove); + [DllImport("user32.dll")] static extern bool TranslateMessage(ref Message message); + [DllImport("user32.dll")] static extern nint DispatchMessage(ref Message message); + [StructLayout(LayoutKind.Sequential)] struct Message { public nint Window; public uint Id; public nuint W; public nint L; public uint Time; public int X; public int Y; public uint Private; } + [STAThread] + static int Main(string[] args) + { + Console.OutputEncoding = new UTF8Encoding(false); + try + { + if (args.SequenceEqual(new[] { "--self-test" })) return SelfTest(); + if (args.SequenceEqual(new[] { "--list" })) { ListDevices(); return 0; } + if (args.Length != 2 || args[0] != "--play") throw new ArgumentException("Invalid audio host command"); + var config = JsonSerializer.Deserialize(Encoding.UTF8.GetString(Convert.FromBase64String(args[1])), Json) + ?? throw new ArgumentException("Invalid configuration"); + if (!new[] { 44100, 48000, 88200, 96000 }.Contains(config.SampleRate) || + !new[] { "wasapi", "wasapi-exclusive", "wasapi-low-latency", "directsound", "asio" }.Contains(config.Backend) || + config.DeviceId.Length > 2048) throw new ArgumentException("Invalid audio configuration"); + return Play(config); + } + catch (Exception error) { Send(new { type = "error", message = error.Message, code = error.HResult }); return 1; } + } + static void ListDevices() + { + var devices = new List(); + var errors = new List(); + using var enumerator = new MMDeviceEnumerator(); + string? defaultId = null; + try { using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); defaultId = device.ID; } catch (Exception e) { errors.Add(e.Message); } + foreach (var device in enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)) + { + using (device) + { + using var probe = device.AudioClient; + var mixRate = probe.MixFormat.SampleRate; + foreach (var backend in new[] { "wasapi", "wasapi-exclusive", "wasapi-low-latency" }) + devices.Add(new { backend, deviceId = device.ID, name = device.FriendlyName, isDefault = device.ID == defaultId, deviceSampleRate = mixRate }); + } + } + try + { + foreach (var device in DirectSoundOut.Devices) + devices.Add(new { backend = "directsound", deviceId = device.Guid.ToString(), name = device.Description, isDefault = device.Guid == Guid.Empty, deviceSampleRate = (int?)null }); + } + catch (Exception e) { errors.Add(e.Message); } + try + { + foreach (var name in AsioOut.GetDriverNames()) + devices.Add(new { backend = "asio", deviceId = name, name, isDefault = false, deviceSampleRate = (int?)null }); + } + catch (Exception e) { errors.Add(e.Message); } + Send(new { available = true, architecture = "x64", devices, errors }); + } + static int Play(Configuration config) + { + var source = new PcmQueue(config.SampleRate); + MMDevice? endpoint = null; + using var enumerator = new MMDeviceEnumerator(); + IWavePlayer player; + int deviceRate = config.SampleRate, deviceBufferFrames = 0, primingFrames = 1024; + if (config.Backend.StartsWith("wasapi")) + { + endpoint = config.DeviceId == "default" + ? enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia) + : enumerator.GetDevice(config.DeviceId); + var exclusive = config.Backend == "wasapi-exclusive"; + var mode = exclusive ? AudioClientShareMode.Exclusive : AudioClientShareMode.Shared; + int milliseconds = config.Backend == "wasapi" ? 60 : 10; + primingFrames += config.SampleRate * milliseconds / 1000; + IWaveProvider provider = source; + int mixRate; + using (var probe = endpoint.AudioClient) + { + mixRate = probe.MixFormat.SampleRate; + if (exclusive && !probe.IsFormatSupported(mode, source.WaveFormat)) + { + var pcm16 = new WaveFormat(config.SampleRate, 16, 2); + if (!probe.IsFormatSupported(mode, pcm16)) + throw new NotSupportedException("The selected device does not support exclusive stereo output at this sample rate."); + provider = new WaveFloatTo16Provider(source); + } + } + var wasapi = new WasapiOut(endpoint, mode, true, milliseconds); + player = wasapi; + try { player.Init(provider); deviceRate = exclusive ? wasapi.OutputWaveFormat.SampleRate : mixRate; } + catch { player.Dispose(); endpoint.Dispose(); throw; } + if (exclusive && deviceRate != config.SampleRate) { player.Dispose(); endpoint.Dispose(); throw new NotSupportedException("Exclusive output cannot change the requested sample rate."); } + } + else if (config.Backend == "directsound") + { + var id = config.DeviceId == "default" ? Guid.Empty : Guid.Parse(config.DeviceId); + if (id != Guid.Empty && !DirectSoundOut.Devices.Any(d => d.Guid == id)) throw new ArgumentException("The DirectSound output device is unavailable."); + deviceRate = 0; // DirectSound does not expose the physical mixer sample rate. + primingFrames += config.SampleRate * 60 / 1000; + player = new DirectSoundOut(id, 60); + try { player.Init(source); } catch { player.Dispose(); throw; } + } + else + { + if (!AsioOut.GetDriverNames().Contains(config.DeviceId)) throw new ArgumentException("The selected x64 ASIO driver is unavailable."); + var asio = new AsioOut(config.DeviceId); + player = asio; + try + { + if (!asio.IsSampleRateSupported(config.SampleRate)) throw new NotSupportedException("The ASIO driver does not support this sample rate."); + player.Init(source); + deviceBufferFrames = asio.FramesPerBuffer; + primingFrames += deviceBufferFrames; + } + catch { player.Dispose(); throw; } + } + using (player) + using (endpoint) + using (var done = new ManualResetEventSlim(false)) + { + Exception? failure = null; + player.PlaybackStopped += (_, e) => { if (e.Exception != null) { failure = e.Exception; done.Set(); } }; + Send(new { type = "ready", backend = config.Backend, sampleRate = config.SampleRate, deviceSampleRate = deviceRate, deviceBufferFrames }); + var reader = Task.Run(() => + { + try + { + using var input = Console.OpenStandardInput(); + var bytes = new byte[32768 + 8]; + int carry = 0, read; + while ((read = input.Read(bytes, carry, 32768)) > 0) + { + int total = carry + read, aligned = total - total % 8; + if (aligned > 0) source.Write(bytes, aligned); + carry = total - aligned; + if (carry > 0) Array.Copy(bytes, aligned, bytes, 0, carry); + } + if (carry != 0) throw new InvalidDataException("Incomplete PCM frame"); + } + catch (Exception e) { failure = e; } + finally { done.Set(); } + }); + bool started = false; + while (!done.Wait(5)) + { + if (!started && source.BufferedFrames >= primingFrames) { player.Play(); started = true; } + while (PeekMessage(out var message, 0, 0, 0, 1)) { TranslateMessage(ref message); DispatchMessage(ref message); } + } + if (started) player.Stop(); + if (failure != null) throw failure; + } + return 0; + } + static int SelfTest() + { + var queue = new PcmQueue(48000); + var source = new byte[16]; + BitConverter.GetBytes(0.25f).CopyTo(source, 0); + BitConverter.GetBytes(-0.5f).CopyTo(source, 4); + queue.Write(source, source.Length); + var read = new byte[24]; + if (queue.Read(read, 0, read.Length) != read.Length || BitConverter.ToSingle(read) != 0.25f || + BitConverter.ToSingle(read, 4) != -0.5f || read.Skip(16).Any(b => b != 0) || queue.BufferedFrames != 0) + throw new Exception("PCM queue round trip failed"); + bool alignment = false, bounded = false; + try { queue.Write(source, 7); } catch (InvalidDataException) { alignment = true; } + try { queue.Write(new byte[48000 * 8], 48000 * 8); } catch (IOException) { bounded = true; } + if (!alignment || !bounded) throw new Exception("PCM queue bounds failed"); + Send(new { passed = true, checks = new[] { "stereo-float-round-trip", "zero-fill-underrun", "frame-alignment", "bounded-queue" } }); + return 0; + } +} diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 86e3b8f..8f7a5dd 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -1,6 +1,6 @@ // Generated from shared/ipc-methods.json. Run npm run build after changing the contract. const {contextBridge,ipcRenderer,webUtils}=require('electron'); -const allowed=["attach","audio-tools-ensure","cancel","creation-new","session-create","creation-add-group","creation-import-group","project-delete","project-recover","export","export-audio","import","library","migration-database-import","migration-database-scan","migration-detect","migration-import","migration-scan","model-download","model-import","models","open-license-folder","pair-select","pair-start","pair-stop","peer-append","peer-begin","peer-close","peer-finish","peer-outgoing","peer-prepare","peer-read","peer-select","pick-folder","read-attachment","recording-import","result-open","result-rename","result-delete","result-select","reveal","run","runtime-install","runtime-status","download-settings","update-check","update-download","update-install","playback-budget","take-append","take-delete","take-deleted","take-restore","take-finish","take-import","take-open","take-recover","take-select","take-start","ui-language","update","window","folders-list","folders-edit","feedback-open","attachment-delete","creation-remove-group","model-source-open"]; +const allowed=["attach","audio-tools-ensure","audio-output-info","audio-output-start","audio-output-stop","audio-output-write","cancel","creation-new","session-create","creation-add-group","creation-import-group","project-delete","project-recover","export","export-audio","import","library","migration-database-import","migration-database-scan","migration-detect","migration-import","migration-scan","model-download","model-import","models","open-license-folder","pair-select","pair-start","pair-stop","peer-append","peer-begin","peer-close","peer-finish","peer-outgoing","peer-prepare","peer-read","peer-select","pick-folder","read-attachment","recording-import","result-open","result-rename","result-delete","result-select","reveal","run","runtime-install","runtime-status","download-settings","update-check","update-download","update-install","playback-budget","take-append","take-delete","take-deleted","take-restore","take-finish","take-import","take-open","take-recover","take-select","take-start","ui-language","ui-scale","settings-storage","settings-cleanup-preview","settings-cleanup","settings-open-location","settings-diagnostics","settings-copy-diagnostics","update","window","folders-list","folders-edit","feedback-open","attachment-delete","creation-remove-group","model-source-open"]; contextBridge.exposeInMainWorld('jam',{ language:ipcRenderer.sendSync('jam:initial-ui').language||undefined, call:(method,data)=>{if(!allowed.includes(method))throw new Error('Unsupported operation');return ipcRenderer.invoke('jam:'+method,data);}, diff --git a/desktop/settings-ipc.cjs b/desktop/settings-ipc.cjs new file mode 100644 index 0000000..1ba482f --- /dev/null +++ b/desktop/settings-ipc.cjs @@ -0,0 +1,45 @@ +const { SettingsStorage } = require('./settings-storage.cjs'); +module.exports = function registerSettings({ + handle, + app, + shell, + clipboard, + getWindow, + roots, + isBusy, + getState, +}) { + const storage = new SettingsStorage({ ...roots, isBusy }); + storage.paths.installLog = require('node:path').join( + require('node:path').dirname(roots.library), + 'installation.log', + ); + handle('settings-storage', () => storage.info()); + handle('settings-cleanup-preview', ({ category }) => storage.preview(category)); + handle('settings-cleanup', ({ token }) => storage.clear(token)); + handle('settings-open-location', async ({ id }) => { + const error = await shell.openPath(await storage.location(id)); + if (error) throw new Error(error); + return true; + }); + const diagnostics = () => ({ + version: app.getVersion(), + platform: process.platform, + electron: process.versions.electron, + ...getState(), + locations: storage.paths, + }); + handle('settings-diagnostics', diagnostics); + handle('settings-copy-diagnostics', () => { + clipboard.writeText(JSON.stringify(diagnostics(), null, 2)); + return true; + }); + handle('ui-scale', ({ scale }) => { + if (typeof scale !== 'number' || !Number.isFinite(scale) || scale < 0.8 || scale > 1.5) + throw new Error('界面缩放需要在80%至150%之间'); + const contents = getWindow().webContents; + if (contents.getZoomFactor() !== scale) contents.setZoomFactor(scale); + return { scale: contents.getZoomFactor() }; + }); + return storage; +}; diff --git a/desktop/settings-storage.cjs b/desktop/settings-storage.cjs new file mode 100644 index 0000000..2b64405 --- /dev/null +++ b/desktop/settings-storage.cjs @@ -0,0 +1,231 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const fsp = fs.promises; +const DAY = 86400000; +const UUID = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i; +const signature = (s) => [s.dev, s.ino, s.size, s.mtimeMs, s.ctimeMs].join(':'); +const contained = (root, target) => { + const rel = path.relative(root, target); + return !rel || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel)); +}; +async function safePath(root, target) { + root = path.resolve(root); + target = path.resolve(target); + if (!contained(root, target)) return false; + try { + if ((await fsp.realpath(root)).toLowerCase() !== root.toLowerCase()) return false; + const parts = path.relative(root, target).split(path.sep).filter(Boolean); + let current = root; + for (const part of ['', ...parts]) { + if (part) current = path.join(current, part); + const stat = await fsp.lstat(current); + if (stat.isSymbolicLink() || (stat.isFile() && stat.nlink > 1)) return false; + } + return true; + } catch { + return false; + } +} +async function tree(root, limit = 60000) { + let bytes = 0, + count = 0, + complete = true; + const files = [], + directories = []; + const visit = async (file) => { + if (++count > limit) { + complete = false; + return; + } + let stat; + try { + stat = await fsp.lstat(file); + } catch (error) { + if (error.code !== 'ENOENT') complete = false; + return; + } + if (stat.isSymbolicLink()) { + complete = false; + return; + } + const entry = { + path: file, + signature: signature(stat), + modified: stat.mtimeMs, + bytes: stat.size, + linked: stat.isFile() && stat.nlink > 1, + }; + if (stat.isFile()) { + bytes += stat.size; + files.push(entry); + } else if (stat.isDirectory()) { + directories.push(entry); + try { + for (const child of await fsp.readdir(file)) { + if (count > limit) break; + await visit(path.join(file, child)); + } + } catch { + complete = false; + } + } else complete = false; + }; + await visit(root); + return { bytes, files, directories, complete }; +} +class SettingsStorage { + constructor({ library, runtime, profile, logs, isBusy = () => false, now = Date.now }) { + this.paths = Object.fromEntries( + Object.entries({ library, runtime, profile, logs }).map(([k, v]) => [k, path.resolve(v)]), + ); + Object.assign(this.paths, { + models: path.join(this.paths.runtime, 'models'), + cache: path.join(this.paths.runtime, 'cache'), + temp: path.join(this.paths.runtime, 'temp'), + jobs: path.join(this.paths.runtime, 'jobs'), + }); + this.isBusy = isBusy; + this.now = now; + this.cleaning = false; + this.previews = new Map(); + } + async info() { + const ids = ['library', 'models', 'cache', 'temp', 'jobs', 'profile', 'logs']; + const rows = await Promise.all( + ids.map(async (id) => { + const data = await tree(this.paths[id]); + return { id, path: this.paths[id], bytes: data.bytes, complete: data.complete }; + }), + ); + const runtime = await Promise.all( + ['python', 'venv', 'tools'].map((n) => tree(path.join(this.paths.runtime, n))), + ); + rows.splice(2, 0, { + id: 'runtime', + path: this.paths.runtime, + bytes: runtime.reduce((n, x) => n + x.bytes, 0), + complete: runtime.every((x) => x.complete), + }); + let freeBytes = null; + try { + const disk = await fsp.statfs(this.paths.runtime); + freeBytes = disk.bavail * disk.bsize; + } catch {} + return { locations: rows, freeBytes, busy: this.isBusy() || this.cleaning }; + } + async candidates(category) { + if (!['temp', 'jobs'].includes(category)) throw new Error('此类别不支持清理'); + const root = this.paths[category], + age = category === 'temp' ? DAY : 7 * DAY; + if (!(await safePath(root, root))) return []; + const result = []; + for (const name of await fsp.readdir(root)) { + if (category === 'jobs' && !UUID.test(name)) continue; + const target = path.join(root, name); + const data = await tree(target); + if (!data.complete || !data.files.length || data.files.some((file) => file.linked)) continue; + const entries = [...data.files, ...data.directories]; + if ( + entries.some( + (f) => + this.now() - f.modified < age || + /(?:\.parts?(?:\.|$)|resume|download|\.lock$)/i.test(path.basename(f.path)), + ) + ) + continue; + if (category === 'jobs') { + if ( + data.directories.length !== 1 || + data.files.some((f) => !['request.json', 'worker.log'].includes(path.basename(f.path))) + ) + continue; + try { + const request = JSON.parse(await fsp.readFile(path.join(target, 'request.json'), 'utf8')); + if (!['separate', 'analyze', 'align'].includes(request.operation)) continue; + } catch { + continue; + } + } + if (!(await safePath(root, target))) continue; + result.push({ root, target, ...data }); + } + return result; + } + async preview(category) { + if (this.isBusy() || this.cleaning) throw new Error('请等待录音、下载和处理任务结束后再清理'); + for (const [id, p] of this.previews) if (p.expires < this.now()) this.previews.delete(id); + if (this.previews.size >= 8) this.previews.delete(this.previews.keys().next().value); + const candidates = await this.candidates(category), + token = crypto.randomUUID(); + const value = { category, candidates, expires: this.now() + 5 * 60000 }; + this.previews.set(token, value); + return { + token, + category, + expires: value.expires, + files: candidates.reduce((n, x) => n + x.files.length, 0), + bytes: candidates.reduce((n, x) => n + x.bytes, 0), + }; + } + async clear(token) { + const snapshot = this.previews.get(token); + if (!snapshot || snapshot.expires < this.now()) throw new Error('清理预览已过期,请重新扫描'); + if (this.isBusy() || this.cleaning) throw new Error('请等待录音、下载和处理任务结束后再清理'); + this.previews.delete(token); + this.cleaning = true; + const result = { deletedFiles: 0, freedBytes: 0, skippedFiles: 0, failedFiles: 0 }; + try { + for (const candidate of snapshot.candidates) { + let valid = !this.isBusy(); + for (const entry of [...candidate.directories, ...candidate.files]) { + if (!valid) break; + try { + valid = + (await safePath(candidate.root, entry.path)) && + signature(await fsp.lstat(entry.path)) === entry.signature; + } catch { + valid = false; + } + } + if (!valid) { + result.skippedFiles += candidate.files.length; + continue; + } + for (const entry of candidate.files) { + try { + if ( + this.isBusy() || + !(await safePath(candidate.root, entry.path)) || + signature(await fsp.lstat(entry.path)) !== entry.signature + ) { + result.skippedFiles++; + continue; + } + await fsp.unlink(entry.path); + result.deletedFiles++; + result.freedBytes += entry.bytes; + } catch { + result.failedFiles++; + } + } + // Never recursively remove a directory: new or unrecognized files survive. + for (const directory of [...candidate.directories].reverse()) { + try { + if (await safePath(candidate.root, directory.path)) await fsp.rmdir(directory.path); + } catch {} + } + } + return result; + } finally { + this.cleaning = false; + } + } + async location(id) { + if (!Object.hasOwn(this.paths, id)) throw new Error('无效存储位置'); + const location = this.paths[id]; + if (!fs.existsSync(location)) throw new Error('该位置尚未创建'); + return location; + } +} +module.exports = { SettingsStorage }; diff --git a/desktop/store.cjs b/desktop/store.cjs index cda86ac..1066ef7 100644 --- a/desktop/store.cjs +++ b/desktop/store.cjs @@ -42,8 +42,14 @@ function atomicJson(file, value) { try { fs.writeFileSync(temp, JSON.stringify(value, null, 2)); replaceFile(temp, file); - } finally { - fs.rmSync(temp, { force: true }); + } catch (error) { + // A successful rename consumes the temp file. Clean up only on failure. + try { + fs.unlinkSync(temp); + } catch { + /* Preserve the original write or rename error. */ + } + throw error; } } // Windows scanners and the native installer's INI reader can briefly hold the diff --git a/docs/plans/2026-11-05-settings-follow-up.md b/docs/plans/2026-11-05-settings-follow-up.md new file mode 100644 index 0000000..bd16aca --- /dev/null +++ b/docs/plans/2026-11-05-settings-follow-up.md @@ -0,0 +1,13 @@ +# Settings follow-up and native audio + +User-confirmed scope: remove the settings close button in favor of Back; fix native mouse input after UI zoom/window restore; remove empty runtime log bars; implement genuine Windows audio backend and sample-rate switching, including available ASIO/exclusive devices; redesign onboarding; put #66CCFF first in waveform presets. S3 custom shortcuts remains excluded. + +1. Reproduce native hit testing, harden modal drag-region ownership, and make Back the single settings return action. +2. Fold non-empty cleaned preparation logs into a disclosure; do not render blank log strips. +3. Build an isolated native Windows audio host using pinned NAudio and a bundled portable .NET runtime. Keep all SDKs, NuGet caches, build intermediates and binaries below .runtime on E:. +4. Route the existing final stereo mix through a bounded PCM bridge to WASAPI shared/exclusive, low-latency shared, DirectSound and installed x64 ASIO drivers. Keep browser compatibility output available. Validate devices/rates and restore the previous engine on failure. Recording/capture locks prevent mid-session switching. +5. Add actual sample-rate configuration by rebuilding the processing context while preserving project, position and saved device settings. Reuse the current input capture; label output backend vs recording input accurately. +6. Redesign the guide with workflow illustrations, a readable step rail and persistent actions. Preserve skip/replay and the legacy reference diagram. +7. Test native host lifecycle/IPC/PCM bounds, engine rollback, rates, preferences, guide, blank logs, and settings Back after zoom/restore. Visually inspect both themes and narrow layouts; use actual Windows pointer input for drag-region regressions. + +Do not overwrite the installed application or private music library. Preserve the manual test profile and relaunch the updated development application after acceptance. Physical ASIO/exclusive success depends on installed drivers/device support; enumerate capabilities honestly and report the exact hardware coverage. diff --git a/docs/plans/2026-11-05-settings-workspace.md b/docs/plans/2026-11-05-settings-workspace.md new file mode 100644 index 0000000..ecad960 --- /dev/null +++ b/docs/plans/2026-11-05-settings-workspace.md @@ -0,0 +1,18 @@ +# Settings workspace implementation + +Historical initial plan. Native Windows playback was subsequently approved; see [the follow-up plan](2026-11-05-settings-follow-up.md) and its review for the final scope. + +Approved: S1, S2, S4, S5, S6, S7. S3 is excluded. Existing key bindings remain unchanged. + +Work in E:/Development/immerse-jam on feat/settings-workspace-20261105. Preserve the installed application, private library, model assets, existing license status and previous local audit documents. Put all generated output, test profiles and caches below .runtime on E:. + +1. Add canonical settings structure and design/behavior contracts grounded in existing runtime tokens. +2. Add bounded storage/diagnostic services and test their path, concurrency and stale-preview protections with synthetic files. +3. Add validated appearance/help preferences, delayed shared tooltips and first-run onboarding. +4. Share audio defaults and device-pair calibration with recording, preserving project overrides and recording locks. +5. Compose the categorized searchable settings workspace; model entry opens its category, runtime checks do not install, diagnostic actions remain local. +6. Run formatting, type/i18n/IPC checks, unit tests and build. Exercise the built UI with isolated synthetic fixtures: first run, persistence, navigation, search, audio failures, cleanup preview, protected downloads, busy state, theme/language, keyboard and narrow layout. Capture screenshots on E:. + +Storage cleanup is explicit and previewed. It targets expired temporary contents and inactive old UUID job logs only, checks all path components and revalidates file identity. It never traverses links or deletes resumable downloads, project media, recordings, models, Python environments or unknown task contents. No production cleanup is performed while developing or testing this feature. + +Not included: custom key bindings, path migration, independent monitor volume, backend/ASIO selection, exact sample-buffer control, CPU/GPU tuning, MIDI hardware input, changes to music analysis quality, app installation/deployment or publication. diff --git a/docs/reviews/2026-11-05-settings-follow-up.md b/docs/reviews/2026-11-05-settings-follow-up.md new file mode 100644 index 0000000..3ac290f --- /dev/null +++ b/docs/reviews/2026-11-05-settings-follow-up.md @@ -0,0 +1,57 @@ +# Settings follow-up review + +Date: 2026-11-05. This follows the initial [settings workspace review](2026-11-05-settings-workspace.md). The user approved all five follow-up items and actual Windows native playback, including available ASIO and exclusive devices. S3 custom shortcuts remains excluded. + +## Result + +| Request | Implemented behavior | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Settings return and clicking | One Back action replaces the duplicate settings close button. OS window controls are retained. Managed modal controls and underlying headers have explicit drag-region ownership; only the settings title area drags the window. | +| Preparation black strip | ANSI/CR/blank output is cleaned. Non-empty logs appear under “查看准备记录”; empty logs render no strip. Checking or opening settings does not trigger installation. | +| Playback system and sample rate | Actual compatibility output, WASAPI shared/exclusive/low-latency shared, DirectSound and installed x64 ASIO. Processing rates: 44.1/48/88.2/96 kHz. Unavailable ASIO is disabled. Failed driver startup or preference persistence restores the old route. | +| Guide | Four workflow steps with code-native illustrations, a step rail, fixed actions, Skip and replay. Both themes and narrow layouts are supported. | +| Waveform color | #66CCFF is the first preset. Existing saved colors and the original default are preserved. | + +Native playback receives the existing player's final limited stereo mix through an AudioWorklet/IPC bridge. The browser sink is silent while native output is active. The host releases the output device while idle. Sample-rate changes rebuild the processing context and retain the current project and seek position. Playback, recording and calibration lock engine changes. + +Recording input still uses the selected Windows/browser capture device. This implementation does not add native ASIO recording, a buffer-size editor, CPU/GPU tuning, MIDI input or shortcut customization. The UI distinguishes processing sample rate from the Windows device mix rate. DirectSound reports its device rate as unknown when the API cannot establish it. + +Native output errors are isolated from background analysis jobs, pause playback and notify recording/calibration through the existing audio-route event. Calibration keys distinguish native backend/device/rate; explicit project correction remains authoritative. + +## Build and distribution + +The Windows host uses NAudio Core/Wasapi/Asio 2.2.1 with license notices. An E:-isolated .NET SDK 10.0.403 builds the managed host; a portable .NET runtime 10.0.14 is bundled beside it. No system .NET installation is required. The build script disables first-run certificate generation and places SDK/NuGet caches, objects and versioned host outputs below .runtime. + +Development resolves an immutable host version through current.json. Packaging prepares the native host and includes its portable runtime as extra resources. This review covers source/build integration; a new installer or release was not produced. + +## Validation + +| Check | Evidence and result | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Repository check | Passed TypeScript, desktop syntax and 1,284 translation entries. | +| Unit suite | 144 passed, 0 failed. Includes native caller/config validation, stream ownership, PCM bounds/backpressure, driver startup failure, rate preferences and persistence recovery. | +| Production UI build | Passed. Existing dependency directive/chunk warnings remain. | +| Existing UI regressions | ci-ui-check, controls-check, interaction-check, preferences-check and settings-ui-check passed. | +| Follow-up desktop checks | Passed guide navigation, cleaned log disclosure, 44.1 kHz processing context, preserved 5 s seek position, non-zero final-mix PCM through the IPC bridge to a simulated endpoint, unavailable ASIO, driver/persistence rollback and bilingual/theme coverage. | +| Actual Windows pointer | Passed at 150% and 100% after confirmed maximize/restore. Computer Use clicked the visible Back control; the harness independently checked that settings closed and the saved scale was unchanged. | +| Settings static UI audit | Strict mode: 0 findings in the configured settings scope. The initial whole-project scan's 34 legacy findings remain outside this scope. | +| Source audit | Passed 559 files; generated/private runtime content stays outside source control. | + +Real hardware was also opened briefly with silence, without microphone capture or volume changes: + +| System | Processing rate | Reported device rate | Result | +| ------------------------- | --------------- | ------------------------- | -------------------------------------------------------- | +| WASAPI shared | 48 kHz | 48 kHz | Opened, wrote silence, closed | +| WASAPI low-latency shared | 48 kHz | 48 kHz | Opened, wrote silence, closed | +| WASAPI exclusive | 48 kHz | 48 kHz | Opened, wrote silence, closed | +| DirectSound | 48 kHz | Unknown | Opened, wrote silence, closed | +| Realtek ASIO | 48 kHz | 48 kHz; 480-sample buffer | Opened, wrote silence, closed | +| WASAPI shared | 44.1 kHz | 48 kHz | Opened, wrote silence, closed; rate distinction verified | + +Hardware evidence establishes short host/device lifecycle checks. Audible music quality, long-run synchronization, physical input recording, Bluetooth behavior and 88.2/96 kHz hardware support were not validated. Driver-specific rate support is checked when a user applies the selection. + +Both guide themes, native audio settings, cyan ordering, log disclosure and narrow 150% layouts were visually inspected. Logs, screenshots and structured results are under .runtime/settings-followup-20261105 and are excluded from source control. Tests use disposable profiles and synthetic audio; the installed application and private music library are preserved. + +## Delivery + +Source changes and an updated development build, prepared for a separate settings/native-playback PR based on the installation/data-integrity branch. No installer, release or installed-application replacement is included. The earlier audit PR remains a separate dependency. diff --git a/docs/reviews/2026-11-05-settings-workspace.md b/docs/reviews/2026-11-05-settings-workspace.md new file mode 100644 index 0000000..255ee77 --- /dev/null +++ b/docs/reviews/2026-11-05-settings-workspace.md @@ -0,0 +1,53 @@ +# Settings workspace implementation review + +Historical first-pass review. The later user-approved changes, including the single Back action, redesigned guide and native Windows playback, are covered by [the settings follow-up review](2026-11-05-settings-follow-up.md). Validation counts and hardware boundaries below describe this first pass. + +Date: 2026-11-05. Scope approved before implementation: S1, S2, S4, S5, S6 and S7. S3 (custom key bindings) is excluded. + +## Result + +The desktop settings dialog is now a full-window workspace with five categories: General and appearance, Audio and recording, Models and runtime, Storage and cache, and About and diagnostics. Search accepts Chinese and English terms. The heading and return controls remain visible while content scrolls. Small windows use the shared category Select. + +| Scope | Implemented behavior | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | Categorized navigation, search/empty results, fixed return/close controls and category-specific defaults. Player and project remain mounted. | +| S2 | Native 80–150% interface scale, delayed hover help, first-run guide that can be skipped and replayed, existing theme/accent/waveform preferences. | +| S4 | Shared input/output/channel/latency defaults, explicit application to the current project, per-device calibration and device refresh. Explicit project settings take priority over later global changes, including calibration for the same device pair. | +| S5 | Data locations, category sizes, incomplete-scan state, available disk space, folder actions and previewed cleanup of expired temporary files and old known task records. | +| S6 | Separate runtime check and confirmed prepare/repair actions, installation mode selection, model filters and direct model-management entry. Existing download/import/verification flows are reused. | +| S7 | Version/manual update entry, local log access, local diagnostic summary copy and license notices. | + +SV2 screenshots informed grouping and information hierarchy. Synthesis-specific controls, recording-note rules, MIDI devices, ASIO/backend selection, buffer tuning, CPU/GPU tuning, independent monitor gain and path migration are outside this change. Existing key bindings are preserved. + +## Recovery and data handling + +- Appearance and audio preferences publish only after persistence succeeds. Output-routing failures preserve the saved device. A failed global-default write restores the previous device-pair calibration. +- New projects inherit audio defaults. An existing project's explicit device, channel and latency stay in force until the user applies defaults to that project. +- Settings search and category navigation do not install dependencies. Runtime preparation requires a separate confirmation. +- Cleanup uses an opaque five-minute preview token. It rechecks busy state, containment, links and file identity before removal. It unlinks only known files and removes only empty known directories; it never performs recursive deletion. +- Resumable downloads, music, takes, stems, model weights, Python environments and unknown task contents are outside cleanup. Partial cleanup reports removed, skipped and failed file counts. +- Diagnostic summaries stay on the local clipboard. No telemetry or log/media upload is introduced. + +## Validation + +| Check | Result | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| TypeScript, translation catalog and desktop syntax | Passed; 1,246 translation entries. | +| Unit suite | 134 passed, 0 failed. Covers audio defaults/project precedence, failed persistence, cleanup path/link/race/busy/expiry handling and settings IPC validation. | +| Production build | Passed. Existing dependency directive/chunk warnings remain. | +| Browser UI regression | `scripts/ci-ui-check.cjs` passed. | +| Native playback controls | `scripts/controls-check.cjs` passed. | +| Native waveform/chord/project interactions | `scripts/interaction-check.cjs` passed. | +| Native appearance and recording preferences | `scripts/preferences-check.cjs` passed, including a project correction surviving a subsequent global calibration change. | +| Native settings acceptance | `scripts/settings-ui-check.cjs` passed: onboarding, bilingual search, zoom, themes, tooltip timing, audio failures, calibration, model check/install separation, cleanup preservation, keyboard focus and restart persistence. | +| Settings scoped UI audit | Strict scan passed with 0 findings using `docs/reviews/settings-ui-audit.json`. Dialog/tooltip behavior is documented and checked by native tests because those capability names are unsupported by the static scanner. | + +Native UI acceptance uses disposable application data, mocked devices and synthetic audio. A simulated 180 ms delayed signal produced a 199 ms estimate, within the existing 35 ms tolerance. The clipboard action is stubbed in the native acceptance script; the real handler is tested with an injected clipboard in the unit suite. No private library or real microphone is used. + +Screenshots and detailed logs are local artifacts under `.runtime/settings-validation-20261105/` and are excluded from source control. Chinese/dark, English/light, cleanup confirmation/result, diagnostics, onboarding and narrow 150% layouts were visually inspected. + +The separate whole-project static UI scan still reports 34 findings (17 unresolved native-control ownership prompts and 17 legacy control/style pattern findings). This change's strict scope is the settings feature; the rest of the product has not been brought under a new component ownership policy. + +## Delivery boundary + +Source implementation only. No installed application was replaced, no runtime/model downloads or production cleanup were performed, and no new release or PR was published. Real sound-card, Bluetooth and physical microphone behavior still needs hardware validation. The Android settings layout retains its existing flow; no Android build/device validation is claimed. diff --git a/docs/reviews/settings-ui-audit.json b/docs/reviews/settings-ui-audit.json new file mode 100644 index 0000000..b978c7f --- /dev/null +++ b/docs/reviews/settings-ui-audit.json @@ -0,0 +1,24 @@ +{ + "profile": "product-admin", + "sourceRoots": ["src/features/settings"], + "locale": "zh-CN", + "canonicalMap": "UX-CONTRACT.md", + "requiredCapabilities": ["Select/Listbox", "Form", "Scrollbar", "Toast"], + "ownership": { + "Select/Listbox": "native" + }, + "manualChecks": [ + "Dialog and Tooltip are documented in UX-CONTRACT.md and exercised by desktop UI tests; the static auditor does not recognize these capability names." + ], + "scopeNote": "Static rules cover the settings feature. App-wide legacy findings are recorded separately; shared integration is verified by repository checks and desktop tests.", + "requiredCommands": ["check", "unit", "ui"], + "commands": { + "check": "npm run check", + "unit": "npm test", + "ui": "node scripts/settings-ui-check.cjs" + }, + "evidence": { + "crudFullFlow": "scripts/settings-ui-check.cjs", + "failurePaths": "tests/settings-storage.test.cjs" + } +} diff --git a/package.json b/package.json index 8a1bb23..809484e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "main": "desktop/main.cjs", "scripts": { "dev": "node scripts/dev.cjs", + "build:native-audio": "node scripts/prepare-native-audio.cjs", "build": "node scripts/prepare-preload.cjs && tsc --noEmit && vite build && node scripts/prepare-notices.cjs && node scripts/copy-assets.cjs", "start": "electron .", "test": "node --test tests/*.test.cjs", @@ -80,6 +81,10 @@ "from": "assets", "to": "assets" }, + { + "from": ".runtime/native-audio/package", + "to": "native-audio" + }, { "from": "release/installer-payload", "to": "vendor" diff --git a/scripts/ci-ui-check.cjs b/scripts/ci-ui-check.cjs index 334a31d..40c629c 100644 --- a/scripts/ci-ui-check.cjs +++ b/scripts/ci-ui-check.cjs @@ -101,8 +101,10 @@ let browser; await page.goto('http://127.0.0.1:' + server.address().port); assert.equal(await page.locator('.practice-guide, .brand small').count(), 0); await page.getByRole('button', { name: '第一次使用?', exact: true }).first().click(); + await page.getByRole('heading', { name: '从一首歌开始', exact: true }).waitFor(); + await page.getByText('查看界面图解与快捷操作', { exact: true }).click(); await page.getByRole('img', { name: '练习界面图解', exact: true }).waitFor(); - await page.getByRole('button', { name: '关闭窗口', exact: true }).click(); + await page.getByRole('button', { name: '跳过引导', exact: true }).click(); await page.screenshot({ path: '.runtime/review-desktop-library.png' }); await page .getByRole('button', { name: /交互验收/ }) diff --git a/scripts/feedback-ui-check.cjs b/scripts/feedback-ui-check.cjs index 4b522a7..97429a4 100644 --- a/scripts/feedback-ui-check.cjs +++ b/scripts/feedback-ui-check.cjs @@ -239,8 +239,10 @@ let browser; return; } await page.getByRole('button', { name: '第一次使用?', exact: true }).click(); - assert.equal(await page.locator('.help-first-steps li').count(), 3); - await page.getByRole('button', { name: '关闭窗口', exact: true }).click(); + const guide = page.getByRole('dialog', { name: '使用引导', exact: true }); + await guide.getByRole('heading', { name: '从一首歌开始', exact: true }).waitFor(); + assert.equal(await guide.locator('.help-first-steps li').count(), 3); + await guide.getByRole('button', { name: '跳过引导', exact: true }).click(); assert.equal(await page.getByRole('button', { name: '管理模型', exact: true }).count(), 0); assert.equal(await page.getByText('音乐留在你的设备上', { exact: true }).count(), 0); const nav = page.locator('.sidebar'); diff --git a/scripts/native-audio-check.cjs b/scripts/native-audio-check.cjs new file mode 100644 index 0000000..72531a1 --- /dev/null +++ b/scripts/native-audio-check.cjs @@ -0,0 +1,63 @@ +// Opens output devices with a short silent stream. No input capture or system-volume changes. +const fs = require('node:fs'), + path = require('node:path'); +const { NativeAudio, hostCommand } = require('../desktop/native-audio.cjs'); +const output = path.resolve('.runtime/settings-followup-20261105'); +fs.mkdirSync(output, { recursive: true }); +const events = []; +const service = new NativeAudio({ + ...hostCommand(process.cwd(), false), + emit: (e) => events.push(e), +}); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +(async () => { + const info = await service.info(); + const results = []; + const trials = ['wasapi', 'wasapi-low-latency', 'wasapi-exclusive', 'directsound', 'asio'].map( + (backend) => ({ backend, sampleRate: 48000 }), + ); + trials.push({ backend: 'wasapi', sampleRate: 44100 }); + for (const { backend, sampleRate } of trials) { + const devices = info.devices.filter((d) => d.backend === backend); + const device = devices.find((d) => d.isDefault) || devices[0]; + if (!device) { + results.push({ backend, opened: false, reason: 'No driver/device enumerated' }); + continue; + } + const before = events.length; + try { + const ready = await service.start({ backend, sampleRate, deviceId: device.deviceId }); + for (let i = 0; i < 16; i++) { + await service.write({ streamId: ready.streamId, samples: new Float32Array(1024).buffer }); + await sleep(11); + } + await sleep(60); + const failures = events.slice(before).filter((e) => e.type === 'error'); + if (failures.length) throw new Error(failures.map((e) => e.message).join('; ')); + results.push({ + backend, + opened: true, + sampleRate, + deviceSampleRate: ready.deviceSampleRate, + deviceBufferFrames: ready.deviceBufferFrames, + pcm: 'silence', + }); + } catch (error) { + results.push({ backend, opened: false, sampleRate, reason: error.message }); + } finally { + await service.stop(); + await sleep(250); + } + } + fs.writeFileSync( + path.join(output, 'native-device-check.json'), + JSON.stringify({ enumerated: info, results }, null, 2), + ); + console.log('NATIVE_DEVICE_CHECK', JSON.stringify(results)); + if (!results.some((r) => r.opened)) process.exitCode = 1; +})() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => service.stop()); diff --git a/scripts/package.cjs b/scripts/package.cjs index c1044aa..8038e90 100644 --- a/scripts/package.cjs +++ b/scripts/package.cjs @@ -21,6 +21,12 @@ const env = { TMP: temp, ELECTRON_BUILDER_CACHE: path.join(root, '.runtime', 'builder-cache'), }; +const nativeAudioBuild = spawnSync( + process.execPath, + [path.join(root, 'scripts/prepare-native-audio.cjs')], + { cwd: root, env, stdio: 'inherit', windowsHide: true }, +); +if (nativeAudioBuild.status !== 0) process.exit(nativeAudioBuild.status ?? 1); const payload = spawnSync( process.execPath, [path.join(root, 'scripts/prepare-installer-assets.cjs')], diff --git a/scripts/preferences-check.cjs b/scripts/preferences-check.cjs index 38bf9e1..1776b26 100644 --- a/scripts/preferences-check.cjs +++ b/scripts/preferences-check.cjs @@ -34,7 +34,7 @@ let app, page; }; }); await page.getByRole('button', { name: '跟练/创作', exact: true }).click(); - await page.getByRole('button', { name: '打开 偏好验收', exact: true }).click(); + await page.locator('.session-title').filter({ hasText: '偏好验收' }).click(); await page.waitForFunction(() => !document.querySelector('.play-main').disabled); await page.getByRole('button', { name: '设置与模型', exact: true }).click(); await page.getByRole('combobox', { name: '界面语言', exact: true }).selectOption('en'); @@ -42,6 +42,7 @@ let app, page; await page.getByRole('combobox', { name: 'Accent color', exact: true }).selectOption('blue'); assert.equal(await page.locator('html').getAttribute('lang'), 'en'); assert.equal(await page.locator('html').getAttribute('data-theme'), 'light'); + await page.getByRole('button', { name: 'Models and runtime', exact: true }).click(); assert.ok( await page .locator('.model-description strong') @@ -52,9 +53,9 @@ let app, page; assert.ok(!/[\u4e00-\u9fff]/.test(settingsText.replace('简体中文', '')), settingsText); await page.waitForTimeout(250); await page.screenshot({ path: 'test-output/v014-settings-en-light.png' }); - await page.getByRole('button', { name: 'Close window', exact: true }).click(); + await page.getByRole('button', { name: 'Return to the music workspace', exact: true }).click(); await page.screenshot({ path: 'test-output/v014-player-en-light.png' }); - await page.getByRole('button', { name: 'Practice recording', exact: true }).click(); + await page.getByRole('button', { name: 'Recording setup', exact: true }).click(); await page.evaluate(() => { const context = window.__testContext, destination = context.createMediaStreamDestination(); @@ -100,6 +101,29 @@ let app, page; assert.equal(saved.language, 'en'); assert.equal(saved.theme, 'light'); assert.equal(saved.accent, 'blue'); + await page.getByRole('button', { name: 'Audio and recording', exact: true }).click(); + const globalLatency = page.getByRole('spinbutton', { + name: 'Default latency correction', + exact: true, + }); + await globalLatency.fill('350'); + await globalLatency.blur(); + await page.waitForFunction( + () => JSON.parse(localStorage.getItem('immerse.audio.defaults')).latencyMs === 350, + ); + await page.getByRole('button', { name: 'Return to the music workspace', exact: true }).click(); + await page.getByRole('button', { name: 'Recording setup', exact: true }).click(); + assert.equal( + Number( + await page + .getByRole('spinbutton', { name: 'Recording latency correction', exact: true }) + .inputValue(), + ), + Number(match[1]), + 'changing global calibration must preserve the current project correction', + ); + await page.getByRole('button', { name: 'Close recording window', exact: true }).click(); + await page.getByRole('button', { name: 'Settings & models', exact: true }).click(); await page .getByRole('combobox', { name: 'Interface language', exact: true }) .selectOption('zh-CN'); @@ -108,7 +132,14 @@ let app, page; assert.deepEqual(errors, []); console.log( 'PREFERENCES_CHECK_PASSED', - JSON.stringify({ english: true, lightBlue: true, preferencesSaved: true, calibration, errors }), + JSON.stringify({ + english: true, + lightBlue: true, + preferencesSaved: true, + projectOverridePreserved: true, + calibration, + errors, + }), ); })() .catch(async (error) => { diff --git a/scripts/prepare-native-audio.cjs b/scripts/prepare-native-audio.cjs new file mode 100644 index 0000000..04fa104 --- /dev/null +++ b/scripts/prepare-native-audio.cjs @@ -0,0 +1,105 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const root = path.resolve(__dirname, '..'); +const base = path.join(root, '.runtime', 'native-audio'); +const cache = path.join(root, '.runtime', 'dev-cache'); +const localDotnet = path.join(cache, 'dotnet10', 'sdk', 'dotnet.exe'); +const dotnet = process.env.IMMERSE_DOTNET || (fs.existsSync(localDotnet) ? localDotnet : 'dotnet'); +if (process.platform !== 'win32' || process.arch !== 'x64') + throw new Error('The native audio host is built on Windows x64.'); +const env = { + ...process.env, + DOTNET_CLI_HOME: path.join(cache, 'dotnet-home'), + DOTNET_CLI_TELEMETRY_OPTOUT: '1', + DOTNET_GENERATE_ASPNET_CERTIFICATE: 'false', + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1', + NUGET_PACKAGES: path.join(cache, 'nuget'), + NUGET_HTTP_CACHE_PATH: path.join(cache, 'nuget-http'), + TEMP: path.join(root, '.runtime', 'audit-temp'), + TMP: path.join(root, '.runtime', 'audit-temp'), +}; +function run(executable, args, capture = false) { + const result = spawnSync(executable, args, { + cwd: root, + env, + windowsHide: true, + encoding: 'utf8', + stdio: capture ? 'pipe' : 'inherit', + }); + if (result.status !== 0) + throw new Error(result.stderr || result.error?.message || 'Native audio build failed.'); + return result.stdout || ''; +} +const sdks = run(dotnet, ['--list-sdks'], true); +if (!/^10\./m.test(sdks)) + throw new Error('Install a .NET 10 SDK on the development drive or set IMMERSE_DOTNET.'); +const runtimes = run(dotnet, ['--list-runtimes'], true); +const matches = [...runtimes.matchAll(/^Microsoft\.NETCore\.App (10\.\d+\.\d+) \[(.+)\]$/gm)].sort( + (a, b) => a[1].localeCompare(b[1], undefined, { numeric: true }), +); +if (!matches.length) throw new Error('The selected SDK has no .NET 10 runtime.'); +const [, runtimeVersion, runtimeParent] = matches.at(-1); +const runtimeRoot = path.resolve(runtimeParent, '..', '..'); +const sources = [ + 'desktop/native-audio/Program.cs', + 'desktop/native-audio/ImmerseAudioHost.csproj', + 'desktop/native-audio/NuGet.config', + 'desktop/native-audio/NAudio-LICENSE.txt', + 'scripts/prepare-native-audio.cjs', +]; +const hash = crypto.createHash('sha256'); +for (const file of sources) hash.update(fs.readFileSync(path.join(root, file))); +hash.update(runtimeVersion); +const revision = hash.digest('hex').slice(0, 20); +const output = path.join(base, 'versions', revision); +const assembly = path.join(output, 'ImmerseAudioHost.dll'); +const runtimeExe = path.join(output, 'dotnet.exe'); +if (!fs.existsSync(path.join(output, 'complete.json'))) { + fs.mkdirSync(output, { recursive: true }); + run(dotnet, [ + 'publish', + 'desktop/native-audio/ImmerseAudioHost.csproj', + '-c', + 'Release', + '-o', + output, + '--self-contained', + 'false', + '--configfile', + 'desktop/native-audio/NuGet.config', + '-p:BaseIntermediateOutputPath=' + path.join(base, 'obj') + path.sep, + '-p:MSBuildProjectExtensionsPath=' + path.join(base, 'obj') + path.sep, + '-p:BaseOutputPath=' + path.join(base, 'bin') + path.sep, + ]); + fs.copyFileSync(path.join(runtimeRoot, 'dotnet.exe'), runtimeExe); + fs.cpSync( + path.join(runtimeRoot, 'host', 'fxr', runtimeVersion), + path.join(output, 'host', 'fxr', runtimeVersion), + { recursive: true }, + ); + fs.cpSync( + path.join(runtimeParent, runtimeVersion), + path.join(output, 'shared', 'Microsoft.NETCore.App', runtimeVersion), + { recursive: true }, + ); + const licenses = path.join(output, 'licenses'); + fs.mkdirSync(licenses, { recursive: true }); + fs.copyFileSync( + path.join(root, 'desktop', 'native-audio', 'NAudio-LICENSE.txt'), + path.join(licenses, 'NAudio.txt'), + ); + for (const name of ['LICENSE.txt', 'ThirdPartyNotices.txt']) + fs.copyFileSync(path.join(runtimeRoot, name), path.join(licenses, 'DotNET-' + name)); + run(runtimeExe, [assembly, '--self-test']); + fs.writeFileSync( + path.join(output, 'complete.json'), + JSON.stringify({ revision, runtimeVersion, naudio: '2.2.1' }, null, 2), + ); +} +fs.mkdirSync(base, { recursive: true }); +fs.writeFileSync(path.join(base, 'current.json'), JSON.stringify({ revision }, null, 2)); +// This package staging folder is not used by the running development host. +fs.cpSync(output, path.join(base, 'package'), { recursive: true }); +console.log('Native audio host ready: ' + output); diff --git a/scripts/prepare-notices.cjs b/scripts/prepare-notices.cjs index 85c0945..47fa070 100644 --- a/scripts/prepare-notices.cjs +++ b/scripts/prepare-notices.cjs @@ -52,6 +52,11 @@ for (const family of ['BricolageGrotesque', 'JetBrainsMono']) { path.join(dir, family + '-OFL.txt'), ); } +fs.copyFileSync('desktop/native-audio/NAudio-LICENSE.txt', path.join(dir, 'NAudio-LICENSE.txt')); +rows.push( + 'NAudio 2.2.1 — MIT (native Windows audio output)\nhttps://github.com/naudio/NAudio/tree/v2.2.1', + 'Microsoft .NET 10 portable runtime — license and third-party notices bundled in native-audio/licenses\nhttps://github.com/dotnet/runtime', +); rows.push( 'Bricolage Grotesque — SIL OFL 1.1\nhttps://github.com/google/fonts/tree/main/ofl/bricolagegrotesque', 'JetBrains Mono — SIL OFL 1.1\nhttps://github.com/JetBrains/JetBrainsMono', diff --git a/scripts/settings-followup-check.cjs b/scripts/settings-followup-check.cjs new file mode 100644 index 0000000..f96071b --- /dev/null +++ b/scripts/settings-followup-check.cjs @@ -0,0 +1,354 @@ +// Isolated UI acceptance. Native endpoints are simulated here; native-audio-check.cjs tests real silent output separately. +const { _electron: electron } = require('playwright'); +const fs = require('node:fs'), + path = require('node:path'), + assert = require('node:assert/strict'); +const { createInteractionFixture } = require('./fixtures.cjs'); +const { range } = require('./ui-gestures.cjs'); +const { data, store, p, resultB } = createInteractionFixture(20); +store.selectResult(p.id, resultB); +const output = path.resolve('.runtime/settings-followup-20261105'); +fs.mkdirSync(output, { recursive: true }); +let app, page; +const errors = [], + checks = []; +const shot = async (name) => { + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + await page.waitForTimeout(250); + const bytes = await app.evaluate(async ({ BrowserWindow }) => + (await BrowserWindow.getAllWindows()[0].webContents.capturePage()).toPNG().toString('base64'), + ); + fs.writeFileSync(path.join(output, name + '.png'), Buffer.from(bytes, 'base64')); +}; +const ready = () => + page.waitForFunction( + () => document.querySelector('.play-main') && !document.querySelector('.play-main').disabled, + ); +const engineReady = () => + page.waitForFunction(() => { + const el = document.querySelector('select[aria-label="音频系统"]'); + return el && !el.matches(':disabled'); + }); +const back = () => page.getByRole('button', { name: '返回音乐工作区', exact: true }).click(); +const open = async (section) => { + await page.getByRole('button', { name: '设置与模型', exact: true }).click(); + if (section) await page.getByRole('button', { name: section, exact: true }).click(); +}; +const inputAcknowledged = () => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Native pointer acknowledgement timed out')), + 600000, + ); + process.stdin.once('data', () => { + clearTimeout(timer); + resolve(); + }); + }); +(async () => { + app = await electron.launch({ + executablePath: require('electron'), + args: ['.', '--mute-audio'], + cwd: process.cwd(), + env: { + ...process.env, + IMMERSE_LOCALE: 'zh-CN', + IMMERSE_DATA_DIR: data, + IMMERSE_PROFILE_DIR: path.join(data, 'profile'), + IMMERSE_RUNTIME_DIR: path.resolve('.runtime/audit-runtime'), + }, + }); + page = await app.firstWindow(); + page.on('pageerror', (e) => errors.push(e.message)); + await app.evaluate(({ BrowserWindow, ipcMain }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setMinimumSize(360, 280); + win.setSize(1360, 900); + win.showInactive(); + win.on('page-title-updated', (e) => e.preventDefault()); + win.setTitle('Immerse Jam · 设置修复验收'); + global.__followup = { + starts: [], + stops: [], + active: new Set(), + packets: 0, + energy: 0, + hideAsio: false, + }; + ipcMain.removeHandler('jam:audio-output-info'); + ipcMain.handle('jam:audio-output-info', () => ({ + available: true, + devices: [ + 'wasapi', + 'wasapi-low-latency', + 'wasapi-exclusive', + 'directsound', + ...(global.__followup.hideAsio ? [] : ['asio']), + ].flatMap((backend) => [ + { + backend, + deviceId: backend === 'asio' ? 'Synthetic ASIO' : 'default', + name: backend === 'asio' ? 'Synthetic ASIO' : 'Synthetic output', + isDefault: true, + deviceSampleRate: 48000, + }, + ...(backend === 'asio' + ? [ + { + backend, + deviceId: 'unavailable-driver', + name: 'Unavailable driver', + isDefault: false, + deviceSampleRate: null, + }, + ] + : []), + ]), + })); + ipcMain.removeHandler('jam:audio-output-start'); + ipcMain.handle('jam:audio-output-start', (_e, config) => { + if ( + config.deviceId === 'unavailable-driver' || + (config.backend === 'wasapi-exclusive' && config.sampleRate === 96000) + ) + throw new Error('Synthetic driver unavailable'); + const id = 'stream-' + (global.__followup.starts.length + 1); + global.__followup.starts.push(config); + global.__followup.active.add(id); + return { + type: 'ready', + streamId: id, + ...config, + deviceSampleRate: config.backend === 'wasapi' ? 48000 : config.sampleRate, + deviceBufferFrames: 256, + }; + }); + ipcMain.removeHandler('jam:audio-output-stop'); + ipcMain.handle('jam:audio-output-stop', (_e, { streamId }) => { + global.__followup.stops.push(streamId); + global.__followup.active.delete(streamId); + return true; + }); + ipcMain.removeHandler('jam:audio-output-write'); + ipcMain.handle('jam:audio-output-write', (_e, { streamId, samples }) => { + if (!global.__followup.active.has(streamId)) return { accepted: false, stale: true }; + const values = new Float32Array(samples); + global.__followup.packets++; + for (const value of values) global.__followup.energy += Math.abs(value); + return { accepted: true }; + }); + }); + const instrument = () => { + window.__audioContexts = []; + const Original = window.AudioContext; + window.AudioContext = class extends Original { + constructor(...args) { + super(...args); + window.__audioContexts.push(this); + } + }; + }; + await page.addInitScript(instrument); + await page.evaluate(instrument); + await page.getByRole('button', { name: '第一次使用?', exact: true }).first().click(); + await page.getByRole('heading', { name: '从一首歌开始', exact: true }).waitFor(); + await shot('01-guide-dark'); + await page.getByRole('button', { name: '下一步', exact: true }).click(); + await shot('02-guide-analysis'); + await page.getByRole('button', { name: '打开模型设置', exact: true }).click(); + await page.getByRole('heading', { name: '模型与运行库', exact: true }).waitFor(); + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.send('jam:event', { + scope: 'runtime', + type: 'log', + message: '\x1b[0m \r\n', + }), + ); + await page.waitForTimeout(100); + assert.equal(await page.locator('.settings-install-log').count(), 0); + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.send('jam:event', { + scope: 'runtime', + type: 'log', + message: '\x1b[36m准备运行库\x1b[0m\r\n就绪', + }), + ); + await page.getByText('查看准备记录', { exact: true }).click(); + assert.equal(await page.locator('.settings-install-log').innerText(), '准备运行库\n就绪'); + await shot('06-preparation-log'); + checks.push('guide navigation and non-empty folded preparation logs'); + await back(); + await page.getByRole('button', { name: '打开 交互验收', exact: true }).click(); + await ready(); + await range(page, '播放进度', 5); + await open(); + assert.equal(await page.locator('.settings-close').count(), 0); + assert.equal( + await page.locator('.wave-color-swatches button').first().getAttribute('aria-label'), + '波形颜色 #66CCFF', + ); + await page.locator('.wave-color-swatches button').first().click(); + assert.equal( + await page.evaluate(() => + JSON.parse(localStorage.getItem('immerse.preferences')).waveColor.toLowerCase(), + ), + '#66ccff', + ); + await shot('03-general-cyan'); + await page.getByRole('button', { name: '音频与录音', exact: true }).click(); + await engineReady(); + const system = page.getByRole('combobox', { name: '音频系统', exact: true }), + rate = page.getByRole('combobox', { name: '播放采样率', exact: true }); + await rate.selectOption('44100'); + await engineReady(); + assert.equal(await page.evaluate(() => window.__audioContexts.at(-1).sampleRate), 44100); + await back(); + assert.ok( + Math.abs( + Number(await page.getByRole('slider', { name: '播放进度', exact: true }).inputValue()) - 5, + ) < 0.05, + ); + await open('音频与录音'); + await engineReady(); + await system.selectOption('wasapi'); + await engineReady(); + await shot('04-engine-native'); + await back(); + await page.locator('.play-main').click(); + await page.waitForTimeout(700); + const flowing = await app.evaluate(() => ({ + packets: global.__followup.packets, + energy: global.__followup.energy, + })); + assert.ok(flowing.packets > 4 && flowing.energy > 1, JSON.stringify(flowing)); + await page.locator('.play-main').click(); + await page.waitForTimeout(250); + assert.equal(await app.evaluate(() => global.__followup.active.size), 0); + checks.push( + 'real 44.1 kHz processing context, preserved seek position and non-zero final mix over the IPC bridge', + ); + await open('音频与录音'); + await engineReady(); + await system.selectOption('asio'); + await engineReady(); + const device = page.getByRole('combobox', { name: '原生输出设备', exact: true }); + await device.selectOption('unavailable-driver'); + await engineReady(); + assert.equal(await device.inputValue(), 'Synthetic ASIO'); + assert.match(await page.locator('.audio-engine-settings [role=alert]').innerText(), /切换失败/); + await shot('05-engine-rollback'); + await page.evaluate(() => { + const original = Storage.prototype.setItem; + window.__restoreStorage = () => { + Storage.prototype.setItem = original; + }; + Storage.prototype.setItem = function (key, value) { + if (key === 'immerse.audio.engine') throw new Error('Synthetic disk full'); + return original.call(this, key, value); + }; + }); + await rate.selectOption('96000'); + await engineReady(); + assert.equal(await rate.inputValue(), '44100'); + assert.equal(await page.evaluate(() => window.__audioContexts.at(-1).sampleRate), 44100); + await page.evaluate(() => window.__restoreStorage()); + await system.selectOption('web-audio'); + await engineReady(); + await app.evaluate(() => { + global.__followup.hideAsio = true; + }); + await page.getByRole('button', { name: '刷新音频系统', exact: true }).click(); + await engineReady(); + await page.getByText('未检测到可用的64位ASIO驱动。', { exact: true }).waitFor(); + assert.equal( + await system.locator('option[value=asio]').evaluate((option) => option.disabled), + true, + ); + await system.focus(); + await system.press('End'); + await page.waitForFunction( + () => JSON.parse(localStorage.getItem('immerse.audio.engine')).backend === 'directsound', + ); + await engineReady(); + await system.selectOption('web-audio'); + await engineReady(); + checks.push('ASIO capability detection, driver failure and failed-persistence rollback'); + await page.getByRole('button', { name: '常规与界面', exact: true }).click(); + await page.getByRole('combobox', { name: '明暗主题', exact: true }).selectOption('light'); + await back(); + await page.getByRole('button', { name: '第一次使用?', exact: true }).first().click(); + await shot('07-guide-light'); + await page.getByRole('button', { name: '跳过引导', exact: true }).click(); + await open(); + await page.getByRole('combobox', { name: '明暗主题', exact: true }).selectOption('dark'); + await page.getByRole('combobox', { name: '界面语言', exact: true }).selectOption('en'); + await page.getByRole('button', { name: 'Audio and recording', exact: true }).click(); + const english = await page.locator('.audio-engine-settings').innerText(); + assert.ok(!/[一-鿿]/.test(english), english); + await page.getByRole('button', { name: 'General and appearance', exact: true }).click(); + await page + .getByRole('combobox', { name: 'Interface language', exact: true }) + .selectOption('zh-CN'); + checks.push('bilingual audio settings and guide visuals in both themes'); + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setSize(640, 720); + win.maximize(); + win.unmaximize(); + }); + await page.getByRole('combobox', { name: '界面缩放', exact: true }).selectOption('1.5'); + assert.equal(await page.locator('html').getAttribute('data-desktop-modal'), 'open'); + const dragOwners = await page + .locator('.nr-nav-brand,.nr-ph.player-header,.library-header') + .evaluateAll((nodes) => + nodes.map((e) => getComputedStyle(e).getPropertyValue('-webkit-app-region')), + ); + assert.ok( + dragOwners.length > 0 && dragOwners.every((value) => value === 'no-drag'), + JSON.stringify(dragOwners), + ); + await shot('08-narrow-150'); + if (process.argv.includes('--keep-open')) { + console.log('NATIVE_POINTER_STAGE_1_READY'); + await inputAcknowledged(); + assert.equal(await page.locator('.settings-workspace').count(), 0); + checks.push('actual Windows pointer closes settings at 150% after window restore'); + await open(); + await page.getByRole('combobox', { name: '界面缩放', exact: true }).selectOption('1'); + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setSize(1100, 760); + win.maximize(); + win.unmaximize(); + }); + console.log('NATIVE_POINTER_STAGE_2_READY'); + await inputAcknowledged(); + assert.equal(await page.locator('.settings-workspace').count(), 0); + checks.push('actual Windows pointer closes settings at 100% after maximize/restore'); + } else await back(); + assert.deepEqual(errors, []); + fs.writeFileSync( + path.join(output, 'followup-ui-result.json'), + JSON.stringify({ passed: true, checks, errors, fixture: data }, null, 2), + ); + console.log('SETTINGS_FOLLOWUP_CHECK_PASSED', JSON.stringify({ checks, errors, output })); +})() + .catch(async (error) => { + console.error(error); + process.exitCode = 1; + await page?.screenshot({ path: path.join(output, 'followup-failed.png') }).catch(() => {}); + fs.writeFileSync( + path.join(output, 'followup-ui-result.json'), + JSON.stringify( + { passed: false, error: String(error), checks, errors, fixture: data }, + null, + 2, + ), + ); + }) + .finally(async () => { + await app?.close(); + }); diff --git a/scripts/settings-pointer-check.cjs b/scripts/settings-pointer-check.cjs new file mode 100644 index 0000000..17650ba --- /dev/null +++ b/scripts/settings-pointer-check.cjs @@ -0,0 +1,105 @@ +const { _electron: electron } = require('playwright'); +const fs = require('node:fs'), + path = require('node:path'), + readline = require('node:readline'), + assert = require('node:assert/strict'); +const parent = path.resolve('.runtime/test-workspaces'); +fs.mkdirSync(parent, { recursive: true }); +const root = fs.mkdtempSync(path.join(parent, 'pointer-')); +const output = path.resolve('.runtime/settings-followup-20261105'); +fs.mkdirSync(output, { recursive: true }); +const rl = readline.createInterface({ input: process.stdin, terminal: false }); +function ack() { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + rl.off('line', line); + reject(new Error('Pointer verification timed out')); + }, 600000); + const line = (text) => { + if (text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').trim() !== 'verify') return; + clearTimeout(timer); + rl.off('line', line); + resolve(); + }; + rl.on('line', line); + }); +} +let app, page; +const checks = []; +(async () => { + app = await electron.launch({ + executablePath: require('electron'), + args: ['.', '--mute-audio'], + cwd: process.cwd(), + env: { + ...process.env, + IMMERSE_LOCALE: 'zh-CN', + IMMERSE_DATA_DIR: path.join(root, 'library'), + IMMERSE_PROFILE_DIR: path.join(root, 'profile'), + IMMERSE_RUNTIME_DIR: path.resolve('.runtime/audit-runtime'), + }, + }); + page = await app.firstWindow(); + await app.evaluate(({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows()[0]; + w.setMinimumSize(360, 280); + w.on('page-title-updated', (e) => e.preventDefault()); + w.setTitle('Immerse Jam · 原生点击验收'); + w.showInactive(); + }); + await page.getByRole('button', { name: '跳过引导', exact: true }).click(); + for (const scale of [1.5, 1]) { + await page.getByRole('button', { name: '设置与模型', exact: true }).click(); + await page.getByRole('combobox', { name: '界面缩放', exact: true }).selectOption(String(scale)); + await page.waitForFunction( + (scale) => JSON.parse(localStorage.getItem('immerse.preferences')).uiScale === scale, + scale, + ); + await app.evaluate(async ({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows()[0]; + if (!w.isMaximized()) + await new Promise((resolve) => { + w.once('maximize', resolve); + w.maximize(); + }); + await new Promise((resolve) => { + w.once('unmaximize', resolve); + w.unmaximize(); + }); + w.setSize(1000, 780); + w.showInactive(); + }); + const state = await app.evaluate(({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows()[0]; + return { scale: w.webContents.getZoomFactor(), maximized: w.isMaximized() }; + }); + assert.equal(state.scale, scale); + assert.equal(state.maximized, false); + console.log('POINTER_READY', JSON.stringify(state)); + await ack(); + assert.equal(await page.locator('.settings-workspace').count(), 0); + assert.equal( + await page.evaluate(() => JSON.parse(localStorage.getItem('immerse.preferences')).uiScale), + scale, + ); + checks.push({ scale, restored: true, nativeBackClosedSettings: true }); + console.log('POINTER_PASSED', scale); + } + fs.writeFileSync( + path.join(output, 'native-pointer-result.json'), + JSON.stringify({ passed: true, checks, fixture: root }, null, 2), + ); +})() + .catch((error) => { + console.error(error); + process.exitCode = 1; + fs.writeFileSync( + path.join(output, 'native-pointer-result.json'), + JSON.stringify({ passed: false, checks, error: String(error) }, null, 2), + ); + }) + .finally(async () => { + rl.close(); + process.stdin.pause(); + await app?.close(); + }); diff --git a/scripts/settings-ui-check.cjs b/scripts/settings-ui-check.cjs new file mode 100644 index 0000000..fdff6c8 --- /dev/null +++ b/scripts/settings-ui-check.cjs @@ -0,0 +1,373 @@ +// Runs the built desktop UI against disposable local data. No private library or real microphone is used. +const { _electron: electron } = require('playwright'); +const fs = require('node:fs'), + path = require('node:path'), + assert = require('node:assert/strict'); +const { Store } = require('../desktop/store.cjs'); +const parent = path.resolve('.runtime/test-workspaces'); +fs.mkdirSync(parent, { recursive: true }); +const root = fs.mkdtempSync(path.join(parent, 'settings-ui-')); +const data = path.join(root, 'library'), + runtime = path.join(root, 'runtime'), + profile = path.join(root, 'profile'); +new Store(data); +const output = path.resolve( + process.env.IMMERSE_SETTINGS_QA_DIR || '.runtime/settings-validation-20261105', +); +fs.mkdirSync(output, { recursive: true }); +let app, page; +const errors = [], + checks = []; +const shot = async (name) => { + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + // Native capture may otherwise return the preceding compositor frame or a theme transition. + await page.waitForTimeout(350); + const encoded = await app.evaluate(async ({ BrowserWindow }) => + (await BrowserWindow.getAllWindows()[0].webContents.capturePage()).toPNG().toString('base64'), + ); + fs.writeFileSync(path.join(output, name + '.png'), Buffer.from(encoded, 'base64')); +}; +const assertVisible = async (locator) => { + await locator.waitFor({ state: 'visible' }); +}; +(async () => { + app = await electron.launch({ + executablePath: require('electron'), + args: [ + '.', + '--mute-audio', + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + ], + cwd: process.cwd(), + env: { + ...process.env, + IMMERSE_LOCALE: 'zh-CN', + IMMERSE_DATA_DIR: data, + IMMERSE_PROFILE_DIR: profile, + IMMERSE_RUNTIME_DIR: runtime, + }, + }); + page = await app.firstWindow(); + page.on('pageerror', (e) => errors.push(e.message)); + await app.evaluate(({ BrowserWindow, ipcMain }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setMinimumSize(360, 280); + win.setSize(1360, 900); + win.showInactive(); + global.__settingsInstallCalls = 0; + global.__settingsCopyCalls = 0; + ipcMain.removeHandler('jam:runtime-install'); + ipcMain.handle('jam:runtime-install', () => { + global.__settingsInstallCalls++; + return { installing: false }; + }); + // Avoid changing the user's clipboard while testing the copy feedback. + ipcMain.removeHandler('jam:settings-copy-diagnostics'); + ipcMain.handle('jam:settings-copy-diagnostics', () => { + global.__settingsCopyCalls++; + return true; + }); + }); + await assertVisible(page.getByRole('heading', { name: '从一首歌开始', exact: true })); + await shot('01-first-run-guide'); + await page.getByRole('button', { name: '下一步', exact: true }).click(); + await assertVisible(page.getByRole('heading', { name: '准备模型,再开始分析', exact: true })); + await page.getByRole('button', { name: '打开模型设置', exact: true }).click(); + await assertVisible(page.getByRole('heading', { name: '模型与运行库', exact: true })); + await page.waitForFunction(() => document.activeElement?.classList.contains('settings-back')); + await page.getByRole('button', { name: '返回音乐工作区', exact: true }).click(); + await page.getByRole('button', { name: '第一次使用?', exact: true }).first().click(); + await page.getByRole('button', { name: '下一步', exact: true }).click(); + await page.getByRole('button', { name: '上一步', exact: true }).click(); + await page.getByRole('button', { name: '跳过引导', exact: true }).click(); + await page.addInitScript(() => { + window.__settingsDevices = [ + { kind: 'audioinput', deviceId: 'usb-a', label: 'Synthetic USB input' }, + { kind: 'audiooutput', deviceId: 'speaker-a', label: 'Synthetic speakers' }, + { kind: 'audiooutput', deviceId: 'broken-output', label: 'Synthetic unavailable output' }, + ]; + navigator.mediaDevices.enumerateDevices = async () => window.__settingsDevices; + const Original = window.AudioContext; + window.AudioContext = class extends Original { + constructor(...args) { + super(...args); + window.__settingsContext = this; + } + }; + window.AudioContext.prototype.setSinkId = async function (id) { + if (id === 'broken-output') throw new Error('Synthetic output unavailable'); + Object.defineProperty(this, 'sinkId', { value: id, writable: true, configurable: true }); + }; + }); + await page.reload(); + await page.getByRole('button', { name: '设置与模型', exact: true }).waitFor(); + assert.equal(await page.locator('.onboarding-guide').count(), 0); + checks.push('first-run guide is skippable and does not repeat after reload'); + await page.getByRole('button', { name: '设置与模型', exact: true }).hover(); + await page.waitForSelector('.help-tooltip', { state: 'visible', timeout: 5000 }); + assert.match(await page.locator('.help-tooltip').innerText(), /更改界面/); + await shot('10-hover-help'); + await page.getByRole('button', { name: '设置与模型', exact: true }).click(); + await assertVisible(page.getByRole('heading', { name: '常规与界面', exact: true })); + assert.equal(await page.getByRole('button', { name: '快捷键', exact: true }).count(), 0); + await shot('02-general-dark'); + await page.getByRole('checkbox', { name: '悬停说明', exact: true }).uncheck(); + await page.getByRole('button', { name: '返回音乐工作区', exact: true }).click(); + await page.getByRole('button', { name: '设置与模型', exact: true }).hover(); + await page.waitForTimeout(2200); + assert.equal(await page.locator('.help-tooltip').count(), 0); + await page.getByRole('button', { name: '设置与模型', exact: true }).click(); + await page.getByRole('checkbox', { name: '悬停说明', exact: true }).check(); + checks.push('delayed help appears and can be disabled'); + await page.getByRole('combobox', { name: '界面缩放', exact: true }).selectOption('1.25'); + assert.ok( + Math.abs( + (await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.getZoomFactor(), + )) - 1.25, + ) < 0.0001, + ); + await page.getByRole('combobox', { name: '界面缩放', exact: true }).selectOption('1'); + await page.getByRole('combobox', { name: '界面语言', exact: true }).selectOption('en'); + await page.getByRole('combobox', { name: 'Color mode', exact: true }).selectOption('light'); + await shot('03-general-en-light'); + const general = await page.locator('.settings-workspace').innerText(); + assert.ok(!/[一-鿿]/.test(general.replaceAll('简体中文', '')), general); + await page + .getByRole('searchbox', { name: 'Search settings', exact: true }) + .fill('unmatched-settings-20261105'); + await assertVisible(page.getByRole('heading', { name: 'No matching settings', exact: true })); + await page.getByRole('button', { name: 'Clear settings search', exact: true }).click(); + await page.getByRole('searchbox', { name: 'Search settings', exact: true }).fill('缓存'); + await assertVisible(page.getByRole('heading', { name: 'Storage and cache', exact: true })); + await page.getByRole('button', { name: 'Clear settings search', exact: true }).click(); + checks.push('native zoom, English light theme and bilingual search'); + await page.getByRole('button', { name: 'Audio and recording', exact: true }).click(); + await page + .getByRole('combobox', { name: 'Default input device', exact: true }) + .selectOption('usb-a'); + await page + .getByRole('combobox', { name: 'Default output device', exact: true }) + .selectOption('speaker-a'); + const latency = page.getByRole('spinbutton', { name: 'Default latency correction', exact: true }); + await latency.fill('2501'); + await latency.blur(); + await assertVisible( + page.getByRole('alert').filter({ hasText: 'Latency correction must be between' }), + ); + const invalidSaved = await page.evaluate(() => + JSON.parse(localStorage.getItem('immerse.audio.defaults')), + ); + assert.equal(invalidSaved.latencyMs, 0); + await latency.fill('42'); + await latency.blur(); + await page.waitForFunction( + () => JSON.parse(localStorage.getItem('immerse.audio.defaults')).latencyMs === 42, + ); + await page + .getByRole('combobox', { name: 'Default output device', exact: true }) + .selectOption('broken-output'); + await assertVisible(page.getByRole('alert').filter({ hasText: 'Synthetic output unavailable' })); + assert.equal( + await page.getByRole('combobox', { name: 'Default output device', exact: true }).inputValue(), + 'speaker-a', + ); + checks.push('audio defaults, invalid latency and routing failure preserve saved values'); + await page.evaluate(() => { + const context = window.__settingsContext, + destination = context.createMediaStreamDestination(); + navigator.mediaDevices.getUserMedia = async () => destination.stream; + const create = context.createOscillator.bind(context); + context.createOscillator = function (...args) { + const oscillator = create(...args), + start = oscillator.start.bind(oscillator); + oscillator.start = function (at) { + const buffer = context.createBuffer(1, 4800, context.sampleRate), + samples = buffer.getChannelData(0); + for (let i = 0; i < samples.length; i++) + samples[i] = 0.3 * Math.sin(i * 0.2) * Math.exp(-i / 500); + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(destination); + source.start(at + 0.18); + return start(at); + }; + return oscillator; + }; + }); + await page.getByRole('button', { name: 'Test and estimate latency', exact: true }).click(); + await assertVisible(page.getByRole('button', { name: 'Cancel calibration', exact: true })); + assert.equal( + await page.getByRole('combobox', { name: 'Default output device', exact: true }).isDisabled(), + true, + ); + await page.waitForSelector('.calibration-result', { timeout: 25000 }); + await page.getByRole('button', { name: 'Apply correction', exact: true }).click(); + await page.waitForFunction( + () => Math.abs(JSON.parse(localStorage.getItem('immerse.audio.defaults')).latencyMs - 180) < 35, + ); + await shot('04-audio-calibration'); + checks.push('global calibration survives busy state and saves a synthetic 180ms correction'); + await page.getByRole('button', { name: 'Models and runtime', exact: true }).click(); + await page.getByRole('button', { name: 'Check runtime', exact: true }).click(); + await page + .getByRole('combobox', { name: 'Runtime installation mode', exact: true }) + .selectOption('cpu'); + await page.getByRole('button', { name: 'Prepare / repair runtime', exact: true }).click(); + const prepare = page.getByRole('dialog', { name: 'Prepare runtime', exact: true }); + await assertVisible(prepare); + await prepare.getByRole('button', { name: 'Cancel', exact: true }).click(); + assert.equal(await app.evaluate(() => global.__settingsInstallCalls), 0); + await page + .getByRole('combobox', { name: 'Filter models', exact: true }) + .selectOption('installed'); + await assertVisible(page.getByText('No models match this filter.', { exact: true })); + await page.getByRole('combobox', { name: 'Filter models', exact: true }).selectOption('all'); + await shot('05-models-runtime'); + checks.push('runtime status and mode selection do not start installation; model filtering works'); + fs.mkdirSync(path.join(runtime, 'temp'), { recursive: true }); + const expired = path.join(runtime, 'temp', 'expired.bin'), + partial = path.join(runtime, 'temp', 'resume.part'); + const model = path.join(runtime, 'models', 'protected-model.bin'); + for (const file of [expired, partial, model]) { + fs.writeFileSync(file, 'synthetic-fixture'); + const old = new Date(Date.now() - 3 * 86400000); + fs.utimesSync(file, old, old); + } + await page.getByRole('button', { name: 'Storage and cache', exact: true }).click(); + await page.locator('.settings-storage-row').first().waitFor(); + await page.waitForFunction( + () => + document.querySelector('[data-storage-id="temp"] .settings-byte-count')?.textContent === + '34 B', + ); + await page.getByRole('button', { name: 'Preview cleanup', exact: true }).first().click(); + const cleanup = page.getByRole('dialog', { name: 'Confirm cleanup', exact: true }); + await assertVisible(cleanup); + assert.match(await cleanup.innerText(), /Files to remove: 1/); + await shot('06-storage-preview'); + await cleanup.getByRole('button', { name: 'Confirm cleanup', exact: true }).click(); + await assertVisible(page.locator('.settings-result')); + assert.equal(fs.existsSync(expired), false); + assert.ok(fs.existsSync(partial)); + assert.ok(fs.existsSync(model)); + assert.match(await page.locator('.settings-result').innerText(), /Files removed: 1/); + await shot('07-storage-result'); + await assert.rejects( + () => page.evaluate(() => window.jam.call('settings-open-location', { id: '../../Windows' })), + /无效|Invalid/, + ); + checks.push( + 'native cleanup preview removes only synthetic expired data; partial downloads and models survive', + ); + await page.getByRole('button', { name: 'About and diagnostics', exact: true }).click(); + await page.getByRole('button', { name: 'Copy diagnostic summary', exact: true }).click(); + await assertVisible(page.getByText('Diagnostic summary copied', { exact: true })); + assert.equal(await app.evaluate(() => global.__settingsCopyCalls), 1); + await shot('08-about-diagnostics'); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(640, 720)); + await assertVisible(page.getByRole('combobox', { name: 'Settings categories', exact: true })); + await page + .getByRole('combobox', { name: 'Settings categories', exact: true }) + .selectOption('general'); + await page.getByRole('combobox', { name: 'Interface scale', exact: true }).selectOption('1.5'); + assert.ok( + await page + .getByRole('button', { name: 'Return to the music workspace', exact: true }) + .isVisible(), + ); + const layout = await page.evaluate(() => ({ + width: innerWidth, + scroll: document.querySelector('.settings-workspace').scrollWidth, + })); + assert.ok(layout.scroll <= layout.width + 2, JSON.stringify(layout)); + await shot('09-narrow-150'); + await page.getByRole('combobox', { name: 'Interface scale', exact: true }).selectOption('1'); + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.focus(); + win.webContents.focus(); + }); + await page.waitForFunction(() => document.hasFocus(), undefined, { timeout: 5000 }); + await page.getByRole('searchbox', { name: 'Search settings', exact: true }).focus(); + await page.keyboard.press('Tab'); + const focus = await page.evaluate(() => ({ + inside: !!document.activeElement.closest('.settings-workspace-dialog'), + focused: document.hasFocus(), + tag: document.activeElement.tagName, + })); + assert.equal(focus.inside, true, JSON.stringify(focus)); + assert.equal(focus.focused, true, JSON.stringify(focus)); + assert.equal(focus.tag, 'SELECT', JSON.stringify(focus)); + await page.keyboard.press('Space'); + await page.keyboard.press('Home'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await assertVisible(page.getByRole('heading', { name: 'Models and runtime', exact: true })); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].blur()); + await page.getByRole('button', { name: 'Return to the music workspace', exact: true }).click(); + assert.equal(await page.getByRole('button', { name: 'Manage models', exact: true }).count(), 0); + await page + .getByRole('button', { name: require('../shared/locales/en.json')['设置与模型'], exact: true }) + .click(); + await page + .getByRole('combobox', { name: 'Settings categories', exact: true }) + .selectOption('models'); + await assertVisible(page.getByRole('heading', { name: 'Models and runtime', exact: true })); + await page.getByRole('button', { name: 'Return to the music workspace', exact: true }).click(); + await app.evaluate(() => { + delete process.env.IMMERSE_LOCALE; + }); + await page.reload(); + await page.waitForSelector('html[lang="en"]'); + assert.equal(await page.locator('.onboarding-guide').count(), 0); + const saved = await page.evaluate(() => ({ + preferences: JSON.parse(localStorage.getItem('immerse.preferences')), + audio: JSON.parse(localStorage.getItem('immerse.audio.defaults')), + })); + assert.equal(saved.preferences.language, 'en'); + assert.equal(saved.preferences.theme, 'light'); + assert.equal(saved.audio.inputDeviceId, 'usb-a'); + assert.equal(saved.audio.outputDeviceId, 'speaker-a'); + checks.push('narrow 150% layout, keyboard focus, canonical model entry and restart persistence'); + assert.deepEqual(errors, []); + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + passed: true, + checks, + errors, + simulatedDelayMs: 180, + calibrationMs: saved.audio.latencyMs, + fixture: root, + }, + null, + 2, + ), + ); + console.log('SETTINGS_UI_CHECK_PASSED', JSON.stringify({ checks, errors, output })); +})() + .catch(async (error) => { + console.error(error); + process.exitCode = 1; + await page + ?.screenshot({ path: path.join(output, 'failed.png'), timeout: 5000 }) + .catch(() => {}); + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { passed: false, error: String(error), checks, errors, fixture: root }, + null, + 2, + ), + ); + }) + .finally(async () => { + await app?.close(); + }); diff --git a/shared/ipc-methods.json b/shared/ipc-methods.json index ed7839d..3f65c06 100644 --- a/shared/ipc-methods.json +++ b/shared/ipc-methods.json @@ -1,6 +1,10 @@ [ "attach", "audio-tools-ensure", + "audio-output-info", + "audio-output-start", + "audio-output-stop", + "audio-output-write", "cancel", "creation-new", "session-create", @@ -59,6 +63,13 @@ "take-select", "take-start", "ui-language", + "ui-scale", + "settings-storage", + "settings-cleanup-preview", + "settings-cleanup", + "settings-open-location", + "settings-diagnostics", + "settings-copy-diagnostics", "update", "window", "folders-list", diff --git a/shared/locales/en.json b/shared/locales/en.json index 470151e..0782684 100644 --- a/shared/locales/en.json +++ b/shared/locales/en.json @@ -1125,5 +1125,191 @@ "上下拖动,松手放置;拖出列表取消": "Drag vertically and release to place; move outside the list to cancel", "准备音频并加载分析组件": "Preparing audio and analysis tools", "音频已就绪,正在载入节拍与调性分析": "Audio ready; loading beat and key analysis", - "时间微调 {0}": "Timing for {0}" + "时间微调 {0}": "Timing for {0}", + "使用引导": "Getting started", + "输出设备不可用,播放已临时使用系统默认设备": "The output device is unavailable. Playback is temporarily using the system default.", + "源文件与手动导入": "Source files and manual import", + "版本 {0}": "Version {0}", + "日志与诊断": "Logs and diagnostics", + "诊断摘要包含版本、运行环境状态和数据路径,仅复制到本机剪贴板。": "The summary includes the app version, runtime status and data paths. It is copied only to your local clipboard.", + "打开应用日志": "Open app logs", + "打开安装日志": "Open installation log", + "打开任务记录": "Open task records", + "诊断摘要已复制": "Diagnostic summary copied", + "复制诊断摘要": "Copy diagnostic summary", + "许可与第三方声明": "Licenses and third-party notices", + "设置已保存": "Settings saved", + "原设备不可用,已恢复系统默认,请重新检查延迟。": "The previous device is unavailable. System defaults are restored; check latency again.", + "已保存设备(当前未检测到)": "Saved device (not currently detected)", + "音频设备 {0}": "Audio device {0}", + "默认音频设备": "Default audio devices", + "新工程使用以下设置;已有工程的单独设置会保留。": "New projects use these defaults. Existing project-specific settings are preserved.", + "录音或校准期间不能切换设备。": "Devices cannot be changed during recording or calibration.", + "默认输入设备": "Default input device", + "默认输出设备": "Default output device", + "默认输入声道": "Default input channels", + "延迟校正(毫秒)": "Latency correction (ms)", + "录音偏晚填正数,偏早填负数;按设备组合保存。": "Use a positive value for late recordings and a negative value for early recordings. Saved per device pair.", + "默认延迟校正": "Default latency correction", + "延迟校正需要在-2000至2000毫秒之间": "Latency correction must be between -2000 and 2000 ms.", + "当前播放采样率": "Current playback sample rate", + "{0} Hz": "{0} Hz", + "播放器尚未初始化": "Player has not started yet", + "当前工程使用单独的录音设置。": "This project uses its own recording settings.", + "当前工程使用默认音频设置。": "This project uses the default audio settings.", + "默认设置已用于当前工程": "Defaults applied to the current project", + "将默认设置用于当前工程": "Apply defaults to this project", + "延迟校准": "Latency calibration", + "连接好录音设备后,使用现有校准工具测量并试听。": "Connect your recording device, then measure and preview latency with the calibration tool.", + "恢复音频默认值": "Restore audio defaults", + "显示与帮助": "Display and help", + "界面缩放": "Interface scale", + "调整整个窗口的文字与控件大小。": "Adjust text and control sizes throughout the window.", + "悬停说明": "Help tooltips", + "鼠标停留约2秒显示说明;键盘聚焦时直接显示。": "Show help after about 2 seconds of hover, or immediately on keyboard focus.", + "从导入、分析到录音与导出,逐步了解工作流程。": "Follow the workflow from importing and analysis to recording and export.", + "重新观看教程": "Replay the guide", + "恢复外观与帮助默认值": "Restore appearance and help defaults", + "修改会自动保存。": "Changes are saved automatically.", + "运行环境可用": "Runtime is available", + "运行环境尚未准备好": "Runtime is not ready", + "运行环境": "Runtime", + "待准备": "Not ready", + "检查只读取当前状态。安装或修复需要单独确认。": "Checking only reads the current status. Installation or repair requires a separate confirmation.", + "检查运行环境": "Check runtime", + "运行库安装模式": "Runtime installation mode", + "更改此选项不会启动安装。": "Changing this selection does not start installation.", + "首次下载CPU约1–2GB,NVIDIA约3–5GB,模型另计;已有下载会复用。": "The initial download is about 1–2 GB for CPU or 3–5 GB for NVIDIA, plus models. Existing downloads are reused.", + "准备 / 修复运行库": "Prepare / repair runtime", + "筛选模型": "Filter models", + "没有符合条件的模型。": "No models match this filter.", + "准备运行库": "Prepare runtime", + "将下载、校验并准备所选运行库。已有模型和音乐会保留,下载保存在当前数据目录。": "The selected runtime will be downloaded, verified and prepared in the current data directory. Existing models and music are preserved.", + "确认准备": "Confirm preparation", + "从一首歌开始": "Start with a song", + "在音乐库选择“导入音乐”,也可以拖入音频、视频或完整工程。导入内容保存在你选择的数据目录。": "Choose Import music in the library, or drop in audio, video or a complete project. Imported content is saved in your selected data directory.", + "先播放原曲,确认输出设备和音量。": "Play the original track first to check your output device and volume.", + "准备模型,再开始分析": "Prepare a model, then analyze", + "分析与分轨": "Analysis and separation", + "在“模型与运行库”检查环境,按需要下载模型。回到歌曲选择分析方式;分析完成后再选择要练习的声部。": "Check the runtime under Models and runtime, then download a model if needed. Return to the song to choose an analysis mode and select parts to practice.", + "首次下载需要时间,可在模型页查看进度和重试。": "The first download may take a while. View progress and retry on the models page.", + "循环练习,录音回听": "Loop, record and review", + "练习与录音": "Practice and recording", + "新建跟练,关闭自己演奏的声部,设置循环起止。录音前确认输入设备与声道,并运行延迟校准。": "Create a practice session, mute the part you will play and set the loop boundaries. Check your input device and channels, then calibrate latency before recording.", + "录音会保存为独立记录,可选择不同录音进行回听。": "Each recording is saved separately so you can choose different takes for review.", + "导出需要的结果": "Export the results you need", + "导出与管理": "Export and storage", + "导出界面可以选择分轨、混音或完整工程。需要继续编辑或迁移时,使用完整工程保留相关资料。": "Export individual parts, a mix or a complete project. Use a complete project to preserve the related content for further editing or transfer.", + "存储与缓存页可以查看各类内容的占用和所在目录。": "Storage and cache shows where each type of content is stored and how much space it uses.", + "打开模型设置": "Open model settings", + "跳过引导": "Skip guide", + "上一步": "Previous", + "开始使用": "Get started", + "下一步": "Next", + "查看界面图解与快捷操作": "View the interface diagram and quick controls", + "常规与界面": "General and appearance", + "语言、外观、缩放与使用帮助。": "Language, appearance, scale and help.", + "音频与录音": "Audio and recording", + "默认设备、输入声道与延迟校准。": "Default devices, input channels and latency calibration.", + "模型与运行库": "Models and runtime", + "检查环境,下载、导入与校验模型。": "Check the runtime and download, import or verify models.", + "存储与缓存": "Storage and cache", + "查看数据位置、占用与可清理内容。": "View data locations, disk usage and eligible cleanup items.", + "关于与诊断": "About and diagnostics", + "版本、更新、日志与第三方声明。": "Version, updates, logs and third-party notices.", + "返回音乐工作区": "Return to the music workspace", + "返回": "Back", + "按你的设备与使用习惯调整。": "Adjust the app for your devices and workflow.", + "搜索设置": "Search settings", + "搜索设置…": "Search settings…", + "清除设置搜索": "Clear settings search", + "设置分类": "Settings categories", + "偏好设置自动保存;安装与清理需要单独确认。": "Preferences save automatically. Installation and cleanup require separate confirmation.", + "设置内容": "Settings content", + "找到{0}个分类": "Found {0} categories", + "没有匹配的设置": "No matching settings", + "试试设备、缩放、缓存或日志。": "Try device, scale, cache or logs.", + "清除搜索": "Clear search", + "模型文件": "Model files", + "运行库与工具": "Runtime and tools", + "下载与运行缓存": "Download and runtime cache", + "临时文件": "Temporary files", + "任务记录": "Task records", + "应用设置": "App settings", + "应用日志": "App logs", + "存储占用": "Storage usage", + "扫描中…": "Scanning…", + "重新扫描": "Scan again", + "音乐、模型、运行库与临时内容分别统计。": "Music, models, the runtime and temporary content are measured separately.", + "运行库所在磁盘可用": "Free space on the runtime drive", + "未知": "Unknown", + "正在统计存储占用…": "Calculating storage usage…", + "部分内容未能统计,显示的是已扫描大小。": "Some items could not be scanned. The size shown covers scanned items only.", + "打开{0}目录": "Open {0} folder", + "分类清理": "Cleanup by category", + "先查看可清理数量和大小,再确认。不会清除音乐、录音、模型、运行库或可续传下载。": "Preview the file count and size before confirming. Music, recordings, models, the runtime and resumable downloads are preserved.", + "请等待录音、下载和处理任务结束后再清理": "Wait for recording, downloads and processing tasks to finish before cleaning up.", + "过期临时文件": "Expired temporary files", + "仅包含超过24小时、没有续传标记的临时内容。": "Only temporary content older than 24 hours without resume markers.", + "预览清理": "Preview cleanup", + "旧任务记录": "Old task records", + "仅包含超过7天的已结束任务记录;清除后无法查看这些日志。": "Only records from completed tasks older than 7 days. These logs will no longer be available after cleanup.", + "确认清理": "Confirm cleanup", + "预计清理{0}个文件,释放{1}。": "Files to remove: {0}. Estimated space: {1}.", + "没有可清理的过期内容。": "No expired content is eligible for cleanup.", + "预览后发生变化的文件会跳过。": "Files changed after the preview are skipped.", + "已清理{0}个文件,释放{1};跳过{2},失败{3}。": "Files removed: {0}; space freed: {1}; skipped: {2}; failed: {3}.", + "清理中…": "Cleaning up…", + "重新预览": "Preview again", + "更改界面、音频设备与存储设置。": "Change appearance, audio devices and storage settings.", + "查看运行环境状态,下载或校验模型。": "View runtime status, download models or verify their files.", + "运行环境:就绪": "Runtime: ready", + "运行环境:待准备": "Runtime: not ready", + "查看状态与模型": "View status and models", + "此类别不支持清理": "This category does not support cleanup.", + "清理预览已过期,请重新扫描": "The cleanup preview has expired. Scan again.", + "无效存储位置": "Invalid storage location.", + "该位置尚未创建": "This location has not been created yet.", + "正在清理临时内容,请稍后重试": "Temporary content is being cleaned up. Try again shortly.", + "界面缩放需要在80%至150%之间": "Interface scale must be between 80% and 150%.", + "录音设置无效,延迟校正需要在-2000至2000毫秒之间": "Invalid recording settings. Latency correction must be between -2000 and 2000 ms.", + "请先停止播放、录音或校准再切换音频系统": "Stop playback, recording or calibration before changing the audio system.", + "兼容音频输出": "Compatibility audio output", + "Windows Audio(共享模式)": "Windows Audio (Shared mode)", + "Windows Audio(独占模式)": "Windows Audio (Exclusive mode)", + "Windows Audio(低延迟共享)": "Windows Audio (Low-latency shared)", + "音频设置已应用;更换系统、设备或采样率后请重新校准延迟。": "Audio settings applied. Recalibrate latency after changing the system, device or sample rate.", + "切换失败,已恢复原音频设置:{0}": "Switch failed; previous audio settings restored: {0}", + "音频播放": "Audio playback", + "刷新音频系统": "Refresh audio systems", + "音频系统": "Audio system", + "共享模式可与其他应用同时播放;独占模式会单独使用所选输出设备。": "Shared mode plays alongside other apps. Exclusive mode reserves the selected output device.", + "原生输出设备": "Native output device", + "播放采样率": "Playback sample rate", + "更改后重新载入音频引擎,保留当前工程与播放位置。": "Reload the audio engine while keeping the current project and playback position.", + "处理采样率:{0} Hz": "Processing rate: {0} Hz", + "设备采样率:{0} Hz": "Device rate: {0} Hz", + "设备缓冲:{0} 采样": "Device buffer: {0} samples", + "共享模式使用 Windows 设备采样率,必要时转换;独占和 ASIO 按设备支持情况打开所选采样率。录音输入仍使用下方的 Windows 录音设备。": "Shared mode uses the Windows device rate and resamples when needed. Exclusive and ASIO output open at the selected rate when supported. Recording input still uses the Windows recording device below.", + "原生音频输出暂不可用,可继续使用兼容音频输出。": "Native audio output is unavailable. Compatibility audio output remains available.", + "未检测到可用的64位ASIO驱动。": "No available 64-bit ASIO driver was detected.", + "恢复兼容音频输出": "Restore compatibility audio output", + "正在切换音频系统…": "Switching audio system…", + "完整工程": "Complete project", + "操作示意": "Workflow illustration", + "查看准备记录": "Show preparation log", + "快速开始": "Quick start", + "引导步骤": "Guide steps", + "音频系统、设备或采样率无效": "Invalid audio system, device or sample rate.", + "原生音频输出已断开": "Native audio output disconnected.", + "原生音频输出中断": "Native audio output was interrupted.", + "原生音频组件响应超时": "Native audio component timed out.", + "原生音频组件尚未构建或此平台不支持": "The native audio component is not built or this platform is unsupported.", + "音频输出积压,请增大缓冲或改用兼容模式": "Audio output could not keep up. Use a larger driver buffer or compatibility output.", + "原生音频组件返回数据过大": "The native audio component returned too much data.", + "原生音频组件返回数据无效": "The native audio component returned invalid data.", + "无效音频数据": "Invalid audio data.", + "无效音频数据长度": "Invalid audio data length.", + "无效音频采样": "Invalid audio sample.", + "音频中没有可读取的采样": "The audio contains no readable samples." } diff --git a/src/app/App.tsx b/src/app/App.tsx index d39f9b6..e2deda9 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,6 +4,10 @@ import WindowControls from '../shared/WindowControls'; import Select from '../shared/Select'; import MobileActions from '../platform/android/MobileActions'; import UsageHelp from '../features/settings/UsageHelp'; +import OnboardingGuide from '../features/settings/OnboardingGuide'; +import SettingsWorkspace from '../features/settings/SettingsWorkspace'; +import HelpTooltips from '../shared/HelpTooltips'; +import { getPreferences, setPreferences, usePreferences } from './preferences'; import { jamCall } from '../platform/project-call'; import MobileHome from '../platform/android/MobileHome'; import AndroidPointer from '../platform/android/AndroidPointer'; @@ -17,9 +21,7 @@ import { FileMusic, FolderInput, Library, - LoaderCircle, Music2, - Plus, Share2, SlidersHorizontal, Sparkles, @@ -47,7 +49,6 @@ import ProjectDetails from '../features/library/ProjectDetails'; import Sidebar from '../features/library/Sidebar'; import DatabaseMigration from '../features/migration/DatabaseMigration'; import { compatible } from '../features/models/compatibility'; -import ModelManager from '../features/models/ModelManager'; import ResultPicker from '../features/models/ResultPicker'; import Mixer from '../features/player/Mixer'; import PracticeToolbar from '../features/player/PracticeToolbar'; @@ -69,10 +70,9 @@ import AppearanceSettings from '../features/settings/AppearanceSettings'; import StorageLocations from '../features/settings/StorageLocations'; import AnalysisNote from '../features/settings/AnalysisNote'; import UpdateCheck from '../features/settings/UpdateCheck'; -import DownloadSettings from '../features/settings/DownloadSettings'; import RemoteTransfer from '../features/transfer/RemoteTransfer'; import { t as tr, message as translateMessage } from '../i18n'; -import { type Model, type Project, type Track } from '../shared/types'; +import { type Project, type Track } from '../shared/types'; import { useWorkspace } from './useWorkspace'; const colors = ['#ed7894', '#d9a86c', '#91bda9', '#a092cd', '#7fa8d3', '#8e949d']; const modes = () => [ @@ -86,6 +86,25 @@ const modes = () => [ ]; export default function App() { const controller = useWorkspace(); + const preferences = usePreferences(); + useEffect(() => { + if (isDesktop) void jamCall('ui-scale', { scale: preferences.uiScale }).catch(() => {}); + }, [preferences.uiScale]); + useEffect(() => { + if ( + !isDesktop || + !controller.libraryReady || + getPreferences().onboardingSeen || + controller.modal + ) + return; + try { + setPreferences({ onboardingSeen: true }); + if (!controller.library.length) controller.setModal('help'); + } catch (error) { + controller.notify((error as Error).message); + } + }, [controller.libraryReady, controller.library.length, controller.modal]); const [live, setLive] = useState(null); const [creatingFolder, setCreatingFolder] = useState(false); const [sessionSource, setSessionSource] = useState(null); @@ -115,15 +134,12 @@ export default function App() { setPosition, playing, setPlaying, - ready, - setReady, loading, loadProgress, setLoading, peaks, setPeaks, models, - setModels, modelId, setModelId, mode, @@ -132,13 +148,6 @@ export default function App() { setJob, toast, setToast, - runtimeMode, - setRuntimeMode, - runtimeMessage, - runtimeDownload, - setRuntimeMessage, - installing, - setInstalling, selected, setSelected, pair, @@ -173,8 +182,6 @@ export default function App() { setWaveWindow, waveLoading, setWaveLoading, - modelDownloads, - setModelDownloads, player, notify, refresh, @@ -829,6 +836,7 @@ export default function App() { )} )} + setDiscardingChanges(false)} @@ -855,6 +863,14 @@ export default function App() { -
-
- IMMERSE JAM -

- { + {!(!isAndroid && (shownModal === 'settings' || shownModal === 'help')) && ( +
+
+ IMMERSE JAM +

{ - separate: tr('分析音乐'), - help: tr('第一次使用?'), - settings: isAndroid ? tr('设置') : tr('设置与模型'), - transfer: tr('设备互传'), - migration: tr('迁移 Audio Jam'), - donate: tr('请作者喝一瓶冰可乐'), - feedback: tr('用户反馈'), - export: tr('导出音乐'), - files: tr('导入与导出'), - }[shownModal || ''] - } -

+ { + separate: tr('分析音乐'), + help: tr('第一次使用?'), + settings: isAndroid ? tr('设置') : tr('设置与模型'), + transfer: tr('设备互传'), + migration: tr('迁移 Audio Jam'), + donate: tr('请作者喝一瓶冰可乐'), + feedback: tr('用户反馈'), + export: tr('导出音乐'), + files: tr('导入与导出'), + }[shownModal || ''] + } +

+
+
- - + )} {shownModal === 'separate' && !isAndroid && ( <>

@@ -994,8 +1016,19 @@ export default function App() { )} - {shownModal === 'help' && } - {shownModal === 'settings' && ( + {shownModal === 'help' && + (isAndroid ? ( + + ) : ( + controller.openSettings('models')} + /> + ))} + {shownModal === 'settings' && !isAndroid && ( + + )} + {shownModal === 'settings' && isAndroid && ( <> {isAndroid && (