fix(0.2.6): WS first-chunk drop, mic permissions, release-body from CHANGELOG
WS dictation pipeline was producing exit-183 from ffmpeg on every
partial because MediaRecorder.start(250) ran before the WebSocket
handshake finished — the first chunk (WebM EBML header) was queued
only into chunksRef and never pushed to the WS, so concatenated
chunks 1..N decoded as malformed WebM. Fix:
- Construct the WebSocket BEFORE starting the recorder so wsRef is
set when the first ondataavailable fires.
- ondataavailable now queues every chunk through wsPendingRef when
the socket isn't OPEN; ws.onopen drains the queue.
- ws.onmessage('error'): fire HTTP fallback immediately instead of
waiting the full fallback-timeout window.
- ws.onclose without prior `final`: same — kick the HTTP path now
if the recorder has already stopped.
Mic permissions:
- New frontend/src-tauri/Info.plist with NSMicrophoneUsageDescription
+ NSCameraUsageDescription. Tauri 2 auto-merges the file at bundle
time (path is the same dir as tauri.conf.json — schema documents
this fallback). Without it, getUserMedia silently fails on macOS
10.14+ TCC.
- Mic-denial toast now includes platform-specific recovery (Settings
paths for macOS/Windows, audio-group check for Linux).
CI / release notes:
- release.yml extracts the matching `## [X.Y.Z]` section from
CHANGELOG.md and feeds it into tauri-action's releaseBody, so
v0.2.6+ tag pushes produce real release notes instead of the
placeholder "Auto-generated release. See commit log for changes."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c654cd9e4a
commit
0308d0863d
@@ -201,6 +201,34 @@ jobs:
|
||||
# installer ships the repo's pyproject.toml + uv.lock + backend/
|
||||
# tree as Tauri resources; lib.rs::ensure_venv_ready recreates the
|
||||
# venv on first launch via `uv sync --frozen --no-dev`.
|
||||
# Extract the matching CHANGELOG.md section so the release body has
|
||||
# real notes instead of "see commit log". Falls back to a one-liner
|
||||
# if the tag has no matching `## [X.Y.Z]` section yet — keeps the
|
||||
# release publishable even when CHANGELOG hasn't been updated.
|
||||
- name: Extract CHANGELOG section for tag
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
BODY=""
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
BODY=$(awk -v tag="$TAG" '
|
||||
/^## \[/ {
|
||||
if (in_section) exit
|
||||
if ($0 ~ "\\[" tag "\\]") { in_section = 1; next }
|
||||
}
|
||||
in_section { print }
|
||||
' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||
fi
|
||||
if [ -z "$BODY" ]; then
|
||||
BODY="Auto-generated release for ${GITHUB_REF_NAME}. See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) and the commit log for details."
|
||||
fi
|
||||
{
|
||||
echo 'body<<RELEASE_BODY_EOF'
|
||||
echo "$BODY"
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -216,7 +244,7 @@ jobs:
|
||||
args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }}
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "OmniVoice Studio ${{ github.ref_name }}"
|
||||
releaseBody: "Auto-generated release. See commit log for changes."
|
||||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||||
releaseDraft: ${{ inputs.draft || 'true' }}
|
||||
prerelease: false
|
||||
updaterJsonPreferNsis: false
|
||||
|
||||
@@ -30,6 +30,8 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
### Fixed
|
||||
- **Backend was killed on every window close** even if the user only intended to dismiss the window. Backend shutdown now fires only on real-quit (`RunEvent::ExitRequested`), not on the close-to-hide path.
|
||||
- **Hotkey rollback.** `set_dictation_shortcut` previously left the user with no global shortcut if `register(new)` failed after `unregister(old)` succeeded. The previous binding is now restored on failure.
|
||||
- **WebSocket dictation pipeline lost the first audio chunk.** `MediaRecorder` was started before the WebSocket finished its handshake, so the first 250 ms chunk — which carries the WebM EBML header — was dropped from the WS stream. Every subsequent server-side ffmpeg conversion then failed with `exit status 183` ("Invalid data found when processing input"), partials never appeared, and the HTTP fallback only fired after the full timeout. The WebSocket is now constructed before the recorder, every chunk is queued through `wsPendingRef` until `ws.onopen` drains it, and a server `error` message (or unexpected `onclose` after the recorder has stopped) fires the HTTP fallback immediately instead of waiting out the timeout.
|
||||
- **Microphone access prompt on macOS.** Added an `Info.plist` with `NSMicrophoneUsageDescription` (and `NSCameraUsageDescription` for forward-compat) so getUserMedia no longer fails silently on macOS 10.14+ TCC. Tauri's bundler auto-merges the file at bundle time. Mic-denial toasts now also include platform-specific recovery hints (Settings paths for macOS/Windows, audio-group check for Linux).
|
||||
|
||||
### Infrastructure
|
||||
- **CI cross-platform check.** PRs now run `cargo check` against the Tauri shell on macOS (Apple Silicon), Windows, and Linux in parallel — surfaces platform-specific Rust regressions before tag push without paying the full ~15 min/platform tauri-bundle cost (full bundling stays in `release.yml` on tag push).
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
macOS shows this string in the system dialog when the app first
|
||||
requests microphone access. Without it, getUserMedia() in the
|
||||
WebView fails silently on macOS 10.14+ (TCC blocks the access
|
||||
and returns NotAllowedError to JS). This file is auto-merged
|
||||
into the app's Info.plist by tauri-bundler at bundle time.
|
||||
-->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OmniVoice needs microphone access for live dictation and voice recording. Audio is processed entirely on your machine — nothing is sent to any external server.</string>
|
||||
|
||||
<!--
|
||||
Same story for camera. We don't currently use it, but if a future
|
||||
feature ever calls getUserMedia({ video: true }) the system will
|
||||
need this string. Cheap to ship now; avoids a future TCC denial.
|
||||
-->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OmniVoice may use the camera for upcoming video features. Video stays on your machine.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -159,21 +159,90 @@ export default function CaptureButton() {
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
|
||||
// Open the WebSocket BEFORE starting the recorder so wsRef is set by
|
||||
// the time the first `ondataavailable` fires. Otherwise the very
|
||||
// first 250 ms chunk — which carries the WebM EBML header — is
|
||||
// dropped from the WS stream, every subsequent chunk decodes as
|
||||
// malformed WebM, and ffmpeg fails with exit 183 on every partial.
|
||||
try {
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsHost = API_BASE.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
|| `${window.location.hostname}:3900`;
|
||||
const wsUrl = `${wsProto}://${wsHost}/ws/transcribe`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
// Drain chunks captured during the handshake.
|
||||
for (const buf of wsPendingRef.current) {
|
||||
try { ws.send(buf); } catch {}
|
||||
}
|
||||
wsPendingRef.current = [];
|
||||
};
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === 'partial') {
|
||||
setPartialText(msg.text || '');
|
||||
} else if (msg.type === 'final') {
|
||||
wsHadFinalRef.current = true;
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
applyResult(msg);
|
||||
try { ws.close(); } catch {}
|
||||
} else if (msg.type === 'error') {
|
||||
// Server failed (e.g. ffmpeg couldn't decode the partial
|
||||
// buffer). Don't wait the full timeout — fire the HTTP
|
||||
// fallback right away so the user still gets a transcript.
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
try { ws.close(); } catch {}
|
||||
wsRef.current = null;
|
||||
if (!wsHadFinalRef.current) sendForTranscription();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
// If the socket closed before delivering `final` and the
|
||||
// recorder has already stopped, the fallback timer is the only
|
||||
// thing left — kick the HTTP path now instead of waiting it
|
||||
// out.
|
||||
if (
|
||||
!wsHadFinalRef.current
|
||||
&& mediaRecorderRef.current
|
||||
&& mediaRecorderRef.current.state === 'inactive'
|
||||
) {
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
sendForTranscription();
|
||||
}
|
||||
};
|
||||
wsRef.current = ws;
|
||||
} catch {
|
||||
// WebSocket not available — will fallback to HTTP POST
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
// Stream chunk to WebSocket for partial results AND to drive the
|
||||
// server's `final` transcription. If the socket is still in
|
||||
// CONNECTING state, queue the chunk so `ws.onopen` can drain it
|
||||
// — otherwise the server's final transcript would lose the
|
||||
// first ~250 ms of audio (the open-handshake window).
|
||||
// Stream every chunk to the WS — queueing through wsPendingRef
|
||||
// until ws.onopen drains it. This guarantees the first chunk
|
||||
// (which carries the WebM EBML header) reaches the server even
|
||||
// if it arrives during the handshake window.
|
||||
e.data.arrayBuffer().then(buf => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws) return;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(buf);
|
||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||
} else {
|
||||
wsPendingRef.current.push(buf);
|
||||
}
|
||||
});
|
||||
@@ -203,49 +272,19 @@ export default function CaptureButton() {
|
||||
setLastEngine('');
|
||||
setLastTime(0);
|
||||
setTrayRecording(true);
|
||||
|
||||
// Open WebSocket for streaming partial results + final transcript
|
||||
try {
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsHost = API_BASE.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
|| `${window.location.hostname}:3900`;
|
||||
const wsUrl = `${wsProto}://${wsHost}/ws/transcribe`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
// Drain chunks captured during the WS handshake.
|
||||
for (const buf of wsPendingRef.current) {
|
||||
try { ws.send(buf); } catch {}
|
||||
}
|
||||
wsPendingRef.current = [];
|
||||
};
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === 'partial') {
|
||||
setPartialText(msg.text || '');
|
||||
} else if (msg.type === 'final') {
|
||||
wsHadFinalRef.current = true;
|
||||
if (fallbackTimerRef.current) {
|
||||
clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
applyResult(msg);
|
||||
try { ws.close(); } catch {}
|
||||
} else if (msg.type === 'error') {
|
||||
// Let the fallback timer fire HTTP POST.
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
ws.onclose = () => { wsRef.current = null; };
|
||||
wsRef.current = ws;
|
||||
} catch {
|
||||
// WebSocket not available — will fallback to HTTP POST
|
||||
wsRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Microphone access denied. Check browser permissions.');
|
||||
// Platform-specific recovery hint — getUserMedia rejects with
|
||||
// NotAllowedError when the OS or user has blocked mic access.
|
||||
const isMac = typeof navigator !== 'undefined'
|
||||
&& /Mac|iPad|iPhone|iPod/.test(navigator.platform || '');
|
||||
const isWindows = typeof navigator !== 'undefined'
|
||||
&& /Win/.test(navigator.platform || '');
|
||||
const hint = isMac
|
||||
? 'macOS: open System Settings → Privacy & Security → Microphone and enable OmniVoice.'
|
||||
: isWindows
|
||||
? 'Windows: open Settings → Privacy & security → Microphone and allow OmniVoice.'
|
||||
: 'Linux: check that your user is in the audio group and the WebView has mic access.';
|
||||
toast.error(`Microphone access denied. ${hint}`, { duration: 6000 });
|
||||
setTrayRecording(false);
|
||||
setState('error');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user