CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Add WhisperTyping feature parity #4033

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/fix-whispertyping-parity-meN58mainopened Mar 24, 2026
8 changed files+485−73
Modifiedapp/page.jsx+55−32View fileUnifiedSplit
55
66const FEATURES = [
77 {
8 icon: '⇧⇩',
9 title: 'Scroll Wheel Toggle',
10 desc: 'Scroll up to record, down to stop. No clicking. No fumbling.',
8 icon: '🌐',
9 title: '50+ Languages',
10 desc: 'Dictate in English, Spanish, French, German, Chinese, Japanese, Korean, Arabic and 40+ more languages.',
1111 color: '#00f0ff',
1212 },
1313 {
14 icon: '〈/〉',
15 title: 'Auto Code Fences',
16 desc: 'Detects coding keywords and wraps your transcript in markdown code blocks automatically.',
14 icon: '⇧⇩',
15 title: 'Scroll Wheel Toggle',
16 desc: 'Scroll up to record, down to stop. Or use push-to-talk. No clicking. No fumbling.',
1717 color: '#00f0ff',
1818 },
1919 {
2020 icon: '🗣',
21 title: 'Voice Commands',
22 desc: '"Refactor this", "Debug this", "Send it" — developer prompts triggered by voice.',
21 title: 'Voice Commands & Punctuation',
22 desc: '"Period", "new line", "question mark" — say punctuation naturally. Plus developer commands like "refactor this".',
2323 color: '#ff3b5c',
2424 },
2525 {
2626 icon: '⤳',
27 title: 'Direct Injection',
28 desc: 'Types directly into Claude, ChatGPT, Gemini & DeepSeek. No clipboard. No paste.',
27 title: 'Works Everywhere',
28 desc: 'Types into any website — Claude, ChatGPT, Gemini, DeepSeek, Gmail, Slack, Notion, and every text field on the web.',
2929 color: '#00ff88',
3030 },
3131 {
3232 icon: '⚙',
3333 title: 'Whisper API Support',
34 desc: 'Free Web Speech by default. Upgrade to Whisper API for 99%+ accuracy with your own key.',
34 desc: 'Free Web Speech by default. Upgrade to OpenAI Whisper for 99%+ accuracy with your own key.',
3535 color: '#ffb800',
3636 },
37 {
38 icon: '📚',
39 title: 'Custom Vocabulary',
40 desc: 'Add technical terms, brand names, and jargon. Create text replacement rules for abbreviations and corrections.',
41 color: '#00f0ff',
42 },
3743 {
3844 icon: '⌥',
39 title: 'Keyboard Shortcut',
40 desc: 'Ctrl+Shift+Space (Win) or Cmd+Shift+Space (Mac) toggles recording instantly.',
45 title: 'Push-to-Talk & Shortcuts',
46 desc: 'Hold Ctrl+Shift for push-to-talk, or use Ctrl+Shift+Space to toggle. Multiple activation modes.',
4147 color: '#00f0ff',
4248 },
49 {
50 icon: '〈/〉',
51 title: 'Auto Code Fences',
52 desc: 'Detects coding keywords and wraps your transcript in markdown code blocks with language detection.',
53 color: '#00f0ff',
54 },
55 {
56 icon: '💬',
57 title: 'Smart Post-Processing',
58 desc: 'Auto-capitalizes sentences, formats punctuation, and cleans up transcription for natural-sounding text.',
59 color: '#ffb800',
60 },
4361]
4462
4563const COMPETITORS = [
46 { name: 'VoiceWave', type: 'Extension', price: 'Free', claude: 'Buggy', codeFence: 'No', voiceCmd: 'No', scrollToggle: 'No' },
47 { name: 'Wispr Flow', type: 'Desktop', price: '$15/mo', claude: 'No', codeFence: 'No', voiceCmd: 'No', scrollToggle: 'No' },
48 { name: 'Voicy', type: 'Extension', price: '$8.49/mo', claude: 'Generic', codeFence: 'No', voiceCmd: 'No', scrollToggle: 'No' },
49 { name: 'Superwhisper', type: 'Desktop', price: '$8.49/mo', claude: 'No', codeFence: 'No', voiceCmd: 'No', scrollToggle: 'No' },
50 { name: '48co', type: 'Extension', price: 'Free', claude: 'Native', codeFence: 'Yes', voiceCmd: 'Yes', scrollToggle: 'Yes' },
64 { name: 'WhisperTyping', type: 'Desktop', price: '$5/mo', languages: '50+', anySite: 'Yes', codeFence: 'No', voiceCmd: 'Limited', pushToTalk: 'Yes' },
65 { name: 'Wispr Flow', type: 'Desktop', price: '$15/mo', languages: '~10', anySite: 'Yes', codeFence: 'No', voiceCmd: 'No', pushToTalk: 'Yes' },
66 { name: 'Voicy', type: 'Extension', price: '$8.49/mo', languages: '~30', anySite: 'No', codeFence: 'No', voiceCmd: 'No', pushToTalk: 'No' },
67 { name: 'Superwhisper', type: 'Desktop', price: '$8.49/mo', languages: '~90', anySite: 'Yes', codeFence: 'No', voiceCmd: 'No', pushToTalk: 'Yes' },
68 { name: '48co', type: 'Extension', price: 'Free', languages: '50+', anySite: 'Yes', codeFence: 'Yes', voiceCmd: 'Yes', pushToTalk: 'Yes' },
5169]
5270
5371const COMMANDS = [
72 { trigger: 'period / comma / question mark', action: 'Inserts punctuation' },
73 { trigger: 'new line / new paragraph', action: 'Inserts line breaks' },
74 { trigger: 'open paren / close paren', action: 'Inserts brackets' },
75 { trigger: 'thumbs up emoji / fire emoji', action: 'Inserts emoji' },
5476 { trigger: 'refactor this', action: 'Pastes refactor prompt' },
5577 { trigger: 'explain this', action: 'Pastes explain prompt' },
56 { trigger: 'debug this', action: 'Pastes debug prompt' },
57 { trigger: 'fix this', action: 'Pastes fix prompt' },
58 { trigger: 'test this', action: 'Pastes test prompt' },
78 { trigger: 'debug this / fix this / test this', action: 'Pastes dev prompt' },
5979 { trigger: 'optimize this', action: 'Pastes optimize prompt' },
6080 { trigger: 'send it', action: 'Submits the message' },
6181 { trigger: 'cancel', action: 'Clears and resets' },
82102 </h1>
83103
84104 <p className="text-white/40 text-sm md:text-base max-w-lg leading-relaxed mb-8">
85 A Chrome extension that injects voice input directly into Claude, ChatGPT, Gemini &amp; DeepSeek.
86 Developer voice commands. Auto code fences. Scroll-wheel control.
105 A Chrome extension that types your voice into any website.
106 50+ languages. Push-to-talk. Voice punctuation. Custom vocabulary.
107 Like WhisperTyping, but free and in your browser.
87108 </p>
88109
89110 {/* Demo widget */}
140161 {/* ── Features Grid ─────────────────────────────────────────── */}
141162 <section className="max-w-5xl mx-auto px-4 py-24">
142163 <h2 className="text-[11px] tracking-[0.3em] text-[#00f0ff]/40 text-center mb-12 uppercase">
143 Built for developers
164 Everything WhisperTyping does, free in your browser
144165 </h2>
145166
146167 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
164185 {/* ── Voice Commands ────────────────────────────────────────── */}
165186 <section className="max-w-3xl mx-auto px-4 py-24">
166187 <h2 className="text-[11px] tracking-[0.3em] text-[#ff3b5c]/40 text-center mb-12 uppercase">
167 Voice Commands
188 Voice Commands & Punctuation
168189 </h2>
169190
170191 <div className="glass rounded-2xl overflow-hidden">
195216 <th className="text-left px-4 py-3 text-white/30 font-normal">Tool</th>
196217 <th className="text-left px-4 py-3 text-white/30 font-normal">Type</th>
197218 <th className="text-left px-4 py-3 text-white/30 font-normal">Price</th>
198 <th className="text-left px-4 py-3 text-white/30 font-normal">Claude</th>
219 <th className="text-left px-4 py-3 text-white/30 font-normal">Languages</th>
220 <th className="text-left px-4 py-3 text-white/30 font-normal">Any Site</th>
199221 <th className="text-left px-4 py-3 text-white/30 font-normal">Code Fences</th>
200222 <th className="text-left px-4 py-3 text-white/30 font-normal">Voice Cmds</th>
201 <th className="text-left px-4 py-3 text-white/30 font-normal">Scroll Toggle</th>
223 <th className="text-left px-4 py-3 text-white/30 font-normal">Push-to-Talk</th>
202224 </tr>
203225 </thead>
204226 <tbody>
208230 <tr key={c.name} className={`border-b border-white/[0.04] ${is48 ? 'bg-[#00f0ff]/5' : ''}`}>
209231 <td className={`px-4 py-3 font-bold ${is48 ? 'text-[#00f0ff]' : 'text-white/60'}`}>{c.name}</td>
210232 <td className="px-4 py-3 text-white/30">{c.type}</td>
211 <td className="px-4 py-3 text-white/30">{c.price}</td>
212 <td className={`px-4 py-3 ${c.claude === 'Native' ? 'text-[#00ff88]' : c.claude === 'Buggy' ? 'text-[#ffb800]' : 'text-white/20'}`}>{c.claude}</td>
233 <td className={`px-4 py-3 ${c.price === 'Free' ? 'text-[#00ff88]' : 'text-white/30'}`}>{c.price}</td>
234 <td className="px-4 py-3 text-white/30">{c.languages}</td>
235 <td className={`px-4 py-3 ${c.anySite === 'Yes' ? 'text-[#00ff88]' : 'text-white/20'}`}>{c.anySite}</td>
213236 <td className={`px-4 py-3 ${c.codeFence === 'Yes' ? 'text-[#00ff88]' : 'text-white/20'}`}>{c.codeFence}</td>
214 <td className={`px-4 py-3 ${c.voiceCmd === 'Yes' ? 'text-[#00ff88]' : 'text-white/20'}`}>{c.voiceCmd}</td>
215 <td className={`px-4 py-3 ${c.scrollToggle === 'Yes' ? 'text-[#00ff88]' : 'text-white/20'}`}>{c.scrollToggle}</td>
237 <td className={`px-4 py-3 ${c.voiceCmd === 'Yes' ? 'text-[#00ff88]' : c.voiceCmd === 'Limited' ? 'text-[#ffb800]' : 'text-white/20'}`}>{c.voiceCmd}</td>
238 <td className={`px-4 py-3 ${c.pushToTalk === 'Yes' ? 'text-[#00ff88]' : 'text-white/20'}`}>{c.pushToTalk}</td>
216239 </tr>
217240 )
218241 })}
225248 <section id="install" className="max-w-3xl mx-auto px-4 py-24 text-center">
226249 <h2 className="text-3xl font-bold mb-4 text-white/90">Get 48co</h2>
227250 <p className="text-white/35 text-sm mb-10 max-w-md mx-auto">
228 Install the Chrome extension and start dictating into any AI chat in seconds.
251 Install the Chrome extension and start dictating into any text field on any website in seconds.
229252 </p>
230253
231254 <div className="flex flex-col md:flex-row gap-4 justify-center items-center mb-8">
Modifiedextension/background.js+13−1View fileUnifiedSplit
3838 autoSubmit: false,
3939 whisperApiKey: '',
4040 engine: 'web-speech', // 'web-speech' | 'whisper'
41 language: 'en',
42 pushToTalk: false,
43 vocabulary: [],
44 replacements: [],
4145 }
4246 const stored = await chrome.storage.local.get(Object.keys(defaults))
4347 return { ...defaults, ...stored }
7781 if (state.engine === 'whisper') {
7882 // Use offscreen document for Whisper API recording
7983 await ensureOffscreen()
80 chrome.runtime.sendMessage({ type: 'OFFSCREEN_START', apiKey: state.whisperApiKey })
84 chrome.runtime.sendMessage({
85 type: 'OFFSCREEN_START',
86 apiKey: state.whisperApiKey,
87 language: state.language || 'en',
88 })
8189 }
8290 await setState({ isRecording: true })
8391 return { ok: true, engine: state.engine }
136144 typeSpeed: 30,
137145 autoSubmit: false,
138146 engine: 'web-speech',
147 language: 'en',
148 pushToTalk: false,
149 vocabulary: [],
150 replacements: [],
139151 })
140152
141153 // Open welcome page so user knows what to do next
Modifiedextension/content.js+201−10View fileUnifiedSplit
8787 },
8888 }
8989
90 // Generic adapter — works on any website by finding the focused/active text input
91 const ADAPTERS_GENERIC = {
92 name: 'Any Site',
93 input: [
94 'textarea:focus',
95 'input[type="text"]:focus',
96 'div[contenteditable="true"]:focus',
97 // Fallbacks: find first visible text input on page
98 'textarea',
99 'div[contenteditable="true"]',
100 'input[type="text"]',
101 'input:not([type])',
102 ],
103 send: [
104 'button[type="submit"]',
105 'form button:last-of-type',
106 ],
107 }
108
90109 function detectSite() {
91110 const host = window.location.hostname
92111 if (host.includes('claude.ai')) return ADAPTERS.claude
93112 if (host.includes('chatgpt.com') || host.includes('chat.openai.com')) return ADAPTERS.chatgpt
94113 if (host.includes('gemini.google.com')) return ADAPTERS.gemini
95114 if (host.includes('chat.deepseek.com')) return ADAPTERS.deepseek
96 return null
115 return ADAPTERS_GENERIC // fallback to generic adapter for any site
97116 }
98117
99118 function getInput(adapter) { return queryFirst(adapter.input) }
100119 function getSend(adapter) { return queryFirst(adapter.send) }
101120
121 // Track the last focused text input for generic mode
122 let lastFocusedInput = null
123 document.addEventListener('focusin', (e) => {
124 const t = e.target
125 if (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.contentEditable === 'true') {
126 lastFocusedInput = t
127 }
128 }, true)
129
102130 function insertText(adapter, text) {
103 const el = getInput(adapter)
131 // On generic sites, prefer the last focused input element
132 let el = getInput(adapter)
133 if (!el && lastFocusedInput) el = lastFocusedInput
104134 if (!el) return false
105135 el.focus()
106136
166196 return null
167197 }
168198
199 // ═══════════════════════════════════════════════════════════════════
200 // PUNCTUATION & EMOJI VOICE SUBSTITUTIONS (WhisperTyping parity)
201 // ═══════════════════════════════════════════════════════════════════
202
203 const PUNCTUATION_MAP = [
204 // Sentence punctuation
205 { pattern: /\b(full stop|period)\b/gi, replacement: '.' },
206 { pattern: /\bcomma\b/gi, replacement: ',' },
207 { pattern: /\b(question mark)\b/gi, replacement: '?' },
208 { pattern: /\b(exclamation mark|exclamation point)\b/gi, replacement: '!' },
209 { pattern: /\bsemicolon\b/gi, replacement: ';' },
210 { pattern: /\bcolon\b/gi, replacement: ':' },
211 { pattern: /\bellipsis\b/gi, replacement: '...' },
212 { pattern: /\bdash\b/gi, replacement: ' — ' },
213 { pattern: /\bhyphen\b/gi, replacement: '-' },
214
215 // Whitespace / structure
216 { pattern: /\b(new line|newline)\b/gi, replacement: '\n' },
217 { pattern: /\b(new paragraph)\b/gi, replacement: '\n\n' },
218 { pattern: /\btab\b/gi, replacement: '\t' },
219
220 // Brackets / quotes
221 { pattern: /\b(open parenthesis|open paren|left paren)\b/gi, replacement: '(' },
222 { pattern: /\b(close parenthesis|close paren|right paren)\b/gi, replacement: ')' },
223 { pattern: /\b(open bracket|left bracket)\b/gi, replacement: '[' },
224 { pattern: /\b(close bracket|right bracket)\b/gi, replacement: ']' },
225 { pattern: /\b(open brace|left brace|open curly)\b/gi, replacement: '{' },
226 { pattern: /\b(close brace|right brace|close curly)\b/gi, replacement: '}' },
227 { pattern: /\b(open quote|begin quote)\b/gi, replacement: '"' },
228 { pattern: /\b(close quote|end quote|unquote)\b/gi, replacement: '"' },
229 { pattern: /\bsingle quote\b/gi, replacement: "'" },
230
231 // Symbols
232 { pattern: /\b(at sign|at symbol)\b/gi, replacement: '@' },
233 { pattern: /\b(hash sign|hashtag|pound sign)\b/gi, replacement: '#' },
234 { pattern: /\b(dollar sign)\b/gi, replacement: '$' },
235 { pattern: /\b(percent sign|percentage)\b/gi, replacement: '%' },
236 { pattern: /\b(ampersand)\b/gi, replacement: '&' },
237 { pattern: /\b(asterisk|star)\b/gi, replacement: '*' },
238 { pattern: /\bforward slash\b/gi, replacement: '/' },
239 { pattern: /\bbackslash\b/gi, replacement: '\\' },
240 { pattern: /\b(pipe|vertical bar)\b/gi, replacement: '|' },
241 { pattern: /\b(underscore)\b/gi, replacement: '_' },
242 { pattern: /\b(equals sign)\b/gi, replacement: '=' },
243 { pattern: /\b(plus sign)\b/gi, replacement: '+' },
244 { pattern: /\b(less than|left angle)\b/gi, replacement: '<' },
245 { pattern: /\b(greater than|right angle)\b/gi, replacement: '>' },
246 { pattern: /\btilde\b/gi, replacement: '~' },
247
248 // Common emojis
249 { pattern: /\b(thumbs up emoji|thumbsup emoji)\b/gi, replacement: '👍' },
250 { pattern: /\b(thumbs down emoji)\b/gi, replacement: '👎' },
251 { pattern: /\b(smiley face|smiley emoji|smile emoji)\b/gi, replacement: '😊' },
252 { pattern: /\b(heart emoji)\b/gi, replacement: '❤️' },
253 { pattern: /\b(fire emoji)\b/gi, replacement: '🔥' },
254 { pattern: /\b(check mark emoji|checkmark emoji)\b/gi, replacement: '✅' },
255 { pattern: /\b(cross mark emoji|x emoji)\b/gi, replacement: '❌' },
256 { pattern: /\b(warning emoji)\b/gi, replacement: '⚠️' },
257 { pattern: /\b(rocket emoji)\b/gi, replacement: '🚀' },
258 { pattern: /\b(thinking emoji)\b/gi, replacement: '🤔' },
259 { pattern: /\b(clap emoji)\b/gi, replacement: '👏' },
260 { pattern: /\b(laughing emoji|lol emoji)\b/gi, replacement: '😂' },
261 { pattern: /\b(crying emoji|sad emoji)\b/gi, replacement: '😢' },
262 { pattern: /\b(wave emoji)\b/gi, replacement: '👋' },
263 { pattern: /\b(party emoji)\b/gi, replacement: '🎉' },
264 { pattern: /\b(eyes emoji)\b/gi, replacement: '👀' },
265 { pattern: /\b(100 emoji)\b/gi, replacement: '💯' },
266 { pattern: /\b(bug emoji)\b/gi, replacement: '🐛' },
267 ]
268
269 function applyPunctuation(text) {
270 let result = text
271 for (const { pattern, replacement } of PUNCTUATION_MAP) {
272 result = result.replace(pattern, replacement)
273 }
274 return result
275 }
276
277 // ═══════════════════════════════════════════════════════════════════
278 // TEXT POST-PROCESSING (auto-capitalize, clean whitespace)
279 // ═══════════════════════════════════════════════════════════════════
280
281 function postProcess(text) {
282 let result = text
283
284 // Apply punctuation/emoji substitutions
285 result = applyPunctuation(result)
286
287 // Apply custom vocabulary replacements
288 if (state.vocabulary && state.vocabulary.length > 0) {
289 for (const { from, to } of state.vocabulary) {
290 if (from && to) {
291 const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
292 result = result.replace(new RegExp('\\b' + escaped + '\\b', 'gi'), to)
293 }
294 }
295 }
296
297 // Apply custom text replacements
298 if (state.replacements && state.replacements.length > 0) {
299 for (const { from, to } of state.replacements) {
300 if (from && to) {
301 result = result.replaceAll(from, to)
302 }
303 }
304 }
305
306 // Remove space before punctuation that was substituted
307 result = result.replace(/\s+([.,;:!?)\]}])/g, '$1')
308 // Add space after punctuation if missing (but not for newlines/tabs)
309 result = result.replace(/([.,;:!?])([A-Za-z])/g, '$1 $2')
310
311 // Auto-capitalize first letter of text
312 result = result.replace(/^(\s*)([a-z])/, (_, ws, ch) => ws + ch.toUpperCase())
313
314 // Auto-capitalize after sentence-ending punctuation
315 result = result.replace(/([.!?]\s+)([a-z])/g, (_, punct, ch) => punct + ch.toUpperCase())
316
317 // Auto-capitalize after newlines
318 result = result.replace(/(\n\s*)([a-z])/g, (_, nl, ch) => nl + ch.toUpperCase())
319
320 // Clean up multiple spaces (but preserve intentional newlines)
321 result = result.replace(/ {2,}/g, ' ')
322
323 return result.trim()
324 }
325
169326 // ═══════════════════════════════════════════════════════════════════
170327 // AUTO CODING MODE
171328 // ═══════════════════════════════════════════════════════════════════
211368 autoCoding: true,
212369 autoSubmit: false,
213370 engine: 'web-speech',
371 language: 'en',
372 pushToTalk: false,
373 vocabulary: [], // [{from, to}] custom word replacements
374 replacements: [], // [{from, to}] text replacement rules
214375 }
215376
216377 let recognition = null
217378 const adapter = detectSite()
218 if (!adapter) return // not on a supported site
219379
220380 // Load persisted settings
221 chrome.storage.local.get(['codingMode', 'autoCoding', 'autoSubmit', 'engine'], (stored) => {
381 chrome.storage.local.get([
382 'codingMode', 'autoCoding', 'autoSubmit', 'engine', 'language',
383 'pushToTalk', 'vocabulary', 'replacements',
384 ], (stored) => {
222385 if (stored.codingMode !== undefined) state.codingMode = stored.codingMode
223386 if (stored.autoCoding !== undefined) state.autoCoding = stored.autoCoding
224387 if (stored.autoSubmit !== undefined) state.autoSubmit = stored.autoSubmit
225388 if (stored.engine !== undefined) state.engine = stored.engine
389 if (stored.language !== undefined) state.language = stored.language
390 if (stored.pushToTalk !== undefined) state.pushToTalk = stored.pushToTalk
391 if (stored.vocabulary) state.vocabulary = stored.vocabulary
392 if (stored.replacements) state.replacements = stored.replacements
226393 })
227394
228395 // ═══════════════════════════════════════════════════════════════════
497664 }
498665 }
499666
500 // Use MutationObserver to wait for the chat input to appear (SPA)
667 // Use MutationObserver to wait for page to be ready (SPA)
501668 const observer = new MutationObserver(() => {
502 if (getInput(adapter)) {
503 inject()
504 }
669 inject()
505670 })
506671 observer.observe(document.body, { childList: true, subtree: true })
507672
574739 recognition = new SpeechRecognition()
575740 recognition.continuous = true
576741 recognition.interimResults = true
577 recognition.lang = 'en-NZ'
742 recognition.lang = state.language || 'en'
578743
579744 recognition.onstart = () => {
580745 state.status = 'recording'
673838 return
674839 }
675840
676 let output = (cmd && cmd.action === 'text') ? cmd.output : text
841 let output = (cmd && cmd.action === 'text') ? cmd.output : postProcess(text)
677842
678843 // Auto coding mode — wrap in code fences if coding content detected
679844 if (state.codingMode || (state.autoCoding && isCodingContent(output))) {
758923 }
759924 })
760925
926 // ── Push-to-talk: Hold Ctrl+Shift to record, release to stop ─────
927 let pttActive = false
928
929 window.addEventListener('keydown', (e) => {
930 if (!state.pushToTalk) return
931 // Hold Ctrl+Shift (no other keys) to start push-to-talk
932 if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && e.key === 'Shift' && !pttActive) {
933 // Don't trigger if user is typing in an input
934 const tag = document.activeElement?.tagName
935 if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.contentEditable === 'true') return
936 pttActive = true
937 startRecording()
938 panel.classList.add('open')
939 e.preventDefault()
940 }
941 })
942
943 window.addEventListener('keyup', (e) => {
944 if (!state.pushToTalk) return
945 if (pttActive && (e.key === 'Shift' || e.key === 'Control')) {
946 pttActive = false
947 stopRecording()
948 e.preventDefault()
949 }
950 })
951
761952 // Draggable widget
762953 let isDragging = false
763954 let dragStartX, dragStartY, widgetStartX, widgetStartY
Modifiedextension/manifest.json+7−14View fileUnifiedSplit
22 "manifest_version": 3,
33 "name": "48co — AI Voice Assistant",
44 "version": "1.0.0",
5 "description": "Developer-first voice input for Claude, ChatGPT, Gemini & DeepSeek. Scroll-wheel mic toggle, voice commands, auto code fences.",
5 "description": "Voice-to-text for any website. 50+ languages, push-to-talk, voice punctuation, custom vocabulary, auto code fences. Free WhisperTyping alternative.",
66 "permissions": [
77 "offscreen",
88 "storage",
1414 "https://chat.openai.com/*",
1515 "https://chatgpt.com/*",
1616 "https://gemini.google.com/*",
17 "https://chat.deepseek.com/*"
17 "https://chat.deepseek.com/*",
18 "https://*/*",
19 "http://*/*"
1820 ],
1921 "background": {
2022 "service_worker": "background.js"
2224 "content_scripts": [
2325 {
2426 "matches": [
25 "https://claude.ai/*",
26 "https://chat.openai.com/*",
27 "https://chatgpt.com/*",
28 "https://gemini.google.com/*",
29 "https://chat.deepseek.com/*"
27 "https://*/*",
28 "http://*/*"
3029 ],
3130 "js": ["content.js"],
3231 "run_at": "document_idle"
5251 "web_accessible_resources": [
5352 {
5453 "resources": ["widget.css", "icons/*"],
55 "matches": [
56 "https://claude.ai/*",
57 "https://chat.openai.com/*",
58 "https://chatgpt.com/*",
59 "https://gemini.google.com/*",
60 "https://chat.deepseek.com/*"
61 ]
54 "matches": ["https://*/*", "http://*/*"]
6255 }
6356 ],
6457 "icons": {
Modifiedextension/offscreen.js+5−1View fileUnifiedSplit
77let mediaRecorder = null
88let audioChunks = []
99let apiKey = ''
10let language = 'en'
1011
1112chrome.runtime.onMessage.addListener((msg) => {
1213 if (msg.type === 'OFFSCREEN_START') {
1314 apiKey = msg.apiKey || ''
15 language = msg.language || 'en'
1416 startRecording()
1517 }
1618 if (msg.type === 'OFFSCREEN_STOP') {
7173 const formData = new FormData()
7274 formData.append('file', audioBlob, 'recording.webm')
7375 formData.append('model', 'whisper-1')
74 formData.append('language', 'en')
76 // Extract base language code for Whisper API (e.g., 'en-US' → 'en')
77 const langCode = (language || 'en').split('-')[0]
78 formData.append('language', langCode)
7579 formData.append('response_format', 'json')
7680
7781 const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
Modifiedextension/popup.html+107−0View fileUnifiedSplit
123123 cursor: pointer;
124124 }
125125
126 /* Vocab/replacement entries */
127 .entry-row {
128 display: flex;
129 align-items: center;
130 justify-content: space-between;
131 padding: 3px 0;
132 font-size: 10px;
133 border-bottom: 1px solid rgba(255,255,255,0.04);
134 }
135 .entry-row .entry-from { color: #00f0ff; flex: 1; }
136 .entry-row .entry-to { color: rgba(255,255,255,0.4); flex: 1; text-align: right; margin-right: 6px; }
137 .entry-row .entry-del {
138 background: none; border: none; color: rgba(255,255,255,0.15);
139 cursor: pointer; font-size: 10px; padding: 0 2px;
140 }
141 .entry-row .entry-del:hover { color: #ff3b5c; }
142
126143 /* Voice commands */
127144 .cmd {
128145 padding: 4px 0;
195212 <span class="label">API Key</span>
196213 <input type="password" id="api-key" placeholder="sk-..." />
197214 </div>
215 <div class="row">
216 <span class="label">Language</span>
217 <select id="language">
218 <option value="en">English</option>
219 <option value="en-US">English (US)</option>
220 <option value="en-GB">English (UK)</option>
221 <option value="en-AU">English (AU)</option>
222 <option value="en-NZ">English (NZ)</option>
223 <option value="es">Spanish</option>
224 <option value="fr">French</option>
225 <option value="de">German</option>
226 <option value="it">Italian</option>
227 <option value="pt">Portuguese</option>
228 <option value="pt-BR">Portuguese (BR)</option>
229 <option value="nl">Dutch</option>
230 <option value="ru">Russian</option>
231 <option value="pl">Polish</option>
232 <option value="uk">Ukrainian</option>
233 <option value="cs">Czech</option>
234 <option value="sv">Swedish</option>
235 <option value="da">Danish</option>
236 <option value="no">Norwegian</option>
237 <option value="fi">Finnish</option>
238 <option value="ro">Romanian</option>
239 <option value="hu">Hungarian</option>
240 <option value="el">Greek</option>
241 <option value="tr">Turkish</option>
242 <option value="ar">Arabic</option>
243 <option value="he">Hebrew</option>
244 <option value="hi">Hindi</option>
245 <option value="bn">Bengali</option>
246 <option value="ta">Tamil</option>
247 <option value="th">Thai</option>
248 <option value="vi">Vietnamese</option>
249 <option value="id">Indonesian</option>
250 <option value="ms">Malay</option>
251 <option value="zh">Chinese</option>
252 <option value="zh-TW">Chinese (Traditional)</option>
253 <option value="ja">Japanese</option>
254 <option value="ko">Korean</option>
255 <option value="af">Afrikaans</option>
256 <option value="bg">Bulgarian</option>
257 <option value="ca">Catalan</option>
258 <option value="hr">Croatian</option>
259 <option value="et">Estonian</option>
260 <option value="is">Icelandic</option>
261 <option value="lv">Latvian</option>
262 <option value="lt">Lithuanian</option>
263 <option value="mk">Macedonian</option>
264 <option value="sr">Serbian</option>
265 <option value="sk">Slovak</option>
266 <option value="sl">Slovenian</option>
267 <option value="sw">Swahili</option>
268 <option value="tl">Filipino</option>
269 <option value="ur">Urdu</option>
270 <option value="fa">Persian</option>
271 </select>
272 </div>
198273 <div class="row">
199274 <span class="label">Noise suppression</span>
200275 <button class="toggle" id="noise-toggle"><span class="knob"></span></button>
216291 <span class="label">Auto-submit</span>
217292 <button class="toggle" id="auto-submit-toggle"><span class="knob"></span></button>
218293 </div>
294 <div class="row">
295 <span class="label">Push-to-talk (Ctrl+Shift)</span>
296 <button class="toggle" id="ptt-toggle"><span class="knob"></span></button>
297 </div>
219298 <div class="row">
220299 <span class="label">Type speed</span>
221300 <input type="range" id="type-speed" min="10" max="100" value="30" />
222301 </div>
223302 </div>
224303
304 <!-- Custom Vocabulary -->
305 <div class="section">
306 <div class="section-title">Custom Vocabulary</div>
307 <div id="vocab-list"></div>
308 <div class="row" style="margin-top:6px">
309 <input type="text" id="vocab-from" placeholder="Heard as..." style="width:90px" />
310 <span style="color:rgba(255,255,255,0.15);margin:0 4px">&rarr;</span>
311 <input type="text" id="vocab-to" placeholder="Replace with..." style="width:90px" />
312 <button class="code-btn" id="vocab-add" style="margin-left:4px">+</button>
313 </div>
314 </div>
315
316 <!-- Text Replacements -->
317 <div class="section">
318 <div class="section-title">Text Replacements</div>
319 <div id="replace-list"></div>
320 <div class="row" style="margin-top:6px">
321 <input type="text" id="replace-from" placeholder="Find..." style="width:90px" />
322 <span style="color:rgba(255,255,255,0.15);margin:0 4px">&rarr;</span>
323 <input type="text" id="replace-to" placeholder="Replace..." style="width:90px" />
324 <button class="code-btn" id="replace-add" style="margin-left:4px">+</button>
325 </div>
326 </div>
327
225328 <!-- Voice Commands Reference -->
226329 <div class="section">
227330 <div class="section-title">Voice Commands</div>
242345 <span class="label">Toggle mic</span>
243346 <span style="color:rgba(255,255,255,0.3)">Ctrl+Shift+Space</span>
244347 </div>
348 <div class="row">
349 <span class="label">Push-to-talk</span>
350 <span style="color:rgba(255,255,255,0.3)">Hold Ctrl+Shift</span>
351 </div>
245352 <div class="row">
246353 <span class="label">Scroll up</span>
247354 <span style="color:rgba(255,255,255,0.3)">Start recording</span>
Modifiedextension/popup.js+68−0View fileUnifiedSplit
6868 chrome.storage.local.set({ whisperApiKey: apiKeyInput.value })
6969})
7070
71// ── Language selector ─────────────────────────────────────────────
72const langSelect = $('#language')
73chrome.storage.local.get('language', (data) => {
74 if (data.language) langSelect.value = data.language
75})
76langSelect.addEventListener('change', () => {
77 const language = langSelect.value
78 chrome.storage.local.set({ language })
79 chrome.runtime.sendMessage({ type: 'SET_STATE', updates: { language } })
80})
81
7182// ── Type speed slider ──────────────────────────────────────────────
7283const typeSpeedSlider = $('#type-speed')
7384chrome.storage.local.get('typeSpeed', (data) => {
8495setupToggle($('#auto-coding-toggle'), 'autoCoding')
8596setupToggle($('#coding-mode-toggle'), 'codingMode')
8697setupToggle($('#auto-submit-toggle'), 'autoSubmit')
98setupToggle($('#ptt-toggle'), 'pushToTalk')
8799
88100// Set initial toggle states from stored defaults
89101chrome.storage.local.get(['noiseSuppression', 'autoCoding'], (data) => {
91103 if (data.autoCoding !== false) $('#auto-coding-toggle').classList.add('on')
92104})
93105
106// ── Custom vocabulary manager ─────────────────────────────────────
107function renderList(containerId, storageKey, items) {
108 const container = document.getElementById(containerId)
109 container.innerHTML = ''
110 items.forEach((item, i) => {
111 const row = document.createElement('div')
112 row.className = 'entry-row'
113 row.innerHTML = `<span class="entry-from">${item.from}</span><span class="entry-to">${item.to}</span><button class="entry-del" data-idx="${i}">&times;</button>`
114 row.querySelector('.entry-del').addEventListener('click', () => {
115 items.splice(i, 1)
116 chrome.storage.local.set({ [storageKey]: items })
117 chrome.runtime.sendMessage({ type: 'SET_STATE', updates: { [storageKey]: items } })
118 renderList(containerId, storageKey, items)
119 })
120 container.appendChild(row)
121 })
122}
123
124// Vocabulary
125let vocabItems = []
126chrome.storage.local.get('vocabulary', (data) => {
127 vocabItems = data.vocabulary || []
128 renderList('vocab-list', 'vocabulary', vocabItems)
129})
130$('#vocab-add').addEventListener('click', () => {
131 const from = $('#vocab-from').value.trim()
132 const to = $('#vocab-to').value.trim()
133 if (from && to) {
134 vocabItems.push({ from, to })
135 chrome.storage.local.set({ vocabulary: vocabItems })
136 chrome.runtime.sendMessage({ type: 'SET_STATE', updates: { vocabulary: vocabItems } })
137 renderList('vocab-list', 'vocabulary', vocabItems)
138 $('#vocab-from').value = ''
139 $('#vocab-to').value = ''
140 }
141})
142
143// Text replacements
144let replaceItems = []
145chrome.storage.local.get('replacements', (data) => {
146 replaceItems = data.replacements || []
147 renderList('replace-list', 'replacements', replaceItems)
148})
149$('#replace-add').addEventListener('click', () => {
150 const from = $('#replace-from').value.trim()
151 const to = $('#replace-to').value.trim()
152 if (from && to) {
153 replaceItems.push({ from, to })
154 chrome.storage.local.set({ replacements: replaceItems })
155 chrome.runtime.sendMessage({ type: 'SET_STATE', updates: { replacements: replaceItems } })
156 renderList('replace-list', 'replacements', replaceItems)
157 $('#replace-from').value = ''
158 $('#replace-to').value = ''
159 }
160})
161
94162// ── Init ───────────────────────────────────────────────────────────
95163detectSite()
Modifiedextension/welcome.html+29−15View fileUnifiedSplit
160160<body>
161161 <div class="container">
162162 <div class="logo">48<span class="co">co</span></div>
163 <p class="subtitle">Voice input for AI chat &mdash; installed successfully</p>
163 <p class="subtitle">Voice-to-text for any website &mdash; installed successfully</p>
164164
165 <!-- Step 1: Go to a supported site -->
165 <!-- Step 1: Go to any website -->
166166 <div class="card">
167167 <div class="step-number">1</div>
168 <h3>Open an AI chat</h3>
169 <p>Navigate to any of these sites. The <span class="highlight">48co widget</span> appears automatically in the bottom-right corner.</p>
168 <h3>Open any website</h3>
169 <p>The <span class="highlight">48co widget</span> appears on every website. Optimized for AI chats but works everywhere.</p>
170170 <div class="sites">
171171 <a href="https://claude.ai" target="_blank" class="site-badge">Claude.ai</a>
172172 <a href="https://chatgpt.com" target="_blank" class="site-badge">ChatGPT</a>
173173 <a href="https://gemini.google.com" target="_blank" class="site-badge">Gemini</a>
174174 <a href="https://chat.deepseek.com" target="_blank" class="site-badge">DeepSeek</a>
175 <span class="site-badge">Gmail, Slack, Notion, any site...</span>
175176 </div>
176177 </div>
177178
179180 <div class="card">
180181 <div class="step-number">2</div>
181182 <h3>Start talking</h3>
182 <p>Three ways to start recording. Pick whichever feels natural.</p>
183 <div class="controls-grid">
183 <p>Four ways to start recording. Pick whichever feels natural.</p>
184 <div class="controls-grid" style="grid-template-columns: 1fr 1fr;">
184185 <div class="control">
185186 <div class="control-icon">&#8679;</div>
186187 <div class="control-label">Scroll up</div>
191192 </div>
192193 <div class="control">
193194 <div class="control-icon">&#8984;&#8679;&#9251;</div>
194 <div class="control-label">Shortcut</div>
195 <div class="control-label">Keyboard shortcut</div>
196 </div>
197 <div class="control">
198 <div class="control-icon">&#9041;</div>
199 <div class="control-label">Push-to-talk (hold Ctrl+Shift)</div>
195200 </div>
196201 </div>
197202 </div>
200205 <div class="card">
201206 <div class="step-number">3</div>
202207 <h3>Stop &mdash; and it types for you</h3>
203 <p><span class="highlight">Scroll down</span> or click again to stop. Your transcript is typed directly into the chat input. No clipboard. No paste. It just appears.</p>
208 <p><span class="highlight">Scroll down</span> or click again to stop. Your transcript is typed directly into the focused text field. Punctuation, capitalization, and formatting are applied automatically.</p>
204209 </div>
205210
206 <!-- Voice commands -->
211 <!-- Voice punctuation & commands -->
207212 <div class="card">
208213 <div class="step-number">&#9733;</div>
209 <h3>Voice commands</h3>
210 <p>Say these phrases and 48co does the rest.</p>
214 <h3>Voice punctuation &amp; commands</h3>
215 <p>Say punctuation naturally. 48co converts it for you.</p>
211216 <div class="commands-list">
212 <div class="cmd-row"><span class="cmd-trigger">&ldquo;refactor this&rdquo;</span> <span class="cmd-action">Pastes refactor prompt</span></div>
213 <div class="cmd-row"><span class="cmd-trigger">&ldquo;explain this&rdquo;</span> <span class="cmd-action">Pastes explain prompt</span></div>
214 <div class="cmd-row"><span class="cmd-trigger">&ldquo;debug this&rdquo;</span> <span class="cmd-action">Pastes debug prompt</span></div>
217 <div class="cmd-row"><span class="cmd-trigger">&ldquo;period&rdquo; / &ldquo;comma&rdquo; / &ldquo;question mark&rdquo;</span> <span class="cmd-action">Inserts punctuation</span></div>
218 <div class="cmd-row"><span class="cmd-trigger">&ldquo;new line&rdquo; / &ldquo;new paragraph&rdquo;</span> <span class="cmd-action">Line breaks</span></div>
219 <div class="cmd-row"><span class="cmd-trigger">&ldquo;open paren&rdquo; / &ldquo;close paren&rdquo;</span> <span class="cmd-action">Brackets</span></div>
220 <div class="cmd-row"><span class="cmd-trigger">&ldquo;thumbs up emoji&rdquo;</span> <span class="cmd-action">Inserts emoji</span></div>
221 <div class="cmd-row"><span class="cmd-trigger">&ldquo;refactor this&rdquo; / &ldquo;debug this&rdquo;</span> <span class="cmd-action">Dev prompts</span></div>
215222 <div class="cmd-row"><span class="cmd-trigger">&ldquo;send it&rdquo;</span> <span class="cmd-action">Submits message</span></div>
216223 <div class="cmd-row"><span class="cmd-trigger">&ldquo;cancel&rdquo;</span> <span class="cmd-action">Clears &amp; resets</span></div>
217224 </div>
218225 </div>
219226
227 <!-- Languages & settings -->
228 <div class="card">
229 <div class="step-number">&#127760;</div>
230 <h3>50+ languages &amp; custom vocabulary</h3>
231 <p>Click the <span class="highlight">48co extension icon</span> in the toolbar to change language, add custom vocabulary, configure text replacements, and switch to the Whisper API for higher accuracy.</p>
232 </div>
233
220234 <!-- Tip: code mode -->
221235 <div class="card">
222236 <div class="step-number">&lt;/&gt;</div>
225239 </div>
226240
227241 <a href="https://claude.ai" target="_blank" class="cta">
228 Open Claude.ai &rarr;
242 Try it on Claude.ai &rarr;
229243 </a>
230244
231245 <div class="footer">
232246
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts