diff --git a/apps/web/src/assets/speech-cues/LICENSE b/apps/web/src/assets/speech-cues/LICENSE new file mode 100644 index 0000000000000..b61895483bc2e --- /dev/null +++ b/apps/web/src/assets/speech-cues/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Christopher Langton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/web/src/assets/speech-cues/analog.wav b/apps/web/src/assets/speech-cues/analog.wav new file mode 100644 index 0000000000000..52fa7585a63f7 Binary files /dev/null and b/apps/web/src/assets/speech-cues/analog.wav differ diff --git a/apps/web/src/assets/speech-cues/classic.wav b/apps/web/src/assets/speech-cues/classic.wav new file mode 100644 index 0000000000000..d541d9899c8b6 Binary files /dev/null and b/apps/web/src/assets/speech-cues/classic.wav differ diff --git a/apps/web/src/assets/speech-cues/click.wav b/apps/web/src/assets/speech-cues/click.wav new file mode 100644 index 0000000000000..dfdfbf150febe Binary files /dev/null and b/apps/web/src/assets/speech-cues/click.wav differ diff --git a/apps/web/src/assets/speech-cues/signature.wav b/apps/web/src/assets/speech-cues/signature.wav new file mode 100644 index 0000000000000..9939637f7195b Binary files /dev/null and b/apps/web/src/assets/speech-cues/signature.wav differ diff --git a/apps/web/src/assets/speech-cues/soft.wav b/apps/web/src/assets/speech-cues/soft.wav new file mode 100644 index 0000000000000..0e78ef119819e Binary files /dev/null and b/apps/web/src/assets/speech-cues/soft.wav differ diff --git a/apps/web/src/assets/speech-cues/thock.wav b/apps/web/src/assets/speech-cues/thock.wav new file mode 100644 index 0000000000000..fb6de136cca56 Binary files /dev/null and b/apps/web/src/assets/speech-cues/thock.wav differ diff --git a/apps/web/src/assets/speech-cues/wood.wav b/apps/web/src/assets/speech-cues/wood.wav new file mode 100644 index 0000000000000..e1fa911e2728e Binary files /dev/null and b/apps/web/src/assets/speech-cues/wood.wav differ diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index ad4977e62dec4..c771f23658bfa 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -26,6 +26,7 @@ import { type SpeechBenchmarkTier, type SpeechCustomWords, type SpeechModelUnloadTimeout, + SpeechRecordingCueSound, type VoiceShortcutMode, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; @@ -57,6 +58,7 @@ import { chooseTranscriptCleanup, useCleanupGrant, } from "../../speech/voiceConsent"; +import { playRecordingCue, RECORDING_CUE_SOUNDS } from "../../speech/recordingCue"; import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; import { runEnvironmentSpeech } from "../../speech/runEnvironmentSpeech"; import { @@ -1357,6 +1359,49 @@ export function VoiceSettingsPanel() { } /> + + + { + if (checked) void playRecordingCue(settings.speechRecordingCueSound); + void updateClientSettings({ speechRecordingCue: checked }); + }} + /> + + } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 953acb63117b6..ae4c47976536c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -617,6 +617,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/voice", searchTerms: ["speech voice keyboard hotkey hold toggle auto"], }, + { + id: "speech-recording-cue", + title: "Sound cue", + to: "/settings/voice", + searchTerms: [ + "speech voice dictation stt recording start audio feedback beep click chime tone classic signature soft wood warm synth thock", + ], + }, { id: "speech-language", title: "Transcription language", diff --git a/apps/web/src/speech/recordingCue.ts b/apps/web/src/speech/recordingCue.ts new file mode 100644 index 0000000000000..5ffc87ecebf77 --- /dev/null +++ b/apps/web/src/speech/recordingCue.ts @@ -0,0 +1,54 @@ +import type { SpeechRecordingCueSound } from "@t3tools/contracts"; + +import analogUrl from "../assets/speech-cues/analog.wav?url"; +import classicUrl from "../assets/speech-cues/classic.wav?url"; +import clickUrl from "../assets/speech-cues/click.wav?url"; +import signatureUrl from "../assets/speech-cues/signature.wav?url"; +import softUrl from "../assets/speech-cues/soft.wav?url"; +import thockUrl from "../assets/speech-cues/thock.wav?url"; +import woodUrl from "../assets/speech-cues/wood.wav?url"; + +// The start sounds from https://github.com/anomalyco/hex, unmodified. See speech-cues/LICENSE. +export const RECORDING_CUE_SOUNDS = { + classic: { label: "Classic", url: classicUrl }, + signature: { label: "Signature", url: signatureUrl }, + click: { label: "Click", url: clickUrl }, + soft: { label: "Soft tone", url: softUrl }, + wood: { label: "Wood", url: woodUrl }, + analog: { label: "Warm synth", url: analogUrl }, + thock: { label: "Thock", url: thockUrl }, +} as const satisfies Record; + +const cueElements = new Map(); + +function cueElement(sound: SpeechRecordingCueSound): HTMLAudioElement { + const existing = cueElements.get(sound); + if (existing) return existing; + const created = new Audio(RECORDING_CUE_SOUNDS[sound].url); + cueElements.set(sound, created); + return created; +} + +/** Loads a cue ahead of time so it can play the moment recording begins. */ +export function preloadRecordingCue(sound: SpeechRecordingCueSound): void { + cueElement(sound); +} + +// Longer than every cue, so a stalled or blocked element cannot hold back recording. +const CUE_WAIT_LIMIT_MS = 1_000; + +/** Plays the sound that tells the user dictation is listening; resolves once it has finished. */ +export function playRecordingCue(sound: SpeechRecordingCueSound): Promise { + const audio = cueElement(sound); + audio.currentTime = 0; + return new Promise((resolve) => { + const finish = () => { + clearTimeout(limit); + audio.removeEventListener("ended", finish); + resolve(); + }; + const limit = setTimeout(finish, CUE_WAIT_LIMIT_MS); + audio.addEventListener("ended", finish); + audio.play().catch(finish); + }); +} diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 41fefeca96d95..7761c833f6476 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -41,6 +41,7 @@ import { createBrowserVoiceInputPlatform } from "./browserVoiceInput"; import { preferredSpeechLanguages } from "./preferredSpeechLanguages"; import { runEnvironmentSpeech } from "./runEnvironmentSpeech"; import { bindAudioGrant, readCleanupGrant, useAudioChoice } from "./voiceConsent"; +import { playRecordingCue, preloadRecordingCue } from "./recordingCue"; const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; const WAITING_STATE: VoiceInputState = { phase: "preparing", error: null, errorAction: null }; @@ -378,6 +379,10 @@ export function useEnvironmentSpeechInput(input: HookInput) { configureRecording: async () => undefined, releaseRecording: async () => platform.cancelRecording(), deleteRecording: platform.deleteRecording, + beforeRecord: async () => { + const { speechRecordingCue, speechRecordingCueSound } = latestRef.current.clientSettings; + if (speechRecordingCue) await playRecordingCue(speechRecordingCueSound); + }, postProcess, onPostProcessingError: () => toastManager.add({ @@ -394,6 +399,9 @@ export function useEnvironmentSpeechInput(input: HookInput) { onStateChange: (value) => { if (disposed) return; if (value.phase === "error") pendingSubmissionRef.current = null; + const { speechRecordingCue, speechRecordingCueSound } = latestRef.current.clientSettings; + if (speechRecordingCue && value.phase === "preparing") + preloadRecordingCue(speechRecordingCueSound); setControllerState({ prepared, grantId, value }); if (value.phase !== "recording" && value.phase !== "transcribing") setPreview(null); }, diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md index 8e396057ef4bc..d7148e1962f95 100644 --- a/docs/user/voice-input.md +++ b/docs/user/voice-input.md @@ -98,6 +98,10 @@ sees the recording, which runs in T3 Code itself. are down, and **Toggle** always needs a second press. Change the chord itself in [Keybindings settings](./keybindings.md). +On web and desktop, turn on **Sound cue** in Voice settings to hear a short sound +when recording starts, and pick the sound you like next to the switch. It is off +by default. + ## Improve recognition - **Language** picks what you speak. **Auto** lets a multilingual model detect diff --git a/packages/client-runtime/src/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts index 41e56933ce104..eaf57ad89bd4e 100644 --- a/packages/client-runtime/src/voice-input/controller.test.ts +++ b/packages/client-runtime/src/voice-input/controller.test.ts @@ -427,11 +427,16 @@ describe("VoiceInputController", () => { ); it("uses the native five-minute cap and commits one final transcript", async () => { - const harness = createHarness(); + const beforeRecord = vi.fn(async () => undefined); + const harness = createHarness({ beforeRecord }); await harness.controller.start(); expect(harness.recorder.record).toHaveBeenCalledWith({ forDuration: VOICE_RECORDING_LIMIT_SECONDS, }); + // The start cue plays in this hook, so it must finish before capture begins. + expect(beforeRecord.mock.invocationCallOrder[0]).toBeLessThan( + harness.recorder.record.mock.invocationCallOrder[0] ?? 0, + ); await harness.controller.handleRecorderStatus({ isFinished: true, hasError: false, diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts index 668705546a489..5cd028f42a980 100644 --- a/packages/client-runtime/src/voice-input/controller.ts +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -77,6 +77,8 @@ export type VoiceInputControllerDependencies = { readonly configureRecording: () => Promise; readonly releaseRecording: () => Promise; readonly deleteRecording: (uri: string) => void; + /** Runs once the microphone is ready and before capture starts, so a start cue stays out of the audio. */ + readonly beforeRecord?: () => Promise; /** Keep a recording whose transcription failed so the next start retries it without recording again. */ readonly retryFailedRecording?: boolean; /** @@ -331,6 +333,8 @@ export class VoiceInputController { if (!this.isCurrent(operationToken)) return; await this.dependencies.recorder.prepareToRecordAsync(abortController.signal); if (!this.isCurrent(operationToken)) return; + await this.dependencies.beforeRecord?.(); + if (!this.isCurrent(operationToken)) return; this.recordingUri = this.dependencies.recorder.uri; this.rememberRecordingUri(this.recordingUri); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 261ff895e5028..612a2db698512 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -593,6 +593,15 @@ describe("voice settings", () => { it("keeps the correction cue personal and project vocabulary on the environment", () => { expect(decodeClientSettings({}).speechCorrectionWord).toBe(""); expect(() => decodeClientSettingsPatch({ speechCorrectionWord: "x".repeat(51) })).toThrow(); + expect(decodeClientSettings({}).speechRecordingCue).toBe(false); + expect(decodeClientSettingsPatch({ speechRecordingCue: true })).toEqual({ + speechRecordingCue: true, + }); + expect(decodeClientSettings({}).speechRecordingCueSound).toBe("classic"); + expect(decodeClientSettingsPatch({ speechRecordingCueSound: "thock" })).toEqual({ + speechRecordingCueSound: "thock", + }); + expect(() => decodeClientSettingsPatch({ speechRecordingCueSound: "shutter" })).toThrow(); expect(DEFAULT_SERVER_SETTINGS.speechProjectCustomWords).toEqual([]); expect(PROJECT_SCOPED_SERVER_SETTING_KEYS).toContain("speechProjectCustomWords"); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1a60524ada9c8..0406a10edd5c8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -219,6 +219,17 @@ export type SnapShotShortcut = typeof SnapShotShortcut.Type; export const SnapShotSound = Schema.Literals(["soft-pop", "camera-shutter"]); export type SnapShotSound = typeof SnapShotSound.Type; const DEFAULT_SNAP_SHOT_SOUND: SnapShotSound = "soft-pop"; +export const SpeechRecordingCueSound = Schema.Literals([ + "classic", + "signature", + "click", + "soft", + "wood", + "analog", + "thock", +]); +export type SpeechRecordingCueSound = typeof SpeechRecordingCueSound.Type; +const DEFAULT_SPEECH_RECORDING_CUE_SOUND: SpeechRecordingCueSound = "classic"; export type SnapShotModifierPairShortcut = Extract; @@ -669,6 +680,10 @@ export const ClientSettingsSchema = Schema.Struct({ ), ), speechCorrectionWord: SpeechCorrectionCue.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + speechRecordingCue: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + speechRecordingCueSound: SpeechRecordingCueSound.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SPEECH_RECORDING_CUE_SOUND)), + ), snapShotEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), snapShotIncludeAccessibility: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), @@ -2175,6 +2190,8 @@ export const ClientSettingsPatch = Schema.Struct({ speechRemoveFillerWords: Schema.optionalKey(Schema.Boolean), speechPostProcessingPrompt: Schema.optionalKey(SpeechPostProcessingPromptSettings), speechCorrectionWord: Schema.optionalKey(SpeechCorrectionCue), + speechRecordingCue: Schema.optionalKey(Schema.Boolean), + speechRecordingCueSound: Schema.optionalKey(SpeechRecordingCueSound), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), snapShotShortcut: Schema.optionalKey(SnapShotShortcut), diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 0ce031a30f359..0a57a8cf28b8c 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -1,5 +1,12 @@ { "customNotices": [ + { + "name": "Hex dictation start sounds", + "bundles": ["assets", "desktop", "web"], + "license": "MIT", + "sourceUrl": "https://github.com/anomalyco/hex/tree/d8159610dd6fe196c55767a8040ac5b1f04d2ba6/resources/audio/sounds", + "noticeFile": "apps/web/src/assets/speech-cues/LICENSE" + }, { "name": "opencode Office viewers", "bundles": ["web"],