Add Nutq: AI voice dictation PWA for mobile and desktop

Wispr Flow-style voice keyboard app: speak naturally, get polished
ready-to-send text. Installable PWA (iPhone/Android/desktop) with:
- Live dictation via Web Speech API, or Whisper via any
  OpenAI-compatible endpoint
- AI polish with writing modes (message/email/notes/formal/raw)
- 80+ recognition languages with auto-detect, personal dictionary
- Local-only history, Arabic RTL + English UI, light/dark themes
- Offline app shell via service worker, GitHub Pages deploy workflow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzXuVcjCFcBhTQT1wsEANC
This commit is contained in:
Claude 2026-07-11 02:17:54 +00:00
parent 2d9193b0d3
commit 0fe31f0358
No known key found for this signature in database
11 changed files with 1255 additions and 0 deletions

31
.github/workflows/deploy-nutq.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Deploy Nutq to GitHub Pages
on:
push:
branches: [main, claude/mobile-desktop-app-y1g2sm]
paths: ["nutq/**", ".github/workflows/deploy-nutq.yml"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: nutq
- id: deployment
uses: actions/deploy-pages@v4

73
nutq/README.md Normal file
View File

@ -0,0 +1,73 @@
# نُطق (Nutq) — لوحة المفاتيح الصوتية بالذكاء الاصطناعي 🎙️
**تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال.**
تطبيق ويب تقدمي (PWA) احترافي مستوحى من Wispr Flow — يعمل على **iPhone و Android والحاسوب (Windows / Mac / Linux)** من كود واحد، ويُثبَّت كتطبيق حقيقي بأيقونة على الشاشة الرئيسية وسطح المكتب.
![Nutq](icons/icon-192.png)
## المميزات
- 🎙️ **إملاء صوتي فوري** — النص يظهر مباشرة أثناء كلامك (محرك المتصفح المجاني)، أو عبر Whisper API لدقة أعلى.
- ✨ **صقل بالذكاء الاصطناعي** — إزالة كلمات الحشو، تصحيح علامات الترقيم، وإعادة صياغة بأسلوبك.
- 📝 **أنماط كتابة** — رسالة، بريد إلكتروني، ملاحظات، رسمي، أو نص خام بلا تعديل.
- 🌍 **أكثر من 100 لغة** — منها العربية بلهجاتها، مع اكتشاف تلقائي للغة.
- 📖 **قاموس شخصي** — أضف أسماءً ومصطلحات خاصة ليحترمها التطبيق عند الكتابة.
- 🕘 **سجل محلي** — كل إملاءاتك محفوظة على جهازك فقط (لا تُرسل لأي خادم).
- 🌗 **واجهة عربية RTL** كاملة + إنجليزية، مع مظهر فاتح وداكن.
- 📵 **يعمل دون إنترنت** — واجهة التطبيق تعمل offline بعد أول زيارة.
## التشغيل والنشر
### 1) تجربة محلية سريعة
```bash
cd nutq
python3 -m http.server 8080
# افتح http://localhost:8080
```
> ملاحظة: الميكروفون يتطلب HTTPS أو localhost.
### 2) النشر المجاني على GitHub Pages (موصى به)
المستودع يتضمن Workflow جاهزاً (`.github/workflows/deploy-nutq.yml`):
1. من إعدادات المستودع على GitHub: **Settings → Pages → Source: GitHub Actions**.
2. ادفع أي تعديل لمجلد `nutq/` وسيُنشر تلقائياً.
3. سيكون التطبيق على: `https://<اسم-المستخدم>.github.io/<اسم-المستودع>/`
يمكن أيضاً النشر على Netlify أو Vercel أو Cloudflare Pages بسحب مجلد `nutq/` فقط.
### 3) التثبيت كتطبيق
- **iPhone (Safari):** افتح الرابط → زر المشاركة → **إضافة إلى الشاشة الرئيسية**.
- **Android (Chrome):** افتح الرابط → قائمة ⋮ → **تثبيت التطبيق**.
- **الحاسوب (Chrome / Edge):** افتح الرابط → أيقونة التثبيت ⊕ في شريط العنوان → **تثبيت**. يصبح تطبيقاً مستقلاً بنافذته الخاصة.
## إعداد الذكاء الاصطناعي (اختياري لكن موصى به)
بدون مفتاح API يعمل الإملاء الفوري مجاناً عبر محرك المتصفح. لتفعيل **الصقل الذكي** و **Whisper**:
1. افتح **الإعدادات ⚙️** داخل التطبيق.
2. أدخل مفتاح API من أي خدمة متوافقة مع OpenAI:
| الخدمة | Base URL | ملاحظات |
|---|---|---|
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` + `whisper-1` |
| Groq (مجاني) | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` + `whisper-large-v3` |
| OpenRouter | `https://openrouter.ai/api/v1` | نماذج متعددة |
> 🔒 **الخصوصية:** المفتاح والسجل والقاموس تُحفظ في متصفحك فقط (localStorage)، ولا يمر أي شيء عبر خوادم وسيطة — الاتصال مباشر من جهازك إلى مزوّد الخدمة الذي تختاره.
## البنية التقنية
- HTML/CSS/JS خالص بدون أي اعتماديات أو خطوة بناء — سهل الصيانة والنشر في أي مكان.
- `Web Speech API` للإملاء الفوري، و`MediaRecorder` + Whisper للتفريغ عالي الدقة.
- Service Worker للعمل دون اتصال + Web App Manifest للتثبيت.
---
# Nutq — AI Voice Keyboard (English)
A professional Wispr Flowstyle PWA: speak naturally and get polished, ready-to-send text. Installs as a real app on iPhone, Android, and desktop from a single codebase. Live dictation via the free browser speech engine or any OpenAI-compatible Whisper endpoint, AI polishing with writing modes (message / email / notes / formal), 100+ languages with auto-detect, a personal dictionary, local-only history, full RTL Arabic + English UI, and offline support. Serve the `nutq/` folder from any static host (a GitHub Pages workflow is included) and add your API key in Settings to enable AI features.

288
nutq/app.css Normal file
View File

@ -0,0 +1,288 @@
/* ===== Nutq — AI voice keyboard ===== */
:root {
--bg: #FAF6E9;
--bg-soft: #F3EDDA;
--card: #FFFDF4;
--ink: #17151A;
--ink-soft: #5C5850;
--line: rgba(23, 21, 26, .1);
--accent: #6D4FE0;
--accent-soft: #E9DFFF;
--accent-ink: #4A32AC;
--danger: #C43D3D;
--shadow: 0 10px 30px rgba(23, 21, 26, .08);
--radius: 22px;
--serif: "Noto Naskh Arabic", "Amiri", Georgia, "Times New Roman", serif;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, "Helvetica Neue", Arial, sans-serif;
}
[data-theme="dark"] {
--bg: #111014;
--bg-soft: #1A181F;
--card: #1E1C24;
--ink: #F3F0E8;
--ink-soft: #A5A09A;
--line: rgba(243, 240, 232, .12);
--accent: #8F75FF;
--accent-soft: #2C2542;
--accent-ink: #C9BAFF;
--shadow: 0 10px 30px rgba(0, 0, 0, .4);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #111014; --bg-soft: #1A181F; --card: #1E1C24;
--ink: #F3F0E8; --ink-soft: #A5A09A; --line: rgba(243,240,232,.12);
--accent: #8F75FF; --accent-soft: #2C2542; --accent-ink: #C9BAFF;
--shadow: 0 10px 30px rgba(0,0,0,.4);
}
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
min-height: 100dvh;
padding-bottom: calc(190px + env(safe-area-inset-bottom));
transition: background .3s, color .3s;
}
svg { width: 22px; height: 22px; fill: currentColor; }
/* ===== Top bar ===== */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between;
padding: calc(10px + env(safe-area-inset-top)) 18px 10px;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
}
.brand { display: flex; align-items: center; gap: 9px; }
.brand-mark { width: 26px; height: 26px; fill: var(--ink); }
.brand-name { font-family: var(--serif); font-size: 24px; font-weight: 700; }
.topbar-actions { display: flex; gap: 8px; }
.icon-btn {
width: 42px; height: 42px; border-radius: 50%;
border: 1px solid var(--line); background: var(--card); color: var(--ink);
display: grid; place-items: center; cursor: pointer;
transition: transform .15s, background .2s;
}
.icon-btn:active { transform: scale(.92); }
/* ===== Main ===== */
.app { max-width: 680px; margin: 0 auto; padding: 8px 18px 0; }
.hero { text-align: center; margin: 18px 0 22px; }
.hero-title {
font-family: var(--serif);
font-size: clamp(34px, 9vw, 52px);
line-height: 1.15; margin: 0 0 12px; font-weight: 700;
}
.hero-sub { color: var(--ink-soft); font-size: 16px; line-height: 1.65; margin: 0 auto; max-width: 46ch; }
.hero.compact { margin: 6px 0 10px; }
.hero.compact .hero-title { font-size: 24px; }
.hero.compact .hero-sub { display: none; }
/* ===== Mode chips ===== */
.modes {
display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 10px;
scrollbar-width: none; -ms-overflow-style: none;
}
.modes::-webkit-scrollbar { display: none; }
.chip {
flex: 0 0 auto; padding: 9px 18px; border-radius: 999px;
border: 1px solid var(--line); background: var(--card); color: var(--ink);
font-size: 14.5px; font-weight: 600; cursor: pointer; font-family: var(--sans);
transition: background .2s, color .2s, border-color .2s;
}
.chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
/* ===== Language pill ===== */
.lang-pill {
display: inline-flex; align-items: center; gap: 7px;
margin: 2px 0 14px; padding: 8px 14px; border-radius: 999px;
border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent-ink);
font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--sans);
}
.lang-pill svg { width: 17px; height: 17px; }
/* ===== Cards ===== */
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px; margin-bottom: 14px;
box-shadow: var(--shadow);
}
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-title { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--ink-soft); }
.badge {
background: var(--accent-soft); color: var(--accent-ink);
padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700;
}
.live-dot { width: 10px; height: 10px; border-radius: 50%; background: #E0483F; animation: blink 1.1s infinite; }
@keyframes blink { 50% { opacity: .25; } }
.transcript, .result {
min-height: 54px; font-size: 17px; line-height: 1.75; outline: none;
white-space: pre-wrap; word-break: break-word;
}
.transcript:empty::before, .result:empty::before {
content: attr(data-placeholder); color: var(--ink-soft); opacity: .6;
}
.transcript .interim { color: var(--ink-soft); opacity: .75; }
.result-card { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
.card-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 10px 16px; border-radius: 14px; cursor: pointer;
border: 1px solid var(--line); background: var(--bg-soft); color: var(--ink);
font-size: 14px; font-weight: 700; font-family: var(--sans);
transition: transform .15s, opacity .2s;
}
.btn:active { transform: scale(.96); }
.btn svg { width: 17px; height: 17px; }
.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.btn.danger { background: transparent; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.btn.small { padding: 7px 13px; font-size: 13px; }
.btn.wide { width: 100%; justify-content: center; padding: 14px; font-size: 16px; margin-top: 6px; }
.status { text-align: center; color: var(--ink-soft); font-size: 14px; min-height: 22px; margin: 4px 0; }
.status.error { color: var(--danger); font-weight: 600; }
/* ===== Features ===== */
.features { display: grid; gap: 12px; margin-top: 10px; }
.feature {
display: flex; gap: 13px; align-items: flex-start;
background: var(--card); border: 1px solid var(--line);
border-radius: 18px; padding: 15px 16px;
}
.f-ico { font-size: 22px; line-height: 1.3; }
.feature b { font-size: 15.5px; }
.feature p { margin: 3px 0 0; color: var(--ink-soft); font-size: 14px; line-height: 1.6; }
/* ===== Mic dock ===== */
.mic-dock {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 14px 0 calc(18px + env(safe-area-inset-bottom));
background: linear-gradient(to top, var(--bg) 62%, transparent);
pointer-events: none;
}
.mic-dock > * { pointer-events: auto; }
.mic-btn {
position: relative; width: 84px; height: 84px; border-radius: 50%;
border: none; cursor: pointer;
background: var(--ink); color: var(--bg);
display: grid; place-items: center;
box-shadow: 0 14px 34px rgba(0,0,0,.25);
transition: transform .15s, background .25s;
}
.mic-btn:active { transform: scale(.93); }
.mic-btn svg { width: 34px; height: 34px; }
.mic-btn.rec { background: #E0483F; color: #fff; }
.mic-ring {
position: absolute; inset: 0; border-radius: 50%;
border: 2px solid #E0483F; opacity: 0; pointer-events: none;
}
.mic-btn.rec .mic-ring { animation: ripple 1.8s infinite; }
.mic-btn.rec .r2 { animation-delay: .9s; }
@keyframes ripple {
0% { transform: scale(1); opacity: .7; }
100% { transform: scale(1.85); opacity: 0; }
}
.mic-hint { font-size: 13.5px; color: var(--ink-soft); font-weight: 600; }
.timer { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px; color: #E0483F; }
.waveform { display: flex; align-items: center; gap: 3px; height: 34px; }
.waveform span {
width: 4px; height: 8px; border-radius: 3px; background: var(--accent);
animation: wave 1s ease-in-out infinite;
}
.waveform span:nth-child(3n) { animation-delay: .15s; }
.waveform span:nth-child(3n+1){ animation-delay: .35s; }
.waveform span:nth-child(4n) { animation-delay: .5s; }
.waveform span:nth-child(5n) { animation-delay: .7s; }
@keyframes wave { 0%,100% { height: 7px; } 50% { height: 30px; } }
/* ===== Sheets ===== */
.sheet-backdrop {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.45);
display: flex; align-items: flex-end; justify-content: center;
animation: fadeIn .2s;
}
@keyframes fadeIn { from { opacity: 0; } }
.sheet {
background: var(--bg); width: 100%; max-width: 680px;
max-height: 88dvh; overflow-y: auto;
border-radius: 26px 26px 0 0; padding: 10px 20px calc(26px + env(safe-area-inset-bottom));
animation: slideUp .28s cubic-bezier(.2,.9,.3,1);
}
@keyframes slideUp { from { transform: translateY(60px); opacity: .4; } }
.sheet-grab { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 14px; }
.sheet h2 { font-family: var(--serif); font-size: 24px; margin: 4px 0 16px; }
.sheet-title-row { display: flex; align-items: center; justify-content: space-between; }
.sheet-title-row h2 { margin: 4px 0 12px; }
.search {
width: 100%; padding: 12px 16px; border-radius: 14px;
border: 1px solid var(--line); background: var(--card); color: var(--ink);
font-size: 16px; margin-bottom: 12px; font-family: var(--sans);
}
.lang-list { display: flex; flex-direction: column; }
.lang-item {
display: flex; align-items: center; justify-content: space-between;
padding: 13px 10px; border-radius: 14px; cursor: pointer;
font-size: 16px; border: none; background: none; color: var(--ink);
text-align: start; font-family: var(--sans); width: 100%;
}
.lang-item .flag { margin-inline-end: 10px; }
.lang-item.selected { background: var(--accent-soft); color: var(--accent-ink); font-weight: 700; }
.lang-item.selected::after { content: "✓"; font-weight: 900; }
/* ===== History ===== */
.history-list { display: flex; flex-direction: column; gap: 10px; }
.history-item {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 14px;
}
.history-item .h-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-soft); margin-bottom: 7px; }
.history-item .h-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
.history-item .h-actions { display: flex; gap: 6px; margin-top: 10px; }
.empty { text-align: center; color: var(--ink-soft); padding: 34px 0; font-size: 15px; }
/* ===== Settings fields ===== */
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 15px; }
.field > span { font-size: 13.5px; font-weight: 700; color: var(--ink-soft); }
.field input[type="text"], .field input[type="password"], .field input[type="url"],
.field select, .field textarea {
padding: 12px 14px; border-radius: 13px; border: 1px solid var(--line);
background: var(--card); color: var(--ink); font-size: 16px; font-family: var(--sans);
width: 100%;
}
.field.row { flex-direction: row; align-items: center; justify-content: space-between; }
.field.row input[type="checkbox"] { width: 22px; height: 22px; accent-color: var(--accent); }
.field-group {
border: 1px dashed var(--line); border-radius: 16px;
padding: 14px; margin-bottom: 15px;
}
.fg-title { font-size: 13.5px; font-weight: 800; margin-bottom: 12px; color: var(--accent-ink); }
.hint { font-size: 12.5px; color: var(--ink-soft); line-height: 1.6; margin: 4px 0 0; }
.hint.center { text-align: center; margin-top: 14px; }
/* ===== Toast ===== */
.toast {
position: fixed; bottom: calc(200px + env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%); z-index: 60;
background: var(--ink); color: var(--bg);
padding: 11px 20px; border-radius: 999px; font-size: 14.5px; font-weight: 700;
box-shadow: var(--shadow); animation: toastIn .25s;
max-width: 88vw; text-align: center;
}
@keyframes toastIn { from { transform: translate(-50%, 14px); opacity: 0; } }
@media (min-width: 720px) {
.hero-title { font-size: 56px; }
body { padding-bottom: 210px; }
}

