diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 58e29af..0d769f8 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -8,64 +8,37 @@ on: permissions: contents: write +concurrency: + group: release-main + cancel-in-progress: false + jobs: - validate-version: - name: Validate version + prepare-version: + name: Prepare release version if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest outputs: version: ${{ steps.package.outputs.version }} tag: ${{ steps.package.outputs.tag }} + release_sha: ${{ steps.commit.outputs.release_sha }} steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 + ref: main - - name: Read package version + - name: Prepare next available version id: package shell: bash run: | set -euo pipefail - version="$(node -p "require('./package.json').version")" - lock_version="$(node -p "require('./package-lock.json').version")" - - if [[ "$version" != "$lock_version" ]]; then - echo "package.json and package-lock.json versions must match." - echo "package.json: $version" - echo "package-lock.json: $lock_version" - exit 1 - fi - - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]{3}$ ]]; then - echo "Version must use x.x.xxx format, for example 1.3.014." - echo "Current version: $version" - exit 1 - fi - + release="$(node scripts/prepare-release-version.js --write)" + version="$(node -e "const value=JSON.parse(process.argv[1]); process.stdout.write(value.version)" "$release")" + tag="$(node -e "const value=JSON.parse(process.argv[1]); process.stdout.write(value.tag)" "$release")" echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - - - name: Require version bump - shell: bash - run: | - set -euo pipefail - - if ! git rev-parse HEAD^ >/dev/null 2>&1; then - echo "Skipping version-bump check for the first commit." - exit 0 - fi - - current_version="${{ steps.package.outputs.version }}" - previous_version="$(git show HEAD^:package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" - - if [[ "$current_version" == "$previous_version" ]]; then - echo "package.json version must change on every commit to main." - echo "Previous version: $previous_version" - echo "Current version: $current_version" - exit 1 - fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Ensure release tag is new shell: bash @@ -74,7 +47,7 @@ jobs: tag="${{ steps.package.outputs.tag }}" if git rev-parse "$tag" >/dev/null 2>&1; then - echo "Tag $tag already exists. Bump package.json before pushing to main." + echo "Tag $tag already exists after automatic version selection." exit 1 fi @@ -87,15 +60,33 @@ jobs: exit 1 fi + - name: Commit prepared version + id: commit + shell: bash + run: | + set -euo pipefail + + if ! git diff --quiet -- package.json package-lock.json README.md; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json README.md + git commit -m "chore(release): prepare ${{ steps.package.outputs.tag }} [skip ci]" + git push origin HEAD:main + fi + + echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + build-windows: name: Build Windows x64 - needs: validate-version + needs: prepare-version if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: windows-2025 steps: - name: Checkout uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare-version.outputs.release_sha }} - name: Setup Node.js uses: actions/setup-node@v6 @@ -172,7 +163,7 @@ jobs: build-macos: name: Build macOS ${{ matrix.name }} - needs: validate-version + needs: prepare-version if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ${{ matrix.runner }} strategy: @@ -194,6 +185,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare-version.outputs.release_sha }} - name: Setup Node.js uses: actions/setup-node@v6 @@ -292,7 +285,7 @@ jobs: create-release: name: Publish release needs: - - validate-version + - prepare-version - build-windows - build-macos if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -310,8 +303,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ needs.validate-version.outputs.tag }} - RELEASE_VERSION: ${{ needs.validate-version.outputs.version }} + RELEASE_TAG: ${{ needs.prepare-version.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare-version.outputs.version }} + RELEASE_SHA: ${{ needs.prepare-version.outputs.release_sha }} shell: bash run: | set -euo pipefail @@ -328,6 +322,6 @@ jobs: gh release create "$RELEASE_TAG" "${assets[@]}" \ --latest \ - --target "$GITHUB_SHA" \ + --target "$RELEASE_SHA" \ --title "OpenFlow $RELEASE_VERSION" \ - --notes "Release published automatically after successful Windows and macOS builds for commit $GITHUB_SHA." + --notes "Release published automatically after successful Windows and macOS builds for commit $RELEASE_SHA." diff --git a/AGENTS.md b/AGENTS.md index 53a3fa3..8c160b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,2 +1,2 @@ -\- Every update, make sure you change the version number accordingly. The version number should be "x.x.xxx" +\- Do not change the app version manually. `.github/workflows/release-main.yml` owns release version updates and must keep the version in `x.x.xxx` format. diff --git a/BUILDING.md b/BUILDING.md index bc94a71..e5c2f31 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -156,10 +156,11 @@ Expected outputs: On a normal branch push the platform workflows only upload CI artifacts. They do not run on version tags and do not create GitHub releases. On a push to `main`, the release -workflow validates that the app version changed, validates the `x.x.xxx` version format, -builds Windows and macOS, and only then publishes a GitHub release with the generated -installers and update metadata. The release workflow and each release job are explicitly -guarded to run only for push events on `main`. +workflow selects the next unused `x.x.xxx` version from the existing release tags, +synchronizes the package files and README in an automated release commit when needed, +builds that exact commit on Windows and macOS, and only then publishes its GitHub release. +The release workflow and each release job are explicitly guarded to run only for push +events on `main`. ## Releases and in-app auto-update @@ -169,9 +170,10 @@ configured in the `build.publish` block of [`package.json`](./package.json) Release flow: -1. Bump `version` in `package.json` and `package-lock.json` (see [`AGENTS.md`](./AGENTS.md) / [`CLAUDE.md`](./CLAUDE.md)). -2. Commit and push to `main`. -3. The release workflow builds Windows and macOS first. +1. Merge or push the application changes to `main`; do not change the version manually. +2. The release workflow advances beyond the newest padded or normalized release tag and + synchronizes `package.json`, `package-lock.json`, and `README.md`. +3. The release workflow builds the prepared commit on Windows and macOS. 4. If every build succeeds, the workflow creates tag `v` and publishes a GitHub release containing the generated installers and update metadata. 5. Once the workflow publishes the release, installed apps detect it: OpenFlow checks on diff --git a/package.json b/package.json index 550df3d..6ad1e52 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "main": "src/main/main.js", "scripts": { "start": "electron .", - "check": "node --check src/main/main.js && node --check src/main/preload.js && node --check src/main/overlay-preload.js && node --check src/renderer/renderer.js && node --check src/renderer/overlay.js", + "check": "node --check src/main/main.js && node --check src/main/preload.js && node --check src/main/overlay-preload.js && node --check src/renderer/renderer.js && node --check src/renderer/feedback-audio.js && node --check src/renderer/overlay.js && npm run test:feedback-audio && npm run test:release-version", + "test:feedback-audio": "node scripts/test-feedback-audio.js", + "test:feedback-audio-runtime": "electron scripts/test-feedback-audio-runtime.js", + "test:release-version": "node scripts/test-release-version.js", "test:startup-error-isolation": "node scripts/test-startup-error-isolation.js", "test:duck-restore": "node scripts/test-duck-restore.js", "build:icons": "node scripts/build-icons.js", diff --git a/scripts/prepare-release-version.js b/scripts/prepare-release-version.js new file mode 100644 index 0000000..671a03f --- /dev/null +++ b/scripts/prepare-release-version.js @@ -0,0 +1,129 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d{3})$/; +const TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/; + +function parseVersion(value, pattern = VERSION_PATTERN) { + const match = String(value || '').match(pattern); + if (!match) { + return null; + } + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }; +} + +function compareVersions(left, right) { + return left.major - right.major || left.minor - right.minor || left.patch - right.patch; +} + +function formatVersion(version) { + return `${version.major}.${version.minor}.${String(version.patch).padStart(3, '0')}`; +} + +function incrementVersion(version) { + if (version.patch < 999) { + return { ...version, patch: version.patch + 1 }; + } + return { major: version.major, minor: version.minor + 1, patch: 0 }; +} + +function findHighestVersion(values, pattern) { + let highest = null; + for (const value of values) { + const parsed = parseVersion(value, pattern); + if (parsed && (!highest || compareVersions(parsed, highest) > 0)) { + highest = parsed; + } + } + return highest; +} + +function chooseReleaseVersion({ packageVersion, lockVersion, tagNames }) { + const highestTag = findHighestVersion(tagNames, TAG_PATTERN) || { major: 0, minor: 0, patch: 0 }; + const nextTaggedVersion = incrementVersion(highestTag); + const trackedVersions = [packageVersion, lockVersion] + .map((value) => parseVersion(value)) + .filter(Boolean) + .map(formatVersion); + const highestTrackedVersion = findHighestVersion(trackedVersions, VERSION_PATTERN); + + // Preserve an already prepared unused version. Otherwise recover stale or mismatched + // files by advancing beyond every padded or normalized release tag in the repository. + const releaseVersion = + highestTrackedVersion && compareVersions(highestTrackedVersion, highestTag) > 0 + ? highestTrackedVersion + : nextTaggedVersion; + return formatVersion(releaseVersion); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function updateVersionFiles(repoRoot, version) { + const packagePath = path.join(repoRoot, 'package.json'); + const lockPath = path.join(repoRoot, 'package-lock.json'); + const readmePath = path.join(repoRoot, 'README.md'); + const packageJson = readJson(packagePath); + const lockJson = readJson(lockPath); + + packageJson.version = version; + lockJson.version = version; + if (lockJson.packages && lockJson.packages['']) { + lockJson.packages[''].version = version; + } + writeJson(packagePath, packageJson); + writeJson(lockPath, lockJson); + + if (fs.existsSync(readmePath)) { + const readme = fs.readFileSync(readmePath, 'utf8'); + const updatedReadme = readme.replace( + /^Current version: `[^`]+`$/m, + `Current version: \`${version}\``, + ); + if (updatedReadme === readme && !readme.includes(`Current version: \`${version}\``)) { + throw new Error('README.md does not contain the expected current-version line.'); + } + fs.writeFileSync(readmePath, updatedReadme); + } +} + +function getGitTags(repoRoot) { + return execFileSync('git', ['tag', '--list'], { cwd: repoRoot, encoding: 'utf8' }) + .split(/\r?\n/) + .filter(Boolean); +} + +function prepareReleaseVersion({ repoRoot = path.resolve(__dirname, '..'), write = false } = {}) { + const packageJson = readJson(path.join(repoRoot, 'package.json')); + const lockJson = readJson(path.join(repoRoot, 'package-lock.json')); + const version = chooseReleaseVersion({ + packageVersion: packageJson.version, + lockVersion: lockJson.version, + tagNames: getGitTags(repoRoot), + }); + if (write) { + updateVersionFiles(repoRoot, version); + } + return { version, tag: `v${version}` }; +} + +if (require.main === module) { + const result = prepareReleaseVersion({ write: process.argv.includes('--write') }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +module.exports = { + chooseReleaseVersion, + compareVersions, + formatVersion, + incrementVersion, + parseVersion, + prepareReleaseVersion, + updateVersionFiles, +}; diff --git a/scripts/test-feedback-audio-runtime.js b/scripts/test-feedback-audio-runtime.js new file mode 100644 index 0000000..3e74e61 --- /dev/null +++ b/scripts/test-feedback-audio-runtime.js @@ -0,0 +1,99 @@ +const assert = require('assert'); +const path = require('path'); +const { app, BrowserWindow, ipcMain } = require('electron'); + +const PLAYBACK_TIMEOUT_MS = 6000; + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readProbe(windowRef) { + return windowRef.webContents.executeJavaScript('window.__feedbackAudioProbe'); +} + +async function waitForEnded(windowRef, expectedCount) { + const deadline = Date.now() + PLAYBACK_TIMEOUT_MS; + while (Date.now() < deadline) { + const probe = await readProbe(windowRef); + if (probe.ended >= expectedCount) { + return probe; + } + await delay(50); + } + return readProbe(windowRef); +} + +async function run() { + ipcMain.handle('get-state', () => ({ + phase: 'idle', + captureMode: null, + audioLevel: 0, + overlayOpacity: 100, + overlayScale: 100, + overlayDynamicSize: false, + soundEffectsEnabled: true, + })); + + const windowRef = new BrowserWindow({ + show: false, + webPreferences: { + preload: path.join(__dirname, '..', 'src', 'main', 'overlay-preload.js'), + contextIsolation: true, + nodeIntegration: false, + autoplayPolicy: 'no-user-gesture-required', + backgroundThrottling: false, + }, + }); + + await windowRef.loadFile(path.join(__dirname, '..', 'src', 'renderer', 'overlay.html')); + await windowRef.webContents.executeJavaScript(` + (() => { + const probe = { plays: 0, ended: 0, errors: [] }; + const originalPlay = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = function instrumentedFeedbackPlay(...args) { + probe.plays += 1; + this.addEventListener('ended', () => { probe.ended += 1; }, { once: true }); + this.addEventListener('error', () => { + probe.errors.push(this.error?.message || 'media-error'); + }, { once: true }); + return originalPlay.apply(this, args).catch((error) => { + probe.errors.push(String(error?.message || error)); + throw error; + }); + }; + window.__feedbackAudioProbe = probe; + })(); + `); + + windowRef.webContents.send('overlay-feedback', { + type: 'play-sound', + payload: { sound: 'start', interrupt: true }, + }); + const firstPlayback = await waitForEnded(windowRef, 1); + assert.strictEqual(firstPlayback.ended >= 1, true, JSON.stringify(firstPlayback)); + + windowRef.webContents.send('overlay-feedback', { type: 'reset-sound-output', payload: {} }); + windowRef.webContents.send('overlay-feedback', { + type: 'play-sound', + payload: { sound: 'close', interrupt: true }, + }); + const finalProbe = await waitForEnded(windowRef, 2); + + assert.strictEqual(finalProbe.plays >= 2, true, JSON.stringify(finalProbe)); + assert.strictEqual(finalProbe.ended >= 2, true, JSON.stringify(finalProbe)); + assert.deepStrictEqual(finalProbe.errors, []); + console.log( + `feedback-audio-runtime-ok: ${finalProbe.plays} plays reached ${finalProbe.ended} ended events on the default output`, + ); + + windowRef.destroy(); +} + +app.whenReady() + .then(run) + .then(() => app.quit()) + .catch((error) => { + console.error(error); + app.exit(1); + }); diff --git a/scripts/test-feedback-audio.js b/scripts/test-feedback-audio.js new file mode 100644 index 0000000..ff7d264 --- /dev/null +++ b/scripts/test-feedback-audio.js @@ -0,0 +1,176 @@ +const assert = require('assert'); +const { createFeedbackAudioController } = require('../src/renderer/feedback-audio'); + +function delay(ms = 0) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function settle() { + await delay(0); + await Promise.resolve(); + await delay(0); +} + +class FakeAudio { + static instances = []; + + static rejectedPlaysRemaining = 0; + + constructor(source) { + this.source = source; + this.currentTime = 0; + this.preload = ''; + this.volume = 1; + this.listeners = new Map(); + this.playCalls = 0; + this.pauseCalls = 0; + this.sinkIds = []; + FakeAudio.instances.push(this); + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + emit(type) { + for (const listener of this.listeners.get(type) || []) { + listener(); + } + } + + load() {} + + pause() { + this.pauseCalls += 1; + } + + play() { + this.playCalls += 1; + if (FakeAudio.rejectedPlaysRemaining > 0) { + FakeAudio.rejectedPlaysRemaining -= 1; + return Promise.reject(new Error('stale output route')); + } + return Promise.resolve(); + } + + setSinkId(sinkId) { + this.sinkIds.push(sinkId); + return Promise.resolve(); + } +} + +function resetFakeAudio() { + FakeAudio.instances = []; + FakeAudio.rejectedPlaysRemaining = 0; +} + +async function testRoutesEveryPlaybackToDefaultOutput() { + resetFakeAudio(); + const controller = createFeedbackAudioController({ + sources: { start: 'start.mp3' }, + AudioConstructor: FakeAudio, + watchdogMs: 1000, + }); + + controller.queueSound('start'); + await settle(); + + const audio = FakeAudio.instances[0]; + assert.deepStrictEqual(audio.sinkIds, ['default']); + assert.strictEqual(audio.playCalls, 1); + audio.emit('ended'); + controller.destroy(); +} + +async function testRejectedPlaybackRebuildsAndRetries() { + resetFakeAudio(); + FakeAudio.rejectedPlaysRemaining = 1; + const failures = []; + const controller = createFeedbackAudioController({ + sources: { start: 'start.mp3' }, + AudioConstructor: FakeAudio, + watchdogMs: 1000, + onPlaybackFailure: (failure) => failures.push(failure), + }); + + controller.queueSound('start'); + await settle(); + await settle(); + + assert.strictEqual(FakeAudio.instances.length, 2, 'failed media element should be replaced'); + assert.strictEqual(FakeAudio.instances[0].playCalls, 1); + assert.strictEqual(FakeAudio.instances[1].playCalls, 1, 'replacement should retry the sound'); + assert.strictEqual(failures.length, 1); + assert.strictEqual(failures[0].reason, 'play-rejected'); + assert.strictEqual(failures[0].willRetry, true); + FakeAudio.instances[1].emit('ended'); + controller.destroy(); +} + +async function testDeviceResetRebuildsActiveOutput() { + resetFakeAudio(); + const controller = createFeedbackAudioController({ + sources: { start: 'start.mp3' }, + AudioConstructor: FakeAudio, + watchdogMs: 1000, + }); + + controller.queueSound('start'); + await settle(); + controller.resetOutput(); + await settle(); + + assert.strictEqual(FakeAudio.instances.length, 2); + assert.strictEqual(FakeAudio.instances[0].pauseCalls > 0, true); + assert.strictEqual(FakeAudio.instances[1].playCalls, 1, 'active feedback should resume on the new output'); + FakeAudio.instances[1].emit('ended'); + controller.destroy(); +} + +async function testWatchdogCannotPermanentlyFreezeQueue() { + resetFakeAudio(); + const failures = []; + const controller = createFeedbackAudioController({ + sources: { start: 'start.mp3', close: 'close.mp3' }, + AudioConstructor: FakeAudio, + watchdogMs: 15, + onPlaybackFailure: (failure) => failures.push(failure), + }); + + controller.queueSound('start'); + controller.queueSound('close'); + await delay(45); + + const startPlayers = FakeAudio.instances.filter((audio) => audio.source === 'start.mp3'); + assert.strictEqual(startPlayers.length >= 2, true, 'watchdog should rebuild a stalled sound'); + assert.strictEqual( + failures.some((failure) => failure.reason === 'watchdog-timeout'), + true, + ); + + const currentStart = startPlayers[startPlayers.length - 1]; + currentStart.emit('ended'); + await settle(); + const closePlayers = FakeAudio.instances.filter((audio) => audio.source === 'close.mp3'); + assert.strictEqual( + closePlayers.some((audio) => audio.playCalls > 0), + true, + 'the queued close feedback should still play after the stalled sound is released', + ); + controller.destroy(); +} + +async function run() { + await testRoutesEveryPlaybackToDefaultOutput(); + await testRejectedPlaybackRebuildsAndRetries(); + await testDeviceResetRebuildsActiveOutput(); + await testWatchdogCannotPermanentlyFreezeQueue(); + console.log('feedback-audio-ok: default routing, rebuild retry, device reset, and watchdog recovery passed'); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/test-release-version.js b/scripts/test-release-version.js new file mode 100644 index 0000000..6399beb --- /dev/null +++ b/scripts/test-release-version.js @@ -0,0 +1,45 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { chooseReleaseVersion, updateVersionFiles } = require('./prepare-release-version'); + +function choose(packageVersion, lockVersion, tagNames) { + return chooseReleaseVersion({ packageVersion, lockVersion, tagNames }); +} + +assert.strictEqual(choose('1.3.045', '1.3.045', ['v1.3.048']), '1.3.049'); +assert.strictEqual(choose('1.3.049', '1.3.049', ['v1.3.048']), '1.3.049'); +assert.strictEqual(choose('1.3.049', '1.3.048', ['v1.3.048']), '1.3.049'); +assert.strictEqual(choose('invalid', '1.3.045', ['v1.3.048']), '1.3.049'); +assert.strictEqual(choose('1.3.049', '1.3.049', ['v1.3.049']), '1.3.050'); +assert.strictEqual(choose('1.3.049', '1.3.049', ['v1.3.49']), '1.3.050'); +assert.strictEqual(choose('1.3.999', '1.3.999', ['v1.3.999']), '1.4.000'); +assert.strictEqual(choose('2.0.001', '2.0.001', ['v1.9.999']), '2.0.001'); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openflow-release-version-')); +try { + fs.writeFileSync( + path.join(tempRoot, 'package.json'), + `${JSON.stringify({ name: 'openflow', version: '1.3.045' }, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(tempRoot, 'package-lock.json'), + `${JSON.stringify({ version: '1.3.044', packages: { '': { version: '1.3.043' } } }, null, 2)}\n`, + ); + fs.writeFileSync(path.join(tempRoot, 'README.md'), 'Current version: `1.3.042`\n'); + + updateVersionFiles(tempRoot, '1.3.049'); + + assert.strictEqual(JSON.parse(fs.readFileSync(path.join(tempRoot, 'package.json'))).version, '1.3.049'); + const updatedLock = JSON.parse(fs.readFileSync(path.join(tempRoot, 'package-lock.json'))); + assert.strictEqual(updatedLock.version, '1.3.049'); + assert.strictEqual(updatedLock.packages[''].version, '1.3.049'); + assert.strictEqual(fs.readFileSync(path.join(tempRoot, 'README.md'), 'utf8'), 'Current version: `1.3.049`\n'); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +console.log( + 'release-version-ok: stale, advanced, normalized-tag, mismatch, rollover, and file-sync cases passed', +); diff --git a/src/main/main.js b/src/main/main.js index 22615dd..0f3f62c 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -2086,6 +2086,7 @@ function recoverOverlayWindowAfterResume() { for (const delayMs of OVERLAY_RESUME_REASSERT_DELAYS_MS) { setTimeout(resyncOverlayWindowPosition, delayMs); } + sendOverlayFeedback('reset-sound-output'); // Windows can keep the transparent always-on-top child window in a stale native // state after sleep/resume. Recreating it clears stale visibility and z-order state. recoverOverlayWindow(900); @@ -4601,6 +4602,14 @@ ipcMain.handle('copy-text', async (_event, text) => { return true; }); +ipcMain.on('overlay-audio-output-changed', () => { + syncAudioControllerConfig(true); + const timer = setTimeout(() => syncAudioControllerConfig(true), 500); + if (typeof timer.unref === 'function') { + timer.unref(); + } +}); + function shutdownChildren() { resetDictationFeedbackState(); releaseCaptureMute(true); diff --git a/src/main/overlay-preload.js b/src/main/overlay-preload.js index 6f74889..1055875 100644 --- a/src/main/overlay-preload.js +++ b/src/main/overlay-preload.js @@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld('flowOverlay', { getState: () => ipcRenderer.invoke('get-state'), dragTo: (position) => ipcRenderer.send('overlay-drag-move', position), endDrag: (position) => ipcRenderer.send('overlay-drag-end', position), + audioOutputChanged: () => ipcRenderer.send('overlay-audio-output-changed'), onStateUpdate: (callback) => { const listener = (_event, state) => callback(state); ipcRenderer.on('app-state', listener); diff --git a/src/renderer/feedback-audio.js b/src/renderer/feedback-audio.js new file mode 100644 index 0000000..ebd4c4a --- /dev/null +++ b/src/renderer/feedback-audio.js @@ -0,0 +1,272 @@ +(function exposeFeedbackAudioController(root, factory) { + const createFeedbackAudioController = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = { createFeedbackAudioController }; + } + + if (root) { + root.createFeedbackAudioController = createFeedbackAudioController; + } +})(typeof globalThis === 'undefined' ? null : globalThis, () => { + function createFeedbackAudioController(options = {}) { + const sources = { ...(options.sources || {}) }; + const AudioConstructor = options.AudioConstructor || (typeof Audio === 'undefined' ? null : Audio); + const scheduleTimeout = options.setTimeout || setTimeout; + const cancelTimeout = options.clearTimeout || clearTimeout; + const watchdogMs = Number(options.watchdogMs) > 0 ? Number(options.watchdogMs) : 4000; + const volume = Number.isFinite(Number(options.volume)) ? Number(options.volume) : 0.25; + const onPlaybackFailure = + typeof options.onPlaybackFailure === 'function' ? options.onPlaybackFailure : () => {}; + + if (!AudioConstructor) { + throw new Error('Feedback audio requires an Audio constructor.'); + } + + const audioByKey = new Map(); + const soundQueue = []; + let enabled = true; + let activePlayback = null; + let drainTimer = null; + let watchdogTimer = null; + let destroyed = false; + + function clearWatchdog() { + if (watchdogTimer !== null) { + cancelTimeout(watchdogTimer); + watchdogTimer = null; + } + } + + function safelyStopAudio(audio) { + if (!audio) { + return; + } + + try { + audio.pause(); + } catch (_error) { + // The media element may already have lost its output device. + } + + try { + audio.currentTime = 0; + } catch (_error) { + // Some failed media elements reject seeking; replacement still recovers them. + } + } + + function releaseActivePlayback(audio) { + if (!activePlayback || (audio && activePlayback.audio !== audio)) { + return; + } + + clearWatchdog(); + activePlayback = null; + scheduleDrain(); + } + + function createAudio(soundKey) { + const audio = new AudioConstructor(sources[soundKey]); + audio.preload = 'auto'; + audio.volume = volume; + audio.addEventListener('ended', () => releaseActivePlayback(audio)); + audio.addEventListener('error', () => recoverActivePlayback(audio, 'media-error')); + + try { + audio.load(); + } catch (_error) { + // Playback will retry with a fresh element if eager loading is unavailable. + } + + return audio; + } + + function replaceAudio(soundKey) { + const previousAudio = audioByKey.get(soundKey); + safelyStopAudio(previousAudio); + const audio = createAudio(soundKey); + audioByKey.set(soundKey, audio); + return audio; + } + + function recoverActivePlayback(audio, reason) { + if (!activePlayback || activePlayback.audio !== audio) { + return; + } + + const failedPlayback = activePlayback; + clearWatchdog(); + activePlayback = null; + replaceAudio(failedPlayback.soundKey); + + const willRetry = failedPlayback.attempt < 1 && enabled && !destroyed; + try { + onPlaybackFailure({ + soundKey: failedPlayback.soundKey, + reason, + willRetry, + }); + } catch (_error) { + // Diagnostics must never prevent the recovery path itself. + } + + if (willRetry) { + soundQueue.unshift({ + soundKey: failedPlayback.soundKey, + attempt: failedPlayback.attempt + 1, + }); + } + scheduleDrain(); + } + + function routeToDefaultOutput(audio) { + if (typeof audio.setSinkId !== 'function') { + return Promise.resolve(); + } + + // Chromium can retain a removed Windows output route. Reasserting "default" + // makes each feedback follow the machine's current default output device. + return Promise.resolve(audio.setSinkId('default')).catch(() => undefined); + } + + function beginPlayback(item) { + const audio = audioByKey.get(item.soundKey) || replaceAudio(item.soundKey); + activePlayback = { + soundKey: item.soundKey, + attempt: item.attempt, + audio, + }; + + try { + audio.currentTime = 0; + } catch (_error) { + recoverActivePlayback(audio, 'seek-failed'); + return; + } + + clearWatchdog(); + watchdogTimer = scheduleTimeout(() => { + recoverActivePlayback(audio, 'watchdog-timeout'); + }, watchdogMs); + + routeToDefaultOutput(audio) + .then(() => { + if (!activePlayback || activePlayback.audio !== audio) { + return undefined; + } + return audio.play(); + }) + .catch(() => { + recoverActivePlayback(audio, 'play-rejected'); + }); + } + + function drainSoundQueue() { + drainTimer = null; + if (destroyed || !enabled || activePlayback || soundQueue.length === 0) { + return; + } + + const item = soundQueue.shift(); + if (!Object.prototype.hasOwnProperty.call(sources, item.soundKey)) { + scheduleDrain(); + return; + } + + beginPlayback(item); + } + + function scheduleDrain() { + if (destroyed || drainTimer !== null) { + return; + } + + drainTimer = scheduleTimeout(drainSoundQueue, 0); + } + + function cancelActivePlayback() { + clearWatchdog(); + if (!activePlayback) { + return null; + } + + const cancelled = activePlayback; + activePlayback = null; + safelyStopAudio(cancelled.audio); + return cancelled; + } + + function stopAll() { + soundQueue.length = 0; + cancelActivePlayback(); + if (drainTimer !== null) { + cancelTimeout(drainTimer); + drainTimer = null; + } + } + + function queueSound(soundKey, queueOptions = {}) { + if (destroyed || !enabled || !Object.prototype.hasOwnProperty.call(sources, soundKey)) { + return; + } + + if (queueOptions.interrupt) { + soundQueue.length = 0; + cancelActivePlayback(); + soundQueue.unshift({ soundKey, attempt: 0 }); + } else { + soundQueue.push({ soundKey, attempt: 0 }); + } + + scheduleDrain(); + } + + function resetOutput() { + if (destroyed) { + return; + } + + const interruptedPlayback = cancelActivePlayback(); + for (const soundKey of Object.keys(sources)) { + replaceAudio(soundKey); + } + if (interruptedPlayback && enabled) { + soundQueue.unshift({ soundKey: interruptedPlayback.soundKey, attempt: 0 }); + } + scheduleDrain(); + } + + function setEnabled(nextEnabled) { + enabled = Boolean(nextEnabled); + if (!enabled) { + stopAll(); + } else { + scheduleDrain(); + } + } + + function destroy() { + stopAll(); + destroyed = true; + for (const audio of audioByKey.values()) { + safelyStopAudio(audio); + } + audioByKey.clear(); + } + + for (const soundKey of Object.keys(sources)) { + replaceAudio(soundKey); + } + + return { + destroy, + queueSound, + resetOutput, + setEnabled, + stopAll, + }; + } + + return createFeedbackAudioController; +}); diff --git a/src/renderer/overlay.html b/src/renderer/overlay.html index 95cc4fb..735c21e 100644 --- a/src/renderer/overlay.html +++ b/src/renderer/overlay.html @@ -63,6 +63,7 @@ + diff --git a/src/renderer/overlay.js b/src/renderer/overlay.js index 7642169..a353af3 100644 --- a/src/renderer/overlay.js +++ b/src/renderer/overlay.js @@ -26,17 +26,11 @@ let currentOverlayState = { overlayScale: 100, overlayDynamicSize: false, }; -let soundEffectsEnabled = true; let overlayBgOpacity = 1; let overlayScale = 1; let overlayDynamicSize = false; let feedbackTimer = null; let activeFeedback = null; -let activeSoundKey = null; -let soundDrainScheduled = false; -let soundWatchdog = null; - -const SOUND_WATCHDOG_MS = 4000; const overlayReadyLabels = { en: 'READY', @@ -76,14 +70,20 @@ const overlayTooShortLabels = { const TOO_SHORT_FEEDBACK_MS = 1600; -const feedbackSounds = { - loaded: new Audio('../assets/audio/loaded.mp3'), - start: new Audio('../assets/audio/start.mp3'), - close: new Audio('../assets/audio/close.mp3'), - cancel: new Audio('../assets/audio/cancel.mp3'), - handsfree: new Audio('../assets/audio/handsfree.mp3'), -}; -const soundQueue = []; +const feedbackAudio = window.createFeedbackAudioController({ + sources: { + loaded: '../assets/audio/loaded.mp3', + start: '../assets/audio/start.mp3', + close: '../assets/audio/close.mp3', + cancel: '../assets/audio/cancel.mp3', + handsfree: '../assets/audio/handsfree.mp3', + }, + volume: 0.25, + watchdogMs: 4000, + onPlaybackFailure: ({ soundKey, reason, willRetry }) => { + console.warn(`Feedback sound ${soundKey} failed (${reason}); retry=${willRetry}.`); + }, +}); const waveBars = Array.from(overlayEls.wave.querySelectorAll('span')); const BAR_COUNT = waveBars.length; @@ -97,12 +97,6 @@ const defaultShape = waveBars.map((_bar, index) => { let targetShape = defaultShape.slice(); let currentShape = defaultShape.slice(); -for (const audio of Object.values(feedbackSounds)) { - audio.preload = 'auto'; - audio.volume = 0.25; - audio.load(); -} - function getOverlayMode(state) { switch (state.phase) { case 'listening': @@ -261,103 +255,6 @@ function renderOverlay(state) { lastOverlayMode = mode; } -function clearSoundWatchdog() { - if (soundWatchdog) { - window.clearTimeout(soundWatchdog); - soundWatchdog = null; - } -} - -function stopAllSounds() { - soundQueue.length = 0; - activeSoundKey = null; - clearSoundWatchdog(); - for (const audio of Object.values(feedbackSounds)) { - audio.pause(); - audio.currentTime = 0; - } -} - -function stopActiveSound() { - clearSoundWatchdog(); - if (!activeSoundKey) { - return; - } - - const audio = feedbackSounds[activeSoundKey]; - activeSoundKey = null; - if (!audio) { - return; - } - - audio.pause(); - audio.currentTime = 0; -} - -function releaseActiveSound(soundKey) { - if (soundKey && activeSoundKey !== soundKey) { - return; - } - clearSoundWatchdog(); - activeSoundKey = null; - scheduleSoundDrain(); -} - -function drainSoundQueue() { - soundDrainScheduled = false; - if (!soundEffectsEnabled || activeSoundKey || soundQueue.length === 0) { - return; - } - - const soundKey = soundQueue.shift(); - const audio = feedbackSounds[soundKey]; - if (!audio) { - drainSoundQueue(); - return; - } - - activeSoundKey = soundKey; - audio.currentTime = 0; - // Safety net: if the audio element never fires ended/error (which permanently - // froze the whole sound queue before), force-release it after a hard timeout. - clearSoundWatchdog(); - soundWatchdog = window.setTimeout(() => releaseActiveSound(soundKey), SOUND_WATCHDOG_MS); - - const playResult = audio.play(); - if (playResult && typeof playResult.catch === 'function') { - playResult.catch(() => { - releaseActiveSound(soundKey); - }); - } -} - -function scheduleSoundDrain() { - if (soundDrainScheduled) { - return; - } - - soundDrainScheduled = true; - // setTimeout (not requestAnimationFrame) so the queue keeps draining even if the - // overlay window is hidden/occluded and its animation frames are paused. - window.setTimeout(drainSoundQueue, 0); -} - -function queueSound(soundKey, options = {}) { - if (!soundEffectsEnabled || !feedbackSounds[soundKey]) { - return; - } - - if (options.interrupt) { - soundQueue.length = 0; - stopActiveSound(); - soundQueue.unshift(soundKey); - } else { - soundQueue.push(soundKey); - } - - scheduleSoundDrain(); -} - function clearActiveFeedback() { if (feedbackTimer) { window.clearTimeout(feedbackTimer); @@ -372,7 +269,7 @@ function showReadyFeedback(soundKey) { clearActiveFeedback(); activeFeedback = 'ready'; renderOverlay(currentOverlayState); - queueSound(soundKey); + feedbackAudio.queueSound(soundKey); feedbackTimer = window.setTimeout(() => { clearActiveFeedback(); renderOverlay(currentOverlayState); @@ -485,15 +382,6 @@ function bindDrag() { }); } -function bindSoundLifecycle() { - for (const [soundKey, audio] of Object.entries(feedbackSounds)) { - const release = () => releaseActiveSound(soundKey); - - audio.addEventListener('ended', release); - audio.addEventListener('error', release); - } -} - function handleFeedback(feedback) { if (!feedback || typeof feedback !== 'object') { return; @@ -507,10 +395,13 @@ function handleFeedback(feedback) { showTooShortFeedback(); break; case 'play-sound': - queueSound(feedback.payload?.sound, { + feedbackAudio.queueSound(feedback.payload?.sound, { interrupt: Boolean(feedback.payload?.interrupt), }); break; + case 'reset-sound-output': + feedbackAudio.resetOutput(); + break; default: break; } @@ -538,17 +429,20 @@ function initTheme() { async function bootstrap() { initTheme(); const initialState = await window.flowOverlay.getState(); - soundEffectsEnabled = Boolean(initialState.soundEffectsEnabled); + feedbackAudio.setEnabled(Boolean(initialState.soundEffectsEnabled)); applyWaveLevel(initialState.audioLevel || 0); renderOverlay(initialState); bindDrag(); - bindSoundLifecycle(); + + if (navigator.mediaDevices && typeof navigator.mediaDevices.addEventListener === 'function') { + navigator.mediaDevices.addEventListener('devicechange', () => { + feedbackAudio.resetOutput(); + window.flowOverlay.audioOutputChanged(); + }); + } window.flowOverlay.onStateUpdate((state) => { - soundEffectsEnabled = Boolean(state.soundEffectsEnabled); - if (!soundEffectsEnabled) { - stopAllSounds(); - } + feedbackAudio.setEnabled(Boolean(state.soundEffectsEnabled)); renderOverlay(state); }); window.flowOverlay.onAudioLevelUpdate((level) => {