From 2e68e95c7261e20ccec84f6b6632bc828a1069c4 Mon Sep 17 00:00:00 2001 From: aaamosh Date: Tue, 6 Oct 2026 02:02:18 +0500 Subject: [PATCH 1/3] Integrate Linux beta.4 hands-free controls into native macOS acceptance Retain the shared production implementation and silent PCM capture fix. Add native recognition-to-routing tests for all seven commands and five controls, packaged UI/CLI settings checks, upgrade/rollback notes, and a separately gated beta.4 Mac preview promotion. Preserve previous Mac and Linux releases. Co-authored-by: ChatGPT --- .github/workflows/macos.yml | 37 +++++- docs/MACOS.md | 71 +++++++++- docs/RELEASING.md | 22 +++- docs/TESTING.md | 27 +++- docs/releases/macos-beta4-preview-1.md | 68 ++++++++++ scripts/publish-macos-preview.cjs | 12 +- scripts/smoke-macos-voice.cjs | 176 +++++++++++++++++++++++++ scripts/smoke-packaged.cjs | 16 ++- scripts/verify-macos-package.cjs | 36 +++++ test/native-voice-smoke.test.cjs | 69 ++++++++++ test/publish-macos-preview.test.cjs | 19 ++- test/wake-manager-macos.test.cjs | 34 ++++- 12 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 docs/releases/macos-beta4-preview-1.md create mode 100644 scripts/smoke-macos-voice.cjs create mode 100644 test/native-voice-smoke.test.cjs diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 2f6d9d7..6583b9e 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -11,7 +11,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && startsWith(github.event.head_commit.message, 'Publish macOS beta 4 preview with voice commands') && github.sha || 'checks' }} cancel-in-progress: true jobs: @@ -101,3 +101,38 @@ jobs: path: build/macos-qa/${{ matrix.arch }}/ if-no-files-found: warn retention-days: 30 + + publish-voice-preview: + name: Publish the authorized macOS hands-free preview + needs: macos + # Only the explicit owner-authorized publication commit can write a release. + if: >- + github.event_name == 'push' && github.repository == 'aaamosh/DotDial' && + github.ref == 'refs/heads/main' && github.event.head_commit.id == github.sha && + (github.event.head_commit.message == 'Publish macOS beta 4 preview with voice commands' || + startsWith(github.event.head_commit.message, format('{0}{1}', 'Publish macOS beta 4 preview with voice commands', fromJSON('"\n"')))) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22.12.0 + - uses: actions/download-artifact@v4 + with: + name: dotdial-macos-arm64-preview-${{ github.sha }} + path: build/macos-release/arm64 + - uses: actions/download-artifact@v4 + with: + name: dotdial-macos-x64-preview-${{ github.sha }} + path: build/macos-release/x64 + - name: Publish only the exact native artifacts after all acceptance gates + run: node scripts/publish-macos-preview.cjs + env: + GH_TOKEN: ${{ github.token }} diff --git a/docs/MACOS.md b/docs/MACOS.md index 1c12c41..6628888 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -18,7 +18,7 @@ Login Items, local audio cues and application bundles for both Mac architectures hotkeys, sounds and saved replies do not need a system Node or Python install. Download the published **Apple Silicon or Intel DMG** from the -[macOS Preview 2 release](https://github.com/aaamosh/DotDial/releases/tag/v0.1.0-beta.3-macos-preview.2). +[macOS beta.4 hands-free preview](https://github.com/aaamosh/DotDial/releases/tag/v0.1.0-beta.4-macos-preview.1). The release also includes application ZIPs, source manifests and one combined `SHA256SUMS` file. Its notes identify the exact source revision and native CI run. @@ -136,6 +136,46 @@ When enabled, wake continues listening locally during calls even if transmission to the dot is muted. The wake phrase can re-enable microphone and speakers. Turn wake off and save if you do not want local listening. See [Privacy](PRIVACY.md). +## Hands-free call commands (beta.4) + +The beta.4 Mac preview contains the same seven configurable commands as Linux +beta.4. After local wake setup, enable **Hands-free call commands** in +**Settings → Voice** and save. This option is off by default, including upgrades. + +| Action | Default English phrase | +| --- | --- | +| Microphone off | Microphone off | +| Microphone on | Microphone on | +| Speakers off | Radio silence | +| Speakers on | Sound on please | +| Hang up | Hang up | +| Play saved replies | Replay messages | +| Stop playback | Stop the replay | + +Edit the phrases in Voice settings or `wakeWord.commands`. Save applies them +while a call is active: only the local recognizer is replaced, not the call. +Each phrase has two to six English words. Overlap with the wake phrase or another +command is rejected. The normal wake phrase still starts or reactivates a call. +The selected Mac microphone is shared with the existing Electron capture path; +Python receives audio on stdin and does not open another microphone. + +Microphone and speaker choices are independent. Speaker mute also stops replay; +voice hangup cancels unfinished actions. The listener stays available during +idle saved-reply playback so **Stop the replay** works. A bare wake detection is +suppressed during that playback and its short decoding tail to avoid placing an +accidental call. Disabling commands leaves ordinary wake behavior available. + +Call microphone mute stops transmission, not local wake listening. Turn off wake +and save to stop local listening. Speech before mute takes effect may reach the +dot, and loudspeaker audio may trigger commands through the microphone. A headset +and distinct phrases reduce that risk. No new cloud recognition or audio storage +is added by these commands. + +The older [beta.3 Preview 2](https://github.com/aaamosh/DotDial/releases/tag/v0.1.0-beta.3-macos-preview.2) +does not contain these commands. The new preview retains its silent-sink capture +fix and all original native acceptance gates, and adds a twelfth required gate +for command recognition and routing. + ## Start at login Enable **Start at login** in the installed application. DotDial uses macOS Login @@ -188,9 +228,13 @@ Config reports and doctor do not open a call or capture audio. Quit DotDial before replacing the `.app`. Keep the last working installer until you have checked the new version. Replace only the application, then reopen it; -settings and saved replies remain in Application Support. This port does not -introduce a configuration-schema migration. If a later release introduces one, -follow that release's migration and rollback instructions before downgrading. +settings and saved replies remain in Application Support. Beta.4 adds +`wakeWord.commandsEnabled` and `wakeWord.commands` within configuration version 1. +Missing fields receive opt-out defaults on upgrade. Before upgrading, preserve a +copy of `config.json`: after saving beta.4 settings, beta.3's strict parser rejects +these new fields. To roll back, restore the pre-upgrade config along with the old +app, without replacing or deleting `data/` or `recordings/`. Alternatively remove +only those two keys from a backed-up copy of the config before using beta.3. To uninstall, turn off Start at login, quit DotDial and move the application to the Trash. User data remains. If you also want to delete the browser session, model, @@ -247,7 +291,7 @@ valid signed entitlements do not establish a TCC permission grant, Developer ID signing or notarization. Synthetic checks do not establish that real ChatGPT authentication, a microphone permission prompt, physical audio hardware or Login Items will work on every Mac. -Preview 2 retains these remaining acceptance gaps. +The beta.4 hands-free preview retains these remaining acceptance gaps. Before promoting it beyond preview, test those with an actual user session on the minimum supported macOS and a current version. Do not put account data or recordings in CI. @@ -255,3 +299,20 @@ recordings in CI. The application icon is derived from `src/assets/dotdial-macos.svg`, using the existing DotDial mark. The reviewed ICNS file is included in source and pinned by the public-source scanner; icon generation tools are not needed to build the app. + +## Native voice-command acceptance + +The required `native_voice_commands` gate runs the packaged listener, native +managed Python, `WakeManager` and `VoiceCommands` through twelve speech cases. +All seven defaults must produce exactly the expected command and dispatch, +without extra wakes. Controls also cover the wake phrase with commands enabled, +unrelated speech, disabled commands, an edited phrase and the old phrase after +replacement. Speech is generated by the already-built, pinned Flite SLT voice. +The report and WAV provenance are preserved as `voice-commands.json` and +`voice-fixtures/` in the native QA artifact. + +This gate injects synthetic PCM at the capture callback and uses an isolated call +state and dispatcher. The separate `native_wake_pipeline` gate exercises actual +Electron capture and the bounded PCM pipe. Neither is an authenticated account +call or physical-device test. Additional regressions check live phrase edits, +stale-event rejection, selected-input preservation and command opt-out on Mac. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c286be8..116681b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -70,9 +70,9 @@ The build jobs had read-only repository permissions; only the promotion job coul write release contents. That completed job has been removed from active CI. Regular workflows build and verify packages for pull requests, `main` pushes, -`v*` tags and manual runs, then preserve artifacts and reports. There is no active -publication path in normal CI: both completed macOS promotion jobs have been -removed. The original workflows, publishers and regression tests remain available +`v*` tags and manual runs, then preserve artifacts and reports. Both completed beta.3 macOS promotion jobs have been removed. The separately +gated beta.4 hands-free publication is described below; normal CI runs cannot +invoke it. The original workflows, publishers and regression tests remain available at their respective released source commits for provenance. The first promotion downloaded the distribution artifacts from that same workflow @@ -157,3 +157,19 @@ Complete the physical microphone, real account, TCC, Login Items and minimum-mac session checks before promoting this preview to a more general release. Preserve the explicit preview classification until that acceptance and the planned Apple signing/notarization work are complete. + +## macOS beta.4 hands-free preview + +The next explicit publication uses the separate tag +`v0.1.0-beta.4-macos-preview.1` and application version `0.1.0-beta.4`. The +reviewed main commit's first line must be exactly +`Publish macOS beta 4 preview with voice commands`. The one-time workflow job +waits for both architectures and all twelve required package gates, then promotes +only those same-run artifacts with matching manifests and SHA-256. It does not +rebuild packages or download Release assets. Earlier previews and Linux beta.4 +are outside its mutation target. + +The publisher retains strict draft identity, fixed-tag and no-overwrite checks. +After successful readback, retire this one-time job and link the new downloads. +The archived beta.3 publisher remains accessible at its original source; ordinary +pushes, pull requests and workflow dispatches do not authorize publication. diff --git a/docs/TESTING.md b/docs/TESTING.md index c394fb0..669986d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -34,8 +34,9 @@ copy through the push event. For a branch without a pull request, open a draft pull request or request a manual run. Newer runs cancel older runs only for the same workflow, event and ref; a manual run does not cancel an automatic run. -These workflows preserve build artifacts and reports. They do not publish -GitHub releases. The completed one-time publisher for the first macOS preview +Ordinary workflow runs preserve build artifacts and reports without publishing +GitHub releases. A separate one-time beta.4 publication requires the exact main +commit subject documented in [Releasing](RELEASING.md#macos-beta4-hands-free-preview). The completed one-time publisher for the first macOS preview is retained in the [released source](https://github.com/aaamosh/DotDial/blob/54889c5b04fe3dc32b79084d56bbec6e187a53de/.github/workflows/macos.yml), and its script and regression tests remain in the repository. Removing that job from active CI does not change the public tag or released files. @@ -225,6 +226,7 @@ The verifier requires every stage to pass: | `native_wake_decoder` | The packaged installer repairs a deliberately removed required file in isolated test data; its restored hash matches the original. Native Python dependencies then load the real model and the listener consumes synthetic PCM with a clean EOF. | | `native_wake_pipeline` | Electron capture, IPC and bounded PCM delivery reach the native Python listener, including four pause/resume cycles, stopped-reader cleanup and restart. | | `native_wake_speech` | Generated speech produces the required positive and two negative recognition results described above. | +| `native_voice_commands` | Packaged native recognition and routing pass all twelve command, wake, custom-phrase and negative-control cases described below. | | `disk_image` | The DMG is readable and contains the verified application and Applications shortcut. | The packaged GUI smoke waits for the application to show its settings window @@ -290,3 +292,24 @@ microphone unplug/reconnect, login startup, a spoken wake phrase and a real acco call. Run that session on macOS 13 Ventura as well as a current supported version; deployment metadata alone cannot substitute for a minimum-version runtime test. Keep private account data and recordings out of test artifacts. + +## macOS beta.4 hands-free acceptance + +Native macOS packages retain the previous eleven gates and require a twelfth: +`native_voice_commands`. It invokes `scripts/smoke-macos-voice.cjs` through the +bundled Electron Node runtime with the extracted app, private wake environment, +pinned Flite fixture directory and a fresh report path. No Release asset is +downloaded by this check. + +Twelve cases exercise the actual packaged Python listener and JavaScript routing: +seven default commands; ordinary wake with commands enabled; unrelated speech; +commands disabled; a custom microphone-off phrase; and rejection of the replaced +phrase. Every case requires the exact ordered event sequence, matching dispatch, +successful decoder EOF and clean capture/process teardown. Synthetic PCM replaces +the capture callback and dispatch uses an isolated call state. This complements, +not replaces, real Electron PCM pipeline, cancellation and hardware acceptance. + +`voice-commands.json` preserves results even on a failing case; `voice-fixtures/` +contains generated WAVs linked by hashes to the report and pinned Flite binary. +The CLI gate also checks seven opt-out command defaults, a saved phrase change +and the native Command-Shift-Space shortcut without system Node. diff --git a/docs/releases/macos-beta4-preview-1.md b/docs/releases/macos-beta4-preview-1.md new file mode 100644 index 0000000..5629abb --- /dev/null +++ b/docs/releases/macos-beta4-preview-1.md @@ -0,0 +1,68 @@ +# DotDial for macOS 0.1.0-beta.4 + +**Your voice has the controls.** This native Mac preview includes the hands-free +commands already published in Linux beta.4, using the same configuration, +recognizer and call-control implementation. + +## Turn it on + +In **Settings → Voice**, set up local wake support, enable **Hands-free call +commands**, and save. Commands are off by default and use short English phrases. +Each phrase can be edited without restarting DotDial or reconnecting a call. + +| Action | Default phrase | +| --- | --- | +| Microphone off | Microphone off | +| Microphone on | Microphone on | +| Speakers off | Radio silence | +| Speakers on | Sound on please | +| End the call | Hang up | +| Play saved replies | Replay messages | +| Stop saved-reply playback | Stop the replay | + +Your existing wake phrase still starts a call. Overlapping command/wake phrases +are rejected. Microphone and speakers remain independent; hangup cancels pending +audio actions, and playback can be stopped by voice even after a call has ended. +On macOS the selected Electron microphone feeds the local Python recognizer over +its existing bounded audio pipe. No second Python microphone or cloud recognition +service is introduced. Muting the call stops transmission, not the local listener. +Speech spoken before mute takes effect may reach the dot. Speaker output can be +picked up by the microphone; use distinct phrases or a headset. + +## Downloads and provenance + +Use the `arm64` download on Apple Silicon and `x64` on Intel. Names contain +`preview-{{SOURCE_SHORT}}`; each architecture has a DMG, an app ZIP and a source +manifest. `SHA256SUMS` covers those six files. + +Source: `{{SOURCE_COMMIT}}`. +[Native build and acceptance]({{CI_RUN_URL}}). + +These packages are published only after both native jobs pass all **12 required +package gates**. The new command gate uses the real packaged listener, +WakeManager and command router with synthetic English speech: seven commands, +wake with commands enabled, unrelated speech, commands disabled, a custom phrase +and rejection of its replaced phrase. It checks exact events, dispatch and +process cleanup. Its microphone input and call dispatch are isolated fixtures, +not a hardware or real-account call test. Existing Electron PCM, GUI, four-resume, +backpressure, saved-reply, permission-policy and package checks remain required. + +## Upgrade and limitations + +Quit DotDial, replace the app in Applications and reopen it. Existing profiles, +shortcuts, sounds and saved replies stay in Application Support. Voice commands +remain off until explicitly enabled. Back up `config.json` before upgrading: +once beta.4 saves its new command fields, the older beta.3 strict parser will not +accept them. Restore the pre-upgrade config when rolling back; keep the data and +recordings directories intact. See [the Mac guide](https://github.com/aaamosh/DotDial/blob/{{SOURCE_COMMIT}}/docs/MACOS.md). + +This is an **ad-hoc signed preview, without Apple Developer ID or notarization**. +Native tests run on macOS 15 for both architectures; macOS 13 is the binary +minimum, not a verified runtime result. Physical audio devices, real permission +prompts, sleep/unplug recovery, user-session Login Items and authenticated ChatGPT +calls still require hands-on acceptance. Synthetic speech is a regression check, +not an accuracy measurement across accents, speakers or rooms. + +The application remains unofficial and uses internal web-call routes. An existing +ChatGPT dot is required. Earlier macOS previews and the Linux beta.4 release are +preserved unchanged. diff --git a/scripts/publish-macos-preview.cjs b/scripts/publish-macos-preview.cjs index 471971d..b04a5f1 100644 --- a/scripts/publish-macos-preview.cjs +++ b/scripts/publish-macos-preview.cjs @@ -11,10 +11,10 @@ const crypto = require('node:crypto'); const { spawnSync } = require('node:child_process'); const REPOSITORY = 'aaamosh/DotDial'; -const VERSION = '0.1.0-beta.3'; -const TAG = 'v0.1.0-beta.3-macos-preview.2'; -const TITLE = 'DotDial for macOS — Preview 2'; -const PUBLISH_SUBJECT = 'Publish macOS preview 2 with silent wake audio sink'; +const VERSION = '0.1.0-beta.4'; +const TAG = 'v0.1.0-beta.4-macos-preview.1'; +const TITLE = 'DotDial for macOS 0.1.0-beta.4 - Hands-free preview'; +const PUBLISH_SUBJECT = 'Publish macOS beta 4 preview with voice commands'; const ARCHITECTURES = ['arm64', 'x64']; const API_ROOT = `/repos/${REPOSITORY}`; const SOURCE_PATTERN = /^[a-f0-9]{40}$/; @@ -257,12 +257,12 @@ async function main(env = process.env) { const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8')); const context = validateContext(env, event); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - requireValue(pkg.name === 'dotdial' && pkg.version === VERSION, 'This publisher is restricted to the Preview 2 application version.'); + requireValue(pkg.name === 'dotdial' && pkg.version === VERSION, 'This publisher is restricted to the beta.4 macOS preview application version.'); const distribution = await validateArtifacts(path.join(root, 'build', 'macos-release'), { ...context, electronVersion: pkg.devDependencies.electron }); const output = path.join(root, 'build', 'macos-promotion'); const assets = await addCombinedChecksums(distribution, output); - const body = renderReleaseBody(fs.readFileSync(path.join(root, 'docs', 'releases', 'macos-preview-2.md'), 'utf8'), context); + const body = renderReleaseBody(fs.readFileSync(path.join(root, 'docs', 'releases', 'macos-beta4-preview-1.md'), 'utf8'), context); const notesFile = path.join(output, 'release-notes.md'); fs.writeFileSync(notesFile, body, { mode: 0o644 }); const result = await promoteRelease({ client: githubClient(env), context, assets, body, notesFile }); diff --git a/scripts/smoke-macos-voice.cjs b/scripts/smoke-macos-voice.cjs new file mode 100644 index 0000000..03b866b --- /dev/null +++ b/scripts/smoke-macos-voice.cjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +'use strict'; + +// Native decoder -> WakeManager -> VoiceCommands acceptance. Synthetic speech +// replaces microphone acquisition; dispatch uses an isolated call-state fixture. +// Actual Electron capture and real-account calls are separate acceptance scopes. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawn, spawnSync } = require('node:child_process'); +const { performance } = require('node:perf_hooks'); +const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function pcmFromWav(bytes) { + assert.ok(bytes.length >= 44 && bytes.length <= 16000 * 15 * 2 + 65536, 'bounded speech WAV'); + assert.equal(bytes.toString('ascii', 0, 4), 'RIFF'); + assert.equal(bytes.toString('ascii', 8, 12), 'WAVE'); + assert.equal(bytes.readUInt32LE(4) + 8, bytes.length, 'complete RIFF payload'); + let fmt, data; + let offset = 12; + for (; offset + 8 <= bytes.length;) { + const id = bytes.toString('ascii', offset, offset + 4), size = bytes.readUInt32LE(offset + 4); + assert.ok(offset + 8 + size <= bytes.length, 'complete WAV chunk'); + const chunk = bytes.subarray(offset + 8, offset + 8 + size); + if (id === 'fmt ') { assert.equal(fmt, undefined); fmt = chunk; } + if (id === 'data') { assert.equal(data, undefined); data = chunk; } + offset += 8 + size + (size % 2); + } + assert.equal(offset, bytes.length, 'no truncated trailing WAV chunk'); + assert.ok(fmt && fmt.length >= 16 && data, 'WAV format and samples are present'); + assert.equal(fmt.readUInt16LE(0), 1); assert.equal(fmt.readUInt16LE(2), 1); + assert.equal(fmt.readUInt32LE(4), 16000); assert.equal(fmt.readUInt32LE(8), 32000); + assert.equal(fmt.readUInt16LE(12), 2); + assert.equal(fmt.readUInt16LE(14), 16); + assert.ok(data.length >= 3200 && data.length <= 16000 * 15 * 2 && data.length % 2 === 0); + // Same half-second context at each end as native wake speech acceptance. + const pcm = Buffer.alloc((data.length / 2 + 16000) * 4); + let peak = 0; + for (let offset = 0; offset < data.length; offset += 2) { + const value = data.readInt16LE(offset) / 32768; + peak = Math.max(peak, Math.abs(value)); pcm.writeFloatLE(value, 32000 + offset * 2); + } + assert.ok(peak >= 0.001, 'speech must not be silent'); + return pcm; +} + +function verifyEvents(stdout, expected) { + assert.ok(Buffer.byteLength(stdout) <= 65536, 'bounded recognizer output'); + const events = stdout.trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + assert.deepEqual(events, [{ event: 'ready' }, ...expected], 'exact recognition, without extra commands or wake events'); + return events; +} + +function run(file, args) { + const result = spawnSync(file, args, { encoding: 'utf8', timeout: 10000, maxBuffer: 65536, stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(result.error, undefined); assert.equal(result.status, 0, result.stderr); + return result.stdout; +} + +async function smoke({ source, data, fixtures, output }, report) { + assert.equal(process.platform, 'darwin', 'native macOS is mandatory'); + assert.ok(['arm64', 'x64'].includes(process.arch)); + const { defaults, validateConfig } = require(path.join(source, 'src/config.cjs')); + const { WakeManager } = require(path.join(source, 'src/wake-manager.cjs')); + const { VoiceCommands, VOICE_COMMANDS } = require(path.join(source, 'src/voice_commands.cjs')); + const { MODEL } = require(path.join(source, 'src/wake-runtime.cjs')); + const manifest = JSON.parse(fs.readFileSync(path.join(source, '..', 'dotdial-build.json'), 'utf8')); + assert.equal(manifest.sourceDirty, false); assert.equal(manifest.architecture, process.arch); + assert.equal(defaults.wakeWord.commandsEnabled, false, 'upgrade does not opt users into commands'); + const commands = defaults.wakeWord.commands; + assert.deepEqual(Object.keys(commands).sort(), Object.keys(VOICE_COMMANDS).sort()); + assert.equal(Object.keys(commands).length, 7); + const preparation = JSON.parse(fs.readFileSync(path.join(fixtures, 'provenance.json'), 'utf8')); + assert.equal(preparation.prepareWakeSpeech, 'passed'); + assert.equal(preparation.sourceCommit, '6c9f20dc915b17f5619340069889db0aa007fcdc'); + assert.equal(preparation.hostPlatform, 'darwin'); + assert.equal(preparation.hostMachine, process.arch === 'x64' ? 'x86_64' : 'arm64'); + assert.equal(hash(fs.readFileSync(preparation.binary)), preparation.binarySha256); + const python = path.join(data, 'wake-venv/bin/python'); + const runtime = JSON.parse(run(python, ['-I', '-c', 'import json,sys,platform; print(json.dumps(dict(platform=sys.platform,machine=platform.machine(),prefix=sys.prefix)))'])); + assert.equal(runtime.platform, 'darwin'); assert.equal(runtime.machine, preparation.hostMachine); + assert.equal(fs.realpathSync(runtime.prefix), fs.realpathSync(path.join(data, 'wake-venv'))); + Object.assign(report, { sourceCommit: manifest.sourceCommit, version: manifest.version, sourceRoot: source, + hostPlatform: process.platform, hostArch: process.arch, runtime, commands, generatorSha256: preparation.binarySha256 }); + const generated = path.join(path.dirname(output), 'voice-fixtures'); + fs.mkdirSync(generated, { recursive: true }); + const pcms = new Map(); + function speech(text) { + if (pcms.has(text)) return pcms.get(text); + const filename = path.join(generated, `${hash(text).slice(0, 16)}.wav`); + assert.equal(fs.existsSync(filename), false, 'generated fixtures must be fresh'); + run(preparation.binary, ['-voice', 'slt', '-t', text + '.', '-o', filename]); + const bytes = fs.readFileSync(filename), pcm = pcmFromWav(bytes); + report.fixtures.push({ text, file: path.basename(filename), wavSha256: hash(bytes), pcmSha256: hash(pcm), pcmBytes: pcm.length }); + pcms.set(text, pcm); return pcm; + } + const custom = { ...commands, microphoneOff: 'Disable microphone' }; + validateConfig({ ...defaults, wakeWord: { ...defaults.wakeWord, commands: custom } }); + const cases = Object.keys(commands).map(action => ({ name: action, text: commands[action], action })); + cases.push({ name: 'wake_with_commands', text: defaults.wakeWord.phrase, wake: true }, + { name: 'unrelated_speech', text: 'The weather is calm today' }, + { name: 'commands_disabled', text: commands.microphoneOff, enabled: false }, + { name: 'custom_phrase', text: custom.microphoneOff, action: 'microphoneOff', commands: custom }, + { name: 'old_phrase_removed', text: commands.microphoneOff, commands: custom }); + for (const spec of cases) { + const pcm = speech(spec.text), item = { name: spec.name, result: 'failed' }; + report.cases.push(item); + let child, capture, manager, stdout = '', childError, exited = false; + const state = { state: 'active', missed_playing: spec.action === 'stopPlayback' }, identity = {}; + const dispatched = [], handled = [], handlerErrors = []; + let wakes = 0, closed = false; + const router = new VoiceCommands({ getState: () => state, getCallIdentity: () => identity, + dispatch: async verb => { dispatched.push(verb); return { status: 'accepted' }; } }); + async function until(predicate, label) { + const end = performance.now() + 35000; + while (!predicate()) { + if (childError) throw childError; + if (performance.now() >= end) throw Error(label); + await sleep(10); + } + } + try { + manager = new WakeManager({ paths: { dataDir: data }, platform: 'darwin', + requestMicrophoneAccess: async () => true, + captureFactory: callbacks => { capture = callbacks; return { ready: Promise.resolve(), close: async () => { closed = true; } }; }, + spawn(file, args, options) { + assert.equal(fs.realpathSync(file), fs.realpathSync(python)); + assert.ok(args.includes('--stdin-audio')); assert.ok(!args.includes('--test-file')); + child = spawn(file, args, options); + child.stdout.on('data', bytes => { stdout += bytes.toString(); if (stdout.length > 65536) { childError = Error('stdout_limit'); child.kill('SIGKILL'); } }); + child.once('error', error => { childError = error; }); + child.once('close', () => { exited = true; }); + return child; + }, + onWake: () => { wakes++; }, + onCommand: action => { handled.push(router.handle(action).catch(error => { handlerErrors.push(error.message); })); }, + }); + manager.setCallState(state); + manager.configure({ ...defaults.wakeWord, enabled: true, commandsEnabled: spec.enabled !== false, + commands: spec.commands || commands, modelPath: path.join(data, 'models', MODEL), pythonPath: python }); + await until(() => { if (manager.error) throw Error(manager.error); return manager.status === 'listening'; }, 'voice_start_timeout'); + for (let offset = 0; offset < pcm.length; offset += 6400) await capture.onAudio(pcm.subarray(offset, offset + 6400)); + child.stdin.end(); + await until(() => exited, 'voice_eof_timeout'); + await Promise.all(handled); + assert.equal(child.exitCode, 0); assert.deepEqual(handlerErrors, []); + item.events = verifyEvents(stdout, spec.action ? [{ event: 'command', command: spec.action }] : spec.wake ? [{ event: 'wake' }] : []); + assert.deepEqual(dispatched, spec.action ? [VOICE_COMMANDS[spec.action]] : []); + assert.equal(wakes, spec.wake ? 1 : 0); + Object.assign(item, { result: 'passed', dispatched, wakes, eofExit: true }); + } finally { + if (child && !exited) child.kill('SIGKILL'); + if (manager) await manager.close(); + if (child && !exited) await until(() => exited, 'voice_cleanup_timeout'); + assert.ok(!child || exited, 'native decoder must be reaped'); + assert.ok(!capture || closed, 'synthetic capture must be released'); + item.decoderReaped = exited; item.captureClosed = closed; + } + } + report.nativeVoiceCommands = 'passed'; +} + +async function main() { + const [source, data, fixtures, output] = process.argv.slice(2); + assert.ok([source, data, fixtures, output].every(value => value && path.isAbsolute(value)), 'four absolute paths required'); + const report = { nativeVoiceCommands: 'failed', syntheticSpeech: true, physicalMicrophoneTested: false, + electronCaptureTested: false, accountCallTested: false, dispatchTarget: 'isolated_state_fixture', fixtures: [], cases: [] }; + try { await smoke({ source, data, fixtures, output }, report); } + catch (error) { report.error = String(error.stack || error).slice(-8000); process.exitCode = 1; } + fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify(report)); +} +if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; }); +module.exports = { pcmFromWav, verifyEvents }; diff --git a/scripts/smoke-packaged.cjs b/scripts/smoke-packaged.cjs index c75ee74..0d8b89d 100644 --- a/scripts/smoke-packaged.cjs +++ b/scripts/smoke-packaged.cjs @@ -363,6 +363,7 @@ async function run() { assert.ok(fs.realpathSync(app.getPath('userData')).startsWith(demoRoot + path.sep)); assert.equal(original.config.dot.url, '', 'demo must not use an account'); assert.equal(original.config.wakeWord.enabled, false, 'demo must not start a wake listener'); + assert.equal(original.config.wakeWord.commandsEnabled, false, 'commands require explicit opt-in'); const statusPath = path.join(demoRoot, 'state', 'status.json'); const readStatus = () => JSON.parse(fs.readFileSync(statusPath, 'utf8')); assert.equal(readStatus().demo, true); @@ -370,6 +371,10 @@ async function run() { await evaluate(settings, `(() => { const input = document.querySelector('#dot-display-name'); input.value = 'Packaged smoke'; input.dispatchEvent(new Event('input', { bubbles: true })); + const toggle = document.querySelector('#wake-commands-enabled'); + toggle.checked = true; toggle.dispatchEvent(new Event('change', { bubbles: true })); + const phrase = document.querySelector('#wake-command-microphone-off'); + phrase.value = 'Quiet microphone'; phrase.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('#top-save').click(); })()`); await until(() => JSON.parse(fs.readFileSync(original.path, 'utf8')).dot.displayName === 'Packaged smoke', @@ -377,6 +382,10 @@ async function run() { const saved = await evaluate(settings, 'window.dotdial.readConfig()'); assert.notEqual(saved.hash, original.hash); assert.equal(saved.config.dot.displayName, 'Packaged smoke'); + assert.equal(saved.config.wakeWord.commandsEnabled, true); + assert.equal(saved.config.wakeWord.commands.microphoneOff, 'Quiet microphone'); + assert.equal(Object.keys(saved.config.wakeWord.commands).length, 7); + assert.equal(saved.config.wakeWord.enabled, false, 'editing commands must not start local capture'); const reloaded = new Promise(resolve => settings.webContents.once('did-finish-load', resolve)); settings.webContents.reload(); await reloaded; @@ -387,6 +396,11 @@ async function run() { 'packaged_settings_did_not_survive_reload'); assert.deepEqual((await evaluate(settings, 'window.dotdial.readConfig()')).config, saved.config); const settingsCapture = await capture(settings, outputDirectory, 'settings.png'); + await evaluate(settings, 'document.querySelector(".nav-item[data-section=voice]").click()'); + assert.equal(await evaluate(settings, 'document.querySelector("#wake-commands-enabled").checked'), true); + assert.equal(await evaluate(settings, 'document.querySelector("#wake-command-microphone-off").value'), 'Quiet microphone'); + assert.equal(await evaluate(settings, 'document.querySelector("#wake-command-microphone-off").disabled'), false); + const voiceSettingsCapture = await capture(settings, outputDirectory, 'voice-settings.png'); phase = 'panel'; await evaluate(settings, `(() => { @@ -428,7 +442,7 @@ async function run() { assert.equal(BrowserWindow.getAllWindows().some(window => /^https?:/.test(window.webContents.getURL())), false, 'demo smoke must not open account or other network pages'); phase = 'quit'; - fs.writeSync(1, JSON.stringify({ packagedSmoke: 'passed', result: 'passed', platform: process.platform, + fs.writeSync(1, JSON.stringify({ packagedSmoke: 'passed', voiceControlsPersisted: true, voiceSettingsCapture, result: 'passed', platform: process.platform, arch: process.arch, version: app.getVersion(), electron: process.versions.electron, chromium: process.versions.chrome, packaged: app.isPackaged, elapsed_ms: Date.now() - started, software_rendering_requested: process.argv.includes('--disable-gpu'), diff --git a/scripts/verify-macos-package.cjs b/scripts/verify-macos-package.cjs index d8106a5..e14bed7 100644 --- a/scripts/verify-macos-package.cjs +++ b/scripts/verify-macos-package.cjs @@ -123,6 +123,7 @@ function verifyLockHelper(lockHelper, temporary, env) { async function runRequiredStages(stages, evidence, record = () => {}) { assert.ok(stages.length > 0, 'package verification requires checks'); + assert.equal(new Set(stages.map(([name]) => name)).size, stages.length, 'required stage names must be unique'); const failed = []; evidence.stages = {}; evidence.macOSPackageVerified = false; @@ -216,6 +217,14 @@ async function main() { const initial = JSON.parse(run(launcher, ['config', 'show'], { env }).stdout); assert.equal(initial.hash, null); assert.equal(initial.config.wakeWord.enabled, false); + assert.equal(initial.config.wakeWord.commandsEnabled, false); + assert.equal(Object.keys(initial.config.wakeWord.commands).length, 7); + assert.equal(initial.config.general.hotkey, 'Command+Shift+Space'); + const commandSave = JSON.parse(run(launcher, ['config', 'set', 'wakeWord.commands.microphoneOff', '"Disable microphone"'], { env }).stdout); + assert.equal(commandSave.saved, true); + const commandReadback = JSON.parse(run(launcher, ['config', 'show'], { env }).stdout); + assert.equal(commandReadback.config.wakeWord.commands.microphoneOff, 'Disable microphone'); + assert.equal(commandReadback.config.wakeWord.commandsEnabled, false); const save = JSON.parse(run(launcher, ['config', 'set', 'dot.displayName', '"macOS package smoke"'], { env }).stdout); assert.equal(save.saved, true); const readback = JSON.parse(run(launcher, ['config', 'show'], { env }).stdout); @@ -321,6 +330,33 @@ async function main() { ['different_configured_phrase', 'passed', false], ]); }], + ['native_voice_commands', async () => { + // Real native Python and packaged routing; synthetic speech and isolated + // dispatch, not hardware or an authenticated account call. + const voiceReport = path.join(reportDirectory, 'voice-commands.json'); + fs.rmSync(voiceReport, { force: true }); + let emittedReport; + try { + const result = await runPosixSmoke(executable, + [path.join(__dirname, 'smoke-macos-voice.cjs'), packagedSource, wakeData, speechFixtures, voiceReport], + { env: { ...guiEnv, ELECTRON_RUN_AS_NODE: '1' }, timeout: 180_000 }); + evidence.voiceCommandsSupervision = result.supervision; + emittedReport = parseJsonLine(result.stdout, 'nativeVoiceCommands'); + } catch (error) { + if (error.supervision) evidence.voiceCommandsSupervision = error.supervision; + throw error; + } finally { + if (fs.existsSync(voiceReport)) evidence.voiceCommands = JSON.parse(fs.readFileSync(voiceReport, 'utf8')); + } + const voice = evidence.voiceCommands; + assert.deepEqual(voice, emittedReport, 'file and stdout voice reports agree'); + assert.equal(voice.nativeVoiceCommands, 'passed'); + assert.equal(voice.sourceCommit, manifest.sourceCommit); + assert.equal(voice.hostPlatform, 'darwin'); assert.equal(voice.hostArch, process.arch); + assert.equal(voice.sourceRoot, packagedSource); + assert.equal(voice.cases.length, 12); + assert.ok(voice.cases.every(item => item.result === 'passed' && item.decoderReaped && item.captureClosed)); + }], ['disk_image', async () => { fs.mkdirSync(mount); run('/usr/bin/hdiutil', ['verify', dmg]); diff --git a/test/native-voice-smoke.test.cjs b/test/native-voice-smoke.test.cjs new file mode 100644 index 0000000..39e5b9a --- /dev/null +++ b/test/native-voice-smoke.test.cjs @@ -0,0 +1,69 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pcmFromWav, verifyEvents } = require('../scripts/smoke-macos-voice.cjs'); + +function wav() { + const bytes = Buffer.alloc(44 + 3200); + bytes.write('RIFF'); bytes.writeUInt32LE(bytes.length - 8, 4); bytes.write('WAVEfmt ', 8); + bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22); + bytes.writeUInt32LE(16000, 24); bytes.writeUInt32LE(32000, 28); bytes.writeUInt16LE(2, 32); + bytes.writeUInt16LE(16, 34); bytes.write('data', 36); bytes.writeUInt32LE(3200, 40); + bytes.writeInt16LE(-16384, 44); bytes.writeInt16LE(8192, 46); + return bytes; +} + +test('native command speech preserves signed PCM and adds exactly half-second context', () => { + const pcm = pcmFromWav(wav()); + assert.equal(pcm.length, (1600 + 16000) * 4); + assert.equal(pcm.readFloatLE(32000), -0.5); assert.equal(pcm.readFloatLE(32004), 0.25); + assert.ok(pcm.subarray(0, 32000).every(byte => byte === 0)); + assert.ok(pcm.subarray(pcm.length - 32000).every(byte => byte === 0)); +}); + +test('native command speech rejects silent, stereo, wrong-rate and truncated WAVs', () => { + for (const mutate of [b => b.fill(0, 44), b => b.writeUInt16LE(2, 22), b => b.writeUInt32LE(48000, 24), b => b.writeUInt16LE(8, 34), b => b.writeUInt32LE(1, 28), b => b.writeUInt32LE(999999, 40)]) { + const bytes = wav(); mutate(bytes); assert.throws(() => pcmFromWav(bytes)); + } + assert.throws(() => pcmFromWav(wav().subarray(0, 100))); + const trailing = Buffer.concat([wav(), Buffer.from([1, 2])]); + trailing.writeUInt32LE(trailing.length - 8, 4); + assert.throws(() => pcmFromWav(trailing)); + assert.throws(() => pcmFromWav(Buffer.alloc(16000 * 15 * 2 + 65537))); +}); + +test('native voice acceptance requires exact command labels and rejects extra wakes or duplicates', () => { + const ready = '{"event":"ready"}\n', command = '{"event":"command","command":"hangUp"}\n'; + verifyEvents(ready + command, [{ event: 'command', command: 'hangUp' }]); + verifyEvents(ready, []); + for (const output of [ready, ready + command + command, ready + '{"event":"wake"}\n' + command, + ready + '{"event":"command","command":"microphoneOn"}\n', command + ready, + ready + '{"event":"error","code":"wake_failed"}\n', ready + 'null\n']) { + assert.throws(() => verifyEvents(output, [{ event: 'command', command: 'hangUp' }])); + } +}); + +test('disabled and unrelated speech cannot pass after any detected command or wake', () => { + for (const event of [{ event: 'wake' }, { event: 'command', command: 'microphoneOff' }]) { + assert.throws(() => verifyEvents('{"event":"ready"}\n' + JSON.stringify(event), [])); + } + assert.throws(() => verifyEvents('x'.repeat(65537), [])); +}); + +test('native package acceptance includes voice commands as a required supervised stage', () => { + const source = fs.readFileSync(path.join(__dirname, '../scripts/verify-macos-package.cjs'), 'utf8'); + assert.match(source, /\['native_voice_commands', async \(\) =>/); + assert.match(source, /smoke-macos-voice\.cjs/); + assert.equal((source.match(/\['native_voice_commands', async/g) || []).length, 1); + assert.match(source, /voice\.cases\.length, 12/); +}); + + +test('duplicate required gate names fail before running or overwriting evidence', async () => { + const { runRequiredStages } = require('../scripts/verify-macos-package.cjs'); + let calls = 0; + await assert.rejects(runRequiredStages([['same', async () => { calls++; }], ['same', async () => { calls++; }]], {}), /unique/); + assert.equal(calls, 0); +}); diff --git a/test/publish-macos-preview.test.cjs b/test/publish-macos-preview.test.cjs index a026771..cb95e3a 100644 --- a/test/publish-macos-preview.test.cjs +++ b/test/publish-macos-preview.test.cjs @@ -48,7 +48,7 @@ test('publication context accepts the exact subject and blocks nearby subjects, assert.deepEqual(validateContext(env, event), context); assert.deepEqual(validateContext(env, { head_commit: { ...event.head_commit, message: PUBLISH_SUBJECT + '\n\nReviewed release.' } }), context); for (const message of [PUBLISH_SUBJECT + ' later', 'Do not ' + PUBLISH_SUBJECT, 'Routine build', PUBLISH_SUBJECT.toLowerCase(), - 'Publish macOS preview 1 with bundled runtime notices']) { + 'Publish macOS preview 1 with bundled runtime notices', 'Publish macOS preview 2 with silent wake audio sink']) { assert.throws(() => validateContext(env, { head_commit: { ...event.head_commit, message } }), /exact one-time/); } for (const change of [{ GITHUB_ACTIONS: 'false' }, { GITHUB_EVENT_NAME: 'pull_request' }, @@ -207,28 +207,33 @@ test('new publication creates a draft, verifies seven uploads, then publishes wi assert.equal(state.tag, sourceSha); }); -test('Preview 2 leaves the existing published Preview 1 and all of its assets untouched', async t => { +test('beta.4 Mac publication leaves earlier Mac previews and the Linux release untouched', async t => { const input = await promotionFixture(t); const previousTag = 'v0.1.0-beta.3-macos-preview.1'; const previousSha = '54889c5b04fe3dc32b79084d56bbec6e187a53de'; const previousNames = ['arm64', 'x64'].flatMap(arch => ['.app.zip', '.dmg', '.manifest.json'].map(suffix => - `DotDial-${VERSION}-preview-${previousSha.slice(0, 8)}-macos-${arch}${suffix}`)); + `DotDial-0.1.0-beta.3-preview-${previousSha.slice(0, 8)}-macos-${arch}${suffix}`)); previousNames.push('SHA256SUMS'); const previousRelease = { id: 403612523, tag_name: previousTag, name: 'DotDial for macOS — Preview 1', target_commitish: previousSha, draft: false, prerelease: true, immutable: false, body: 'The previously published release notes must remain intact.', assets: previousNames.map((name, index) => ({ id: 7000 + index, name, state: 'uploaded', size: 1000 + index, digest: `sha256:${digest('previous release bytes: ' + name)}` })) }; - assert.equal(TAG, 'v0.1.0-beta.3-macos-preview.2', 'this one-time publisher may address only the new tag'); - const { client, state } = mockGitHub(input.assets, { otherReleases: [previousRelease] }); + assert.equal(TAG, 'v0.1.0-beta.4-macos-preview.1', 'this one-time publisher may address only the new tag'); + const others = [previousRelease, + { ...structuredClone(previousRelease), id: 403929410, tag_name: 'v0.1.0-beta.3-macos-preview.2', + target_commitish: '0b9924b014d4c46271647e95d97806811fb45229' }, + { ...structuredClone(previousRelease), id: 404066277, tag_name: 'v0.1.0-beta.4', + target_commitish: '7fc4935a3fdc346197f6f6dd17ebd40a77b31008' }]; + const { client, state } = mockGitHub(input.assets, { otherReleases: others }); const result = await promoteRelease({ ...input, client }); assert.equal(result.tag, TAG); assert.equal(result.alreadyPublished, false); - assert.deepEqual(state.otherReleases, [previousRelease], 'existing release metadata and every asset stay unchanged'); + assert.deepEqual(state.otherReleases, others, 'existing release metadata and every asset stay unchanged'); assert.equal(state.release.tag_name, TAG); assert.deepEqual(state.release.assets, input.assets.map(remoteAsset)); for (const args of state.commands) { - assert.equal(args[2], TAG, 'every release mutation targets Preview 2'); + assert.equal(args[2], TAG, 'every release mutation targets the new beta.4 Mac preview'); assert.ok(!args.includes(previousTag)); } }); diff --git a/test/wake-manager-macos.test.cjs b/test/wake-manager-macos.test.cjs index 3bb43f3..29e81e3 100644 --- a/test/wake-manager-macos.test.cjs +++ b/test/wake-manager-macos.test.cjs @@ -64,7 +64,7 @@ test('mac wake gives microphone ownership to Electron, streams bounded PCM and w }); test('mac voice commands use argv while stdin stays binary PCM through idle replay', async t => { - const received = [], commands = { stopPlayback: 'Stop the replay' }; + const received = [], commands = require('../src/config.cjs').defaults.wakeWord.commands; const f = fixture(t, { onCommand: action => received.push(action) }); f.manager.configure({ ...config, commandsEnabled: true, commands }); await until(() => f.children.length === 1); @@ -156,3 +156,35 @@ test('installing with a selected base Python starts the recognizer from its mana f.children[1].ready(); f.captures[0].finish(); await tick(); assert.equal(f.manager.status, 'listening'); }); + + +test('mac live command edits replace only the local decoder, reject stale labels and retain selected input', async t => { + const commands = require('../src/config.cjs').defaults.wakeWord.commands; + const received = []; + const f = fixture(t, { onCommand: action => received.push(action) }); + const settings = { ...config, commandsEnabled: true, commands }; + f.manager.setCallState({ state: 'active', missed_playing: false }); + f.manager.configure(settings, { inputDeviceId: 'label:Private headset' }); + await until(() => f.children.length === 1); + f.children[0].ready(); f.captures[0].finish(); await tick(); + f.manager.configure({ ...settings, commands: { ...commands, microphoneOff: 'Disable microphone' } }); + assert.equal(f.captures[0].closed, true); + await until(() => f.children.length === 2); + const previous = f.children[0], current = f.children[1]; + const event = Buffer.from('{"event":"command","command":"microphoneOff"}\n'); + previous.stdout.emit('data', event); current.stdout.emit('data', event); + assert.deepEqual(received, [], 'neither stale nor not-yet-listening commands execute'); + current.ready(); f.captures[1].finish(); await tick(); + current.stdout.emit('data', event); + assert.deepEqual(received, ['microphoneOff']); + assert.equal(f.manager.callState.state, 'active'); + assert.equal(f.manager.inputDeviceId, 'label:Private headset'); + assert.equal(JSON.parse(current.args[current.args.indexOf('--commands-json') + 1]).microphoneOff, 'Disable microphone'); + await assert.rejects(f.captures[0].callbacks.onAudio(Buffer.alloc(6400)), { code: 'wake_capture_cancelled' }); + f.manager.configure({ ...settings, commandsEnabled: false }); + await until(() => f.children.length === 3); + f.children[2].ready(); f.captures[2].finish(); await tick(); + f.children[2].stdout.emit('data', event); + assert.ok(!f.children[2].args.includes('--commands-json')); + assert.deepEqual(received, ['microphoneOff']); +}); From 40f307bfcbc33c4b59e4f064f82a847f1c7e2c60 Mon Sep 17 00:00:00 2001 From: aaamosh Date: Tue, 6 Oct 2026 02:37:02 +0500 Subject: [PATCH 2/3] Correct native voice fixture lifetime and UI dependency checks Respect the disabled command inputs while wake is off and exercise all toggle combinations without enabling capture. Supply a fixed one-second silent tail for finite speech while keeping speech bytes and model thresholds unchanged. Preserve same-WAV half/one-second native comparisons separately from the twelve mandatory recognition-to-routing cases. Co-authored-by: ChatGPT --- scripts/smoke-macos-voice.cjs | 32 ++++++++++++++++++++++++++------ scripts/smoke-packaged.cjs | 17 ++++++++++++++++- test/native-voice-smoke.test.cjs | 15 ++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/scripts/smoke-macos-voice.cjs b/scripts/smoke-macos-voice.cjs index 03b866b..e176a14 100644 --- a/scripts/smoke-macos-voice.cjs +++ b/scripts/smoke-macos-voice.cjs @@ -13,7 +13,8 @@ const { performance } = require('node:perf_hooks'); const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); -function pcmFromWav(bytes) { +function pcmFromWav(bytes, tailFrames = 16000) { + assert.ok([8000, 16000].includes(tailFrames), 'only the fixed half/one-second tail comparison is admitted'); assert.ok(bytes.length >= 44 && bytes.length <= 16000 * 15 * 2 + 65536, 'bounded speech WAV'); assert.equal(bytes.toString('ascii', 0, 4), 'RIFF'); assert.equal(bytes.toString('ascii', 8, 12), 'WAVE'); @@ -35,8 +36,10 @@ function pcmFromWav(bytes) { assert.equal(fmt.readUInt16LE(12), 2); assert.equal(fmt.readUInt16LE(14), 16); assert.ok(data.length >= 3200 && data.length <= 16000 * 15 * 2 && data.length % 2 === 0); - // Same half-second context at each end as native wake speech acceptance. - const pcm = Buffer.alloc((data.length / 2 + 16000) * 4); + // A finite fixture needs enough look-ahead after its final speech frame. + // Live capture keeps supplying audio; EOF must not truncate the model window. + // Keep speech and the half-second leading context unchanged; use a fixed 1s tail. + const pcm = Buffer.alloc((data.length / 2 + 8000 + tailFrames) * 4); let peak = 0; for (let offset = 0; offset < data.length; offset += 2) { const value = data.readInt16LE(offset) / 32768; @@ -86,15 +89,32 @@ async function smoke({ source, data, fixtures, output }, report) { hostPlatform: process.platform, hostArch: process.arch, runtime, commands, generatorSha256: preparation.binarySha256 }); const generated = path.join(path.dirname(output), 'voice-fixtures'); fs.mkdirSync(generated, { recursive: true }); - const pcms = new Map(); + const pcms = new Map(), wavs = new Map(); function speech(text) { if (pcms.has(text)) return pcms.get(text); const filename = path.join(generated, `${hash(text).slice(0, 16)}.wav`); assert.equal(fs.existsSync(filename), false, 'generated fixtures must be fresh'); run(preparation.binary, ['-voice', 'slt', '-t', text + '.', '-o', filename]); const bytes = fs.readFileSync(filename), pcm = pcmFromWav(bytes); - report.fixtures.push({ text, file: path.basename(filename), wavSha256: hash(bytes), pcmSha256: hash(pcm), pcmBytes: pcm.length }); - pcms.set(text, pcm); return pcm; + report.fixtures.push({ text, file: path.basename(filename), wavSha256: hash(bytes), pcmSha256: hash(pcm), pcmBytes: pcm.length, leadingSilenceSeconds: 0.5, trailingSilenceSeconds: 1 }); + wavs.set(text, bytes); pcms.set(text, pcm); return pcm; + } + // Same WAV, unchanged model/settings, two fresh decoder processes. Preserve + // this diagnostic separately: it never replaces any of the twelve required cases. + speech(commands.playMissedReplies); + report.tailComparison = []; + for (const tailFrames of [8000, 16000]) { + const bytes = wavs.get(commands.playMissedReplies); + const input = pcmFromWav(bytes, tailFrames); + const result = spawnSync(python, ['-I', path.join(source, 'src/wake/listener.py'), + '--stdin-audio', '--model', path.join(data, 'models', MODEL), + '--phrase', defaults.wakeWord.phrase, '--sensitivity', String(defaults.wakeWord.sensitivity), + '--commands-json', JSON.stringify(commands)], { input, encoding: 'utf8', timeout: 15000, maxBuffer: 65536 }); + const observation = { trailingSilenceSeconds: tailFrames / 16000, wavSha256: hash(bytes), + pcmSha256: hash(input), exitCode: result.status, stdout: result.stdout || '', error: result.error?.code || null }; + report.tailComparison.push(observation); + assert.equal(result.error, undefined, 'bounded diagnostic decode must exit'); + assert.equal(result.status, 0, result.stderr); } const custom = { ...commands, microphoneOff: 'Disable microphone' }; validateConfig({ ...defaults, wakeWord: { ...defaults.wakeWord, commands: custom } }); diff --git a/scripts/smoke-packaged.cjs b/scripts/smoke-packaged.cjs index 0d8b89d..341487f 100644 --- a/scripts/smoke-packaged.cjs +++ b/scripts/smoke-packaged.cjs @@ -399,7 +399,22 @@ async function run() { await evaluate(settings, 'document.querySelector(".nav-item[data-section=voice]").click()'); assert.equal(await evaluate(settings, 'document.querySelector("#wake-commands-enabled").checked'), true); assert.equal(await evaluate(settings, 'document.querySelector("#wake-command-microphone-off").value'), 'Quiet microphone'); - assert.equal(await evaluate(settings, 'document.querySelector("#wake-command-microphone-off").disabled'), false); + assert.equal(await evaluate(settings, 'document.querySelector("#wake-command-microphone-off").disabled'), true, + 'wake off keeps commands unavailable even when their opt-in is saved'); + // Exercise form dependencies without saving wake on or opening a microphone. + for (const [wakeEnabled, commandsEnabled, disabled] of [[true, true, false], [true, false, true], [false, true, true]]) { + await evaluate(settings, `(() => { + for (const [id, checked] of [['wake-enabled', ${wakeEnabled}], ['wake-commands-enabled', ${commandsEnabled}]]) { + const input = document.getElementById(id); + input.checked = checked; input.dispatchEvent(new Event('change', { bubbles: true })); + } + })()`); + const fields = await evaluate(settings, `Array.from(document.querySelectorAll('#wake-command-fields input')).map(input => input.disabled)`); + assert.equal(fields.length, 7); + assert.ok(fields.every(value => value === disabled), 'all seven command fields follow both toggles'); + } + assert.deepEqual((await evaluate(settings, 'window.dotdial.readConfig()')).config, saved.config, + 'unsaved dependency checks must not enable local listening on disk'); const voiceSettingsCapture = await capture(settings, outputDirectory, 'voice-settings.png'); phase = 'panel'; diff --git a/test/native-voice-smoke.test.cjs b/test/native-voice-smoke.test.cjs index 39e5b9a..3944436 100644 --- a/test/native-voice-smoke.test.cjs +++ b/test/native-voice-smoke.test.cjs @@ -15,12 +15,12 @@ function wav() { return bytes; } -test('native command speech preserves signed PCM and adds exactly half-second context', () => { +test('native command speech preserves signed PCM and keeps speech and leading context while supplying one second of model look-ahead', () => { const pcm = pcmFromWav(wav()); - assert.equal(pcm.length, (1600 + 16000) * 4); + assert.equal(pcm.length, (1600 + 24000) * 4); assert.equal(pcm.readFloatLE(32000), -0.5); assert.equal(pcm.readFloatLE(32004), 0.25); assert.ok(pcm.subarray(0, 32000).every(byte => byte === 0)); - assert.ok(pcm.subarray(pcm.length - 32000).every(byte => byte === 0)); + assert.ok(pcm.subarray(pcm.length - 64000).every(byte => byte === 0)); }); test('native command speech rejects silent, stereo, wrong-rate and truncated WAVs', () => { @@ -67,3 +67,12 @@ test('duplicate required gate names fail before running or overwriting evidence' await assert.rejects(runRequiredStages([['same', async () => { calls++; }], ['same', async () => { calls++; }]], {}), /unique/); assert.equal(calls, 0); }); + + +test('finite-fixture comparison changes only trailing silence, never speech or recognition thresholds', () => { + const short = pcmFromWav(wav(), 8000), full = pcmFromWav(wav(), 16000); + assert.equal(full.length - short.length, 32000); + assert.deepEqual(full.subarray(0, short.length), short); + assert.ok(full.subarray(short.length).every(byte => byte === 0)); + for (const frames of [0, -1, 16001, 32000, NaN]) assert.throws(() => pcmFromWav(wav(), frames)); +}); From a90f3418dce555838a2d2fe001db6b85c1d635bc Mon Sep 17 00:00:00 2001 From: aaamosh Date: Tue, 6 Oct 2026 02:52:26 +0500 Subject: [PATCH 3/3] Evaluate a wider command beam with native decoder comparisons Keep wake-only decoding and all acoustic thresholds unchanged. Command mode has eight configured labels and now retains eight active paths. Add bounded same-PCM native comparisons of four/eight/sixteen paths and floating-point encoders to distinguish beam pruning from quantized inference behavior. The twelve original acceptance cases remain mandatory. Co-authored-by: ChatGPT --- scripts/compare-native-voice.cjs | 41 ++++++++++++++++++++++++++++++++ scripts/smoke-macos-voice.cjs | 19 ++------------- src/wake/listener.py | 4 +++- test/wake-stdin.test.cjs | 3 ++- 4 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 scripts/compare-native-voice.cjs diff --git a/scripts/compare-native-voice.cjs b/scripts/compare-native-voice.cjs new file mode 100644 index 0000000..10c4b55 --- /dev/null +++ b/scripts/compare-native-voice.cjs @@ -0,0 +1,41 @@ +'use strict'; + +// Counterfactual native decoder measurements. Never modifies the app, model, +// user settings or twelve mandatory recognition-to-routing acceptance cases. +const assert = require('node:assert/strict'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const wrapper = ` +import runpy, sys, sherpa_onnx +listener, paths, precision, *arguments = sys.argv[1:] +original = sherpa_onnx.KeywordSpotter +def diagnostic_spotter(**options): + options['max_active_paths'] = int(paths) + for part in ('encoder', 'decoder', 'joiner'): + if precision == 'all' or precision == part: + options[part] = options[part].replace('.int8.onnx', '.onnx') + return original(**options) +sherpa_onnx.KeywordSpotter = diagnostic_spotter +sys.argv = [listener, *arguments] +runpy.run_path(listener, run_name='__main__') +`; +function compareDecoder({ python, source, data, model, wake, commands, pcm, report }) { + report.recognitionComparison = []; + for (const [paths, precision] of [[4, 'none'], [8, 'none'], [16, 'none'], [4, 'encoder'], [4, 'all']]) { + const start = performance.now(); + const result = spawnSync(python, ['-I', '-c', wrapper, + path.join(source, 'src/wake/listener.py'), String(paths), precision, + '--stdin-audio', '--model', path.join(data, 'models', model), '--phrase', wake, + '--sensitivity', '6', '--commands-json', JSON.stringify(commands)], + { input: pcm, encoding: 'utf8', timeout: 15000, maxBuffer: 65536 }); + const observation = { maxActivePaths: paths, fp32: precision, + pcmSha256: crypto.createHash('sha256').update(pcm).digest('hex'), + elapsedMs: performance.now() - start, exitCode: result.status, + stdout: result.stdout || '', error: result.error?.code || null }; + report.recognitionComparison.push(observation); + assert.equal(result.error, undefined, 'counterfactual decoder exits within its bound'); + assert.equal(result.status, 0, result.stderr); + } +} +module.exports = { compareDecoder }; diff --git a/scripts/smoke-macos-voice.cjs b/scripts/smoke-macos-voice.cjs index e176a14..154e0b3 100644 --- a/scripts/smoke-macos-voice.cjs +++ b/scripts/smoke-macos-voice.cjs @@ -99,23 +99,8 @@ async function smoke({ source, data, fixtures, output }, report) { report.fixtures.push({ text, file: path.basename(filename), wavSha256: hash(bytes), pcmSha256: hash(pcm), pcmBytes: pcm.length, leadingSilenceSeconds: 0.5, trailingSilenceSeconds: 1 }); wavs.set(text, bytes); pcms.set(text, pcm); return pcm; } - // Same WAV, unchanged model/settings, two fresh decoder processes. Preserve - // this diagnostic separately: it never replaces any of the twelve required cases. - speech(commands.playMissedReplies); - report.tailComparison = []; - for (const tailFrames of [8000, 16000]) { - const bytes = wavs.get(commands.playMissedReplies); - const input = pcmFromWav(bytes, tailFrames); - const result = spawnSync(python, ['-I', path.join(source, 'src/wake/listener.py'), - '--stdin-audio', '--model', path.join(data, 'models', MODEL), - '--phrase', defaults.wakeWord.phrase, '--sensitivity', String(defaults.wakeWord.sensitivity), - '--commands-json', JSON.stringify(commands)], { input, encoding: 'utf8', timeout: 15000, maxBuffer: 65536 }); - const observation = { trailingSilenceSeconds: tailFrames / 16000, wavSha256: hash(bytes), - pcmSha256: hash(input), exitCode: result.status, stdout: result.stdout || '', error: result.error?.code || null }; - report.tailComparison.push(observation); - assert.equal(result.error, undefined, 'bounded diagnostic decode must exit'); - assert.equal(result.status, 0, result.stderr); - } + require('./compare-native-voice.cjs').compareDecoder({ python, source, data, model: MODEL, + wake: defaults.wakeWord.phrase, commands, pcm: speech(commands.playMissedReplies), report }); const custom = { ...commands, microphoneOff: 'Disable microphone' }; validateConfig({ ...defaults, wakeWord: { ...defaults.wakeWord, commands: custom } }); const cases = Object.keys(commands).map(action => ({ name: action, text: commands[action], action })); diff --git a/src/wake/listener.py b/src/wake/listener.py index 90d07f0..4af9652 100644 --- a/src/wake/listener.py +++ b/src/wake/listener.py @@ -194,7 +194,9 @@ def parent_watch(): decoder=str(model / "decoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx"), joiner=str(model / "joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx"), keywords_file=keyword.name, num_threads=1, sample_rate=16000, - feature_dim=80, max_active_paths=4, keywords_score=3.0, + # Commands add seven competing labels; widen search without + # lowering the acoustic trigger threshold or changing wake-only mode. + feature_dim=80, max_active_paths=8 if commands is not None else 4, keywords_score=3.0, keywords_threshold=round(.21-.02*max(1,min(10,args.sensitivity)),3), num_trailing_blanks=1, provider="cpu") stream = spotter.create_stream() diff --git a/test/wake-stdin.test.cjs b/test/wake-stdin.test.cjs index 7e109e1..b15b33e 100644 --- a/test/wake-stdin.test.cjs +++ b/test/wake-stdin.test.cjs @@ -133,7 +133,8 @@ class Stream: self.index+=1 class Spotter: def __init__(self, **options): - assert options['max_active_paths'] == 4 + assert options['max_active_paths'] == 8 + assert options['keywords_score'] == 3.0 and options['keywords_threshold'] == 0.09 entries=Path(options['keywords_file']).read_text().splitlines() assert len(entries) == 8 and entries[0] == 'HEY DOT @wake' assert {entry.rsplit(' @',1)[1] for entry in entries} == {'wake', *m['COMMAND_KEYS']}