586
nutq/app.js Normal file
View File

@ -0,0 +1,586 @@
/* ===== Nutq AI voice keyboard =====
* Speak naturally get polished, ready-to-send text.
* Engines: Web Speech API (free, live) or any OpenAI-compatible
* Whisper endpoint. Polishing via OpenAI-compatible chat API.
* Everything is stored locally on the device.
*/
"use strict";
/* ---------- i18n ---------- */
const I18N = {
ar: {
appName: "نُطق",
heroTitle: "إملاء صوتي<br>بلا أخطاء",
heroSub: "تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.",
modeMessage: "رسالة", modeEmail: "بريد إلكتروني", modeNotes: "ملاحظات", modeFormal: "رسمي", modeRaw: "نص خام",
rawTranscript: "النص المسموع", polished: "✨ النص المصقول",
transcriptPh: "سيظهر كلامك هنا…",
copy: "نسخ", copied: "تم النسخ ✓", share: "مشاركة", repolish: "إعادة الصقل",
tapToTalk: "اضغط وتكلّم", tapToStop: "اضغط للإيقاف",
listening: "أستمع إليك…", transcribing: "جارٍ تفريغ الصوت…", polishing: "جارٍ صقل النص…",
history: "السجل", clearAll: "مسح الكل", settings: "الإعدادات",
uiLang: "لغة الواجهة", engine: "محرك التعرّف على الصوت",
engineAuto: "تلقائي (الأفضل المتاح)", engineWeb: "المتصفح (مجاني، فوري)", engineWhisper: "Whisper عبر API (أدق)",
aiSection: "إعدادات الذكاء الاصطناعي (للصقل و Whisper)",
apiKey: "مفتاح API", baseUrl: "عنوان الخدمة (Base URL)", model: "نموذج الصقل", whisperModel: "نموذج التفريغ الصوتي",
apiHint: "يدعم أي خدمة متوافقة مع OpenAI: OpenAI، Groq (مجاني)، OpenRouter وغيرها. المفتاح يُحفظ على جهازك فقط.",
dictionary: "القاموس الشخصي (أسماء ومصطلحات، افصل بينها بفواصل)",
dictPh: "مثال: جلال، شركة لُمى، GPT-SoVITS",
autoPolish: "صقل تلقائي بعد التوقف",
theme: "المظهر", themeAuto: "تلقائي", themeLight: "فاتح", themeDark: "داكن",
save: "حفظ", saved: "تم الحفظ ✓",
searchLang: "ابحث عن لغة…", autoDetect: "اكتشاف تلقائي",
f1t: "يعمل مع كل التطبيقات", f1d: "أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.",
f2t: "صقل بالذكاء الاصطناعي", f2d: "بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.",
f3t: "أكثر من 100 لغة", f3d: "العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.",
f4t: "قاموسك الشخصي", f4d: "أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.",
errNoMic: "تعذّر الوصول إلى الميكروفون. تحقّق من الأذونات.",
errNoSpeech: "المتصفح لا يدعم التعرّف على الصوت. أضف مفتاح API في الإعدادات لاستخدام Whisper.",
errNoKey: "أضف مفتاح API في الإعدادات أولاً لاستخدام هذه الميزة.",
errApi: "خطأ من الخدمة: ", errNet: "تعذّر الاتصال بالخدمة. تحقّق من الإنترنت.",
noHistory: "لا يوجد شيء في السجل بعد.", confirmClear: "هل تريد مسح كل السجل؟",
deleted: "تم الحذف", noPolishKey: "أُبقي النص كما هو — أضف مفتاح API في الإعدادات لتفعيل الصقل الذكي.",
installHint: "لتثبيت التطبيق: شارك ← أضف إلى الشاشة الرئيسية",
},
en: {
appName: "Nutq",
heroTitle: "Dictation<br>without the mistakes",
heroSub: "Talk naturally — Nutq turns your speech into polished, ready-to-send text, in any app and 100+ languages.",
modeMessage: "Message", modeEmail: "Email", modeNotes: "Notes", modeFormal: "Formal", modeRaw: "Raw",
rawTranscript: "Raw transcript", polished: "✨ Polished text",
transcriptPh: "Your speech will appear here…",
copy: "Copy", copied: "Copied ✓", share: "Share", repolish: "Re-polish",
tapToTalk: "Tap & talk", tapToStop: "Tap to stop",
listening: "Listening…", transcribing: "Transcribing…", polishing: "Polishing…",
history: "History", clearAll: "Clear all", settings: "Settings",
uiLang: "Interface language", engine: "Speech engine",
engineAuto: "Auto (best available)", engineWeb: "Browser (free, live)", engineWhisper: "Whisper via API (accurate)",
aiSection: "AI settings (polish & Whisper)",
apiKey: "API key", baseUrl: "Base URL", model: "Polish model", whisperModel: "Transcription model",
apiHint: "Works with any OpenAI-compatible service: OpenAI, Groq (free), OpenRouter… Your key never leaves this device.",
dictionary: "Personal dictionary (names & terms, comma-separated)",
dictPh: "e.g. Jalal, Luma Inc, GPT-SoVITS",
autoPolish: "Auto-polish after stopping",
theme: "Theme", themeAuto: "Auto", themeLight: "Light", themeDark: "Dark",
save: "Save", saved: "Saved ✓",
searchLang: "Search languages…", autoDetect: "Auto-detect",
f1t: "Works with every app", f1d: "Dictate, then paste into WhatsApp, email, or anywhere else.",
f2t: "AI-polished output", f2d: "No filler words, no typos — your voice, cleaned up and ready to send.",
f3t: "100+ languages", f3d: "Arabic, English, French and more — with auto-detect.",
f4t: "Personal dictionary", f4d: "Add custom names and terms so Nutq learns your lingo.",
errNoMic: "Microphone access failed. Check permissions.",
errNoSpeech: "This browser has no speech recognition. Add an API key in Settings to use Whisper.",
errNoKey: "Add an API key in Settings first.",
errApi: "Service error: ", errNet: "Could not reach the service. Check your connection.",
noHistory: "Nothing here yet.", confirmClear: "Clear all history?",
deleted: "Deleted", noPolishKey: "Kept raw text — add an API key in Settings to enable AI polish.",
installHint: "To install: Share → Add to Home Screen",
},
};
/* ---------- Languages (speech recognition targets) ---------- */
const LANGS = [
["auto", "", ""],
["ar-SA", "🇸🇦", "العربية"], ["ar-EG", "🇪🇬", "العربية (مصر)"], ["ar-MA", "🇲🇦", "العربية (المغرب)"],
["en-US", "🇺🇸", "English (US)"], ["en-GB", "🇬🇧", "English (UK)"], ["en-AU", "🇦🇺", "English (AU)"], ["en-IN", "🇮🇳", "English (India)"],
["fr-FR", "🇫🇷", "Français"], ["fr-CA", "🇨🇦", "Français (Canada)"],
["es-ES", "🇪🇸", "Español"], ["es-MX", "🇲🇽", "Español (México)"], ["es-AR", "🇦🇷", "Español (Argentina)"],
["de-DE", "🇩🇪", "Deutsch"], ["it-IT", "🇮🇹", "Italiano"],
["pt-BR", "🇧🇷", "Português (Brasil)"], ["pt-PT", "🇵🇹", "Português"],
["ru-RU", "🇷🇺", "Русский"], ["uk-UA", "🇺🇦", "Українська"],
["tr-TR", "🇹🇷", "Türkçe"], ["fa-IR", "🇮🇷", "فارسی"], ["ur-PK", "🇵🇰", "اردو"],
["hi-IN", "🇮🇳", "हिन्दी"], ["bn-BD", "🇧🇩", "বাংলা"], ["ta-IN", "🇮🇳", "தமிழ்"], ["te-IN", "🇮🇳", "తెలుగు"],
["ja-JP", "🇯🇵", "日本語"], ["ko-KR", "🇰🇷", "한국어"],
["zh-CN", "🇨🇳", "中文 (简体)"], ["zh-TW", "🇹🇼", "中文 (繁體)"], ["zh-HK", "🇭🇰", "粵語 (香港)"],
["id-ID", "🇮🇩", "Bahasa Indonesia"], ["ms-MY", "🇲🇾", "Bahasa Melayu"],
["th-TH", "🇹🇭", "ไทย"], ["vi-VN", "🇻🇳", "Tiếng Việt"], ["fil-PH", "🇵🇭", "Filipino"],
["nl-NL", "🇳🇱", "Nederlands"], ["sv-SE", "🇸🇪", "Svenska"], ["nb-NO", "🇳🇴", "Norsk"],
["da-DK", "🇩🇰", "Dansk"], ["fi-FI", "🇫🇮", "Suomi"], ["is-IS", "🇮🇸", "Íslenska"],
["pl-PL", "🇵🇱", "Polski"], ["cs-CZ", "🇨🇿", "Čeština"], ["sk-SK", "🇸🇰", "Slovenčina"],
["hu-HU", "🇭🇺", "Magyar"], ["ro-RO", "🇷🇴", "Română"], ["bg-BG", "🇧🇬", "Български"],
["el-GR", "🇬🇷", "Ελληνικά"], ["sr-RS", "🇷🇸", "Српски"], ["hr-HR", "🇭🇷", "Hrvatski"],
["sl-SI", "🇸🇮", "Slovenščina"], ["lt-LT", "🇱🇹", "Lietuvių"], ["lv-LV", "🇱🇻", "Latviešu"], ["et-EE", "🇪🇪", "Eesti"],
["he-IL", "🇮🇱", "עברית"], ["am-ET", "🇪🇹", "አማርኛ"], ["sw-KE", "🇰🇪", "Kiswahili"],
["af-ZA", "🇿🇦", "Afrikaans"], ["zu-ZA", "🇿🇦", "isiZulu"],
["ka-GE", "🇬🇪", "ქართული"], ["hy-AM", "🇦🇲", "Հայերեն"], ["az-AZ", "🇦🇿", "Azərbaycanca"],
["kk-KZ", "🇰🇿", "Қазақша"], ["uz-UZ", "🇺🇿", "Oʻzbekcha"],
["ne-NP", "🇳🇵", "नेपाली"], ["si-LK", "🇱🇰", "සිංහල"], ["km-KH", "🇰🇭", "ខ្មែរ"], ["lo-LA", "🇱🇦", "ລາວ"],
["my-MM", "🇲🇲", "မြန်မာ"], ["mn-MN", "🇲🇳", "Монгол"],
["ca-ES", "🏴", "Català"], ["eu-ES", "🏴", "Euskara"], ["gl-ES", "🏴", "Galego"],
["mr-IN", "🇮🇳", "मराठी"], ["gu-IN", "🇮🇳", "ગુજરાતી"], ["kn-IN", "🇮🇳", "ಕನ್ನಡ"], ["ml-IN", "🇮🇳", "മലയാളം"], ["pa-IN", "🇮🇳", "ਪੰਜਾਬੀ"],
];
/* ---------- Polish prompts ---------- */
const MODE_PROMPTS = {
message: "Rewrite the dictated text as a natural, friendly chat message. Keep it short and casual, as if sent on WhatsApp or iMessage.",
email: "Rewrite the dictated text as a clear, well-structured email body. Add a suitable greeting and sign-off placeholder if appropriate. Use short paragraphs and bullet points where they help.",
notes: "Rewrite the dictated text as concise personal notes: short bullet points capturing every idea, no fluff.",
formal: "Rewrite the dictated text in polished, formal, professional language suitable for official correspondence.",
raw: null,
};
/* ---------- State ---------- */
const DEFAULTS = {
uiLang: (navigator.language || "ar").startsWith("ar") ? "ar" : "en",
lang: "auto",
mode: "message",
engine: "auto",
apiKey: "",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini",
whisperModel: "whisper-1",
dictionary: "",
autoPolish: true,
theme: "auto",
};
let S = loadSettings();
let history = loadJSON("nutq.history", []);
let recState = "idle"; // idle | listening | transcribing | polishing
let recognition = null, mediaRecorder = null, mediaStream = null, chunks = [];
let finalText = "", timerInt = null, timerStart = 0;
function loadSettings() {
return Object.assign({}, DEFAULTS, loadJSON("nutq.settings", {}));
}
function loadJSON(k, fb) {
try { return JSON.parse(localStorage.getItem(k)) ?? fb; } catch { return fb; }
}
function saveJSON(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
/* ---------- DOM helpers ---------- */
const $ = (id) => document.getElementById(id);
const t = (key) => (I18N[S.uiLang] || I18N.ar)[key] || key;
function applyI18n() {
document.documentElement.lang = S.uiLang;
document.documentElement.dir = S.uiLang === "ar" ? "rtl" : "ltr";
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.dataset.i18n;
if (key === "heroTitle") el.innerHTML = t(key);
else el.textContent = t(key);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
const v = t(el.dataset.i18nPlaceholder);
if ("placeholder" in el) el.placeholder = v;
else el.dataset.placeholder = v;
});
$("transcript").dataset.placeholder = t("transcriptPh");
updateLangLabel();
}
function applyTheme() {
const root = document.documentElement;
if (S.theme === "auto") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", S.theme);
}
function toast(msg, ms = 2200) {
const el = $("toast");
el.textContent = msg;
el.hidden = false;
clearTimeout(el._t);
el._t = setTimeout(() => (el.hidden = true), ms);
}
function setStatus(msg, isErr = false) {
const el = $("status");
el.textContent = msg || "";
el.classList.toggle("error", isErr);
}
function updateLangLabel() {
const item = LANGS.find(([code]) => code === S.lang);
$("langLabel").textContent =
!item || item[0] === "auto" ? t("autoDetect") : `${item[1]} ${item[2]}`;
}
/* ---------- Recording UI ---------- */
function setRecUI(on) {
$("btnMic").classList.toggle("rec", on);
$("btnMic").querySelector(".mic-ico").hidden = on;
$("btnMic").querySelector(".stop-ico").hidden = !on;
$("waveform").hidden = !on;
$("timer").hidden = !on;
$("liveDot").hidden = !on;
$("micHint").textContent = on ? t("tapToStop") : t("tapToTalk");
$("hero").classList.toggle("compact", on || !!finalText);
$("features").hidden = on || !!finalText || !!$("result").textContent;
if (on) {
$("transcriptCard").hidden = false;
timerStart = Date.now();
timerInt = setInterval(() => {
const s = Math.floor((Date.now() - timerStart) / 1000);
$("timer").textContent =
String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
}, 500);
} else {
clearInterval(timerInt);
$("timer").textContent = "00:00";
}
}
function renderTranscript(interim = "") {
const el = $("transcript");
el.innerHTML = "";
el.appendChild(document.createTextNode(finalText));
if (interim) {
const span = document.createElement("span");
span.className = "interim";
span.textContent = (finalText ? " " : "") + interim;
el.appendChild(span);
}
}
/* ---------- Engine selection ---------- */
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
function pickEngine() {
if (S.engine === "webspeech") return SR ? "webspeech" : null;
if (S.engine === "whisper") return S.apiKey ? "whisper" : null;
// auto: prefer live browser engine, fall back to whisper
if (SR) return "webspeech";
if (S.apiKey) return "whisper";
return null;
}
/* ---------- Web Speech engine ---------- */
function startWebSpeech() {
recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
if (S.lang !== "auto") recognition.lang = S.lang;
else if (S.uiLang === "ar") recognition.lang = "ar-SA";
recognition.onresult = (e) => {
let interim = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const txt = e.results[i][0].transcript;
if (e.results[i].isFinal) finalText = (finalText + " " + txt).trim();
else interim += txt;
}
renderTranscript(interim);
};
recognition.onerror = (e) => {
if (e.error === "not-allowed" || e.error === "service-not-allowed") {
stopDictation(false);
setStatus(t("errNoMic"), true);
}
// "no-speech" and "aborted" are benign; onend handles restart
};
recognition.onend = () => {
// Browsers stop after silence — keep listening until user taps stop.
if (recState === "listening") {
try { recognition.start(); } catch { /* already started */ }
}
};
recognition.start();
}
/* ---------- Whisper engine ---------- */
async function startWhisperRecording() {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"]
.find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || "";
mediaRecorder = new MediaRecorder(mediaStream, mime ? { mimeType: mime } : undefined);
mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
mediaRecorder.start(1000);
}
async function transcribeWithWhisper() {
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" });
const ext = (mediaRecorder.mimeType || "audio/webm").includes("mp4") ? "mp4"
: (mediaRecorder.mimeType || "").includes("ogg") ? "ogg" : "webm";
const form = new FormData();
form.append("file", blob, "audio." + ext);
form.append("model", S.whisperModel || "whisper-1");
if (S.lang !== "auto") form.append("language", S.lang.split("-")[0]);
if (S.dictionary.trim()) form.append("prompt", S.dictionary.trim());
const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/audio/transcriptions", {
method: "POST",
headers: { Authorization: "Bearer " + S.apiKey },
body: form,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(t("errApi") + res.status + " " + body.slice(0, 200));
}
const data = await res.json();
return (data.text || "").trim();
}
/* ---------- AI polish ---------- */
async function polish(text) {
const sysMode = MODE_PROMPTS[S.mode];
if (!sysMode) return text; // raw mode
if (!S.apiKey) {
toast(t("noPolishKey"), 3200);
return text;
}
const dict = S.dictionary.trim()
? `\nPersonal dictionary (respect these exact spellings): ${S.dictionary.trim()}`
: "";
const sys =
"You are Nutq, an AI dictation cleanup engine. The user dictated text by voice. " +
"Fix transcription artifacts, remove filler words, add correct punctuation and formatting, " +
"and keep the user's own voice and meaning. Always answer in the SAME language as the dictated text. " +
"Output ONLY the rewritten text, no explanations." +
"\nStyle: " + sysMode + dict;
const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + S.apiKey,
},
body: JSON.stringify({
model: S.model || "gpt-4o-mini",
temperature: 0.3,
messages: [
{ role: "system", content: sys },
{ role: "user", content: text },
],
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(t("errApi") + res.status + " " + body.slice(0, 200));
}
const data = await res.json();
return (data.choices?.[0]?.message?.content || "").trim() || text;
}
/* ---------- Dictation flow ---------- */
async function startDictation() {
finalText = "";
$("result").textContent = "";
$("resultCard").hidden = true;
renderTranscript();
const engine = pickEngine();
if (!engine) {
setStatus(SR ? t("errNoKey") : t("errNoSpeech"), true);
openSheet("settingsSheet");
return;
}
try {
if (engine === "webspeech") startWebSpeech();
else await startWhisperRecording();
recState = "listening";
setRecUI(true);
setStatus(t("listening"));
} catch (err) {
console.error(err);
setStatus(t("errNoMic"), true);
}
}
async function stopDictation(process = true) {
const wasEngine = recognition ? "webspeech" : "whisper";
recState = "idle";
if (recognition) { try { recognition.stop(); } catch {} recognition = null; }
if (mediaRecorder && mediaRecorder.state !== "inactive") {
await new Promise((r) => { mediaRecorder.onstop = r; mediaRecorder.stop(); });
}
if (mediaStream) { mediaStream.getTracks().forEach((tr) => tr.stop()); mediaStream = null; }
setRecUI(false);
if (!process) return;
try {
if (wasEngine === "whisper" && chunks.length) {
recState = "transcribing";
setStatus(t("transcribing"));
finalText = await transcribeWithWhisper();
renderTranscript();
}
finalText = $("transcript").textContent.trim();
if (!finalText) { setStatus(""); return; }
if (S.autoPolish) await runPolish();
else setStatus("");
} catch (err) {
console.error(err);
setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true);
} finally {
recState = "idle";
}
}
async function runPolish() {
const text = $("transcript").textContent.trim();
if (!text) return;
recState = "polishing";
setStatus(t("polishing"));
try {
const out = await polish(text);
$("result").textContent = out;
$("resultCard").hidden = false;
$("resultMode").textContent = t("mode" + S.mode[0].toUpperCase() + S.mode.slice(1));
setStatus("");
addHistory({ raw: text, polished: out, mode: S.mode, ts: Date.now() });
$("result").scrollIntoView({ behavior: "smooth", block: "center" });
} catch (err) {
console.error(err);
setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true);
} finally {
recState = "idle";
}
}
/* ---------- History ---------- */
function addHistory(item) {
history.unshift(item);
history = history.slice(0, 200);
saveJSON("nutq.history", history);
}
function renderHistory() {
const list = $("historyList");
list.innerHTML = "";
if (!history.length) {
list.innerHTML = `<div class="empty">${t("noHistory")}</div>`;
return;
}
history.forEach((item, i) => {
const div = document.createElement("div");
div.className = "history-item";
const date = new Date(item.ts).toLocaleString(S.uiLang === "ar" ? "ar" : "en", {
dateStyle: "medium", timeStyle: "short",
});
div.innerHTML = `
<div class="h-meta"><span>${date}</span><span class="badge">${t("mode" + item.mode[0].toUpperCase() + item.mode.slice(1))}</span></div>
<div class="h-text"></div>
<div class="h-actions">
<button class="btn small primary h-copy">${t("copy")}</button>
<button class="btn small danger h-del"></button>
</div>`;
div.querySelector(".h-text").textContent = item.polished || item.raw;
div.querySelector(".h-copy").onclick = () => copyText(item.polished || item.raw);
div.querySelector(".h-del").onclick = () => {
history.splice(i, 1);
saveJSON("nutq.history", history);
renderHistory();
toast(t("deleted"));
};
list.appendChild(div);
});
}
/* ---------- Clipboard / share ---------- */
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
toast(t("copied"));
}
/* ---------- Sheets ---------- */
function openSheet(id) { $(id).hidden = false; document.body.style.overflow = "hidden"; }
function closeSheet(id) { $(id).hidden = true; document.body.style.overflow = ""; }
["langSheet", "historySheet", "settingsSheet"].forEach((id) => {
$(id).addEventListener("click", (e) => { if (e.target === $(id)) closeSheet(id); });
});
/* ---------- Language list ---------- */
function renderLangs(filter = "") {
const list = $("langList");
list.innerHTML = "";
const q = filter.trim().toLowerCase();
LANGS.filter(([code, , name]) =>
!q || name.toLowerCase().includes(q) || code.toLowerCase().includes(q)
).forEach(([code, flag, name]) => {
const btn = document.createElement("button");
btn.className = "lang-item" + (S.lang === code ? " selected" : "");
btn.innerHTML = code === "auto"
? `<span><span class="flag">🌐</span>${t("autoDetect")}</span>`
: `<span><span class="flag">${flag}</span>${name}</span>`;
btn.onclick = () => {
S.lang = code;
saveJSON("nutq.settings", S);
updateLangLabel();
closeSheet("langSheet");
};
list.appendChild(btn);
});
}
/* ---------- Settings ---------- */
function fillSettingsForm() {
$("setUiLang").value = S.uiLang;
$("setEngine").value = S.engine;
$("setApiKey").value = S.apiKey;
$("setBaseUrl").value = S.baseUrl;
$("setModel").value = S.model;
$("setWhisperModel").value = S.whisperModel;
$("setDictionary").value = S.dictionary;
$("setAutoPolish").checked = S.autoPolish;
$("setTheme").value = S.theme;
}
function saveSettingsForm() {
S.uiLang = $("setUiLang").value;
S.engine = $("setEngine").value;
S.apiKey = $("setApiKey").value.trim();
S.baseUrl = $("setBaseUrl").value.trim() || DEFAULTS.baseUrl;
S.model = $("setModel").value.trim() || DEFAULTS.model;
S.whisperModel = $("setWhisperModel").value.trim() || DEFAULTS.whisperModel;
S.dictionary = $("setDictionary").value;
S.autoPolish = $("setAutoPolish").checked;
S.theme = $("setTheme").value;
saveJSON("nutq.settings", S);
applyI18n();
applyTheme();
closeSheet("settingsSheet");
toast(t("saved"));
}
/* ---------- Wire up ---------- */
$("btnMic").onclick = () => {
if (recState === "listening") stopDictation();
else if (recState === "idle") startDictation();
};
$("btnCopy").onclick = () => copyText($("result").textContent);
$("btnShare").onclick = async () => {
const text = $("result").textContent;
if (navigator.share) { try { await navigator.share({ text }); } catch {} }
else copyText(text);
};
$("btnRepolish").onclick = () => { if (recState === "idle") runPolish(); };
$("btnLang").onclick = () => { renderLangs(); openSheet("langSheet"); $("langSearch").value = ""; };
$("langSearch").oninput = (e) => renderLangs(e.target.value);
$("btnHistory").onclick = () => { renderHistory(); openSheet("historySheet"); };
$("btnClearHistory").onclick = () => {
if (confirm(t("confirmClear"))) {
history = [];
saveJSON("nutq.history", history);
renderHistory();
}
};
$("btnSettings").onclick = () => { fillSettingsForm(); openSheet("settingsSheet"); };
$("btnSaveSettings").onclick = saveSettingsForm;
document.querySelectorAll(".chip").forEach((chip) => {
chip.onclick = () => {
document.querySelectorAll(".chip").forEach((c) => c.classList.remove("active"));
chip.classList.add("active");
S.mode = chip.dataset.mode;
saveJSON("nutq.settings", S);
};
});
// restore active mode chip
document.querySelectorAll(".chip").forEach((c) =>
c.classList.toggle("active", c.dataset.mode === S.mode));
/* ---------- Init ---------- */
applyI18n();
applyTheme();
$("verInfo").textContent = "Nutq v1.0";
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(() => {});
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

BIN
nutq/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
nutq/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

216
nutq/index.html Normal file
View File

@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي</title>
<meta name="description" content="تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال. إملاء صوتي بالذكاء الاصطناعي يدعم أكثر من 100 لغة.">
<meta name="theme-color" content="#FAF6E9" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#111014" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="نُطق">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="stylesheet" href="app.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="9" width="2.6" height="6" rx="1.3"/>
<rect x="8" y="5" width="2.6" height="14" rx="1.3"/>
<rect x="13" y="8" width="2.6" height="8" rx="1.3"/>
<rect x="18" y="10" width="2.6" height="4" rx="1.3"/>
</svg>
<span class="brand-name" data-i18n="appName">نُطق</span>
</div>
<div class="topbar-actions">
<button class="icon-btn" id="btnHistory" aria-label="السجل" title="السجل">
<svg viewBox="0 0 24 24"><path d="M12 4a8 8 0 1 1-7.7 10.1.9.9 0 0 1 1.74-.46A6.2 6.2 0 1 0 5.8 12H8l-3.2 3.6L1.6 12h2.4A8 8 0 0 1 12 4Zm0 3.6c.5 0 .9.4.9.9v3.1l2.6 1.5a.9.9 0 0 1-.9 1.56l-3-1.74a.9.9 0 0 1-.5-.82V8.5c0-.5.4-.9.9-.9Z"/></svg>
</button>
<button class="icon-btn" id="btnSettings" aria-label="الإعدادات" title="الإعدادات">
<svg viewBox="0 0 24 24"><path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Zm0 1.8a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-1.1-7h2.2c.5 0 .9.35 1 .84l.32 1.6c.55.22 1.07.52 1.54.88l1.55-.53a1 1 0 0 1 1.2.45l1.1 1.9a1 1 0 0 1-.2 1.27l-1.23 1.07a6.9 6.9 0 0 1 0 1.78l1.23 1.07a1 1 0 0 1 .2 1.27l-1.1 1.9a1 1 0 0 1-1.2.45l-1.55-.53c-.47.36-.99.66-1.54.88l-.32 1.6a1 1 0 0 1-1 .84h-2.2a1 1 0 0 1-1-.84l-.32-1.6a6.9 6.9 0 0 1-1.54-.88l-1.55.53a1 1 0 0 1-1.2-.45l-1.1-1.9a1 1 0 0 1 .2-1.27l1.23-1.07a6.9 6.9 0 0 1 0-1.78L4.29 9.41a1 1 0 0 1-.2-1.27l1.1-1.9a1 1 0 0 1 1.2-.45l1.55.53c.47-.36.99-.66 1.54-.88l.32-1.6a1 1 0 0 1 1-.84Z"/></svg>
</button>
</div>
</header>
<main class="app">
<!-- Hero -->
<section class="hero" id="hero">
<h1 class="hero-title" data-i18n="heroTitle">إملاء صوتي<br>بلا أخطاء</h1>
<p class="hero-sub" data-i18n="heroSub">تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.</p>
</section>
<!-- Mode chips -->
<section class="modes" id="modes" role="tablist" aria-label="نمط الكتابة">
<button class="chip active" data-mode="message" data-i18n="modeMessage">رسالة</button>
<button class="chip" data-mode="email" data-i18n="modeEmail">بريد إلكتروني</button>
<button class="chip" data-mode="notes" data-i18n="modeNotes">ملاحظات</button>
<button class="chip" data-mode="formal" data-i18n="modeFormal">رسمي</button>
<button class="chip" data-mode="raw" data-i18n="modeRaw">نص خام</button>
</section>
<!-- Language pill -->
<button class="lang-pill" id="btnLang">
<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 1.8c-.9 0-2 1.5-2.6 4.2h5.2C14 5.3 12.9 3.8 12 3.8Zm-4.4 4.2c.3-1.5.8-2.8 1.4-3.7A8.2 8.2 0 0 0 4.9 8h2.7Zm8.8 0h2.7a8.2 8.2 0 0 0-4.1-3.7c.6.9 1.1 2.2 1.4 3.7ZM4.1 13.2h3.1a22 22 0 0 1 0-3.4H4.1a8.3 8.3 0 0 0 0 3.4Zm4.9 0h6a20 20 0 0 0 0-3.4H9a20 20 0 0 0 0 3.4Zm7.8 0h3.1a8.3 8.3 0 0 0 0-3.4h-3.1a22 22 0 0 1 0 3.4ZM7.6 15h-2.7a8.2 8.2 0 0 0 4.1 3.7c-.6-.9-1.1-2.2-1.4-3.7Zm4.4 5.2c.9 0 2-1.5 2.6-4.2H9.4c.6 2.7 1.7 4.2 2.6 4.2Zm4.4-5.2c-.3 1.5-.8 2.8-1.4 3.7A8.2 8.2 0 0 0 19.1 15h-2.7Z"/></svg>
<span id="langLabel">اكتشاف تلقائي</span>
</button>
<!-- Transcript card -->
<section class="card transcript-card" id="transcriptCard" hidden>
<div class="card-head">
<span class="card-title" data-i18n="rawTranscript">النص المسموع</span>
<span class="live-dot" id="liveDot" hidden></span>
</div>
<div class="transcript" id="transcript" contenteditable="true" data-i18n-placeholder="transcriptPh"></div>
</section>
<!-- Polished result card -->
<section class="card result-card" id="resultCard" hidden>
<div class="card-head">
<span class="card-title" data-i18n="polished">✨ النص المصقول</span>
<span class="badge" id="resultMode"></span>
</div>
<div class="result" id="result" contenteditable="true"></div>
<div class="card-actions">
<button class="btn primary" id="btnCopy">
<svg viewBox="0 0 24 24"><path d="M8 7V4.8C8 3.8 8.8 3 9.8 3h9.4c1 0 1.8.8 1.8 1.8v9.4c0 1-.8 1.8-1.8 1.8H17v2.2c0 1-.8 1.8-1.8 1.8H5.8c-1 0-1.8-.8-1.8-1.8V8.8C4 7.8 4.8 7 5.8 7H8Zm1.8 0h5.4c1 0 1.8.8 1.8 1.8v5.4h2.2V4.8H9.8V7ZM5.8 8.8v9.4h9.4V8.8H5.8Z"/></svg>
<span data-i18n="copy">نسخ</span>
</button>
<button class="btn" id="btnShare">
<svg viewBox="0 0 24 24"><path d="M17 8.6a3.3 3.3 0 1 0-3.1-4.4L8.3 7a3.3 3.3 0 1 0 0 5l5.6 2.8a3.3 3.3 0 1 0 .8-1.6l-5.6-2.8a3.3 3.3 0 0 0 0-1.8l5.6-2.8c.6.7 1.5 1 2.3 1Z"/></svg>
<span data-i18n="share">مشاركة</span>
</button>
<button class="btn" id="btnRepolish">
<svg viewBox="0 0 24 24"><path d="M12 5V2L7.5 6 12 10V7a5 5 0 1 1-4.9 6.1H5.2A6.8 6.8 0 1 0 12 5Z"/></svg>
<span data-i18n="repolish">إعادة الصقل</span>
</button>
</div>
</section>
<div class="status" id="status"></div>
<!-- Features (shown when idle & empty) -->
<section class="features" id="features">
<div class="feature"><span class="f-ico">⌨️</span><div><b data-i18n="f1t">يعمل مع كل التطبيقات</b><p data-i18n="f1d">أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.</p></div></div>
<div class="feature"><span class="f-ico"></span><div><b data-i18n="f2t">صقل بالذكاء الاصطناعي</b><p data-i18n="f2d">بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.</p></div></div>
<div class="feature"><span class="f-ico">🌍</span><div><b data-i18n="f3t">أكثر من 100 لغة</b><p data-i18n="f3d">العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.</p></div></div>
<div class="feature"><span class="f-ico">📖</span><div><b data-i18n="f4t">قاموسك الشخصي</b><p data-i18n="f4d">أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.</p></div></div>
</section>
</main>
<!-- Mic dock -->
<div class="mic-dock">
<div class="waveform" id="waveform" hidden>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="timer" id="timer" hidden>00:00</div>
<button class="mic-btn" id="btnMic" aria-label="ابدأ الإملاء">
<span class="mic-ring r1"></span><span class="mic-ring r2"></span>
<svg viewBox="0 0 24 24" class="mic-ico">
<path d="M12 2a3.5 3.5 0 0 1 3.5 3.5v6a3.5 3.5 0 1 1-7 0v-6A3.5 3.5 0 0 1 12 2Z"/>
<path d="M5.5 11.5a.9.9 0 0 1 1.8 0 4.7 4.7 0 1 0 9.4 0 .9.9 0 0 1 1.8 0 6.5 6.5 0 0 1-5.6 6.44V20h2.2a.9.9 0 1 1 0 1.8H8.9a.9.9 0 1 1 0-1.8h2.2v-2.06a6.5 6.5 0 0 1-5.6-6.44Z"/>
</svg>
<svg viewBox="0 0 24 24" class="stop-ico" hidden><rect x="6" y="6" width="12" height="12" rx="3"/></svg>
</button>
<div class="mic-hint" id="micHint" data-i18n="tapToTalk">اضغط وتكلّم</div>
</div>
<!-- ===== Language sheet ===== -->
<div class="sheet-backdrop" id="langSheet" hidden>
<div class="sheet" role="dialog" aria-label="اختيار اللغة">
<div class="sheet-grab"></div>
<input class="search" id="langSearch" type="search" data-i18n-placeholder="searchLang">
<div class="lang-list" id="langList"></div>
</div>
</div>
<!-- ===== History sheet ===== -->
<div class="sheet-backdrop" id="historySheet" hidden>
<div class="sheet" role="dialog" aria-label="السجل">
<div class="sheet-grab"></div>
<div class="sheet-title-row">
<h2 data-i18n="history">السجل</h2>
<button class="btn danger small" id="btnClearHistory" data-i18n="clearAll">مسح الكل</button>
</div>
<div class="history-list" id="historyList"></div>
</div>
</div>
<!-- ===== Settings sheet ===== -->
<div class="sheet-backdrop" id="settingsSheet" hidden>
<div class="sheet" role="dialog" aria-label="الإعدادات">
<div class="sheet-grab"></div>
<h2 data-i18n="settings">الإعدادات</h2>
<label class="field">
<span data-i18n="uiLang">لغة الواجهة</span>
<select id="setUiLang">
<option value="ar">العربية</option>
<option value="en">English</option>
</select>
</label>
<label class="field">
<span data-i18n="engine">محرك التعرّف على الصوت</span>
<select id="setEngine">
<option value="auto" data-i18n="engineAuto">تلقائي (الأفضل المتاح)</option>
<option value="webspeech" data-i18n="engineWeb">المتصفح (مجاني، فوري)</option>
<option value="whisper" data-i18n="engineWhisper">Whisper عبر API (أدق)</option>
</select>
</label>
<div class="field-group">
<div class="fg-title" data-i18n="aiSection">إعدادات الذكاء الاصطناعي (للصقل و Whisper)</div>
<label class="field">
<span data-i18n="apiKey">مفتاح API</span>
<input id="setApiKey" type="password" placeholder="sk-..." autocomplete="off">
</label>
<label class="field">
<span data-i18n="baseUrl">عنوان الخدمة (Base URL)</span>
<input id="setBaseUrl" type="url" placeholder="https://api.openai.com/v1">
</label>
<label class="field">
<span data-i18n="model">نموذج الصقل</span>
<input id="setModel" type="text" placeholder="gpt-4o-mini">
</label>
<label class="field">
<span data-i18n="whisperModel">نموذج التفريغ الصوتي</span>
<input id="setWhisperModel" type="text" placeholder="whisper-1">
</label>
<p class="hint" data-i18n="apiHint">يدعم أي خدمة متوافقة مع OpenAI: OpenAI، Groq (مجاني)، OpenRouter وغيرها. المفتاح يُحفظ على جهازك فقط.</p>
</div>
<label class="field">
<span data-i18n="dictionary">القاموس الشخصي (أسماء ومصطلحات، افصل بينها بفواصل)</span>
<textarea id="setDictionary" rows="3" data-i18n-placeholder="dictPh"></textarea>
</label>
<label class="field row">
<span data-i18n="autoPolish">صقل تلقائي بعد التوقف</span>
<input id="setAutoPolish" type="checkbox" checked>
</label>
<label class="field">
<span data-i18n="theme">المظهر</span>
<select id="setTheme">
<option value="auto" data-i18n="themeAuto">تلقائي</option>
<option value="light" data-i18n="themeLight">فاتح</option>
<option value="dark" data-i18n="themeDark">داكن</option>
</select>
</label>
<button class="btn primary wide" id="btnSaveSettings" data-i18n="save">حفظ</button>
<p class="hint center" id="verInfo"></p>
</div>
</div>
<div class="toast" id="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>

18
nutq/manifest.webmanifest Normal file
View File

@ -0,0 +1,18 @@
{
"name": "نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي",
"short_name": "نُطق",
"description": "تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال. يدعم أكثر من 100 لغة.",
"lang": "ar",
"dir": "rtl",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#FAF6E9",
"theme_color": "#FAF6E9",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

43
nutq/sw.js Normal file
View File

@ -0,0 +1,43 @@
/* Nutq service worker — offline-first app shell */
const CACHE = "nutq-v1";
const SHELL = [
"./",
"./index.html",
"./app.css",
"./app.js",
"./manifest.webmanifest",
"./icons/icon-192.png",
"./icons/icon-512.png",
"./icons/apple-touch-icon.png",
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
// Never intercept API calls — only cache same-origin static assets.
if (url.origin !== location.origin || e.request.method !== "GET") return;
e.respondWith(
caches.match(e.request).then(
(hit) =>
hit ||
fetch(e.request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
return res;
})
)
);
});