diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..a7616b9 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "tauri-mcp": { + "type": "stdio", + "command": "tauri-mcp", + "args": [ + "--config", + "tauri-mcp.toml", + "--log-level", + "info", + "serve" + ] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cd7d21b..f8e53f5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -16,15 +16,11 @@ body: validations: required: true - - type: checkboxes - id: personas + - type: textarea + id: who attributes: - label: Affected personas - description: Who benefits most from this change? - options: - - label: Child (primary user) - - label: Adult - - label: Therapist / Caregiver + label: Who benefits + description: Which users or situations does this help (e.g. school laptop, secondary touchscreen, caregivers)? - type: textarea id: solution diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c366091..529c360 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,9 +10,9 @@ Use a [Conventional Commits](https://www.conventionalcommits.org/) **PR title** -## Persona / accessibility impact +## Accessibility impact - + ## Test plan diff --git a/.gitignore b/.gitignore index eb843d7..a457a74 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ dist-ssr # Rust / Tauri src-tauri/target/ src-tauri/.tauri/ + +# Local patched tauri-mcp source (binary installed via cargo) +.cursor/tauri-mcp/ + +# Git worktrees +.worktrees/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d4ca8c..d9ceda3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ After cloning or forking, maintainers should ensure: ## Accessibility changes -Changes that affect usability for people with motor disabilities should align with [docs/accessibility-requirements.md](docs/accessibility-requirements.md). Mention which persona (Child, Adult, Therapist) is affected in your PR description. +Changes that affect usability for people with motor disabilities should align with [docs/accessibility-requirements.md](docs/accessibility-requirements.md). Note accessibility impact in your PR description. ## Code style diff --git a/README.md b/README.md index 9127a81..a8f5338 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Assistive virtual keyboard and mouse for Windows — built for users with severe motor disabilities who operate a wheelchair-mounted touchscreen while applications run on a laptop display. -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Platform: Windows](https://img.shields.io/badge/platform-Windows-lightgrey)]() -[![Build](https://github.com/pountzas/reach-Panel/actions/workflows/build.yml/badge.svg)](https://github.com/pountzas/reach-Panel/actions/workflows/build.yml) +[License: MIT](LICENSE) +[Platform: Windows](#requirements) +[Build](https://github.com/pountzas/reach-Panel/actions/workflows/build.yml) Built with Tauri, React, Rust, and SQLite. @@ -18,45 +18,59 @@ Pre-built installers are on the [Releases](https://github.com/pountzas/reach-Pan > **macOS builds are experimental.** CI produces `.dmg` bundles, but input injection and TTS still require Windows APIs for full functionality. See [Roadmap](#roadmap). + + ## Screenshots -| Main layout | Virtual trackpad | Settings | -|-------------|------------------|----------| -| ![Main keyboard layout](docs/images/keyboard.png) | ![Virtual trackpad](docs/images/trackpad.png) | ![Settings panel](docs/images/settings.png) | -_Replace with captures from your release build when ready — see [docs/images/README.md](docs/images/README.md)._ +| Main layout | Virtual trackpad | Settings | +| -------------------- | ---------------- | -------------- | +| Main keyboard layout | Virtual trackpad | Settings panel | + + +*Replace with captures from your release build when ready — see [docs/images/README.md](docs/images/README.md).* ## Features + + ### Keyboard - System-wide keyboard injection with sticky modifiers - Adjustable on-screen keyboard (size, spacing, colors, opacity) - Fn key on the bottom row maps number keys 1–0 and `-` `=` to F1–F12 - Keyboard / synthesizer mode toggle with two-octave piano keyboard -- Language switch key with country flag icons (EN ↔ EL) -- Predictive text (English + Greek) with disable toggle +- Language switch key with country flag icons (follows installed Windows keyboards) +- Predictive text with offline word packs (English bundled; other languages downloadable), learns from typing, disable toggle - Macro builder with JSON import/export + + ### Mouse - Virtual trackpad with left/right/floating placement - Move, click, scroll from the accessibility touchscreen - Toggle between trackpad and on-screen numeric keypad + + ### Communication - Quick Actions bar (launch apps and URLs) - Communication phrases with TTS (Windows SAPI) - Emergency phrase section with show/hide toggle + + ### Voice dictation (Windows) - Mic control on the keyboard toolbar (enable **Show dictation (mic)** in Settings) -- Uses the **typing language** (EN ↔ EL), not the UI language -- **English:** Windows Speech Recognition (WinRT) when the speech language pack is installed and online speech is allowed +- Uses the **typing language**, not the UI language +- **English and Western European languages (DE, FR, IT, ES, PT):** Windows Speech Recognition (WinRT) when the speech language pack is installed and online speech is allowed - **Greek and other languages without a Windows speech pack:** [Groq](https://groq.com) cloud Whisper (`whisper-large-v3-turbo`) over the internet + + #### Set up Groq for Greek dictation 1. Create a free account at [console.groq.com](https://console.groq.com). @@ -65,16 +79,18 @@ _Replace with captures from your release build when ready — see [docs/images/R 4. Under the keyboard / dictation options, paste the key into **Groq API key (cloud dictation)**. 5. Set **typing language** to Greek (EL), enable the mic control if needed, then start dictation. -You can also set the `GROQ_API_KEY` environment variable instead of (or as a fallback for) the Settings field. An internet connection is required for cloud dictation. English WinRT dictation does not need a Groq key. +You can also set the `GROQ_API_KEY` environment variable instead of (or as a fallback for) the Settings field. An internet connection is required for cloud dictation. WinRT dictation for English and Western European languages does not need a Groq key. -If Windows online speech is off, open **Settings → Privacy & security → Speech** and turn on **Online speech recognition**. For English speech packs, use **Settings → Time & language → Speech**. +If Windows online speech is off, open **Settings → Privacy & security → Speech** and turn on **Online speech recognition**. For speech packs, use **Settings → Time & language → Speech**. ### Profiles & accessibility - Profiles (Child, Parent, Therapist) - Multi-monitor detection and positioning - Head tracking calibration wizard (webcam-based) -- English and Greek UI +- UI languages: English, Greek, German, French, Italian, Spanish, Portuguese + + ## Requirements @@ -88,6 +104,8 @@ For development: - Rust (via [rustup](https://rustup.rs/)) - Visual Studio Build Tools with the C++ workload + + ## Development ```bash @@ -101,6 +119,8 @@ For Rust builds on Windows, ensure the MSVC environment is active. If `link.exe` "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat" ``` + + ## Build from source ```bash @@ -113,22 +133,30 @@ Installers and executables are written to: src-tauri/target/release/bundle/ ``` + + ## Releases Windows and macOS bundles are built in CI. Windows installers are published automatically when a [release-please](https://github.com/googleapis/release-please) Release PR is merged on `main`. Development happens on `dev`; merging `dev` → `main` opens the Release PR. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full branch and versioning workflow. ## Architecture -| Path | Responsibility | -|------|----------------| -| `src/` | React frontend (keyboard, mouse, phrases, settings, head tracking) | -| `src-tauri/src/input/` | Platform keyboard/mouse injection (Windows `SendInput`; macOS stubs) | -| `src-tauri/src/window/` | Monitor enumeration (Windows APIs; macOS stub) | -| `src-tauri/src/tts/` | Text-to-speech (Windows SAPI / WinRT; macOS stub) | -| `src-tauri/src/stt/` | Voice dictation (WinRT speech recognition; Groq cloud fallback) | -| `src-tauri/src/db/` | SQLite persistence (profiles, phrases, macros) | -| `src-tauri/src/prediction/` | Predictive text suggestions | -| `docs/accessibility-requirements.md` | Personas and acceptance criteria | + +| Path | Responsibility | +| ------------------------------------ | -------------------------------------------------------------------- | +| `src/` | React frontend (keyboard, mouse, phrases, settings, head tracking) | +| `src-tauri/src/input/` | Platform keyboard/mouse injection (Windows `SendInput`; macOS stubs) | +| `src-tauri/src/window/` | Monitor enumeration (Windows APIs; macOS stub) | +| `src-tauri/src/tts/` | Text-to-speech (Windows SAPI / WinRT; macOS stub) | +| `src-tauri/src/stt/` | Voice dictation (WinRT speech recognition; Groq cloud fallback) | +| `src-tauri/src/db/` | SQLite persistence (profiles, phrases, macros) | +| `src-tauri/src/prediction/` | Predictive text + word-pack install/download | +| `src-tauri/resources/wordpacks/` | Bundled English dictionary | +| `wordpacks-dist/` | Downloadable packs for GitHub `wordpacks-v1` release | +| `docs/accessibility-requirements.md` | Target users and acceptance criteria | + + + ## Roadmap @@ -136,6 +164,8 @@ Windows and macOS bundles are built in CI. Windows installers are published auto - AutoHotkey macro import - macOS port: CGEvent input, AVSpeechSynthesizer, native monitor APIs (experimental CI builds in progress) + + ## Known limitations - Input injection does not work into elevated (admin) applications or UAC prompts @@ -143,6 +173,8 @@ Windows and macOS bundles are built in CI. Windows installers are published auto - Voice dictation is Windows-only; Greek (and other WinRT-unsupported languages) need a Groq API key and internet - Cloud dictation is utterance-based (speak, pause) rather than fully continuous streaming + + ## Contributing Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/accessibility-requirements.md](docs/accessibility-requirements.md) before opening a pull request. @@ -159,4 +191,4 @@ MIT — see [LICENSE](LICENSE). ## Disclaimer -This is assistive software, not a medical device. Test thoroughly with your own hardware and accessibility setup before relying on it in daily use. +This is assistive software, not a medical device. Test thoroughly with your own hardware and accessibility setup before relying on it in daily use. \ No newline at end of file diff --git a/docs/accessibility-requirements.md b/docs/accessibility-requirements.md index a5ba384..54b501d 100644 --- a/docs/accessibility-requirements.md +++ b/docs/accessibility-requirements.md @@ -1,22 +1,20 @@ # Accessibility Requirements -## Personas +## Who it's for -### Child (primary) -- 7-year-old with complete tetraplegia -- Good head, eye, and pointing-finger movement -- Long attention span; comfortable with touchscreen tablets -- Needs large keys, simple navigation, emergency phrases +ReachPanel is for people who cannot use a physical keyboard and need touch-first control of a Windows PC. It was originally built for a child with complete tetraplegia so he can do schoolwork on a touch-capable laptop. -### Adult -- Full feature set, smaller keys acceptable, macro and layout customization +Typical needs: -### Therapist / Caregiver -- Profile management, emergency phrase visibility toggle, monitor selection, calibration +- Large keys, simple navigation, and emergency phrases +- Good use of head, eye, and pointing-finger movement on a touchscreen +- Single touch laptop at school, or a secondary touch display at distance when available + +Caregivers can manage named profiles, emergency phrase visibility, monitor selection, and head-tracking calibration in Settings. ## Layout Wireframes -### Layout A (default) +### Layout A (default — mouse right) ``` +---------------------------------------------------+ | Quick Actions | @@ -38,39 +36,24 @@ +---------------------------------------------------+ ``` -### Layout C (floating mouse) -``` -+---------------------------------------------------+ -| Quick Actions | -+---------------------------------------------------+ -| Keyboard (full width) | -+---------------------------------------------------+ -| Suggestions | -+---------------------------------------------------+ - - +------------------+ - | Floating Mouse | - +------------------+ -``` - ## Acceptance Criteria | Feature | Criteria | |---------|----------| | Keyboard injection | Types into Notepad, Chrome, Word, Teams, Explorer | | Sticky keys | Ctrl stays active until next key or toggle off | -| Mouse trackpad | Move, click, scroll from wheelchair touchscreen | -| Mouse placement | Right, left, floating; persisted per profile | +| Mouse trackpad | Move, click, scroll from the accessibility touchscreen | +| Mouse placement | Right or left next to the keyboard; persisted per profile | | Quick actions | Launch exe and URL targets | | Phrases | Type, speak, or both; favorites and emergency toggle | | TTS | Offline via Windows SAPI installed voices | -| Prediction | Top suggestions per language; disable toggle | +| Prediction | Offline word packs (English bundled; other languages downloadable); learn on typed words; disable toggle | | Macros | Multi-step sequences; JSON import/export | | Head tracking | Calibration wizard; touch/head mode toggle | | Multi-monitor | List displays; position app on accessibility screen | -| Profiles | Child, Parent, Therapist with separate settings | +| Profiles | Named saved profiles with separate settings | ## Known Limitations - Input injection does not work into elevated (admin) applications or UAC prompts -- Eye tracking and AutoHotkey import are future phases +- Eye tracking, AutoHotkey import, and an Android tablet companion (connects to this Windows app to control the PC) are future phases diff --git a/docs/images/README.md b/docs/images/README.md index fd223a5..a3ab51b 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -14,4 +14,4 @@ These images appear in the root [README.md](../../README.md). 2. Capture PNG screenshots at roughly 1200×700 (main window size). 3. Replace the files in this folder and commit. -Use Windows **Snipping Tool** or **Win+Shift+S** for captures. Prefer the Child profile with default layout for consistency. +Use Windows **Snipping Tool** or **Win+Shift+S** for captures. Prefer the default profile and layout for consistency. diff --git a/scripts/generate-wordpacks.mjs b/scripts/generate-wordpacks.mjs new file mode 100644 index 0000000..e14ba01 --- /dev/null +++ b/scripts/generate-wordpacks.mjs @@ -0,0 +1,76 @@ +import fs from "fs"; +import https from "https"; +import path from "path"; + +const LANGS = { + en: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/en/en_50k.txt", + el: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/el/el_50k.txt", + de: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/de/de_50k.txt", + fr: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/fr/fr_50k.txt", + it: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/it/it_50k.txt", + es: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/es/es_50k.txt", + pt: "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/pt/pt_50k.txt", +}; + +const LIMIT = 8000; +const VERSION = 1; + +const MAX_REDIRECTS = 5; + +function fetchText(url, redirectsLeft = MAX_REDIRECTS) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + reject(new Error(`Too many redirects for ${url}`)); + res.resume(); + return; + } + fetchText(res.headers.location, redirectsLeft - 1).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`${url} -> ${res.statusCode}`)); + res.resume(); + return; + } + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }) + .on("error", reject); + }); +} + +function parse(text) { + const words = []; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parts = trimmed.split(/\s+/); + const word = parts[0]; + const freq = Number(parts[1] || 0); + if (!word || !Number.isFinite(freq)) continue; + if (!/^[\p{L}][\p{L}'’-]*$/u.test(word)) continue; + if (word.length < 2) continue; + words.push([word.toLowerCase(), Math.max(1, Math.round(freq))]); + if (words.length >= LIMIT) break; + } + return words; +} + +for (const [lang, url] of Object.entries(LANGS)) { + process.stdout.write(`Fetching ${lang}...\n`); + const text = await fetchText(url); + const words = parse(text); + const pack = { language: lang, version: VERSION, words }; + const json = JSON.stringify(pack); + fs.mkdirSync("wordpacks-dist", { recursive: true }); + fs.mkdirSync(path.join("src-tauri", "resources", "wordpacks"), { recursive: true }); + fs.writeFileSync(path.join("wordpacks-dist", `${lang}.json`), json); + if (lang === "en") { + fs.writeFileSync(path.join("src-tauri", "resources", "wordpacks", "en.json"), json); + } + process.stdout.write(` ${lang}: ${words.length} words, ${json.length} bytes\n`); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6497f04..41f0d51 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3105,7 +3105,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "reach-panel" -version = "0.5.1" +version = "0.6.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 96d1d4a..e549019 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,11 +25,11 @@ chrono = { version = "0.4", features = ["serde"] } base64 = "0.22" anyhow = "1" rfd = "0.17" -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["rt", "time"] } +ureq = { version = "2", default-features = true, features = ["tls", "json"] } [target.'cfg(windows)'.dependencies] cpal = "0.15" -ureq = { version = "2", default-features = true, features = ["tls", "json"] } windows-future = "0.3" image = { version = "0.25", default-features = false, features = ["png"] } windows = { version = "0.58", features = [ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e0239d8..8555eec 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,7 +14,7 @@ "core:window:allow-set-always-on-top", "core:window:allow-set-position", "core:window:allow-set-size", - "opener:default", + "opener:allow-default-urls", { "identifier": "opener:allow-open-url", "allow": [ diff --git a/src-tauri/resources/wordpacks/README.md b/src-tauri/resources/wordpacks/README.md new file mode 100644 index 0000000..affa377 --- /dev/null +++ b/src-tauri/resources/wordpacks/README.md @@ -0,0 +1,40 @@ +# Prediction word packs + +Offline frequency dictionaries used by ReachPanel predictive text. + +## Format + +```json +{ + "language": "en", + "version": 1, + "words": [["the", 22761659], ["hello", 12345]] +} +``` + +Each pack is the top ~8,000 alphabetic tokens from [HermitDave FrequencyWords](https://github.com/hermitdave/FrequencyWords) (2018 subtitle corpus lists). + +## Bundled vs downloadable + +| File | Shipped with app | Install | +| --- | --- | --- | +| `src-tauri/resources/wordpacks/en.json` | Yes | Auto-installed on first launch | +| `wordpacks-dist/{el,de,fr,it,es,pt}.json` | No | Settings → Install (download or local dist in dev) | + +## Publishing downloadable packs + +Create a GitHub release with tag **`wordpacks-v1`** on `pountzas/reach-Panel` and attach: + +- `el.json`, `de.json`, `fr.json`, `it.json`, `es.json`, `pt.json` + +from `wordpacks-dist/`. + +Download URL used by the app: + +`https://github.com/pountzas/reach-Panel/releases/download/wordpacks-v1/{lang}.json` + +## Regenerating + +```bash +node scripts/generate-wordpacks.mjs +``` diff --git a/src-tauri/resources/wordpacks/en.json b/src-tauri/resources/wordpacks/en.json new file mode 100644 index 0000000..56852aa --- /dev/null +++ b/src-tauri/resources/wordpacks/en.json @@ -0,0 +1 @@ +{"language":"en","version":1,"words":[["you",28787591],["the",22761659],["to",17099834],["it",13631703],["and",10572938],["that",10203742],["of",8915110],["is",7400675],["in",7337058],["what",6900164],["we",6755687],["me",6444985],["this",5739788],["he",5516364],["for",5174060],["my",4938948],["on",4821861],["have",4764010],["your",4610945],["do",4419883],["was",4401531],["no",4374975],["not",4262273],["be",4210868],["are",4203821],["don",4158644],["know",3892394],["can",3826118],["with",3806977],["but",3631462],["all",3544700],["so",3434152],["just",3334392],["there",3148528],["here",3138591],["they",3060204],["like",2983027],["get",2883193],["she",2778359],["go",2738504],["if",2630800],["right",2576821],["out",2510010],["about",2487348],["up",2459855],["at",2431398],["him",2431059],["now",2298830],["oh",2298308],["one",2263956],["come",2203919],["well",2159909],["her",2154982],["how",2140365],["yeah",2000641],["will",1969807],["got",1968389],["want",1950845],["think",1839473],["as",1792220],["see",1781493],["did",1742764],["good",1741730],["who",1734491],["why",1722797],["from",1709582],["let",1705262],["his",1666361],["yes",1634050],["when",1531731],["going",1520767],["time",1453708],["an",1449181],["okay",1428884],["back",1405024],["look",1348467],["us",1346903],["would",1340070],["them",1327509],["where",1322226],["were",1315964],["take",1312232],["then",1275502],["had",1274537],["or",1267646],["been",1265150],["our",1191941],["gonna",1188190],["tell",1176290],["really",1174710],["man",1173588],["some",1166914],["say",1153915],["hey",1149292],["could",1111837],["didn",1100643],["by",1090424],["need",1040131],["something",1038638],["has",1035310],["too",1022558],["more",1010390],["way",1000182],["down",978966],["make",963885],["very",945653],["never",930831],["only",887363],["people",885915],["over",880841],["because",880112],["little",869522],["please",842120],["love",830324],["should",823711],["mean",821275],["said",818878],["sorry",818019],["give",811919],["off",799351],["am",785830],["thank",773577],["any",767968],["two",764564],["even",744544],["much",738599],["doing",732145],["sure",709390],["thing",697528],["these",683128],["help",666286],["first",656387],["into",655747],["anything",640206],["still",639540],["find",626520],["life",624596],["nothing",622415],["sir",619500],["day",617674],["god",611924],["work",611677],["their",601171],["uh",592651],["again",590019],["maybe",581087],["must",580931],["before",578606],["other",577197],["wait",574752],["stop",574016],["call",572997],["after",572761],["won",571621],["talk",567036],["away",564268],["than",561014],["thought",558542],["home",556894],["night",556318],["put",552246],["great",545768],["those",528404],["last",527395],["better",520328],["everything",519795],["told",517951],["new",508486],["things",503642],["always",503417],["keep",498392],["long",496267],["years",481860],["leave",478282],["does",478174],["money",471643],["around",471463],["doesn",471037],["name",455543],["place",444863],["ever",444088],["feel",444014],["guys",442241],["father",437537],["guy",435832],["made",433900],["old",431911],["isn",429536],["which",422483],["big",417218],["lot",411660],["done",408760],["hello",405534],["nice",405497],["believe",403874],["girl",402557],["someone",401162],["fine",400530],["thanks",395690],["wanted",392510],["kind",392242],["coming",389949],["house",388585],["every",388520],["ok",388338],["through",388127],["being",386528],["course",381816],["stay",378726],["left",378587],["dad",378540],["enough",376973],["happened",375703],["came",374486],["may",373526],["mother",372846],["wrong",370949],["world",370620],["bad",369996],["might",367145],["three",366626],["today",366395],["listen",365791],["another",365421],["understand",365210],["hear",360665],["remember",358291],["ask",354323],["own",352542],["same",348167],["show",347408],["else",340756],["kill",339509],["talking",337629],["found",337382],["next",335573],["getting",331186],["care",330956],["car",330261],["looking",326550],["son",325997],["try",325748],["woman",324673],["went",321824],["hi",320316],["dead",317589],["many",314850],["mind",312927],["wasn",312240],["friend",308204],["best",307071],["mom",305139],["hell",304275],["morning",303550],["trying",303288],["boy",302508],["together",302404],["yourself",302013],["job",298911],["saw",297985],["family",294543],["real",294536],["without",290509],["baby",290496],["room",290206],["wouldn",288422],["already",287837],["move",283807],["most",283006],["seen",282420],["live",281046],["miss",280930],["actually",279595],["huh",274656],["shit",273312],["both",270720],["heard",269851],["once",269704],["ready",267268],["head",266844],["called",264588],["used",263566],["idea",263097],["knew",262988],["hold",262367],["happy",262141],["door",261118],["such",261104],["fuck",260121],["brother",259877],["also",259803],["pretty",259500],["bit",258929],["took",258808],["haven",258463],["yet",257947],["men",257319],["whole",257109],["start",256417],["use",256079],["while",255970],["since",254427],["wife",254038],["days",253171],["guess",250320],["tomorrow",248729],["matter",247812],["meet",247466],["bring",247343],["tonight",246514],["everyone",246032],["run",244200],["wanna",243109],["hard",242871],["ah",242456],["alone",240475],["myself",240460],["school",240176],["gone",240082],["um",238853],["end",237387],["saying",236600],["phone",235246],["play",233865],["looks",233814],["couldn",233368],["fucking",232845],["problem",232325],["few",231375],["friends",230464],["gotta",230351],["ago",229751],["open",229669],["anyone",228332],["killed",228297],["hope",226993],["face",225482],["until",225171],["lost",222398],["police",221380],["excuse",219855],["turn",218563],["business",215855],["case",215274],["wants",215149],["says",215090],["true",214773],["die",214062],["heart",213914],["soon",213279],["each",211943],["worry",211329],["later",208878],["year",208705],["watch",207949],["music",207205],["hand",206247],["having",206106],["probably",202736],["beautiful",200811],["doctor",198424],["sit",198149],["eat",197654],["thinking",195875],["young",194810],["second",193687],["working",193172],["water",193014],["person",192652],["part",192248],["kids",192050],["late",191989],["stuff",190629],["exactly",189513],["under",189347],["death",189000],["minute",188958],["pay",188822],["crazy",188767],["forget",188628],["everybody",188532],["kid",187858],["change",186962],["gave",186445],["happen",186041],["damn",184228],["five",183626],["drink",182580],["far",182287],["knows",181663],["its",180594],["whatever",180082],["eyes",179260],["shut",178378],["aren",178286],["hit",177770],["taking",177667],["easy",177337],["times",176522],["check",176507],["hands",176291],["minutes",175091],["deal",174929],["different",174657],["means",174311],["point",174205],["inside",174081],["makes",172281],["asked",171740],["somebody",171596],["mine",170636],["making",169037],["body",168798],["afraid",168774],["sleep",168103],["chance",167981],["dear",167351],["quite",166980],["four",166957],["anyway",166702],["close",166677],["ain",166551],["party",166137],["fun",165693],["against",165033],["word",164938],["comes",164503],["important",164386],["set",164165],["shall",164162],["story",164040],["number",163259],["daughter",163071],["least",162905],["waiting",162571],["hurt",161421],["wish",160204],["moment",159995],["fight",159911],["week",159444],["husband",159394],["girls",159061],["rest",158976],["married",157817],["fire",157532],["game",157501],["nobody",157014],["mr",156641],["children",156181],["side",155667],["stand",155391],["read",154667],["though",154479],["cut",154407],["started",154345],["sister",154095],["supposed",153117],["between",152520],["child",151864],["goes",151195],["hours",151156],["speak",150927],["women",150803],["behind",148469],["almost",148405],["truth",148389],["blood",147913],["able",147205],["lady",147121],["anymore",146903],["playing",146615],["gets",146064],["shot",144772],["reason",144399],["trouble",143235],["break",143107],["war",142825],["city",142573],["walk",142055],["town",141835],["trust",141327],["met",141208],["office",141140],["question",140970],["brought",140661],["yours",140553],["welcome",140327],["high",140078],["wow",139593],["couple",138996],["half",138527],["died",138432],["cool",138139],["free",137598],["either",137448],["seems",136699],["power",136611],["whoa",136564],["bye",136276],["buy",136137],["telling",136109],["honey",135631],["tried",135401],["front",135341],["team",134358],["answer",134088],["gun",133072],["boys",132594],["line",132433],["send",131999],["news",131599],["stupid",131497],["bed",131445],["hurry",130182],["full",129735],["months",129732],["save",129390],["sometimes",128779],["become",128716],["along",128220],["hate",128153],["food",127657],["outside",127251],["light",126657],["needs",126398],["dog",125769],["country",125731],["clear",125730],["order",125446],["em",125033],["fact",124995],["lord",124880],["captain",124874],["six",124087],["hot",123994],["funny",123924],["black",123718],["alive",122833],["pick",122698],["feeling",122065],["living",121552],["cause",121264],["ahead",120295],["lose",118712],["king",118230],["plan",118129],["dinner",117970],["sighs",117410],["sort",117267],["leaving",117050],["shouldn",117029],["running",116973],["boss",116736],["alright",116629],["promise",115908],["taken",115236],["safe",114104],["ma",114026],["book",113963],["sent",113959],["white",112881],["hour",112782],["anybody",112582],["small",112316],["perfect",112229],["lives",110583],["special",110344],["parents",110206],["john",109966],["himself",109748],["perhaps",109240],["sounds",109140],["serious",108774],["sick",108596],["company",108073],["ha",107974],["scared",107906],["uncle",107826],["poor",107444],["red",106589],["past",106439],["earth",106394],["possible",106080],["shoot",106067],["touch",105846],["sound",105298],["top",105282],["ass",105009],["laughs",104913],["cannot",103913],["asking",102974],["win",102956],["glad",102848],["control",102782],["hmm",102607],["human",102595],["drive",102436],["hair",101852],["jack",101666],["bitch",101302],["luck",101228],["murder",101133],["happens",100847],["air",100137],["ten",100133],["daddy",100005],["finally",99904],["chuckles",99607],["fast",99084],["cold",99032],["seem",98575],["laughing",98420],["words",98242],["hospital",98076],["street",98007],["hang",97778],["dance",97339],["meeting",97328],["till",97044],["others",96631],["catch",96609],["follow",96529],["sense",96442],["sex",96431],["lie",95930],["evening",95321],["master",95131],["known",95125],["dream",95089],["write",94964],["million",94514],["voice",94104],["sweet",94057],["rather",94057],["felt",93826],["sign",92512],["lucky",92377],["somewhere",92265],["bet",91573],["jesus",91143],["longer",91035],["calling",90868],["worked",90771],["quiet",90552],["looked",90441],["less",90011],["pull",89880],["beat",89787],["careful",89667],["coffee",89650],["return",89451],["secret",89236],["weeks",88925],["weren",88427],["date",88407],["seeing",88192],["fall",87762],["given",87738],["ooh",87683],["fault",87420],["straight",87378],["takes",86959],["song",86877],["future",86832],["gentlemen",86660],["loved",86138],["changed",86133],["road",85751],["calm",85638],["wonderful",85617],["mad",85366],["turned",85279],["drop",85186],["ladies",85029],["learn",84936],["step",84882],["absolutely",84856],["early",84836],["explain",84368],["clean",84238],["piece",84224],["yesterday",83823],["throw",83679],["picture",83461],["land",83422],["feet",83295],["wonder",82903],["questions",82887],["speaking",82769],["worth",82559],["darling",82240],["dude",82190],["giving",82085],["president",81980],["eye",81953],["quick",81548],["moving",81193],["figure",81048],["state",80818],["strong",80501],["sam",80423],["none",80315],["amazing",80267],["ones",79546],["works",79323],["act",79020],["needed",78920],["weird",78317],["law",78264],["worried",78083],["report",77824],["goodbye",77514],["missing",77326],["choice",77197],["happening",77052],["chief",76951],["wedding",76940],["strange",76863],["general",76816],["pain",76799],["kidding",76744],["decided",76683],["ya",76658],["pass",76635],["tired",76540],["class",76489],["officer",76015],["kept",75930],["wake",75776],["worse",75595],["busy",75557],["eh",75178],["mistake",74957],["kiss",74790],["court",74580],["building",74492],["finish",74299],["during",74298],["age",74262],["ship",74224],["caught",74060],["marry",73938],["meant",73929],["sell",73802],["dark",73770],["watching",73700],["system",73678],["suppose",73465],["evidence",73113],["movie",73070],["ride",73070],["completely",72777],["mouth",72733],["totally",72478],["birthday",72140],["tv",72054],["forgive",71915],["born",71906],["imagine",71873],["information",71840],["instead",71571],["definitely",71524],["security",71503],["certainly",71435],["film",71407],["month",71163],["lying",71098],["unless",70979],["train",70974],["seven",70968],["wear",70955],["clothes",70929],["michael",70741],["hotel",70659],["christmas",70624],["attack",70583],["hasn",70449],["round",70043],["expect",69614],["sing",69575],["terrible",69499],["george",69441],["bag",69186],["history",69119],["blue",68839],["near",68790],["broke",68629],["station",68571],["seriously",68524],["forever",68510],["david",68505],["frank",68472],["except",68440],["thinks",68354],["message",68305],["entire",68282],["joe",68244],["table",67702],["talked",67666],["across",67595],["rock",67361],["lovely",67236],["handle",67089],["middle",66734],["buddy",66718],["paid",66526],["protect",66508],["using",66503],["floor",66456],["ran",66177],["swear",66157],["spend",66119],["situation",66029],["ring",66018],["anywhere",65981],["dangerous",65974],["bill",65959],["york",65880],["army",65795],["lead",65756],["bought",65752],["finished",65659],["fair",65474],["sun",65448],["letter",65448],["fool",65432],["attention",65421],["club",65275],["simple",65090],["interesting",65082],["space",64991],["test",64969],["box",64788],["group",64761],["single",64614],["sitting",64521],["marriage",64465],["join",64456],["fear",64352],["peace",64262],["forgot",64169],["force",63737],["short",63714],["normal",63676],["charlie",63672],["present",63307],["enjoy",63294],["mike",63219],["crime",63084],["horse",62923],["ground",62860],["american",62835],["count",62763],["area",62755],["charge",62746],["honor",62722],["lunch",62582],["miles",62523],["radio",62502],["idiot",62357],["ball",62325],["surprise",62170],["paper",62166],["key",62153],["boat",62148],["quickly",62072],["gold",61935],["bar",61927],["fish",61813],["mark",61805],["tom",61785],["wearing",61749],["crying",61698],["accident",61671],["government",61646],["eight",61400],["fell",61371],["cover",61052],["certain",61050],["interested",61029],["deep",60938],["star",60903],["sea",60812],["agree",60716],["problems",60633],["detective",60633],["prison",60581],["major",60560],["stick",60548],["offer",60444],["difficult",60404],["smart",60397],["personal",60381],["record",60360],["stopped",60200],["hide",60133],["whether",59993],["bank",59962],["trip",59906],["relax",59715],["america",59703],["public",59687],["list",59557],["afternoon",59466],["brain",59464],["fix",59349],["bastard",59332],["proud",59309],["tea",59277],["service",59277],["screaming",59151],["forward",59064],["angry",58748],["park",58713],["soul",58677],["fighting",58657],["peter",58654],["rich",58651],["agent",58608],["blow",58531],["paul",58529],["dress",58429],["mary",58366],["missed",58324],["scene",58017],["killing",57890],["standing",57866],["saved",57667],["mm",57659],["respect",57647],["killer",57555],["ice",57545],["mess",57542],["tough",57486],["feels",57388],["church",57254],["sad",57179],["cell",57033],["drunk",56919],["share",56854],["camera",56757],["mm-hmm",56720],["within",56709],["card",56659],["fly",56595],["ben",56502],["girlfriend",56475],["laugh",56460],["smell",56399],["broken",56391],["mum",56281],["honest",56234],["often",55998],["starting",55979],["calls",55940],["spent",55836],["third",55750],["english",55677],["visit",55638],["mama",55372],["judge",55336],["window",55285],["hungry",55259],["dare",55131],["relationship",55128],["moved",55096],["prove",55079],["private",54978],["wall",54813],["seat",54615],["i-i",54460],["position",54449],["lieutenant",54321],["realize",54306],["lt",54305],["especially",54148],["machine",54140],["walking",54115],["art",54096],["pleasure",54085],["bloody",54026],["college",53933],["french",53911],["involved",53718],["cry",53674],["became",53603],["lived",53550],["impossible",53527],["obviously",53527],["neither",53452],["accept",53291],["boyfriend",53124],["besides",53110],["queen",53006],["teacher",53004],["cops",52981],["sake",52827],["loves",52751],["carry",52734],["teach",52731],["apartment",52714],["upset",52565],["green",52557],["la",52546],["liked",52485],["cute",52240],["evil",52175],["professor",52026],["contact",51750],["joke",51728],["cop",51701],["huge",51581],["holy",51499],["store",51492],["jail",51429],["likes",51334],["lawyer",51332],["doubt",51287],["continue",51279],["appreciate",51258],["cat",51175],["shop",50976],["driving",50866],["congratulations",50847],["wrote",50800],["village",50792],["quit",50649],["field",50617],["james",50562],["wine",50522],["decision",50446],["south",50364],["sleeping",50357],["slow",50276],["laughter",50196],["island",50107],["glass",50103],["beginning",50069],["cash",49976],["dying",49963],["hundred",49823],["whose",49808],["difference",49743],["plane",49704],["push",49690],["continues",49684],["mmm",49655],["singing",49144],["eating",49119],["north",49107],["mrs",49087],["tree",49017],["madam",48931],["gift",48891],["truck",48807],["putting",48797],["bear",48674],["board",48666],["grab",48653],["beer",48641],["stuck",48497],["magic",48484],["support",48351],["rules",48345],["grunts",48253],["partner",48247],["reach",48221],["wind",48063],["colonel",48036],["max",47938],["immediately",47938],["seconds",47936],["thousand",47934],["gives",47900],["experience",47824],["cheers",47819],["victim",47739],["upon",47699],["computer",47672],["christ",47650],["planet",47564],["promised",47559],["bus",47534],["henry",47491],["dirty",47462],["search",47439],["staying",47241],["dreams",47210],["arrest",46994],["holding",46938],["suddenly",46833],["usually",46770],["lots",46570],["shoes",46490],["er",46383],["jump",46358],["rid",46242],["yo",46233],["knock",46228],["owe",46153],["harry",46028],["grow",46010],["worst",45845],["river",45834],["aunt",45834],["patient",45828],["kitchen",45812],["aah",45774],["fat",45737],["passed",45678],["final",45614],["summer",45611],["bob",45572],["listening",45564],["escape",45553],["everywhere",45430],["moon",45422],["arms",45390],["turns",45374],["address",45332],["ow",45251],["match",45223],["gasps",45214],["grand",45183],["yep",45096],["shh",44939],["nervous",44913],["de",44903],["choose",44853],["themselves",44832],["decide",44806],["mate",44782],["drinking",44770],["press",44624],["bother",44619],["foot",44588],["hadn",44545],["blame",44534],["crap",44529],["drugs",44516],["type",44489],["rings",44331],["mission",44289],["named",44264],["heaven",44231],["picked",44114],["paris",44110],["race",44089],["risk",44005],["books",43897],["further",43839],["danny",43801],["action",43798],["allowed",43797],["orders",43786],["learned",43716],["price",43671],["arrived",43640],["nick",43636],["otherwise",43596],["pictures",43550],["fit",43502],["smoke",43421],["favor",43370],["played",43357],["notice",43315],["awesome",43298],["smile",43295],["director",43294],["guard",43270],["begin",43130],["spot",43079],["surprised",43018],["innocent",42962],["narrator",42916],["herself",42884],["london",42880],["feelings",42843],["enemy",42799],["battle",42785],["ourselves",42731],["alex",42710],["dollars",42705],["allow",42649],["nine",42538],["gay",42536],["department",42487],["guilty",42459],["apart",42417],["earlier",42407],["duty",42329],["jim",42323],["suit",42315],["bell",42299],["west",42219],["legs",42152],["hero",42093],["destroy",42078],["stage",42074],["bunch",42064],["according",42026],["chicken",41996],["bigger",41873],["grunting",41866],["low",41837],["helping",41832],["admit",41817],["closed",41801],["names",41772],["witness",41753],["upstairs",41732],["arm",41726],["steal",41720],["jimmy",41706],["kick",41697],["twice",41693],["cross",41692],["ways",41675],["sergeant",41650],["indeed",41591],["gas",41535],["keeping",41371],["energy",41290],["pregnant",41290],["waste",41240],["helped",41235],["fired",41193],["favorite",41153],["tony",41145],["taste",41080],["locked",41079],["places",41041],["writing",41035],["adam",41028],["brothers",41025],["starts",41012],["prince",40914],["form",40833],["sold",40796],["silly",40758],["mention",40753],["build",40738],["throat",40719],["hole",40658],["figured",40590],["track",40578],["ringing",40518],["lock",40462],["leg",40449],["above",40388],["hiding",40387],["steve",40377],["seemed",40343],["breakfast",40285],["engine",40282],["written",40246],["complete",40210],["video",40195],["applause",40194],["however",40098],["pressure",40065],["fresh",40049],["weapon",40033],["sky",39986],["stole",39897],["study",39882],["burn",39874],["reading",39869],["crowd",39856],["treat",39803],["roll",39791],["double",39770],["spirit",39556],["danger",39518],["cost",39485],["empty",39482],["level",39476],["memory",39331],["itself",39330],["acting",39326],["interest",39252],["nose",39194],["plans",39142],["following",39136],["bathroom",39095],["built",39041],["closer",39022],["ray",38995],["sarah",38933],["band",38918],["groans",38867],["apparently",38705],["excited",38640],["richard",38625],["losing",38585],["animals",38518],["flight",38504],["nature",38496],["raise",38449],["pop",38419],["client",38407],["bomb",38395],["neck",38371],["suspect",38365],["warm",38355],["extra",38335],["bottle",38229],["van",38223],["heavy",38220],["mommy",38167],["dogs",38164],["wild",38162],["ridiculous",38121],["simply",38100],["lee",38047],["animal",38036],["showed",38007],["billy",37951],["shooting",37914],["keeps",37914],["camp",37907],["guns",37893],["medical",37844],["shame",37793],["hoping",37769],["whom",37735],["bird",37711],["majesty",37699],["flowers",37699],["famous",37634],["asleep",37535],["beauty",37515],["driver",37474],["keys",37443],["rain",37417],["awful",37405],["large",37373],["local",37344],["deserve",37283],["goddamn",37270],["stone",37203],["jane",37198],["grace",37180],["consider",37165],["weekend",37115],["wondering",37023],["lay",36984],["plenty",36941],["willing",36938],["pants",36916],["sweetheart",36907],["skin",36861],["excellent",36849],["asshole",36844],["beach",36841],["faith",36815],["beg",36807],["fuckin",36802],["responsible",36781],["military",36733],["cheering",36723],["opportunity",36711],["common",36698],["bottom",36680],["german",36610],["whoever",36535],["cook",36531],["walked",36528],["papers",36502],["justice",36481],["hall",36354],["commander",36345],["drug",36316],["main",36292],["knife",36197],["devil",36189],["necessary",36124],["although",36046],["princess",36046],["lights",36039],["flying",36023],["dick",35996],["rose",35942],["knowing",35876],["clearly",35837],["hat",35828],["agreed",35752],["johnny",35735],["corner",35734],["code",35711],["note",35701],["tommy",35690],["due",35669],["correct",35595],["apologize",35587],["language",35569],["stars",35549],["faster",35547],["cars",35472],["folks",35431],["bullshit",35397],["fellow",35335],["several",35232],["grandma",35216],["shows",35209],["leader",35155],["leaves",35150],["restaurant",35126],["east",35109],["shouting",35108],["blind",35066],["ghost",35051],["cup",35016],["gotten",35001],["tight",34972],["conversation",34935],["tells",34873],["lies",34827],["nor",34821],["pulled",34816],["hanging",34808],["speed",34794],["stories",34792],["health",34788],["advice",34773],["held",34733],["uh-huh",34672],["murdered",34599],["beyond",34595],["rule",34517],["hardly",34482],["possibly",34442],["inspector",34442],["cousin",34395],["trial",34375],["emergency",34359],["ought",34342],["eddie",34314],["somehow",34291],["hearing",34211],["states",34128],["account",34088],["spoke",34035],["file",33969],["understood",33953],["angel",33922],["tape",33904],["milk",33863],["powerful",33854],["weapons",33779],["practice",33775],["manager",33775],["pardon",33755],["vote",33737],["jake",33704],["national",33697],["career",33682],["minister",33653],["super",33639],["taught",33613],["robert",33595],["biggest",33564],["plays",33550],["natural",33540],["dancing",33491],["copy",33489],["plus",33414],["martin",33293],["cake",33256],["freedom",33196],["among",33190],["breath",33176],["operation",33161],["chris",33139],["crew",33113],["challenge",33099],["market",33081],["meat",33050],["towards",33032],["bringing",33020],["dropped",32997],["student",32987],["lied",32979],["strength",32950],["size",32841],["breathe",32841],["color",32837],["monster",32819],["loud",32757],["photo",32757],["aw",32746],["nearly",32730],["sight",32715],["greatest",32692],["games",32623],["bridge",32588],["dressed",32547],["arrested",32506],["horrible",32492],["coach",32471],["planning",32400],["checked",32333],["breaking",32321],["noticed",32301],["fantastic",32288],["screams",32273],["serve",32190],["ideas",32182],["investigation",32178],["center",32146],["older",32130],["pack",32110],["soldiers",32098],["nonsense",32097],["doc",32073],["project",32040],["training",31973],["example",31952],["trick",31937],["prepared",31857],["science",31833],["united",31812],["travel",31791],["incredible",31786],["grandpa",31767],["paying",31696],["character",31657],["teeth",31615],["criminal",31574],["charles",31567],["chinese",31561],["truly",31514],["honestly",31501],["bro",31473],["survive",31440],["bobby",31434],["target",31415],["feed",31409],["nurse",31406],["fake",31355],["records",31339],["breathing",31306],["sweetie",31295],["numbers",31285],["oil",31269],["suicide",31261],["belong",31225],["whoo",31217],["perfectly",31201],["amy",31193],["forgotten",31150],["remain",31135],["original",31108],["papa",31095],["onto",31052],["concerned",31047],["credit",31029],["ugh",31008],["invited",30993],["discuss",30922],["research",30907],["easier",30876],["view",30874],["chair",30826],["hurts",30804],["strike",30801],["roger",30768],["fill",30683],["condition",30624],["mountain",30565],["dr",30549],["nowhere",30470],["sheriff",30458],["kim",30447],["turning",30420],["brown",30395],["recognize",30366],["heads",30350],["audience",30335],["jealous",30274],["pretend",30225],["society",30208],["finding",30196],["shirt",30183],["comfortable",30156],["meaning",30144],["guest",30128],["pieces",30122],["dry",30102],["letting",30057],["began",30049],["aye",30029],["jeff",29984],["female",29953],["release",29892],["ho",29883],["cards",29871],["pray",29862],["unfortunately",29849],["balls",29799],["destroyed",29789],["ended",29775],["universe",29765],["prepare",29762],["opinion",29749],["movies",29742],["soldier",29719],["wash",29707],["program",29668],["heat",29645],["usual",29636],["ticket",29625],["stolen",29618],["prefer",29606],["aware",29598],["surely",29565],["male",29548],["base",29547],["matters",29539],["lift",29528],["lab",29504],["command",29479],["proof",29430],["cream",29378],["selling",29357],["believed",29337],["create",29323],["afford",29290],["sunday",29282],["total",29264],["dumb",29229],["threw",29223],["france",29172],["birth",29167],["created",29134],["realized",29081],["british",29069],["noise",29049],["nuts",29037],["students",29034],["birds",29028],["social",28990],["brilliant",28972],["bodies",28945],["tie",28920],["opened",28868],["ours",28853],["bucks",28842],["kevin",28773],["mister",28739],["ugly",28713],["ryan",28655],["focus",28619],["dan",28601],["opens",28532],["exist",28526],["followed",28510],["england",28505],["draw",28503],["purpose",28471],["letters",28465],["daniel",28404],["opening",28386],["bullet",28377],["anna",28373],["lately",28340],["stayed",28309],["falling",28291],["season",28290],["ends",28242],["suggest",28213],["joy",28200],["distance",28199],["responsibility",28183],["whenever",28151],["issue",28140],["thousands",27986],["process",27930],["sword",27928],["shower",27901],["weak",27884],["fucked",27883],["lonely",27863],["happiness",27844],["eric",27842],["tiny",27829],["desk",27820],["pool",27818],["property",27815],["forced",27813],["settle",27798],["indistinct",27777],["weight",27761],["received",27728],["gang",27722],["bite",27719],["friday",27696],["disappeared",27695],["interview",27692],["expecting",27676],["kinda",27662],["surgery",27630],["horses",27542],["mayor",27530],["babe",27518],["ancient",27510],["handsome",27462],["thomas",27442],["saturday",27434],["staff",27397],["lines",27383],["unit",27376],["fan",27372],["gentleman",27345],["introduce",27302],["fate",27299],["split",27273],["recently",27204],["expected",27140],["add",27132],["ordered",27127],["slowly",27121],["alarm",27116],["member",27112],["slept",27077],["signed",27074],["enter",27074],["spanish",27061],["garden",27044],["brings",27005],["brave",26990],["pig",26989],["model",26977],["finger",26967],["medicine",26958],["access",26937],["failed",26879],["flat",26848],["easily",26840],["discovered",26836],["based",26829],["screw",26824],["insane",26788],["cares",26777],["weather",26740],["fingers",26736],["san",26690],["scott",26686],["path",26681],["soft",26642],["harm",26574],["style",26569],["community",26540],["sees",26505],["basically",26461],["al",26447],["signal",26438],["nope",26427],["spare",26393],["speech",26393],["covered",26386],["shake",26384],["loose",26356],["snow",26308],["russian",26288],["lake",26265],["bright",26241],["roof",26204],["ohh",26185],["sending",26184],["paint",26174],["remind",26171],["pal",26163],["naked",26122],["post",26113],["sugar",26110],["heading",26098],["streets",26067],["damage",26061],["silence",26053],["doors",26020],["pete",26016],["ed",26011],["success",25989],["wet",25987],["nah",25962],["amount",25945],["members",25937],["kate",25931],["manage",25874],["safety",25861],["returned",25860],["harder",25812],["fbi",25798],["block",25785],["showing",25785],["fancy",25771],["chef",25754],["contract",25746],["dig",25698],["chest",25688],["good-bye",25685],["drinks",25643],["dave",25633],["buried",25623],["brian",25604],["trade",25560],["journey",25518],["stomach",25500],["changes",25456],["details",25451],["thoughts",25436],["divorce",25433],["funeral",25423],["maria",25422],["football",25398],["reality",25398],["theory",25360],["gosh",25335],["ruined",25312],["gate",25308],["william",25303],["spread",25291],["outta",25290],["japanese",25288],["sudden",25262],["coat",25261],["sooner",25236],["cheese",25222],["larry",25197],["spring",25184],["page",25183],["ears",25168],["simon",25165],["castle",25150],["hidden",25143],["storm",25143],["cos",25116],["personally",25058],["artist",25056],["hill",25054],["exciting",25052],["permission",24999],["jerry",24979],["expensive",24978],["tickets",24973],["forest",24943],["barely",24909],["eggs",24897],["goin",24873],["regret",24846],["lesson",24843],["lover",24842],["bread",24828],["andy",24822],["subject",24820],["legal",24785],["growing",24779],["ill",24763],["jason",24753],["mood",24739],["owner",24722],["caused",24686],["beeping",24670],["points",24659],["dating",24645],["loss",24625],["secretary",24623],["revenge",24565],["santa",24532],["likely",24527],["rent",24451],["connection",24450],["assistant",24449],["reasons",24431],["yelling",24425],["painting",24401],["trees",24400],["doctors",24380],["rush",24375],["foreign",24370],["rough",24370],["murderer",24363],["century",24363],["nights",24324],["pair",24293],["runs",24284],["pocket",24261],["farm",24254],["matt",24245],["bike",24233],["obvious",24230],["ate",24218],["grew",24208],["professional",24205],["tim",24197],["goodness",24190],["parts",24186],["alan",24104],["university",24079],["square",24077],["grandfather",24064],["europe",24042],["genius",24032],["cases",24023],["ruin",24008],["winter",24005],["tongue",24001],["memories",24000],["da",23954],["buying",23941],["cancer",23935],["clock",23907],["ocean",23905],["dna",23889],["liar",23882],["tour",23875],["thief",23870],["bleep",23861],["lisa",23839],["rights",23824],["including",23813],["competition",23805],["smith",23799],["planned",23797],["emily",23792],["boring",23737],["victims",23737],["mentioned",23716],["bones",23635],["plant",23599],["bless",23587],["warning",23583],["knocking",23577],["re",23559],["crash",23547],["bedroom",23542],["lower",23529],["silver",23523],["groaning",23492],["madame",23490],["defense",23479],["results",23478],["toilet",23467],["event",23463],["complicated",23462],["shape",23460],["priest",23453],["royal",23448],["cheap",23430],["romantic",23427],["downstairs",23403],["invite",23394],["fortune",23379],["tears",23377],["avoid",23374],["reached",23358],["higher",23354],["familiar",23348],["telephone",23343],["burning",23326],["filled",23319],["kelly",23313],["alice",23311],["airport",23293],["jobs",23268],["grown",23266],["walter",23260],["rachel",23258],["giant",23227],["insurance",23218],["woods",23211],["scare",23192],["pleased",23158],["period",23158],["political",23126],["player",23087],["stops",23086],["secrets",23070],["laura",23050],["repeat",23036],["photos",23035],["finds",23019],["statement",22990],["suck",22982],["younger",22961],["china",22926],["humans",22912],["delicious",22901],["particular",22890],["proper",22889],["rome",22884],["belongs",22870],["attacked",22868],["bath",22866],["hired",22838],["site",22834],["knowledge",22822],["led",22806],["guests",22805],["celebrate",22764],["map",22726],["horn",22716],["eventually",22679],["pity",22667],["powers",22649],["ashamed",22623],["assume",22616],["glasses",22604],["rise",22596],["fixed",22575],["request",22533],["officers",22496],["pounds",22483],["data",22478],["carefully",22477],["per",22472],["depends",22413],["jury",22389],["waited",22384],["positive",22369],["attorney",22345],["direction",22341],["families",22338],["doin",22289],["forces",22284],["location",22282],["walls",22247],["useless",22228],["grant",22210],["saving",22196],["speaks",22181],["fought",22170],["meal",22145],["deliver",22124],["answers",22116],["changing",22102],["temple",22036],["scary",22035],["millions",22009],["offered",21983],["regular",21961],["carrying",21905],["official",21871],["jacket",21863],["switch",21862],["grave",21831],["chase",21830],["role",21813],["odd",21790],["sexy",21774],["faces",21771],["becomes",21768],["closes",21761],["badly",21750],["tall",21737],["confused",21737],["affair",21719],["television",21718],["shock",21717],["raised",21712],["panting",21711],["pizza",21677],["image",21669],["clears",21657],["golden",21644],["patients",21543],["watched",21542],["committed",21517],["sexual",21499],["suffer",21496],["arthur",21491],["wherever",21483],["plate",21466],["appear",21449],["chocolate",21440],["clever",21429],["hm",21429],["mercy",21410],["shots",21399],["lucy",21366],["dealing",21356],["trap",21352],["charges",21347],["phil",21338],["bang",21327],["poison",21323],["butt",21314],["drove",21309],["yourselves",21282],["headed",21258],["babies",21235],["yellow",21226],["soup",21202],["mystery",21181],["picking",21173],["sat",21163],["wound",21144],["traffic",21084],["courage",21070],["hunt",21068],["indian",21049],["rat",21042],["terms",21034],["italian",21021],["emma",21005],["checking",20978],["disease",20973],["managed",20964],["winner",20958],["council",20941],["appointment",20930],["monday",20923],["crack",20916],["threat",20913],["jenny",20899],["physical",20898],["nation",20894],["source",20892],["chose",20888],["healthy",20853],["carl",20851],["victory",20846],["rick",20835],["stood",20827],["kicked",20800],["annie",20794],["disgusting",20786],["palace",20785],["below",20771],["shopping",20754],["neighborhood",20746],["march",20744],["lips",20725],["midnight",20723],["piss",20720],["advantage",20711],["pure",20685],["india",20682],["aside",20677],["jerk",20633],["mail",20627],["dawn",20598],["effect",20595],["spell",20592],["freak",20578],["wood",20570],["screwed",20566],["enemies",20563],["gary",20553],["awake",20552],["chuckling",20547],["vacation",20544],["violence",20527],["leo",20521],["grandmother",20515],["modern",20496],["luke",20488],["firm",20482],["prime",20480],["heh",20476],["touched",20462],["talent",20450],["whore",20444],["piano",20426],["license",20413],["cooking",20410],["honour",20377],["concern",20376],["moves",20349],["available",20347],["factory",20341],["gods",20335],["value",20324],["central",20322],["union",20316],["mirror",20313],["studio",20312],["media",20306],["taxi",20268],["dies",20261],["iron",20252],["hearts",20232],["desire",20229],["rob",20228],["claire",20226],["fred",20224],["songs",20215],["washington",20212],["monkey",20211],["pride",20205],["pills",20181],["miracle",20179],["swim",20171],["burned",20170],["smells",20103],["joking",20073],["christian",20041],["bleeding",20017],["hook",20015],["beating",20006],["protection",19995],["treatment",19950],["ear",19950],["carter",19940],["metal",19938],["disappear",19933],["grateful",19921],["extremely",19901],["treasure",19899],["rescue",19892],["capable",19848],["passing",19834],["chatter",19834],["result",19823],["suffering",19819],["sisters",19817],["laid",19791],["governor",19790],["guards",19757],["louis",19754],["text",19730],["literally",19728],["remove",19723],["becoming",19723],["performance",19713],["stronger",19713],["rate",19700],["deeply",19685],["scream",19681],["desert",19666],["vehicle",19664],["illegal",19658],["pulling",19630],["throwing",19613],["josh",19609],["unbelievable",19591],["ted",19583],["zero",19582],["dean",19581],["curious",19576],["sean",19572],["candy",19571],["bone",19546],["tied",19530],["edge",19515],["load",19510],["susan",19462],["ahh",19459],["mountains",19451],["boom",19432],["former",19422],["holiday",19399],["riding",19398],["scoffs",19383],["hundreds",19379],["motherfucker",19379],["sue",19375],["bags",19374],["stealing",19327],["appears",19313],["arrive",19306],["remains",19299],["decent",19294],["issues",19278],["tip",19263],["fail",19258],["bride",19249],["pissed",19247],["penny",19236],["claim",19233],["friendship",19225],["desperate",19211],["flower",19199],["dramatic",19189],["refuse",19168],["solve",19166],["theme",19163],["loving",19152],["properly",19152],["dragon",19123],["mostly",19116],["chuck",19107],["directly",19104],["surface",19100],["false",19075],["cast",19073],["junior",19055],["hunting",19043],["silent",19042],["thou",19007],["egg",19000],["germany",18972],["punch",18951],["africa",18950],["woke",18947],["intelligence",18945],["borrow",18900],["winning",18898],["falls",18897],["popular",18896],["escaped",18891],["witch",18890],["convinced",18875],["warn",18847],["announcer",18839],["champagne",18824],["dust",18820],["kyle",18807],["someday",18800],["fallen",18795],["stranger",18791],["presence",18788],["tear",18766],["internet",18765],["monsieur",18763],["therefore",18733],["technology",18679],["tires",18678],["toast",18638],["wise",18634],["clark",18625],["americans",18614],["notes",18614],["smoking",18610],["hank",18609],["ls",18609],["april",18608],["wolf",18566],["precious",18562],["rooms",18562],["coast",18547],["treated",18542],["material",18538],["released",18533],["uniform",18533],["hated",18529],["considered",18518],["rice",18503],["exchange",18494],["steps",18492],["blake",18491],["convince",18445],["beast",18414],["cow",18392],["files",18385],["signs",18381],["drag",18380],["cab",18379],["hung",18378],["carried",18366],["ordinary",18352],["successful",18352],["eve",18346],["mistakes",18340],["houses",18338],["chattering",18327],["brains",18320],["japan",18308],["creature",18303],["fourth",18301],["separate",18291],["cleaning",18286],["california",18275],["lf",18264],["ll",18255],["shift",18253],["elizabeth",18247],["chicago",18217],["exact",18213],["karen",18204],["goal",18194],["expert",18181],["served",18169],["direct",18122],["score",18120],["row",18117],["marks",18117],["twenty",18112],["receive",18100],["series",18098],["section",18094],["pilot",18070],["jackson",18045],["darkness",18029],["thy",18023],["sale",18014],["destiny",18000],["cure",17999],["spoken",17993],["armed",17960],["rare",17927],["helen",17910],["grade",17882],["juice",17879],["wide",17850],["tower",17842],["solution",17830],["schedule",17817],["explosion",17799],["wheel",17795],["cigarette",17794],["julie",17794],["fruit",17773],["blew",17768],["victor",17768],["talks",17763],["sacrifice",17760],["range",17758],["button",17747],["reports",17743],["prisoner",17740],["pie",17716],["effort",17715],["youth",17703],["pot",17692],["eaten",17689],["taylor",17672],["knees",17671],["garage",17652],["parking",17645],["ambulance",17637],["staring",17630],["chances",17624],["circumstances",17621],["sin",17617],["gordon",17605],["progress",17605],["unusual",17603],["county",17598],["stairs",17597],["campaign",17583],["vision",17563],["reporter",17553],["equipment",17536],["moments",17531],["joey",17518],["mass",17516],["alien",17513],["trapped",17495],["helps",17480],["route",17472],["defend",17469],["trace",17465],["nasty",17464],["tests",17462],["magazine",17461],["rocks",17459],["dump",17455],["rude",17432],["searching",17430],["clue",17430],["connected",17429],["amen",17429],["pushed",17408],["merry",17407],["zone",17386],["senior",17374],["closing",17372],["freeze",17372],["emperor",17360],["incident",17339],["snake",17335],["mexico",17326],["lily",17324],["actor",17318],["newspaper",17312],["sentence",17312],["greg",17299],["leading",17295],["friendly",17294],["jones",17294],["chosen",17293],["engaged",17285],["julia",17281],["charming",17277],["mustn",17276],["anger",17258],["spending",17255],["learning",17252],["spy",17251],["sharp",17246],["shadow",17240],["warrant",17239],["elevator",17231],["kingdom",17231],["ricky",17230],["jamie",17227],["squad",17220],["shoulder",17198],["wave",17194],["amanda",17189],["highness",17174],["kinds",17172],["fishing",17170],["sometime",17142],["attitude",17122],["sucks",17110],["negative",17091],["screen",17070],["workers",17054],["bored",17028],["cameras",17026],["similar",17016],["understanding",17000],["beeps",16989],["chain",16978],["bull",16952],["gasping",16948],["fully",16943],["robin",16921],["collect",16907],["gorgeous",16895],["morgan",16891],["passion",16878],["writer",16877],["empire",16875],["international",16875],["curse",16867],["hire",16865],["bastards",16864],["puts",16846],["leads",16844],["id",16835],["blows",16823],["sons",16815],["estate",16813],["customers",16788],["maggie",16770],["gunshot",16763],["i-",16759],["device",16734],["troops",16729],["hug",16724],["design",16691],["movement",16686],["thunder",16685],["object",16680],["loser",16663],["pen",16656],["sobbing",16648],["politics",16636],["despite",16632],["alcohol",16625],["climb",16614],["clients",16613],["conference",16610],["provide",16609],["marty",16606],["seek",16595],["witnesses",16584],["earn",16558],["trash",16555],["valley",16540],["fashion",16521],["howard",16500],["episode",16500],["prize",16489],["previously",16487],["sports",16468],["wanting",16465],["debt",16464],["wishes",16453],["hitler",16449],["wire",16416],["thee",16410],["circle",16410],["approach",16374],["stock",16373],["facts",16365],["remembered",16359],["percent",16352],["normally",16348],["tail",16347],["joined",16342],["education",16326],["bravo",16320],["library",16307],["rape",16303],["wings",16298],["growling",16293],["thrown",16290],["tank",16273],["exhales",16267],["authority",16265],["current",16251],["failure",16251],["nightmare",16248],["hitting",16244],["glory",16221],["knocked",16221],["studying",16217],["barry",16213],["agency",16192],["apple",16192],["border",16172],["rope",16159],["duck",16153],["reputation",16145],["confidence",16138],["yard",16138],["beloved",16136],["lack",16112],["actual",16107],["skills",16100],["films",16089],["mental",16088],["salt",16079],["flesh",16064],["anne",16062],["secure",16048],["highly",16045],["federal",16026],["sand",16019],["emotional",16018],["setting",16012],["nothin",15992],["senator",15992],["services",15982],["robbery",15978],["hates",15968],["fed",15965],["reward",15944],["theater",15940],["johnson",15936],["commit",15921],["patrick",15903],["entirely",15891],["extraordinary",15889],["ships",15878],["opposite",15870],["parties",15865],["stands",15856],["terry",15820],["chat",15812],["events",15804],["mummy",15781],["bay",15777],["slip",15773],["disappointed",15771],["settled",15766],["begins",15758],["andrew",15748],["alert",15747],["detail",15745],["pink",15742],["fellas",15736],["accepted",15736],["background",15730],["garbage",15709],["panic",15707],["minds",15707],["belt",15707],["blowing",15705],["hollywood",15691],["agreement",15659],["jackie",15659],["pushing",15648],["ability",15648],["tiger",15646],["whispering",15642],["culture",15640],["saint",15618],["jesse",15612],["task",15600],["solid",15581],["doll",15579],["intend",15570],["district",15566],["pa",15566],["gee",15564],["custody",15562],["ignore",15550],["naturally",15531],["useful",15524],["attempt",15516],["abandoned",15502],["cutting",15498],["kissed",15495],["guarantee",15493],["barking",15493],["gather",15484],["policy",15482],["violent",15464],["maid",15457],["embarrassing",15455],["childhood",15445],["wasting",15434],["bow",15423],["chick",15412],["sara",15387],["disaster",15366],["revolution",15350],["online",15339],["demon",15328],["heavily",15326],["coincidence",15318],["perform",15312],["thin",15296],["terrific",15296],["mac",15285],["crown",15281],["identity",15281],["virgin",15276],["impressive",15272],["windows",15229],["no-one",15212],["potential",15193],["guitar",15191],["committee",15152],["dozen",15139],["delivery",15137],["advance",15126],["quietly",15115],["stan",15106],["teaching",15100],["hunter",15097],["latest",15091],["hurting",15089],["swing",15079],["capital",15073],["counting",15069],["drew",15068],["dirt",15061],["whistle",15045],["marie",15044],["hits",15034],["doug",15030],["deny",15012],["urgent",15006],["threatened",14987],["behavior",14984],["molly",14980],["mixed",14977],["stays",14944],["assure",14943],["subtitles",14939],["explanation",14934],["wins",14921],["unique",14905],["chasing",14899],["billion",14895],["duke",14892],["production",14892],["slave",14888],["agents",14888],["carol",14887],["lets",14876],["cruel",14872],["mask",14863],["sally",14857],["behave",14851],["bury",14798],["massive",14789],["pathetic",14789],["species",14775],["approaching",14768],["jo",14768],["loan",14751],["struggle",14736],["dish",14734],["trusted",14722],["jumped",14699],["firing",14695],["channel",14688],["abby",14677],["stopping",14667],["asks",14655],["jean",14651],["swimming",14648],["daily",14647],["basement",14645],["linda",14644],["quarter",14642],["erm",14625],["bound",14619],["navy",14616],["article",14614],["guide",14614],["accused",14612],["transfer",14609],["guts",14598],["quality",14596],["fella",14583],["roman",14575],["greater",14569],["damned",14557],["couch",14529],["cheer",14507],["vegas",14494],["shoe",14489],["title",14488],["shy",14479],["punishment",14476],["los",14462],["clicks",14462],["closet",14440],["bye-bye",14436],["fever",14430],["coward",14421],["flash",14418],["impressed",14417],["bruce",14413],["siren",14411],["prisoners",14410],["sides",14403],["bowl",14401],["katie",14399],["supply",14396],["sensitive",14396],["hopefully",14389],["rolling",14377],["roy",14370],["dollar",14367],["tone",14366],["voices",14350],["cheating",14310],["confess",14303],["demand",14303],["museum",14302],["unknown",14300],["drama",14284],["suspicious",14279],["adult",14267],["todd",14262],["frightened",14246],["warned",14244],["steady",14241],["kills",14233],["crossed",14222],["lou",14221],["particularly",14220],["sandwich",14200],["laws",14196],["joint",14190],["bullets",14186],["package",14169],["trained",14156],["thursday",14150],["boots",14143],["possibility",14141],["albert",14140],["civil",14131],["june",14126],["germans",14122],["baseball",14106],["express",14103],["miserable",14078],["tuesday",14074],["parker",14072],["lion",14069],["joseph",14059],["refused",14058],["shine",14055],["assault",14053],["hong",14048],["jokes",14047],["breaks",14045],["argue",14042],["you-",14035],["trail",14033],["option",14026],["recall",14021],["mighty",14014],["collection",14008],["oliver",13994],["wilson",13994],["accent",13993],["believes",13985],["decisions",13978],["embarrassed",13972],["mobile",13970],["customer",13954],["terribly",13954],["sec",13941],["charity",13935],["considering",13923],["pussy",13922],["donna",13918],["protecting",13916],["flag",13907],["kidnapped",13898],["italy",13890],["balance",13887],["creatures",13885],["nuclear",13873],["wallet",13863],["shout",13856],["vincent",13852],["entered",13850],["impression",13849],["angela",13848],["jay",13847],["degrees",13842],["favour",13830],["reaction",13825],["network",13816],["jessica",13814],["version",13814],["concert",13801],["gear",13780],["singer",13777],["diamond",13777],["defeat",13775],["steven",13772],["lane",13771],["blast",13764],["wounded",13763],["anytime",13761],["partners",13760],["ages",13759],["charlotte",13759],["ceremony",13753],["league",13742],["bars",13738],["orange",13738],["specific",13708],["conditions",13699],["plastic",13694],["crisis",13690],["fifth",13690],["term",13676],["self",13657],["offering",13655],["bud",13654],["suffered",13646],["reported",13643],["commissioner",13637],["rip",13626],["mysterious",13617],["entrance",13616],["messed",13609],["tunnel",13593],["designed",13591],["neighbor",13590],["aboard",13582],["sweat",13581],["justin",13578],["ghosts",13576],["crimes",13566],["costs",13561],["deserves",13558],["dreaming",13557],["bust",13552],["el",13551],["teddy",13549],["kong",13546],["messages",13545],["enjoying",13542],["afterwards",13540],["comfort",13536],["mason",13518],["surrender",13513],["gym",13513],["kitty",13499],["crush",13497],["yup",13494],["arranged",13475],["suits",13469],["pee",13455],["stress",13446],["cave",13446],["wing",13445],["jordan",13443],["arrange",13442],["comrade",13438],["noble",13437],["tricks",13428],["betty",13426],["launch",13420],["legend",13418],["mix",13418],["golf",13415],["imagination",13415],["fox",13409],["kissing",13398],["butter",13398],["tax",13389],["gross",13387],["edward",13367],["texas",13351],["insist",13332],["marcus",13314],["classic",13308],["selfish",13305],["skull",13299],["frankie",13299],["shown",13295],["bat",13294],["survived",13293],["print",13293],["champion",13290],["bills",13286],["therapy",13278],["lifetime",13272],["throughout",13270],["nathan",13261],["toward",13252],["sauce",13249],["experiment",13242],["margaret",13236],["describe",13221],["ross",13220],["grass",13219],["cheat",13204],["attend",13202],["homework",13202],["lewis",13201],["betrayed",13199],["servant",13198],["cap",13198],["tyler",13196],["bible",13193],["practically",13180],["catherine",13179],["steel",13177],["wipe",13177],["burns",13175],["generation",13163],["cabin",13159],["financial",13153],["industry",13143],["bond",13131],["sheep",13127],["scientists",13124],["beaten",13120],["heck",13118],["deck",13118],["fans",13116],["script",13108],["brand",13107],["degree",13103],["painful",13100],["coughing",13093],["religion",13083],["cats",13076],["stephen",13069],["influence",13066],["fuel",13066],["drawing",13056],["uh-oh",13043],["rabbit",13027],["exercise",13015],["attractive",13008],["touching",12992],["virus",12984],["realise",12977],["torture",12970],["blessed",12960],["plain",12952],["rebecca",12950],["recent",12948],["barbara",12947],["footsteps",12944],["sacred",12939],["removed",12931],["pretending",12910],["wasted",12909],["jewish",12898],["goodnight",12889],["robot",12886],["crystal",12885],["confirm",12878],["lad",12876],["mile",12874],["division",12873],["scratch",12856],["reminds",12848],["walks",12837],["ending",12835],["russia",12828],["contest",12826],["valuable",12803],["distant",12801],["dumped",12793],["motion",12791],["electricity",12789],["centre",12782],["murders",12778],["tina",12777],["jeremy",12772],["seal",12770],["standard",12762],["ellen",12762],["wore",12754],["surrounded",12750],["struck",12749],["inform",12745],["gentle",12745],["status",12743],["purse",12740],["prints",12737],["players",12735],["charged",12733],["begging",12730],["delivered",12713],["kenny",12713],["buck",12711],["nina",12708],["construction",12705],["confirmed",12705],["confession",12702],["trigger",12698],["cells",12693],["frankly",12691],["ali",12691],["exit",12688],["response",12684],["cancel",12681],["argument",12669],["principal",12667],["turkey",12662],["lucas",12659],["clinic",12658],["greetings",12651],["neighbors",12646],["vice",12641],["toy",12634],["hannah",12632],["everyday",12627],["generous",12622],["gain",12620],["idiots",12614],["holly",12610],["enjoyed",12603],["jungle",12593],["link",12591],["underground",12588],["smiling",12580],["mistress",12573],["teams",12567],["product",12560],["beef",12560],["whispers",12549],["religious",12544],["presents",12544],["identify",12538],["chip",12537],["chffffff",12527],["surveillance",12526],["carlos",12522],["vampire",12502],["routine",12497],["uses",12485],["michelle",12482],["underneath",12477],["systems",12464],["temperature",12455],["waves",12451],["tribe",12451],["brad",12442],["deputy",12440],["sophie",12437],["headquarters",12437],["equal",12433],["phones",12424],["ken",12419],["reckon",12411],["related",12408],["incredibly",12408],["chill",12405],["spit",12399],["tracks",12399],["oscar",12396],["makeup",12393],["bug",12385],["sounded",12385],["spirits",12381],["nerve",12377],["divorced",12374],["stake",12356],["port",12355],["doorbell",12355],["worries",12344],["nephew",12340],["miller",12331],["units",12323],["just-",12321],["proceed",12314],["landing",12310],["traitor",12303],["outfit",12301],["chloe",12298],["bail",12295],["fields",12290],["patience",12288],["recording",12286],["foolish",12268],["loaded",12268],["tokyo",12261],["davis",12258],["costume",12253],["wayne",12252],["injured",12251],["somethin",12242],["ian",12239],["pet",12235],["cage",12231],["digging",12218],["spain",12200],["seats",12199],["awkward",12197],["cleaned",12182],["pattern",12181],["filthy",12179],["visiting",12178],["jews",12170],["answering",12168],["concentrate",12157],["someplace",12156],["citizens",12153],["aim",12152],["nancy",12149],["affairs",12143],["thick",12143],["sport",12131],["basic",12130],["electric",12125],["pleasant",12122],["cliff",12109],["nail",12105],["russell",12102],["environment",12101],["western",12101],["average",12100],["ease",12094],["raped",12088],["interrupt",12088],["judy",12085],["satisfied",12083],["beep",12076],["starving",12075],["documents",12070],["anniversary",12069],["beth",12061],["election",12060],["warrior",12054],["forth",12049],["fetch",12029],["banks",12029],["placed",12026],["timing",12009],["stones",12006],["complex",11993],["frozen",11992],["replace",11983],["prayer",11982],["skip",11977],["angeles",11974],["guilt",11973],["tune",11959],["woo",11943],["actions",11940],["conscience",11934],["officially",11933],["martha",11902],["machines",11899],["smaller",11897],["determined",11890],["blown",11886],["hail",11884],["unhappy",11881],["booth",11880],["pour",11879],["berlin",11871],["cleared",11869],["packed",11868],["wrap",11865],["randy",11863],["behalf",11840],["reasonable",11835],["trunk",11828],["homes",11826],["festival",11823],["tradition",11821],["cigarettes",11817],["le",11814],["beside",11813],["harvey",11810],["motive",11808],["beings",11805],["bishop",11803],["dealer",11801],["defendant",11798],["backup",11798],["wounds",11782],["ouch",11779],["ann",11776],["nearby",11769],["drank",11765],["effects",11765],["jonathan",11764],["dennis",11751],["benefit",11751],["adventure",11744],["territory",11737],["ve",11729],["apology",11723],["dylan",11723],["unlike",11719],["owns",11717],["boxes",11699],["thus",11684],["clay",11682],["developed",11679],["busted",11678],["pipe",11672],["gray",11667],["goods",11659],["favourite",11650],["salad",11646],["loyal",11641],["atmosphere",11639],["eva",11633],["freaking",11630],["dropping",11626],["strangers",11621],["mouse",11619],["downtown",11608],["francisco",11606],["heroes",11601],["pit",11599],["rotten",11586],["paradise",11584],["meantime",11582],["jess",11577],["organization",11576],["hills",11575],["exam",11573],["cock",11565],["fairy",11562],["earl",11562],["comment",11561],["activity",11555],["frame",11555],["knight",11542],["testing",11541],["habit",11536],["shelter",11527],["flow",11524],["jennifer",11509],["holes",11505],["prevent",11504],["anthony",11504],["lend",11500],["cooper",11490],["figures",11488],["boston",11484],["sample",11484],["strip",11481],["landed",11481],["buzzing",11477],["monk",11475],["slightly",11474],["produce",11471],["annoying",11467],["judgment",11459],["laundry",11458],["ron",11456],["lousy",11454],["souls",11454],["existence",11448],["belly",11442],["tries",11441],["foster",11435],["returning",11434],["answered",11431],["ward",11428],["plants",11419],["actress",11417],["chairman",11416],["individual",11409],["hopes",11409],["tattoo",11407],["fence",11402],["sink",11400],["punk",11400],["confident",11399],["yay",11393],["mistaken",11385],["limit",11384],["bothering",11383],["st",11375],["uncomfortable",11371],["wednesday",11367],["gifts",11365],["policeman",11364],["na",11360],["precisely",11346],["lawyers",11345],["greek",11339],["merely",11335],["criminals",11335],["underwear",11333],["hoped",11315],["earned",11315],["reveal",11310],["appeared",11306],["derek",11306],["heavens",11304],["personality",11303],["batman",11300],["virginia",11288],["wives",11285],["colour",11281],["worker",11277],["pope",11257],["instructions",11252],["intelligent",11251],["worrying",11246],["vince",11246],["comin",11237],["cried",11236],["traveling",11223],["bells",11222],["impact",11206],["robbed",11188],["relief",11188],["host",11183],["footage",11171],["odds",11170],["patrol",11170],["circus",11167],["mud",11166],["captured",11164],["lessons",11157],["occasion",11152],["sets",11151],["pulse",11145],["ad",11131],["invented",11131],["diamonds",11128],["matthew",11128],["auntie",11127],["cloud",11125],["francis",11123],["angels",11121],["hers",11120],["classes",11120],["mm-hm",11120],["signature",11114],["complain",11112],["blah",11108],["monitor",11106],["options",11104],["claims",11103],["flies",11093],["pat",11084],["britain",11077],["hid",11076],["wailing",11070],["listened",11059],["countries",11055],["vic",11054],["yell",11053],["rats",11046],["wondered",11041],["smooth",11036],["resist",11034],["companies",11033],["fantasy",11031],["passport",11030],["pitch",11022],["hammer",11015],["homicide",11008],["casey",11004],["holds",11004],["flew",10994],["jacob",10984],["noon",10982],["helicopter",10980],["dishes",10980],["spin",10980],["charm",10980],["slap",10977],["apply",10970],["fools",10969],["screeching",10966],["discover",10961],["previous",10960],["kit",10959],["authorities",10958],["moaning",10957],["photograph",10953],["sales",10950],["fifty",10941],["mickey",10939],["beneath",10937],["farewell",10937],["clouds",10936],["slipped",10931],["represent",10930],["deaf",10924],["facing",10917],["offense",10906],["citizen",10905],["clown",10883],["snap",10881],["messing",10878],["hood",10873],["twelve",10871],["interests",10866],["cheated",10865],["liz",10861],["informed",10853],["humanity",10845],["producer",10843],["technically",10839],["accounts",10832],["extreme",10829],["gettin",10829],["cia",10825],["ii",10823],["pays",10813],["profile",10808],["oxygen",10804],["jeez",10797],["gene",10796],["shed",10791],["minor",10781],["ex",10780],["theatre",10776],["scientific",10774],["lovers",10769],["chaos",10764],["rocket",10752],["math",10739],["stubborn",10738],["august",10736],["oi",10736],["chips",10734],["intense",10734],["grey",10729],["talkin",10729],["terrorist",10728],["angle",10724],["invitation",10721],["gus",10715],["gambling",10714],["respond",10704],["thirty",10699],["procedure",10698],["absolute",10696],["investigate",10695],["tragedy",10691],["stable",10690],["session",10682],["capture",10679],["marrying",10677],["ripped",10671],["attacks",10669],["stretch",10667],["bush",10666],["dates",10660],["unable",10659],["gates",10658],["mars",10656],["artists",10655],["increase",10629],["tastes",10620],["cared",10611],["bottles",10606],["highest",10590],["whirring",10589],["meanwhile",10589],["nate",10583],["grabbed",10579],["pigs",10566],["chop",10564],["olivia",10560],["wheels",10557],["shocked",10546],["reverend",10539],["commercial",10539],["escort",10537],["engagement",10535],["corpse",10531],["louise",10525],["worthy",10519],["scale",10519],["beats",10517],["williams",10516],["threatening",10510],["exists",10509],["academy",10509],["acts",10506],["hop",10501],["judges",10490],["furniture",10487],["shared",10484],["ralph",10474],["consequences",10473],["engineer",10471],["schools",10470],["softly",10462],["bombs",10459],["caroline",10457],["shark",10456],["transport",10456],["population",10449],["succeed",10441],["creepy",10420],["sneak",10416],["studies",10402],["destruction",10401],["keith",10400],["protected",10400],["monsters",10398],["joining",10396],["punished",10395],["lightning",10394],["malcolm",10382],["shell",10381],["fascinating",10378],["chamber",10376],["ethan",10374],["romance",10374],["instance",10371],["jumping",10367],["groups",10365],["exhausted",10362],["testify",10361],["studied",10355],["walker",10355],["pin",10355],["imagined",10353],["drill",10349],["investigating",10348],["ye",10344],["experienced",10323],["elena",10323],["necklace",10321],["loyalty",10321],["junk",10313],["cole",10307],["cries",10306],["stanley",10305],["rita",10304],["southern",10300],["blocks",10290],["emotions",10289],["begun",10285],["maya",10285],["liver",10281],["serving",10281],["matches",10279],["surgeon",10278],["granted",10275],["jazz",10272],["july",10271],["supplies",10271],["deeper",10270],["typical",10266],["kicking",10265],["obey",10262],["dancer",10251],["remote",10235],["daughters",10230],["moron",10229],["latin",10223],["teachers",10214],["toby",10209],["sandy",10207],["dragged",10206],["strategy",10205],["parent",10204],["jin",10200],["album",10199],["compared",10181],["rubbish",10181],["helpful",10173],["powder",10172],["sync",10168],["bum",10165],["passes",10162],["joan",10153],["fort",10152],["choices",10148],["alley",10145],["supper",10144],["penis",10143],["alibi",10141],["sammy",10138],["pole",10136],["coke",10133],["scientist",10126],["fighter",10121],["highway",10103],["blade",10100],["alpha",10093],["diet",10092],["liquor",10090],["jet",10082],["widow",10080],["liberty",10080],["philip",10080],["moral",10077],["carrie",10062],["award",10055],["grief",10054],["thirsty",10053],["ashley",10047],["random",10039],["suspects",10038],["intention",10035],["julian",10026],["active",10024],["tools",10020],["driven",10014],["trauma",10014],["headache",10008],["safely",10001],["alexander",10001],["knee",10000],["lads",9999],["novel",9997],["conversations",9995],["waters",9992],["lookin",9991],["invisible",9991],["internal",9988],["peaceful",9987],["humming",9986],["washed",9985],["drives",9981],["talented",9970],["li",9969],["aid",9965],["elephant",9951],["troubles",9940],["core",9939],["serial",9936],["painted",9934],["divine",9926],["jam",9923],["goat",9922],["opera",9918],["thieves",9915],["guessing",9912],["objection",9894],["whiskey",9893],["florida",9889],["resistance",9888],["dressing",9887],["attached",9884],["aaron",9883],["brief",9876],["punish",9876],["eternal",9873],["lois",9872],["required",9862],["victoria",9858],["fabulous",9853],["twins",9850],["characters",9847],["cooked",9846],["ln",9844],["ruth",9836],["dreamed",9835],["arts",9831],["naughty",9829],["stabbed",9824],["tend",9824],["diane",9819],["tap",9813],["soap",9811],["locker",9803],["development",9803],["images",9802],["prick",9801],["global",9793],["string",9786],["bitter",9785],["sharing",9785],["corn",9781],["craig",9781],["fits",9779],["tent",9777],["forms",9776],["votes",9774],["harold",9771],["propose",9771],["monica",9766],["constantly",9765],["granny",9765],["nicely",9761],["nerves",9761],["arguing",9761],["abuse",9756],["relatives",9754],["survival",9752],["gloves",9750],["tracking",9747],["zoe",9744],["bend",9739],["review",9732],["connect",9723],["separated",9723],["elder",9722],["beard",9718],["admiral",9717],["diana",9717],["salary",9710],["areas",9709],["disturb",9707],["pro",9706],["maintain",9704],["solved",9703],["wealth",9699],["bitches",9695],["possession",9686],["sang",9686],["gimme",9685],["plates",9684],["shoulders",9683],["burnt",9678],["recorded",9676],["upper",9674],["counts",9673],["tale",9672],["profit",9672],["colleague",9669],["warehouse",9667],["hostage",9667],["shore",9666],["porn",9665],["miami",9663],["fifteen",9662],["visitors",9662],["bo",9661],["net",9660],["insult",9659],["owen",9658],["cities",9655],["causing",9655],["lemon",9653],["heal",9653],["banging",9652],["honeymoon",9650],["appeal",9647],["marshall",9644],["critical",9636],["creating",9636],["scotland",9635],["crane",9625],["enormous",9624],["testimony",9624],["praise",9619],["fights",9619],["indians",9616],["commission",9616],["growls",9614],["nicole",9611],["sins",9604],["fraud",9599],["branch",9598],["happily",9592],["bout",9588],["covering",9576],["occurred",9568],["beans",9553],["raw",9552],["muscle",9544],["pages",9543],["tense",9542],["relationships",9541],["management",9540],["assignment",9538],["blonde",9536],["catching",9534],["exposed",9534],["canada",9533],["dont",9523],["han",9522],["von",9519],["comedy",9514],["marco",9511],["stroke",9508],["whistling",9506],["buildings",9504],["shaking",9503],["facility",9502],["appropriate",9500],["remarkable",9499],["transferred",9494],["drawn",9493],["tits",9493],["clicking",9492],["symbol",9490],["motor",9489],["employees",9481],["ambassador",9480],["mothers",9479],["pile",9476],["feeding",9476],["soda",9472],["checks",9469],["bears",9466],["cookies",9466],["slut",9463],["awfully",9457],["stepped",9454],["toys",9454],["levels",9454],["differently",9453],["magnificent",9453],["steak",9450],["tube",9449],["leaders",9449],["superior",9444],["herr",9443],["pan",9442],["expression",9438],["currently",9421],["throne",9410],["deadly",9410],["bee",9409],["tooth",9408],["depressed",9400],["potatoes",9398],["wicked",9391],["resources",9388],["native",9386],["centuries",9384],["karl",9383],["poetry",9374],["multiple",9370],["cable",9369],["shortly",9364],["buzz",9363],["socks",9361],["fingerprints",9361],["goddess",9359],["chin",9359],["anxious",9354],["september",9351],["colors",9351],["basketball",9350],["promises",9347],["explained",9346],["appearance",9341],["musical",9325],["sends",9325],["slide",9324],["aii",9323],["shawn",9321],["ultimate",9313],["pork",9308],["communication",9307],["payment",9305],["structure",9302],["paperwork",9301],["obsessed",9298],["lazy",9297],["russians",9294],["actors",9292],["squeeze",9291],["magical",9287],["colleagues",9282],["admire",9273],["madness",9271],["roads",9270],["entry",9269],["injury",9265],["burden",9262],["racing",9262],["manner",9260],["freezing",9255],["bits",9246],["norman",9245],["mount",9239],["battery",9237],["dessert",9237],["pound",9235],["visited",9233],["motel",9233],["rumbling",9232],["cinema",9230],["fond",9227],["halfway",9225],["rifle",9221],["reception",9216],["statue",9214],["fridge",9213],["brick",9209],["abandon",9208],["lap",9207],["natalie",9194],["wars",9192],["recommend",9188],["brush",9186],["nelson",9184],["pill",9181],["concept",9181],["delay",9179],["quarters",9178],["korea",9178],["janet",9176],["rub",9168],["mall",9166],["soil",9166],["complaint",9162],["sail",9160],["baker",9155],["coughs",9145],["bucket",9143],["description",9140],["badge",9133],["measure",9133],["hip",9131],["function",9130],["indistinctly",9127],["harris",9124],["foundation",9122],["labor",9117],["willie",9117],["waiter",9109],["homeless",9106],["proved",9104],["unfortunate",9101],["log",9100],["meetings",9099],["giggles",9098],["combat",9092],["poem",9092],["polish",9090],["handed",9089],["mia",9086],["weed",9084],["raymond",9076],["tons",9075],["satellite",9059],["jersey",9059],["eagle",9058],["privacy",9058],["chapter",9056],["treating",9054],["wisdom",9047],["flip",9047],["entering",9047],["barn",9047],["farmer",9046],["clan",9045],["budget",9026],["pub",9026],["temporary",9025],["effective",9022],["sonny",9017],["wendy",9013],["juan",9011],["lamb",9011],["plot",9006],["riley",9001],["proposal",8992],["sticks",8989],["searched",8988],["blessing",8982],["rage",8981],["bothered",8977],["sydney",8976],["basis",8975],["manners",8973],["recover",8972],["twist",8971],["neil",8967],["leak",8964],["item",8963],["catholic",8955],["sighing",8952],["retired",8951],["kings",8944],["inner",8942],["cents",8941],["affect",8941],["forbidden",8938],["patch",8938],["drops",8934],["necessarily",8933],["provided",8929],["meters",8928],["booked",8926],["returns",8924],["gloria",8921],["fires",8921],["humor",8919],["torn",8908],["e-mail",8903],["rocky",8902],["introduced",8900],["creative",8898],["betray",8895],["soviet",8894],["various",8893],["added",8891],["discovery",8890],["cowboy",8890],["suspected",8878],["suggested",8874],["delighted",8873],["sticking",8873],["dive",8872],["shane",8870],["radar",8868],["prosecutor",8868],["amber",8866],["vast",8866],["quinn",8864],["owned",8858],["cookie",8853],["finest",8852],["felix",8852],["pump",8846],["valentine",8845],["counter",8839],["sorts",8835],["coin",8830],["warren",8829],["slaves",8828],["marine",8827],["illness",8825],["sack",8822],["bid",8821],["require",8819],["moscow",8819],["largest",8817],["washing",8817],["achieve",8816],["causes",8812],["mo",8810],["dope",8809],["nest",8803],["palm",8801],["logan",8799],["constant",8796],["boo",8795],["medication",8789],["gravity",8781],["booze",8781],["noah",8779],["evan",8778],["abroad",8778],["christine",8775],["rising",8769],["analysis",8765],["cuts",8764],["eats",8763],["employee",8759],["vodka",8757],["damaged",8754],["solar",8753],["height",8753],["pops",8753],["yards",8749],["terrified",8744],["suitcase",8738],["chemical",8730],["preparing",8724],["advanced",8719],["fund",8719],["wears",8715],["chirping",8714],["web",8698],["phase",8690],["prom",8687],["suggesting",8686],["fleet",8685],["lame",8681],["irish",8678],["editor",8675],["october",8672],["assumed",8669],["whip",8668],["mel",8664],["region",8664],["amongst",8659],["temper",8657],["discussion",8656],["engines",8653],["operate",8647],["sunshine",8645],["acted",8641],["undercover",8638],["horror",8635],["tested",8631],["smash",8631],["contrary",8629],["roses",8627],["poker",8624],["wrapped",8624],["korean",8622],["graham",8620],["mi",8618],["fu",8617],["ivan",8613],["focused",8612],["liam",8611],["tragic",8607],["carla",8604],["demons",8596],["rounds",8595],["entertainment",8594],["gathered",8592],["uhh",8591],["yells",8590],["lincoln",8590],["crossing",8585],["blanket",8580],["nut",8577],["travis",8576],["pockets",8576],["limited",8575],["paintings",8569],["sober",8569],["caesar",8567],["sis",8566],["intended",8563],["niece",8561],["computers",8561],["tennis",8551],["needle",8546],["chicks",8545],["traditional",8545],["delicate",8544],["independent",8539],["leslie",8534],["lauren",8528],["grandson",8524],["repair",8522],["brandon",8518],["executive",8515],["colin",8513],["recognized",8510],["holmes",8509],["regarding",8508],["weakness",8507],["aliens",8506],["gunshots",8505],["european",8504],["method",8503],["tool",8496],["demands",8492],["permit",8491],["towel",8491],["daisy",8489],["nowadays",8488],["spider",8488],["northern",8488],["rangers",8484],["zoo",8483],["praying",8477],["antonio",8477],["passengers",8474],["con",8473],["australia",8472],["technique",8469],["cellphone",8464],["gently",8463],["operations",8461],["planes",8460],["bunny",8457],["gunfire",8452],["shining",8451],["backwards",8448],["skill",8447],["uh-",8443],["baron",8441],["kidnapping",8437],["iike",8435],["marshal",8433],["petty",8431],["sealed",8428],["massage",8424],["raising",8423],["menu",8423],["diary",8423],["fucker",8421],["wagon",8420],["audition",8417],["mitch",8417],["mexican",8416],["casino",8413],["bacon",8411],["diego",8408],["dutch",8403],["promotion",8402],["click",8401],["forgiveness",8399],["burst",8398],["permanent",8395],["interfere",8394],["chanting",8391],["dammit",8389],["spencer",8387],["recovered",8386],["registered",8379],["sunny",8377],["packing",8376],["scenes",8376],["superman",8375],["questioning",8373],["unfair",8369],["admitted",8368],["ancestors",8368],["polite",8368],["completed",8357],["swallow",8357],["conduct",8352],["prayers",8348],["sheet",8346],["fried",8345],["avenue",8333],["poisoned",8332],["reverse",8324],["newspapers",8321],["ford",8320],["complaining",8315],["senses",8313],["investment",8308],["absurd",8303],["potato",8297],["understands",8295],["grows",8294],["inspired",8287],["ruby",8278],["celebrating",8278],["tag",8276],["ram",8274],["requires",8269],["bonnie",8269],["bugs",8268],["leonard",8267],["elliot",8266],["hooked",8265],["jill",8264],["ranch",8263],["safer",8257],["worn",8256],["crashing",8254],["lands",8252],["heather",8248],["ash",8244],["leather",8241],["grounds",8236],["assuming",8235],["nails",8234],["dana",8232],["conspiracy",8232],["thanksgiving",8229],["pierre",8229],["guardian",8218],["rumors",8216],["yen",8215],["megan",8215],["ashes",8212],["terror",8207],["misunderstanding",8203],["tire",8199],["boats",8192],["parade",8190],["halt",8189],["immunity",8186],["adults",8185],["steam",8179],["kang",8176],["closely",8176],["pearl",8175],["soccer",8169],["adrian",8159],["priority",8157],["shouts",8155],["donald",8151],["happier",8150],["khan",8150],["struggling",8148],["root",8144],["larger",8128],["november",8128],["boot",8127],["conflict",8125],["laughed",8125],["debbie",8120],["cherry",8118],["honking",8117],["concerns",8115],["celebration",8115],["sore",8113],["unconscious",8112],["cattle",8112],["breasts",8109],["fairly",8109],["fee",8109],["recognise",8107],["wade",8107],["radiation",8107],["holidays",8107],["raining",8107],["forgetting",8105],["lungs",8097],["register",8096],["shadows",8095],["specifically",8092],["sarge",8091],["ideal",8087],["turtle",8080],["spoil",8079],["deserved",8074],["ronnie",8072],["gina",8069],["candidate",8068],["lance",8068],["types",8068],["bloke",8066],["explode",8064],["immediate",8061],["belonged",8061],["identified",8056],["di",8051],["hunger",8049],["closest",8047],["combination",8040],["nanny",8037],["seth",8037],["gabriel",8036],["develop",8036],["detectives",8036],["severe",8032],["en",8029],["organized",8028],["franklin",8022],["gut",8020],["splendid",8020],["dismissed",8012],["pm",8007],["yang",8005],["halloween",8004],["ellie",8003],["vulnerable",7998],["continued",7998],["vital",7997],["comrades",7996],["bureau",7996],["drawer",7995],["harper",7993],["retreat",7993],["carson",7991],["benny",7990],["pillow",7989],["troy",7987],["ace",7987],["cart",7985],["nap",7983],["chan",7983],["ham",7982],["announce",7978],["borrowed",7976],["parked",7969],["galaxy",7968],["voted",7967],["muffled",7967],["sebastian",7966],["mmm-hmm",7965],["jewelry",7964],["kisses",7961],["luggage",7957],["groom",7954],["oops",7954],["lighter",7953],["masters",7948],["alliance",7944],["buddies",7942],["clothing",7940],["denied",7939],["drown",7935],["alike",7933],["excellency",7933],["relative",7928],["photographs",7925],["burger",7924],["dough",7923],["alicia",7921],["wreck",7920],["approve",7918],["produced",7916],["christina",7915],["physically",7914],["stations",7913],["eli",7912],["samantha",7912],["spiritual",7910],["sire",7909],["roaring",7909],["stab",7907],["inch",7906],["servants",7906],["limits",7905],["unexpected",7899],["arrival",7898],["whimpering",7898],["basket",7897],["sheets",7895],["scandal",7894],["stink",7891],["homer",7887],["tara",7886],["christopher",7886],["toes",7886],["shield",7881],["shooter",7879],["instant",7872],["objects",7871],["rehearsal",7869],["disturbing",7868],["directed",7864],["scan",7861],["electronic",7860],["republic",7860],["overnight",7860],["counsel",7855],["sirens",7854],["dorothy",7853],["bait",7852],["dignity",7850],["explains",7850],["lean",7849],["heroin",7848],["tracy",7845],["rex",7841],["barney",7839],["gossip",7839],["humble",7837],["economy",7837],["worlds",7827],["impress",7819],["clara",7818],["tissue",7818],["threaten",7814],["et",7814],["bump",7813],["supreme",7809],["bingo",7807],["mitchell",7806],["excitement",7805],["mankind",7803],["document",7799],["broad",7798],["vietnam",7796],["vessel",7796],["lit",7794],["dale",7794],["killers",7790],["connor",7790],["mario",7787],["foul",7784],["stare",7782],["deaths",7780],["hut",7776],["elements",7774],["coma",7770],["laying",7769],["coffin",7767],["paula",7766],["cocaine",7765],["puppy",7764],["louder",7761],["handled",7759],["announcement",7759],["oath",7758],["mob",7753],["cotton",7748],["deposit",7747],["taxes",7741],["injuries",7735],["jen",7731],["autopsy",7728],["advise",7727],["gig",7723],["lecture",7721],["posted",7719],["skinny",7719],["lo",7717],["include",7716],["revealed",7713],["infected",7711],["relieved",7707],["assistance",7707],["solo",7703],["determine",7695],["terrorists",7694],["elsewhere",7693],["lamp",7692],["practical",7685],["marked",7684],["adorable",7682],["rubber",7682],["purple",7681],["operating",7681],["suite",7680],["apologies",7679],["personnel",7676],["beam",7674],["poet",7674],["samples",7673],["arrow",7670],["counselor",7670],["carpet",7667],["allen",7667],["audrey",7665],["spotted",7664],["floating",7661],["corrected",7648],["located",7648],["journalist",7646],["insisted",7645],["operator",7633],["rely",7632],["singh",7631],["fooled",7627],["arrives",7625],["leon",7619],["walt",7614],["eastern",7614],["ay",7613],["shave",7607],["remaining",7606],["toss",7606],["anderson",7602],["lip",7597],["acid",7597],["nora",7593],["si",7588],["december",7586],["anonymous",7582],["inn",7578],["planted",7577],["oldest",7576],["breast",7568],["sixth",7566],["error",7563],["reporting",7562],["egypt",7553],["islands",7553],["brother-in-law",7545],["jules",7536],["roommate",7535],["sings",7535],["scum",7530],["visitor",7527],["giggling",7525],["described",7525],["geez",7525],["inches",7524],["commitment",7523],["hercules",7521],["react",7521],["cows",7517],["protest",7517],["misery",7513],["theft",7510],["embrace",7510],["darn",7507],["harsh",7507],["andrea",7506],["communicate",7505],["fathers",7504],["faithful",7503],["hector",7501],["uh-uh",7501],["flame",7500],["medal",7498],["silk",7495],["photographer",7495],["trevor",7493],["cemetery",7493],["rear",7491],["freddy",7491],["banana",7491],["allison",7485],["emotion",7482],["chap",7480],["dated",7478],["excuses",7477],["shitty",7475],["bin",7468],["jew",7465],["gum",7462],["crushed",7462],["efforts",7459],["content",7459],["corporal",7450],["realised",7449],["del",7447],["rap",7446],["hopeless",7445],["debate",7444],["era",7442],["luckily",7442],["ton",7438],["liquid",7435],["swell",7434],["peggy",7431],["feast",7431],["murphy",7430],["attracted",7428],["application",7420],["profession",7420],["bench",7419],["vanessa",7418],["ahem",7416],["symptoms",7415],["scout",7409],["crashed",7406],["keen",7404],["aircraft",7395],["formed",7394],["hon",7387],["aggressive",7383],["pale",7382],["significant",7382],["duncan",7380],["clubs",7379],["visual",7379],["embassy",7375],["defence",7374],["cabinet",7374],["display",7373],["fame",7372],["gibbs",7369],["drowned",7366],["wolves",7364],["filming",7364],["dunno",7363],["cursed",7360],["twin",7358],["sorrow",7354],["vault",7351],["defeated",7350],["watson",7349],["quote",7348],["penalty",7347],["bargain",7347],["pepper",7346],["speaker",7343],["compare",7342],["satan",7341],["helmet",7341],["scar",7339],["copies",7335],["fuss",7333],["envelope",7330],["perry",7325],["stinks",7323],["african",7322],["arrangements",7321],["wished",7318],["honored",7316],["believing",7311],["discussed",7309],["assigned",7308],["rosa",7305],["oven",7301],["jenna",7299],["attacking",7299],["tin",7297],["sonic",7293],["collar",7290],["mere",7287],["romeo",7286],["belief",7284],["storage",7281],["rusty",7276],["psycho",7269],["climbing",7269],["melissa",7267],["fears",7266],["sum",7265],["dedicated",7262],["ladder",7260],["alternative",7253],["raid",7253],["dining",7250],["ms",7250],["corporate",7248],["folk",7245],["jon",7243],["replaced",7242],["bailey",7241],["un",7239],["heels",7238],["duties",7236],["smarter",7236],["existed",7236],["surprising",7235],["chess",7234],["requested",7233],["cruise",7226],["primary",7225],["rhythm",7224],["drum",7222],["models",7221],["motorcycle",7221],["adopted",7218],["barrel",7217],["cargo",7217],["planets",7215],["shove",7215],["wha",7214],["surprises",7208],["rosie",7206],["dings",7204],["trailer",7200],["cannon",7198],["tables",7196],["arse",7196],["ally",7195],["lawrence",7193],["wee",7190],["tapes",7186],["dull",7185],["sandra",7185],["teenage",7184],["kent",7183],["cooperate",7180],["risks",7178],["dug",7177],["worthless",7177],["arriving",7175],["kindness",7173],["ties",7172],["items",7171],["and-",7169],["grip",7167],["relations",7164],["rumor",7163],["skirt",7161],["frog",7160],["paige",7153],["lloyd",7151],["claimed",7146],["perfume",7146],["instrumental",7146],["flames",7145],["association",7145],["loses",7142],["january",7140],["spike",7140],["israel",7137],["eleven",7136],["gallery",7134],["ginger",7134],["activities",7131],["iraq",7131],["boarding",7121],["fist",7118],["stuffed",7114],["improve",7114],["creation",7113],["connie",7111],["infection",7108],["strikes",7107],["wiped",7107],["tension",7107],["whistles",7105],["kindly",7104],["certificate",7103],["curtis",7102],["bold",7100],["threats",7099],["claudia",7099],["sid",7095],["twisted",7092],["suspended",7092],["affected",7090],["envy",7089],["whale",7087],["dial",7086],["cameron",7085],["martial",7085],["controlled",7084],["lung",7084],["nigga",7077],["coal",7074],["upside",7074],["imperial",7073],["pam",7072],["paranoid",7063],["signing",7061],["congress",7060],["villa",7056],["candles",7055],["filling",7055],["becky",7053],["comic",7053],["faint",7053],["recovery",7052],["volunteer",7052],["tech",7051],["bleed",7050],["positions",7050],["cindy",7047],["narrow",7036],["forbid",7033],["stuart",7032],["perspective",7030],["faced",7028],["neat",7027],["pierce",7027],["observe",7024],["cycle",7022],["psychic",7022],["handling",7022],["goose",7019],["accidentally",7018],["conclusion",7018],["designer",7017],["sits",7016],["importance",7014],["spots",7006],["francs",7004],["sweep",7003],["destroying",7000],["communist",6994],["blackmail",6988],["pacific",6984],["risky",6984],["glorious",6982],["filed",6981],["hatred",6973],["cam",6972],["deals",6971],["pos",6970],["fooling",6967],["persons",6965],["scotch",6965],["executed",6959],["brakes",6958],["blank",6956],["tender",6954],["kurt",6953],["turner",6951],["tub",6944],["sucker",6939],["slight",6937],["addition",6933],["campus",6931],["ritual",6931],["prey",6930],["so-called",6929],["graduate",6927],["anyways",6926],["ew",6926],["fry",6923],["genuine",6921],["ned",6918],["judging",6915],["doubts",6915],["warden",6914],["ceiling",6909],["practicing",6904],["somewhat",6901],["established",6899],["attempted",6898],["zombie",6895],["generations",6895],["yöu",6891],["gathering",6886],["warriors",6885],["mick",6882],["electrical",6880],["collins",6875],["sobs",6875],["couples",6872],["depend",6872],["institute",6870],["miranda",6868],["principle",6868],["fixing",6867],["therapist",6866],["compete",6863],["compliment",6859],["worship",6858],["residence",6855],["democracy",6848],["joel",6844],["published",6843],["myth",6843],["helpless",6843],["screwing",6841],["kennedy",6836],["execution",6833],["appetite",6828],["formal",6824],["strictly",6823],["sucked",6822],["tanks",6821],["chickens",6815],["gwen",6813],["technical",6813],["stella",6811],["rod",6808],["meets",6803],["shepherd",6803],["brazil",6802],["brutal",6802],["alison",6801],["vain",6800],["switched",6797],["chi",6797],["deer",6789],["brooklyn",6787],["dresses",6786],["retire",6786],["overcome",6778],["inspiration",6778],["cunt",6777],["benjamin",6777],["nicky",6777],["thrilled",6775],["tae",6771],["suggestion",6771],["dummy",6768],["sailor",6767],["destination",6765],["bass",6760],["picnic",6760],["assholes",6760],["idol",6759],["trucks",6757],["rank",6755],["denise",6751],["yu",6750],["veronica",6749],["blocked",6747],["strict",6747],["hiya",6746],["formula",6745],["clerk",6743],["samurai",6738],["disturbed",6738],["vera",6737],["cafe",6737],["wandering",6737],["consciousness",6735],["val",6731],["nikki",6728],["min",6721],["bare",6720],["hans",6717],["caleb",6717],["butcher",6715],["arrangement",6715],["chased",6714],["ministry",6714],["brandy",6713],["sweater",6711],["boobs",6711],["locate",6706],["apologise",6701],["nazi",6700],["parole",6694],["examine",6694],["trains",6692],["candle",6692],["prior",6689],["stream",6688],["that-",6685],["passage",6681],["reaching",6680],["organ",6680],["directions",6678],["asses",6677],["outer",6677],["proves",6677],["morris",6677],["luxury",6673],["pistol",6669],["losers",6666],["funds",6662],["sunset",6661],["strings",6661],["fortunately",6660],["gotcha",6660],["tomb",6655],["freddie",6654],["but-",6653],["engineering",6649],["element",6645],["balloon",6642],["ty",6639],["broadcast",6639],["spray",6639],["ninja",6637],["eternity",6636],["handy",6634],["to-",6631],["crawl",6626],["slice",6625],["nations",6625],["behaviour",6624],["pace",6620],["stiff",6620],["lick",6617],["concrete",6610],["allah",6610],["vegetables",6608],["euros",6606],["sausage",6605],["constable",6604],["spinning",6603],["rolls",6601],["genetic",6597],["loudly",6596],["old-fashioned",6596],["marvelous",6595],["subway",6595],["creep",6594],["scheme",6590],["performed",6584],["ex-wife",6583],["plug",6583],["rejected",6579],["brenda",6576],["respected",6573],["backs",6569],["protocol",6562],["muscles",6555],["kidney",6554],["finishing",6553],["products",6553],["im",6553],["wu",6549],["invasion",6544],["affection",6544],["woody",6543],["endless",6543],["discipline",6542],["horns",6541],["absence",6538],["waking",6537],["dudes",6536],["philosophy",6535],["cracked",6533],["nicholas",6531],["reed",6529],["verdict",6529],["drake",6527],["connections",6526],["privilege",6526],["anyhow",6523],["scent",6523],["naive",6522],["daylight",6521],["controls",6520],["airplane",6520],["values",6519],["jolly",6519],["swore",6516],["blaring",6515],["consent",6513],["puzzle",6510],["disagree",6505],["argh",6496],["winds",6496],["vanished",6494],["wizard",6493],["ranger",6492],["celebrity",6491],["sniffles",6491],["stores",6488],["aha",6485],["intentions",6482],["plague",6480],["farmers",6480],["breeze",6478],["rascal",6477],["convenient",6470],["bonus",6470],["dong",6470],["hush",6469],["instinct",6463],["entitled",6463],["porter",6462],["maurice",6462],["wrist",6461],["originally",6459],["coins",6459],["drain",6453],["physics",6452],["phoebe",6452],["furious",6452],["sniffs",6450],["vampires",6450],["defending",6447],["sector",6447],["income",6446],["finn",6445],["offended",6444],["decides",6440],["laser",6438],["fled",6436],["beers",6435],["boxing",6435],["accurate",6435],["kung",6434],["snakes",6431],["thumb",6429],["bald",6428],["sharon",6425],["suspicion",6424],["convicted",6423],["roots",6422],["shares",6420],["bernard",6419],["jessie",6418],["wang",6418],["harrison",6412],["blues",6412],["confusing",6410],["lobby",6408],["recipe",6408],["las",6408],["winston",6405],["generally",6401],["seeking",6398],["secretly",6398],["declare",6397],["freaked",6395],["missile",6395],["ensure",6394],["tricky",6391],["pointed",6390],["hockey",6389],["bubble",6388],["contacts",6388],["maximum",6386],["reminded",6386],["carriage",6381],["curtain",6374],["clues",6370],["hostages",6367],["circles",6367],["author",6367],["wallace",6366],["receiving",6362],["debts",6359],["cellar",6358],["tortured",6357],["tips",6355],["mates",6354],["mortal",6352],["reporters",6352],["associate",6351],["honesty",6350],["laptop",6347],["jung",6345],["katherine",6339],["rainbow",6336],["seed",6336],["mature",6334],["deed",6333],["kay",6331],["expenses",6320],["discussing",6319],["pirate",6318],["meals",6318],["performing",6317],["remained",6315],["prostitute",6312],["adams",6312],["retirement",6309],["platform",6308],["claus",6307],["erica",6304],["beds",6300],["writes",6299],["illusion",6299],["mill",6298],["dealt",6298],["hears",6292],["tryin",6290],["manhattan",6290],["mole",6289],["rang",6287],["prosecution",6287],["fatal",6284],["copper",6283],["troubled",6282],["heir",6278],["traces",6277],["owners",6276],["length",6276],["dentist",6275],["zach",6274],["lesbian",6274],["enterprise",6274],["tournament",6273],["travelling",6273],["confessed",6270],["roast",6269],["website",6268],["ja",6266],["mikey",6266],["flood",6263],["harbor",6263],["lola",6262],["rory",6259],["richie",6257],["mansion",6257],["mademoiselle",6256],["auction",6255],["desires",6252],["bachelor",6250],["haunted",6247],["distracted",6245],["smashed",6240],["rodney",6238],["scheduled",6236],["negotiate",6229],["champ",6229],["allows",6229],["shotgun",6226],["nigger",6224],["cocktail",6224],["donkey",6222],["pointing",6222],["butler",6218],["dizzy",6217],["methods",6216],["reference",6216],["georgia",6215],["bully",6215],["lydia",6215],["collapse",6214],["alfred",6211],["ana",6209],["minimum",6209],["chairs",6208],["hee",6207],["patty",6204],["owes",6202],["included",6201],["carmen",6201],["civilization",6201],["prices",6196],["wealthy",6193],["bart",6192],["depression",6192],["tide",6191],["rented",6190],["bra",6189],["lin",6189],["fellows",6186],["rot",6186],["password",6186],["th",6185],["kirk",6184],["pedro",6184],["spill",6182],["cured",6180],["naomi",6179],["hardest",6179],["deceased",6176],["sleepy",6175],["static",6174],["hawaii",6172],["almighty",6170],["murray",6169],["offers",6169],["bruno",6167],["phrase",6167],["disgrace",6167],["sources",6166],["approval",6165],["turtles",6164],["screech",6163],["snack",6163],["collected",6158],["ding",6157],["fur",6156],["jewels",6153],["jan",6153],["cheek",6152],["legally",6152],["ditch",6152],["benefits",6150],["roars",6149],["arnold",6145],["dime",6143],["domestic",6143],["chuckle",6141],["hack",6138],["ransom",6138],["chang",6138],["bore",6136],["muslim",6134],["loads",6133],["waitress",6133],["nurses",6131],["glenn",6131],["spite",6131],["rebel",6130],["clearing",6130],["andre",6129],["fortunate",6127],["survivors",6127],["reply",6125],["disappoint",6125],["orbit",6124],["expose",6124],["gin",6118],["austin",6117],["toe",6116],["sympathy",6115],["intel",6106],["growth",6104],["psychiatrist",6102],["slams",6100],["encounter",6100],["daphne",6097],["passenger",6096],["monkeys",6095],["accompany",6092],["hats",6092],["column",6088],["sigh",6086],["pounding",6085],["economic",6081],["tales",6080],["theirs",6075],["brat",6073],["follows",6072],["spreading",6072],["sister-in-law",6071],["burt",6070],["hilarious",6069],["hallway",6066],["hamilton",6063],["the-",6061],["exclusive",6060],["chimes",6059],["greet",6059],["decades",6059],["bowling",6056],["buttons",6055],["confusion",6054],["thud",6046],["hooker",6042],["ruining",6041],["dreadful",6041],["collecting",6036],["lions",6034],["sheila",6030],["douglas",6028],["february",6027],["elvis",6024],["forty",6023],["sleeps",6022],["sung",6022],["brass",6019],["shrink",6019],["zack",6017],["whimpers",6016],["sworn",6015],["email",6010],["proven",6008],["tasty",6008],["covers",6007],["sandwiches",6006],["rarely",6006],["wyatt",6002],["programme",6001],["irene",5998],["dee",5994],["contacted",5990],["chemistry",5989],["assured",5988],["applied",5987],["elected",5987],["subjects",5985],["lust",5983],["label",5982],["shakespeare",5981],["tan",5980],["ego",5980],["forgiven",5979],["phoenix",5978],["companion",5978],["lottery",5977],["worm",5976],["publicity",5976],["butterfly",5975],["dialogue",5975],["cal",5973],["tremendous",5971],["pervert",5970],["establish",5968],["dearest",5967],["intimate",5967],["eager",5966],["se",5964],["cathy",5963],["atlantic",5960],["contains",5960],["thompson",5959],["shirley",5958],["bets",5958],["inhales",5957],["presentation",5957],["allergic",5956],["den",5956],["wells",5955],["spoiled",5953],["chen",5952],["accomplished",5951],["repay",5951],["politicians",5950],["logic",5950],["gratitude",5948],["afghanistan",5946],["exception",5946],["ultimately",5946],["pablo",5944],["compromise",5944],["majority",5944],["journal",5943],["signals",5942],["bent",5941],["dock",5939],["meg",5939],["essential",5938],["campbell",5938],["climate",5937],["experiments",5936],["ink",5936],["magazines",5935],["rivers",5935],["teresa",5935],["administration",5935],["passionate",5934],["precise",5932],["lawn",5932],["via",5929],["jeans",5929],["murmuring",5928],["resort",5926],["announced",5925],["buddha",5922],["fries",5920],["capacity",5918],["lena",5917],["opponent",5917],["chains",5916],["minus",5914],["caitlyn",5914],["apples",5914],["angie",5913],["seattle",5911],["aww",5910],["instrument",5908],["healing",5907],["convention",5906],["messenger",5906],["explore",5904],["sonia",5903],["versus",5900],["survivor",5895],["ruled",5893],["boyd",5892],["clarence",5890],["madison",5890],["cough",5883],["experts",5881],["disguise",5881],["archer",5880],["bicycle",5880],["noises",5871],["inviting",5870],["pregnancy",5868],["declared",5867],["yi",5866],["vicky",5865],["drums",5864],["wooden",5862],["innocence",5860],["mouths",5855],["bees",5854],["creaking",5853],["cloth",5852],["sasha",5850],["unh",5848],["skies",5846],["painter",5846],["allies",5846],["miguel",5845],["pond",5843],["runway",5842],["robbie",5841],["assist",5839],["lana",5839],["presented",5835],["mona",5834],["sickness",5834],["civilian",5833],["fighters",5832],["facebook",5832],["customs",5831],["florence",5828],["remembers",5824],["sharks",5822],["juliet",5821],["shrimp",5817],["tore",5817],["seventh",5814],["ou",5813],["avoiding",5809],["dawson",5808],["isaac",5807],["rolled",5803],["championship",5803],["household",5802],["float",5801],["chad",5801],["ballet",5799],["cracking",5798],["raj",5797],["possessed",5796],["harmless",5794],["curiosity",5792],["vicious",5792],["officials",5789],["glove",5789],["nearest",5787],["manny",5786],["begged",5786],["que",5785],["withdraw",5784],["shocking",5781],["guessed",5777],["nightmares",5776],["greedy",5776],["forehead",5776],["corps",5775],["pirates",5774],["rudy",5774],["reservation",5770],["hunters",5768],["fog",5768],["flu",5765],["good-looking",5762],["hint",5757],["peanut",5757],["stark",5756],["gamble",5755],["erin",5755],["autumn",5753],["bracelet",5753],["contain",5752],["arrogant",5748],["simpson",5745],["piper",5744],["knocks",5743],["sperm",5741],["gesture",5741],["materials",5740],["lindsay",5736],["alexis",5734],["ticking",5733],["standards",5733],["ribs",5731],["receipt",5727],["regards",5724],["whatsoever",5721],["homeland",5720],["leap",5719],["targets",5719],["snoring",5718],["damon",5718],["leaf",5717],["jar",5713],["brooke",5712],["proposed",5711],["savage",5708],["caring",5708],["ivy",5707],["approximately",5707],["kidnap",5702],["carries",5701],["bounce",5701],["creek",5700],["pony",5698],["cleaner",5698],["businessman",5697],["ha-ha",5695],["lunatic",5695],["unlikely",5694],["gal",5694],["situations",5693],["colony",5693],["sells",5691],["choi",5690],["chambers",5690],["volume",5684],["sweets",5683],["panties",5682],["respects",5681],["reads",5680],["seeds",5678],["engage",5676],["explosives",5673],["stewart",5670],["violet",5668],["mutual",5668],["lighting",5668],["scores",5667],["lenny",5667],["savings",5666],["beware",5665],["stephanie",5664],["educated",5662],["complaints",5661],["cassie",5660],["doomed",5660],["offensive",5656],["harvest",5655],["qualified",5654],["relaxed",5652],["cape",5652],["buffalo",5651],["triple",5650],["fireworks",5648],["palmer",5648],["armor",5648],["poverty",5647],["leadership",5644],["rode",5640],["ambition",5633],["cakes",5632],["trousers",5627],["bean",5627],["melt",5627],["racist",5624],["bam",5623],["ankle",5622],["occupied",5618],["nicer",5618],["correctly",5615],["terrifying",5615],["hugh",5614],["gasp",5613],["villain",5613],["willy",5613],["marc",5610],["draft",5608],["factor",5601],["whew",5597],["countess",5597],["portrait",5595],["suggests",5589],["font",5589],["blair",5588],["learnt",5587],["pencil",5582],["coordinates",5582],["collapsed",5580],["reserve",5579],["jeffrey",5577],["shirts",5574],["umbrella",5570],["stevie",5568],["ta",5568],["traveled",5566],["auto",5565],["logical",5565],["chew",5565],["eliminate",5563],["beverly",5562],["carbon",5562],["cancelled",5562],["crawling",5562],["stepping",5562],["independence",5562],["medium",5560],["canceled",5559],["tess",5555],["fanny",5554],["seated",5554],["upbeat",5553],["asian",5552],["drowning",5552],["messy",5552],["quest",5551],["represents",5548],["fletcher",5548],["memorial",5548],["doyle",5546],["stressed",5544],["bearing",5544],["watches",5544],["spoon",5540],["cooperation",5539],["tucker",5535],["earthquake",5535],["sailing",5534],["involve",5533],["houston",5532],["asia",5524],["spectacular",5523],["reliable",5523],["it-",5522],["breach",5520],["sophisticated",5518],["echo",5517],["indicate",5517],["swamp",5516],["vehicles",5515],["manual",5512],["elegant",5512],["compound",5508],["serves",5508],["scares",5508],["rattling",5507],["pursue",5507],["dreamt",5506],["stefan",5504],["revolutionary",5502],["wa",5502],["rig",5500],["convincing",5498],["fiona",5497],["tribal",5497],["cleveland",5496],["lex",5495],["depth",5495],["blaming",5492],["aids",5492],["elaine",5491],["wakes",5491],["occur",5488],["camping",5480],["fart",5480],["stamp",5478],["kathy",5478],["assassin",5475],["approved",5475],["crucial",5474],["annual",5474],["torch",5474],["dallas",5473],["fulfill",5472],["bon",5471],["resting",5469],["reunion",5466],["witnessed",5464],["graduated",5462],["sub",5460],["howling",5459],["clyde",5458],["hospitals",5457],["stall",5456],["legacy",5455],["otto",5454],["riot",5454],["shops",5453],["whereabouts",5453],["teenager",5451],["samuel",5450],["squealing",5449],["mines",5448],["danced",5448],["crowded",5447],["orleans",5445],["literature",5444],["morgue",5443],["strongest",5442],["agnes",5441],["transmission",5439],["panel",5438],["sole",5438],["sequence",5438],["sexually",5438],["georgie",5436],["hugo",5436],["denny",5435],["smack",5434],["congratulate",5432],["anxiety",5432],["choking",5430],["tolerate",5428],["mode",5426],["communications",5425],["welfare",5425],["dice",5425],["orphan",5424],["locks",5422],["settlement",5422],["advertising",5422],["uniforms",5419],["mcgee",5419],["devoted",5418],["shoots",5418],["principles",5417],["circuit",5416],["nickname",5415],["dot",5415],["elderman",5415],["scenario",5414],["choir",5413],["stunt",5410],["smoked",5410],["isolated",5409],["equally",5408],["statements",5407],["gil",5406],["shaw",5405],["lipstick",5405],["courtesy",5401],["hobby",5399],["hesitate",5398],["hatch",5398],["luis",5397],["sylvia",5393],["phoned",5393],["experiences",5392],["popped",5391],["quid",5389],["fragile",5388],["husbands",5387],["fiction",5387],["persuade",5385],["pastor",5385],["dolls",5385],["pose",5384],["ji",5384],["ming",5384],["rupees",5383],["tricked",5382],["ruins",5381],["sadly",5380],["owed",5380],["importantly",5376],["louie",5375],["industrial",5375],["musician",5371],["labour",5370],["evolution",5369],["salute",5367],["ba",5366],["cent",5364],["bartender",5363],["calendar",5363],["hal",5363],["miracles",5363],["balcony",5359],["horny",5357],["regard",5357],["valerie",5356],["fold",5355],["organs",5353],["nigel",5349],["theo",5348],["einstein",5348],["boris",5346],["suitable",5346],["inaudible",5343],["son-in-law",5343],["melody",5333],["phillip",5331],["movements",5331],["differences",5330],["barbecue",5329],["trading",5326],["strongly",5326],["fluid",5325],["scholarship",5324],["translation",5323],["cricket",5321],["shuttle",5321],["moans",5321],["quicker",5316],["picks",5315],["murderers",5315],["videos",5315],["initial",5314],["perimeter",5313],["questioned",5312],["yea",5312],["cousins",5309],["divided",5308],["linked",5307],["laboratory",5304],["crab",5300],["pulls",5299],["landlord",5298],["reggie",5298],["hostile",5297],["classified",5295],["cultural",5293],["measures",5293],["update",5287],["cottage",5286],["backyard",5286],["amateur",5285],["chopper",5284],["rash",5284],["despair",5284],["honorable",5282],["shiny",5281],["hawk",5280],["shorts",5280],["ni",5277],["representative",5276],["wong",5276],["pouring",5275],["distress",5275],["tanner",5274],["telegram",5273],["pimp",5272],["dip",5272],["trophy",5272],["marilyn",5269],["hallelujah",5269],["neal",5269],["es",5269],["howdy",5267],["misses",5262],["flush",5260],["magician",5257],["dynamite",5256],["cuba",5256],["conquer",5253],["unpleasant",5251],["jealousy",5250],["regrets",5249],["lifted",5247],["increased",5246],["toxic",5246],["broadway",5244],["sakes",5241],["pudding",5241],["rally",5239],["needing",5238],["bloom",5238],["corporation",5237],["buys",5237],["disorder",5236],["interrupting",5236],["sadness",5235],["needn",5234],["cease",5233],["barnes",5233],["scoundrel",5232],["sensible",5231],["evans",5231],["specialist",5230],["dragging",5229],["bribe",5227],["burke",5226],["bothers",5226],["interrogation",5225],["prescription",5224],["referring",5224],["knives",5221],["graduation",5220],["t-shirt",5219],["liking",5217],["bubbles",5217],["grid",5217],["haircut",5212],["regardless",5210],["lodge",5209],["poisoning",5209],["feathers",5207],["edgar",5206],["software",5205],["marina",5202],["sophia",5202],["queens",5201],["oak",5200],["trips",5200],["scaring",5199],["avery",5199],["awhile",5199],["sofa",5198],["conscious",5197],["swords",5196],["instincts",5196],["deliberately",5195],["starve",5194],["venus",5193],["olive",5193],["maniac",5188],["psychological",5187],["robinson",5185],["soo",5182],["belle",5182],["landscape",5181],["straw",5179],["dam",5177],["mandy",5175],["sayin",5174],["accuse",5174],["is-",5172],["rehab",5172],["switzerland",5171],["agenda",5171],["glue",5170],["salesman",5170],["diner",5167],["projects",5166],["floors",5165],["mainly",5165],["biological",5164],["sincere",5158],["weigh",5157],["pad",5156],["swiss",5155],["cue",5154],["witches",5153],["opposed",5153],["brand-new",5151],["tourists",5149],["sneaking",5148],["harvard",5148],["spying",5147],["sketch",5146],["parliament",5143],["behold",5141],["blamed",5140],["topic",5140],["offices",5138],["sacrificed",5135],["loop",5134],["drivers",5134],["battles",5133],["blond",5132],["plead",5132],["races",5132],["tanya",5131],["salmon",5129],["bizarre",5128],["rico",5128],["isabel",5127],["wig",5127],["reaches",5126],["heights",5126],["i-i-i",5126],["possibilities",5122],["wax",5121],["travels",5120],["confidential",5120],["pursuit",5120],["herd",5119],["scissors",5119],["cd",5118],["spies",5116],["exams",5116],["invention",5116],["explaining",5113],["replacement",5112],["bombing",5112],["erik",5109],["springs",5108],["chelsea",5107],["sabrina",5107],["kicks",5105],["integrity",5104],["dash",5102],["jae",5100],["senate",5100],["mentally",5097],["flee",5096],["gangster",5095],["beautifully",5095],["roberts",5094],["slim",5093],["choosing",5091],["promising",5091],["objective",5090],["joyce",5089],["altogether",5089],["attract",5088],["ernie",5087],["melanie",5087],["prep",5086],["neighbours",5086],["nailed",5086],["briefcase",5085],["buzzer",5085],["quitting",5083],["puppet",5083],["restore",5083],["dozens",5082],["betting",5082],["pancakes",5082],["corridor",5082],["embarrass",5081],["visits",5080],["database",5080],["cheryl",5080],["achieved",5078],["sucking",5077],["encourage",5075],["grades",5075],["gps",5074],["barks",5074],["patterns",5072],["relevant",5068],["mafia",5067],["hudson",5067],["expense",5065],["intact",5064],["occasionally",5064],["kiddo",5064],["heartbeat",5062],["identical",5062],["cigar",5060],["billions",5058],["alec",5058],["gained",5057],["venice",5057],["classical",5054],["noodles",5053],["consistent",5053],["clip",5051],["resign",5051],["insects",5051],["failing",5050],["organic",5050],["seize",5050],["charging",5049],["mug",5049],["iris",5046],["crow",5042],["ella",5041],["fountain",5040],["observation",5040],["abortion",5040],["railroad",5039],["straighten",5039],["legitimate",5036],["sting",5035],["youngest",5033],["fork",5032],["du",5032],["flynn",5032],["ciao",5031],["coroner",5030],["jonas",5030],["marching",5029],["touches",5029],["jade",5028],["feared",5027],["griffin",5027],["expedition",5026],["forcing",5024],["neighbour",5016],["digital",5016],["deeds",5015],["milan",5015],["bernie",5015],["a-",5014],["attic",5014],["jr",5012],["whores",5012],["sentimental",5011],["cooler",5011],["eun",5008],["bert",5008],["feds",5007],["reese",5007],["plea",5006],["polly",5005],["metres",5002],["harmony",5002],["difficulty",4998],["bands",4998],["dried",4998],["aunty",4996],["maker",4996],["randall",4995],["supporting",4995],["brake",4994],["girlfriends",4994],["features",4993],["photography",4993],["vengeance",4992],["inappropriate",4992],["vacuum",4989],["throws",4988],["kane",4987],["residents",4987],["endure",4987],["substance",4986],["corrupt",4986],["potter",4986],["investigator",4985],["thread",4985],["priests",4984],["gap",4983],["whining",4980],["scarf",4980],["institution",4978],["immortal",4976],["marsh",4974],["chapel",4973],["desperately",4973],["accidents",4973],["alcoholic",4973],["fabric",4971],["nazis",4969],["railway",4969],["sal",4969],["dame",4968],["rebels",4966],["custom",4965],["pension",4963],["cans",4962],["possess",4961],["kira",4960],["poster",4959],["developing",4956],["addict",4953],["feature",4953],["cope",4952],["lords",4951],["delightful",4950],["wrestling",4948],["rio",4945],["reduce",4943],["rescued",4940],["peg",4939],["mercedes",4937],["guaranteed",4936],["emotionally",4936],["poland",4936],["accountant",4936],["theories",4935],["me-",4933],["tumor",4933],["automatic",4932],["amusing",4932],["unnecessary",4930],["higgins",4930],["voting",4928],["buster",4927],["stadium",4924],["visible",4923],["easter",4923],["jeep",4923],["dj",4922],["les",4922],["garcia",4921],["tracked",4921],["ignorant",4919],["pledge",4918],["deserted",4918],["esther",4918],["rum",4917],["fisher",4915],["wheelchair",4914],["salvation",4913],["lester",4912],["wonders",4912],["clattering",4911],["maintenance",4911],["tobacco",4910],["underwater",4910],["divide",4907],["slightest",4906],["discount",4906],["yoga",4906],["vagina",4905],["rushing",4905],["modest",4904],["make-up",4904],["wes",4902],["reduced",4901],["whisper",4898],["pressing",4898],["tops",4898],["variety",4897],["ignored",4896],["helena",4896],["writers",4895],["grain",4895],["essentially",4894],["archie",4893],["ofthe",4893],["individuals",4893],["meredith",4893],["gavin",4892],["subtle",4891],["interviews",4891],["pinky",4886],["cord",4885],["sites",4884],["brennan",4884],["maddie",4882],["vienna",4882],["issued",4881],["lasted",4881],["explosive",4879],["origin",4878],["unto",4877],["rogue",4876],["x-ray",4875],["superintendent",4874],["cody",4874],["jacques",4874],["ol",4872],["challenges",4869],["corruption",4868],["sullivan",4867],["shutter",4866],["erase",4864],["ski",4862],["forensics",4861],["flavor",4859],["fastest",4859],["joon",4857],["vet",4856],["civilians",4852],["lobster",4851],["housing",4846],["canal",4845],["robots",4845],["shade",4843],["ambitious",4842],["orchestra",4840],["succeeded",4837],["guidance",4837],["hence",4836],["bella",4833],["seoul",4833],["jose",4828],["inevitable",4826],["tick",4825],["rival",4823],["refuses",4820],["fiancé",4819],["purchase",4819],["wander",4819],["ireland",4818],["hanna",4818],["hanged",4818],["napoleon",4813],["elite",4812],["craft",4812],["cult",4811],["whisky",4810],["profits",4809],["shelf",4806],["kai",4806],["allowing",4806],["chart",4806],["accusing",4803],["buzzes",4803],["weekends",4803],["humiliated",4801],["fiancée",4800],["hissing",4800],["treats",4799],["granddaughter",4799],["serena",4797],["submit",4796],["supervisor",4796],["sniffing",4795],["resident",4794],["tearing",4793],["obliged",4792],["opportunities",4791],["knights",4791],["hangs",4789],["jerusalem",4786],["stew",4786],["counted",4785],["instruments",4783],["triumph",4781],["nun",4780],["frequency",4780],["resume",4779],["marines",4779],["distract",4777],["satisfaction",4775],["historical",4774],["creates",4772],["costumes",4771],["swedish",4771],["lizzie",4769],["depending",4766],["sour",4765],["spark",4765],["freely",4764],["scotty",4764],["freaks",4764],["shan",4763],["globe",4762],["peak",4762],["sentenced",4762],["fierce",4761],["scam",4761],["hollow",4761],["anton",4760],["hoo",4760],["concentration",4760],["presume",4760],["invest",4759],["eleanor",4759],["radical",4758],["protective",4757],["smiles",4756],["stunning",4753],["frustrated",4753],["suspenseful",4752],["breed",4752],["adore",4752],["controlling",4751],["reagan",4751],["pasta",4750],["precinct",4749],["swallowed",4748],["execute",4748],["towels",4747],["architect",4747],["delta",4746],["honks",4744],["whoo-hoo",4743],["preserve",4742],["dose",4741],["stove",4739],["codes",4738],["instantly",4737],["bugger",4737],["vivian",4736],["yoon",4736],["doo",4732],["haul",4731],["orphanage",4730],["bolt",4726],["autograph",4724],["sharply",4724],["habits",4723],["villagers",4723],["cracks",4722],["dwight",4722],["omar",4721],["assembly",4720],["meds",4720],["marge",4719],["magnetic",4718],["romans",4716],["joshua",4714],["sorted",4711],["assets",4711],["insulted",4707],["outstanding",4707],["essence",4707],["wardrobe",4707],["ling",4707],["nam",4706],["benson",4705],["restaurants",4704],["disco",4702],["flirting",4702],["abilities",4700],["dolly",4700],["handful",4700],["mice",4698],["pumpkin",4698],["bennett",4696],["sweating",4695],["anchor",4693],["thankful",4693],["interior",4693],["moore",4690],["urge",4688],["surrounding",4686],["strain",4683],["curtains",4682],["gypsy",4682],["tomato",4682],["blanche",4682],["bonds",4681],["compassion",4681],["greece",4679],["ceo",4678],["arabic",4677],["examination",4677],["participate",4674],["veins",4674],["cups",4673],["additional",4672],["sofia",4671],["obsession",4671],["responsibilities",4670],["ridge",4670],["intent",4670],["axe",4669],["reckless",4668],["missiles",4667],["razor",4664],["corners",4664],["frightening",4663],["rains",4662],["mutant",4660],["wally",4659],["whereas",4656],["heavenly",4655],["promoted",4655],["dang",4654],["maiden",4652],["comments",4652],["batteries",4649],["bark",4647],["seas",4647],["congressman",4645],["canyon",4642],["casual",4642],["rack",4642],["expectations",4638],["flowing",4634],["adjust",4634],["decade",4633],["pine",4633],["tails",4633],["baxter",4630],["wires",4628],["pigeon",4626],["finance",4624],["respectable",4622],["demonstration",4620],["prank",4618],["appointed",4618],["treason",4615],["views",4612],["violin",4612],["stir",4611],["lifestyle",4610],["realm",4610],["monty",4609],["roosevelt",4608],["missy",4608],["fitting",4606],["señor",4605],["tuna",4605],["tuck",4605],["inspection",4604],["nursing",4603],["jared",4602],["poop",4601],["cardinal",4601],["altar",4600],["herman",4600],["carlo",4600],["tourist",4599],["conviction",4599],["jelly",4597],["thrill",4597],["lounge",4597],["cha",4596],["zip",4596],["hogan",4595],["dimension",4595],["choke",4594],["backing",4594],["pains",4593],["bodyguard",4593],["rug",4593],["jaw",4592],["ants",4591],["popcorn",4590],["courtroom",4589],["reynolds",4589],["pointless",4589],["documentary",4587],["donor",4586],["mortgage",4586],["tunnels",4586],["enforcement",4585],["demanding",4584],["cocks",4584],["eliminated",4584],["agh",4582],["rapidly",4581],["refer",4580],["shipment",4577],["tasted",4577],["fails",4577],["michel",4576],["rushed",4575],["ordering",4574],["efficient",4574],["outrageous",4569],["gracious",4566],["slaughter",4562],["marijuana",4561],["monroe",4560],["pipes",4559],["backed",4558],["vest",4557],["approached",4557],["mechanic",4555],["mumbai",4553],["arizona",4552],["legendary",4550],["delayed",4549],["resolve",4548],["relation",4547],["amelia",4545],["mechanical",4542],["fare",4542],["karma",4541],["jefferson",4541],["chorus",4540],["nana",4539],["extend",4539],["info",4533],["saddle",4531],["pretended",4528],["equals",4526],["motherfuckers",4524],["artificial",4523],["accomplish",4521],["burial",4520],["lynn",4519],["detention",4518],["vow",4518],["bake",4517],["breakdown",4517],["dvd",4515],["delhi",4512],["selected",4507],["lightly",4506],["dealers",4504],["layer",4501],["gown",4501],["courtney",4495],["incoming",4492],["reid",4492],["countryside",4491],["cheaper",4491],["smelled",4491],["devon",4490],["ducks",4490],["lethal",4489],["swan",4489],["attending",4489],["moonlight",4488],["ropes",4487],["dispatch",4487],["notebook",4484],["punched",4484],["identification",4483],["contracts",4482],["scattered",4482],["trusting",4477],["susie",4476],["prayed",4476],["volunteers",4474],["remembering",4474],["adopt",4473],["calvin",4472],["worms",4470],["consideration",4470],["bandits",4470],["moses",4469],["zombies",4468],["lionel",4466],["non",4464],["transplant",4463],["crackling",4462],["loneliness",4461],["frasier",4461],["epic",4458],["gladly",4458],["marcel",4457],["mon",4456],["frost",4455],["registration",4453],["prophet",4451],["launched",4451],["constitution",4446],["roland",4441],["claude",4441],["sock",4440],["dragons",4439],["buyer",4438],["regulations",4435],["mimi",4432],["volcano",4431],["accomplice",4431],["bounty",4430],["hotels",4429],["favors",4428],["opinions",4428],["pressed",4427],["asylum",4426],["blunt",4425],["duel",4425],["niles",4422],["consult",4422],["nevertheless",4420],["carnival",4420],["au",4419],["baba",4419],["hah",4418],["paths",4415],["rides",4414],["tray",4414],["cynthia",4414],["te",4414],["handcuffs",4414],["shannon",4414],["barrier",4413],["supposedly",4412],["faggot",4411],["hunch",4408],["disposal",4408],["yacht",4408],["stain",4407],["thoughtful",4405],["healed",4405],["thinkin",4404],["syndrome",4403],["hairy",4402],["kansas",4400],["happiest",4399],["entertain",4399],["y-you",4399],["ambush",4397],["recommended",4397],["childish",4397],["meter",4395],["gilbert",4395],["lease",4394],["climbed",4393],["nadia",4393],["unstable",4393],["exploded",4391],["apollo",4390],["rogers",4389],["politician",4389],["rs",4389],["awaits",4388],["abraham",4387],["evelyn",4386],["particles",4385],["violation",4385],["lined",4384],["amazed",4383],["lure",4383],["lyrics",4382],["pod",4382],["fran",4381],["universal",4381],["hulk",4376],["drunken",4376],["hyah",4376],["continuing",4375],["goals",4374],["warming",4372],["dc",4371],["chemicals",4371],["owl",4370],["xena",4368],["compromised",4367],["grounded",4367],["cane",4366],["avenge",4364],["dexter",4364],["phantom",4364],["notion",4363],["directors",4362],["tease",4361],["forensic",4361],["doe",4361],["outcome",4360],["slam",4360],["shattered",4360],["cheque",4359],["baked",4359],["sought",4357],["racket",4355],["hammond",4355],["rider",4352],["exotic",4352],["cory",4351],["extent",4350],["eighth",4350],["stack",4345],["wheat",4345],["shipping",4345],["satisfy",4343],["kara",4341],["departure",4341],["finch",4341],["blocking",4340],["regiment",4338],["kin",4336],["klaus",4335],["surf",4333],["kilometers",4330],["rises",4329],["frighten",4329],["abused",4328],["gerald",4325],["colours",4324],["hath",4323],["marvin",4322],["texts",4322],["spaghetti",4322],["chester",4322],["bosses",4320],["elephants",4319],["snarling",4319],["contained",4319],["laurie",4317],["arab",4317],["bumped",4317],["chili",4316],["yah",4315],["sustained",4313],["abducted",4313],["panicked",4313],["shallow",4312],["diving",4312],["artie",4311],["harriet",4311],["generator",4311],["federation",4310],["mulder",4310],["arresting",4309],["techniques",4309],["feather",4308],["printed",4306],["untie",4306],["jupiter",4305],["hay",4300],["stalking",4300],["conrad",4299],["phony",4298],["brett",4298],["whack",4297],["scored",4297],["continent",4293],["articles",4292],["oxford",4291],["organize",4289],["sensors",4288],["shanghai",4288],["wh",4287],["sniper",4285],["noisy",4284],["framed",4284],["horizon",4284],["twilight",4283],["reflection",4283],["masks",4281],["godfather",4281],["merchant",4280],["wages",4280],["robe",4279],["waist",4279],["stinking",4277],["disappointment",4277],["venture",4274],["voyage",4273],["curry",4271],["ominous",4270],["sunlight",4270],["oui",4269],["overseas",4269],["casualties",4269],["aaah",4269],["mattress",4265],["grill",4265],["marketing",4265],["accepting",4264],["occupation",4264],["deadline",4264],["freed",4264],["exhibit",4263],["poems",4263],["echoing",4262],["shin",4262],["compliments",4262],["til",4261],["define",4261],["urine",4261],["foreman",4260],["facial",4259],["combined",4257],["brody",4255],["probation",4254],["reject",4253],["catches",4248],["dances",4248],["gemma",4248],["pinch",4247],["ingredients",4247],["ra",4247],["hurricane",4245],["chatting",4244],["mini",4243],["heel",4241],["adding",4241],["bandit",4238],["destined",4236],["limo",4235],["spa",4235],["graves",4234],["turkish",4234],["listed",4233],["corpses",4233],["matty",4232],["evacuate",4232],["publish",4232],["judith",4231],["banned",4231],["snaps",4231],["cheeks",4230],["predict",4229],["ohio",4229],["holland",4229],["confront",4228],["utterly",4228],["pals",4228],["pilots",4228],["umm",4228],["jonah",4227],["ironic",4226],["improved",4226],["popping",4226],["mills",4224],["farther",4220],["delivering",4220],["skipper",4218],["flattered",4218],["coconut",4217],["acknowledge",4214],["shuts",4213],["sweden",4213],["hooray",4212],["sip",4212],["blessings",4210],["grocery",4210],["terminal",4208],["parks",4205],["meaningless",4205],["awards",4205],["fade",4205],["hiring",4204],["burton",4203],["fees",4203],["wretched",4202],["krishna",4199],["thoroughly",4198],["exhibition",4198],["spends",4198],["denver",4197],["winners",4193],["doris",4193],["luther",4192],["disappearance",4192],["madrid",4192],["supported",4190],["advised",4190],["regina",4187],["supermarket",4187],["hereby",4186],["pier",4186],["shorter",4186],["betrayal",4185],["philadelphia",4185],["christians",4184],["psychology",4181],["danielle",4180],["trent",4179],["keeper",4179],["detroit",4179],["canadian",4179],["su",4178],["associates",4177],["gerry",4176],["restless",4173],["collector",4172],["marvellous",4172],["physician",4171],["exposure",4171],["grudge",4170],["lid",4166],["followers",4166],["glow",4165],["submarine",4164],["towns",4164],["pawn",4164],["edith",4162],["includes",4161],["lu",4160],["proposition",4160],["joker",4159],["spilled",4158],["opposition",4158],["jang",4158],["haley",4158],["hose",4157],["partnership",4157],["usa",4155],["timmy",4154],["breathes",4151],["whoops",4151],["astronaut",4150],["porch",4148],["leonardo",4148],["responding",4148],["wishing",4148],["boil",4146],["kneel",4146],["tammy",4141],["clumsy",4141],["rapid",4140],["xiao",4140],["stevens",4139],["invested",4138],["janice",4138],["roller",4137],["thai",4137],["justify",4137],["garlic",4136],["yelled",4135],["simone",4134],["ammunition",4134],["sushi",4133],["shaft",4133],["definition",4132],["gardens",4131],["chandler",4129],["ne",4128],["notify",4128],["brady",4128],["beasts",4127],["setup",4127],["greed",4123],["dared",4122],["unlucky",4121],["classroom",4121],["treaty",4120],["hike",4118],["dagger",4118],["spine",4115],["detect",4113],["chow",4112],["des",4110],["teenagers",4109],["handwriting",4109],["mock",4108],["challenging",4108],["salon",4108],["virtue",4107],["lawsuit",4107],["shelly",4105],["clap",4105],["mustache",4104],["offence",4103],["med",4103],["sultan",4102],["attraction",4102],["franco",4101],["courts",4100],["fortress",4100],["café",4099],["peel",4097],["petrol",4096],["disappears",4096],["asset",4096],["warp",4096],["workshop",4095],["traps",4094],["jeannie",4091],["appreciated",4090],["condemned",4089],["garrett",4088],["muttering",4087],["shook",4087],["naval",4086],["egyptian",4085],["expand",4084],["sunrise",4083],["swine",4083],["jingle",4083],["lordship",4083],["peach",4083],["filmed",4083],["pakistan",4080],["oughta",4080],["selection",4079],["squeaking",4079],["condom",4078],["sadie",4077],["barbie",4076],["alongside",4076],["risking",4075],["democratic",4074],["nerd",4074],["dancers",4074],["crook",4072],["mack",4071],["grasp",4071],["translate",4070],["behaving",4068],["humiliating",4067],["quarrel",4066],["lively",4065],["fury",4065],["policemen",4064],["sensei",4064],["elders",4064],["battlefield",4064],["sticky",4063],["glen",4063],["liu",4062],["primitive",4062],["reign",4062],["eugene",4061],["stash",4061],["evolved",4060],["shifts",4060],["unlock",4059],["devices",4059],["peculiar",4058],["talents",4057],["anita",4057],["foreigners",4056],["snacks",4056],["mint",4055],["pissing",4054],["fuse",4054],["manuel",4054],["towers",4054],["freezer",4052],["inheritance",4051],["villages",4049],["beckett",4045],["jewel",4045],["bollocks",4045],["shells",4044],["swept",4043],["recognition",4042],["shovel",4040],["artistic",4039],["qualities",4037],["ferry",4034],["darren",4031],["misunderstood",4029],["portal",4029],["atomic",4028],["javier",4027],["ava",4027],["claiming",4025],["examined",4025],["defensive",4025],["kenneth",4024],["kat",4023],["sensation",4023],["ahhh",4023],["increasing",4021],["fugitive",4020],["urban",4019],["visions",4017],["bathing",4016],["gregory",4016],["delight",4016],["iot",4014],["genes",4014],["boundaries",4012],["caution",4012],["ellis",4011],["strangled",4011],["thorough",4010],["ike",4009],["bites",4008],["nude",4008],["tidy",4007],["patricia",4007],["tate",4006],["obligation",4005],["ari",4003],["clearance",4001],["reserved",4000],["nasa",3999],["spock",3996],["marion",3995],["this-",3995],["reflect",3994],["jammed",3994],["sleeve",3992],["classy",3992],["fag",3990],["angelo",3989],["bradley",3988],["bananas",3988],["ex-husband",3988],["saints",3988],["longest",3987],["confirmation",3986],["mushrooms",3986],["cyrus",3985],["toni",3985],["finals",3983],["millionaire",3983],["adoption",3982],["wired",3981],["comet",3980],["handing",3980],["cheerful",3980],["deb",3980],["acceptable",3978],["realistic",3978],["waits",3977],["sufficient",3977],["cosmic",3976],["nolan",3975],["gabe",3975],["intellectual",3975],["vomit",3972],["warmth",3970],["magnum",3969],["associated",3968],["alas",3967],["engineers",3967],["offend",3966],["overwhelming",3966],["sherry",3966],["inherited",3965],["antoine",3964],["whooping",3962],["scars",3962],["sheldon",3960],["purposes",3955],["robbing",3955],["refrigerator",3954],["instructor",3954],["darrin",3954],["moms",3954],["overtime",3953],["percy",3952],["laurel",3952],["females",3951],["sacrifices",3947],["representing",3943],["drift",3943],["ruthless",3942],["po",3942],["arguments",3941],["crappy",3941],["musicians",3940],["sidney",3938],["depressing",3938],["lasts",3938],["doom",3937],["milo",3936],["cant",3936],["backpack",3934],["aisle",3933],["insulting",3933],["edition",3932],["losses",3932],["sh",3932],["preacher",3931],["schmidt",3929],["swinging",3925],["managing",3924],["sinking",3924],["lacey",3924],["casting",3922],["trials",3919],["demonstrate",3918],["diseases",3918],["beggar",3917],["amounts",3916],["traced",3915],["raven",3913],["activate",3912],["slapped",3911],["heroic",3908],["roar",3907],["queer",3906],["uk",3905],["matching",3905],["trump",3904],["resignation",3903],["newton",3901],["sincerely",3900],["stacy",3900],["saul",3900],["madman",3899],["jenkins",3898],["poke",3898],["spicy",3897],["swap",3897],["programs",3894],["sheer",3893],["spaceship",3893],["schultz",3893],["blink",3893],["steer",3892],["males",3891],["injection",3890],["know-",3890],["ant",3888],["reform",3888],["mother-in-law",3888],["properties",3887],["virtually",3886],["camps",3886],["tapping",3884],["tequila",3882],["inquiry",3881],["fainted",3881],["formation",3879],["shields",3876],["escaping",3876],["unemployed",3875],["murdering",3874],["interviewed",3873],["striking",3872],["vows",3871],["donovan",3871],["antique",3870],["il",3870],["sherlock",3869],["puke",3869],["hysterical",3868],["taller",3868],["employment",3867],["thats",3866],["agrees",3865],["dodge",3864],["unite",3860],["mumbling",3859],["rabbits",3857],["repeating",3856],["mule",3856],["locals",3856],["neighbourhood",3853],["hayes",3852],["bats",3852],["beliefs",3852],["spared",3851],["vegetable",3850],["liv",3848],["bunker",3848],["channels",3847],["phenomenon",3846],["hips",3846],["grayson",3844],["expelled",3840],["camille",3839],["languages",3838],["iv",3838],["premises",3837],["pronounce",3836],["ng",3836],["bridges",3834],["ape",3834],["julius",3833],["bruises",3832],["convict",3831],["prettier",3830],["elections",3829],["annoyed",3828],["hometown",3828],["earrings",3827],["discreet",3827],["poured",3826],["morphine",3826],["henderson",3824],["jun",3824],["narrating",3823],["rookie",3823],["peanuts",3823],["camel",3822],["abe",3822],["chewing",3821],["imagining",3821],["runner",3821],["hyun",3821],["tomatoes",3819],["sponsor",3818],["fulfilled",3816],["probe",3816],["karate",3813],["grease",3812],["dinosaurs",3811],["rebellion",3810],["marta",3808],["lone",3807],["nan",3806],["underestimate",3804],["acres",3803],["carlton",3801],["hacked",3800],["barber",3799],["mining",3799],["aged",3798],["designs",3797],["preferred",3796],["onions",3796],["stray",3796],["void",3793],["sixteen",3792],["slower",3792],["hannibal",3792],["sabotage",3791],["rabbi",3791],["concerning",3789],["dixon",3789],["holt",3787],["mannix",3786],["noted",3786],["peterson",3786],["dental",3786],["obama",3786],["squirrel",3785],["committing",3785],["werewolf",3785],["slipping",3784],["mm-mm",3784],["matthews",3783],["explosions",3783],["seo",3781],["russ",3781],["whitney",3781],["ignoring",3780],["infinite",3780],["cavalry",3780],["lam",3779],["suzanne",3779],["bomber",3779],["ruling",3777],["boiling",3775],["needles",3775],["semester",3773],["irrelevant",3772],["savior",3771],["veil",3769],["tramp",3769],["roberto",3768],["deepest",3768],["perfection",3768],["shutting",3768],["iii",3766],["tiffany",3766],["mentor",3766],["branches",3765],["enthusiasm",3764],["arena",3763],["listens",3762],["hardware",3762],["greatly",3762],["battalion",3762],["moustache",3761],["irresponsible",3761],["audio",3760],["ga",3760],["initiative",3759],["sweaty",3759],["noel",3758],["requests",3757],["distraction",3757],["parrot",3754],["steed",3754],["entertaining",3754],["whipped",3753],["proving",3752],["assaulted",3752],["piggy",3750],["pause",3750],["surviving",3750],["funding",3748],["dinosaur",3748],["acquaintance",3748],["bedtime",3748],["rail",3746],["rooster",3746],["cowards",3746],["hastings",3745],["container",3743],["lori",3743],["secured",3742],["tossed",3741],["stripper",3741],["steele",3740],["waving",3739],["clamoring",3739],["banker",3738],["bacteria",3738],["indicates",3738],["assassination",3737],["starring",3735],["drawings",3735],["cupboard",3735],["sanctuary",3735],["surgical",3735],["pairs",3735],["purely",3734],["temptation",3734],["elderly",3734],["geoffrey",3734],["vibe",3733],["profound",3732],["gong",3732],["transformed",3732],["crop",3732],["detailed",3731],["scrap",3730],["abigail",3729],["careless",3727],["dove",3726],["sloppy",3726],["atlanta",3723],["hideous",3722],["lizard",3720],["separation",3719],["intern",3719],["tab",3719],["psych",3718],["ranks",3718],["parish",3717],["booty",3716],["peasant",3715],["squeal",3715],["oooh",3715],["comb",3715],["bash",3714],["restored",3713],["platoon",3712],["junkie",3712],["involves",3710],["rupert",3709],["norm",3707],["carrier",3707],["fatty",3707],["difficulties",3706],["producers",3705],["superb",3705],["tha",3704],["candidates",3703],["summoned",3703],["refuge",3702],["righteous",3702],["carolina",3701],["onion",3699],["natasha",3699],["pickup",3698],["spells",3697],["wesley",3697],["carrington",3696],["armies",3696],["seasons",3695],["senor",3695],["billie",3695],["specialty",3690],["thirst",3690],["select",3689],["rendezvous",3689],["smiled",3688],["colorado",3687],["visa",3686],["spice",3685],["caller",3684],["foods",3684],["rational",3684],["scottish",3683],["cunning",3682],["grunt",3681],["knot",3681],["hairs",3680],["payments",3679],["granddad",3678],["massacre",3678],["competitive",3677],["herb",3677],["mae",3676],["immune",3674],["grenade",3674],["regularly",3674],["stakes",3673],["kilos",3673],["monks",3672],["gail",3671],["guinea",3671],["invent",3670],["nursery",3669],["protein",3669],["elbow",3668],["keller",3667],["clowns",3666],["thirteen",3665],["tattoos",3665],["flown",3665],["mustard",3665],["terrace",3664],["alter",3663],["franz",3662],["transportation",3661],["what-",3661],["lens",3661],["alaska",3660],["ha-ha-ha",3660],["interference",3660],["freakin",3659],["wont",3659],["weep",3659],["clive",3659],["mourning",3659],["crooked",3659],["trainer",3658],["janitor",3658],["gradually",3657],["immigration",3656],["filth",3656],["momma",3656],["father-in-law",3656],["jackass",3655],["cho",3655],["reminder",3655],["culprit",3655],["gabrielle",3654],["innit",3653],["magistrate",3653],["jasmine",3652],["womb",3650],["texting",3650],["injustice",3650],["figuring",3649],["fracture",3648],["insanity",3646],["peasants",3645],["blossom",3645],["etc",3644],["agony",3642],["sounding",3642],["oz",3642],["elementary",3640],["seized",3636],["teaches",3636],["lovejoy",3636],["phillips",3635],["eighteen",3635],["shack",3634],["teen",3634],["definite",3634],["scrub",3633],["ruler",3631],["mold",3631],["contents",3631],["bitten",3630],["antidote",3630],["was-",3629],["saves",3628],["bunk",3627],["thor",3625],["republican",3625],["graveyard",3625],["attempts",3624],["poirot",3624],["aspect",3624],["relate",3622],["lemonade",3621],["flour",3620],["nypd",3618],["biology",3617],["dusty",3617],["felony",3617],["jax",3617],["fundamental",3616],["jai",3614],["successfully",3613],["potentially",3613],["donnie",3613],["strangely",3612],["condoms",3612],["quantum",3612],["utter",3611],["activated",3610],["installed",3608],["interrupted",3608],["discharge",3607],["devastated",3607],["substitute",3607],["splash",3607],["julio",3606],["exile",3605],["aspirin",3605],["authentic",3602],["summit",3601],["beck",3601],["addiction",3600],["lent",3600],["drone",3600],["involving",3599],["boyfriends",3598],["tango",3595],["permanently",3594],["province",3593],["attempting",3591],["deceived",3589],["triangle",3588],["skipped",3588],["strawberry",3587],["mirrors",3585],["self-defense",3583],["preston",3583],["overwhelmed",3583],["swift",3583],["creeps",3581],["token",3580],["temporarily",3579],["poisonous",3579],["heidi",3579],["diagnosis",3579],["ninth",3578],["locations",3578],["retrieve",3577],["robbers",3576],["conclusions",3575],["joanna",3575],["disabled",3575],["ira",3573],["extension",3572],["lining",3572],["yuri",3571],["hip-hop",3571],["freeman",3571],["scoop",3569],["merlin",3568],["bridget",3567],["revving",3566],["transform",3563],["shelby",3562],["hospitality",3562],["brooks",3561],["survey",3561],["hunted",3560],["cozy",3560],["recommendation",3559],["providing",3558],["microphone",3558],["skeleton",3558],["muslims",3557],["toll",3556],["reconsider",3555],["creaks",3555],["extended",3554],["spear",3554],["overboard",3554],["ai",3552],["clinton",3551],["petition",3550],["baggage",3549],["scumbag",3549],["tribute",3548],["weddings",3547],["facilities",3545],["bangs",3545],["claw",3545],["donation",3544],["isabelle",3543],["traitors",3542],["mei",3541],["jasper",3540],["borders",3538],["australian",3538],["colby",3538],["boxer",3537],["whilst",3536],["misfortune",3535],["elaborate",3535],["sights",3533],["maestro",3531],["mysteries",3530],["loosen",3529],["gifted",3529],["i--i",3527],["involvement",3526],["affects",3525],["summon",3525],["sherman",3525],["churchill",3523],["theresa",3523],["solitary",3522],["wilderness",3522],["baltimore",3522],["olympic",3522],["undo",3521],["mechanism",3521],["vietnamese",3520],["vanish",3520],["dome",3519],["biting",3519],["ronald",3519],["texted",3518],["sage",3516],["yummy",3515],["tactics",3515],["slack",3515],["snapped",3514],["weekly",3514],["bushes",3508],["grandparents",3508],["like-",3508],["henri",3506],["obtain",3505],["hart",3505],["flights",3503],["adventures",3502],["hottest",3501],["grown-up",3500],["eden",3500],["mild",3500],["dom",3500],["fourteen",3499],["smuggling",3498],["blend",3498],["chancellor",3497],["biscuits",3496],["gorilla",3496],["dynasty",3495],["acquired",3494],["businesses",3494],["countless",3493],["fritz",3492],["brent",3489],["gardener",3488],["barracks",3488],["flirt",3488],["cooks",3488],["distorted",3486],["carved",3484],["jumps",3484],["superhero",3484],["forests",3482],["clapping",3482],["psychiatric",3482],["snuck",3481],["roughly",3481],["screamed",3481],["raja",3480],["disappearing",3480],["establishment",3479],["whales",3478],["lump",3477],["dwarf",3477],["environmental",3477],["mam",3477],["pint",3476],["loading",3475],["diplomatic",3475],["exclaims",3474],["context",3474],["volunteered",3473],["risked",3473],["gangs",3473],["boost",3472],["fax",3471],["dante",3471],["partial",3471],["refusing",3469],["gaze",3469],["rene",3469],["nuisance",3468],["terrorism",3468],["jerome",3468],["ungrateful",3467],["donate",3467],["relieve",3467],["grandchildren",3465],["felicity",3465],["maureen",3464],["represented",3464],["co",3462],["neutral",3462],["philippe",3462],["workin",3462],["steering",3462],["rm",3461],["presidential",3461],["rental",3460],["challenged",3460],["julien",3459],["feeds",3459],["je",3458],["vegetarian",3458],["teasing",3458],["surprisingly",3458],["iran",3457],["shrine",3457],["automatically",3456],["timer",3456],["gasoline",3455]]} \ No newline at end of file diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index acef15b..d65bd2d 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -208,6 +208,17 @@ impl Database { language TEXT NOT NULL, frequency INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS pack_words ( + language TEXT NOT NULL, + word TEXT NOT NULL, + frequency INTEGER NOT NULL, + PRIMARY KEY (language, word) + ); + CREATE TABLE IF NOT EXISTS installed_packs ( + language TEXT PRIMARY KEY, + version INTEGER NOT NULL, + installed_at TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS usage_statistics ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id TEXT NOT NULL, @@ -250,15 +261,96 @@ impl Database { .collect(); for profile_id in profile_ids { - self.seed_greek_phrases_if_missing(conn, &profile_id)?; + for (language, phrases) in Self::locale_seed_phrases() { + self.seed_locale_phrases_if_missing(conn, &profile_id, language, phrases)?; + } } Ok(()) } - fn seed_greek_phrases_if_missing(&self, conn: &Connection, profile_id: &str) -> Result<()> { + /// Default phrase packs for non-English UI locales (same 6-intent matrix as English). + fn locale_seed_phrases() -> &'static [(&'static str, &'static [(&'static str, &'static str, bool, bool)])] + { + &[ + ( + "el", + &[ + ("Χρειάζομαι βοήθεια", "both", false, true), + ("Χρειάζομαι νερό", "both", true, false), + ("Κουράστηκα", "speak", false, false), + ("Ευχαριστώ", "speak", true, false), + ("Πονάω", "both", false, true), + ("Έλα εδώ παρακαλώ", "both", false, true), + ], + ), + ( + "de", + &[ + ("Ich brauche Hilfe", "both", false, true), + ("Ich brauche Wasser", "both", true, false), + ("Ich bin müde", "speak", false, false), + ("Danke", "speak", true, false), + ("Ich habe Schmerzen", "both", false, true), + ("Komm bitte her", "both", false, true), + ], + ), + ( + "fr", + &[ + ("J’ai besoin d’aide", "both", false, true), + ("J’ai besoin d’eau", "both", true, false), + ("Je suis fatigué", "speak", false, false), + ("Merci", "speak", true, false), + ("J’ai mal", "both", false, true), + ("Viens ici s’il te plaît", "both", false, true), + ], + ), + ( + "it", + &[ + ("Ho bisogno di aiuto", "both", false, true), + ("Ho bisogno di acqua", "both", true, false), + ("Sono stanco", "speak", false, false), + ("Grazie", "speak", true, false), + ("Ho dolore", "both", false, true), + ("Vieni qui per favore", "both", false, true), + ], + ), + ( + "es", + &[ + ("Necesito ayuda", "both", false, true), + ("Necesito agua", "both", true, false), + ("Estoy cansado", "speak", false, false), + ("Gracias", "speak", true, false), + ("Me duele", "both", false, true), + ("Ven aquí por favor", "both", false, true), + ], + ), + ( + "pt", + &[ + ("Preciso de ajuda", "both", false, true), + ("Preciso de água", "both", true, false), + ("Estou cansado", "speak", false, false), + ("Obrigado", "speak", true, false), + ("Estou com dor", "both", false, true), + ("Vem cá por favor", "both", false, true), + ], + ), + ] + } + + fn seed_locale_phrases_if_missing( + &self, + conn: &Connection, + profile_id: &str, + language: &str, + phrases: &[(&str, &str, bool, bool)], + ) -> Result<()> { let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM phrases WHERE profile_id = ?1 AND language = 'el'", - [profile_id], + "SELECT COUNT(*) FROM phrases WHERE profile_id = ?1 AND language = ?2", + params![profile_id, language], |r| r.get(0), )?; if count > 0 { @@ -276,16 +368,8 @@ impl Database { |r| r.get(0), )?; - let phrases = [ - ("Χρειάζομαι βοήθεια", "both", false, true), - ("Χρειάζομαι νερό", "both", true, false), - ("Κουράστηκα", "speak", false, false), - ("Ευχαριστώ", "speak", true, false), - ("Πονάω", "both", false, true), - ("Έλα εδώ παρακαλώ", "both", false, true), - ]; for (text, action, fav, emergency) in phrases { - let category = if emergency { + let category = if *emergency { &emergency_cat } else { &basic_cat @@ -298,9 +382,9 @@ impl Database { category, text, action, - fav as i32, - emergency as i32, - "el" + *fav as i32, + *emergency as i32, + language ], )?; } @@ -368,48 +452,23 @@ impl Database { )?; } - let greek_phrases = [ - ("Χρειάζομαι βοήθεια", "both", false, true), - ("Χρειάζομαι νερό", "both", true, false), - ("Κουράστηκα", "speak", false, false), - ("Ευχαριστώ", "speak", true, false), - ("Πονάω", "both", false, true), - ("Έλα εδώ παρακαλώ", "both", false, true), - ]; - for (text, action, fav, emergency) in greek_phrases { - let category = if emergency { &emergency_cat } else { &cat_id }; - conn.execute( - "INSERT INTO phrases (id, profile_id, category_id, text, action, is_favorite, is_emergency, language) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", - params![ - Uuid::new_v4().to_string(), - profile_id, - category, - text, - action, - fav as i32, - emergency as i32, - "el" - ], - )?; - } - - let words = [ - ("hello", "en", 100), - ("help", "en", 90), - ("helicopter", "en", 10), - ("water", "en", 80), - ("thank", "en", 70), - ("thanks", "en", 65), - ("please", "en", 60), - ("γεια", "el", 100), - ("βοήθεια", "el", 90), - ("νερό", "el", 80), - ]; - for (word, lang, freq) in words { - conn.execute( - "INSERT INTO predictions (profile_id, word, language, frequency) VALUES (?1,?2,?3,?4)", - params![profile_id, word, lang, freq], - )?; + for (language, phrases) in Self::locale_seed_phrases() { + for (text, action, fav, emergency) in *phrases { + let category = if *emergency { &emergency_cat } else { &cat_id }; + conn.execute( + "INSERT INTO phrases (id, profile_id, category_id, text, action, is_favorite, is_emergency, language) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + Uuid::new_v4().to_string(), + profile_id, + category, + *text, + *action, + *fav as i32, + *emergency as i32, + language + ], + )?; + } } let macro_id = Uuid::new_v4().to_string(); @@ -585,9 +644,10 @@ impl Database { pub fn insert_prediction(&self, profile_id: &str, entry: &PredictionEntry) -> Result<()> { let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; + let word = entry.word.to_lowercase(); conn.execute( "INSERT INTO predictions (profile_id, word, language, frequency) VALUES (?1,?2,?3,?4)", - params![profile_id, entry.word, entry.language, entry.frequency], + params![profile_id, word, entry.language, entry.frequency], )?; Ok(()) } @@ -763,20 +823,124 @@ impl Database { pub fn get_predictions(&self, profile_id: &str, prefix: &str, language: &str, limit: i32) -> Result> { let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; - let pattern = format!("{prefix}%"); + let pattern = format!("{}%", prefix.to_lowercase()); + let mut pack_freq: std::collections::HashMap = std::collections::HashMap::new(); + let mut profile_usage: std::collections::HashMap = + std::collections::HashMap::new(); + + { + let mut stmt = conn.prepare( + "SELECT word, frequency FROM pack_words WHERE language = ?1 AND word LIKE ?2", + )?; + let rows = stmt.query_map(params![language, pattern], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) + })?; + for row in rows { + let (word, freq) = row?; + pack_freq.insert(word, i64::from(freq)); + } + } + + { + let mut stmt = conn.prepare( + "SELECT word, frequency FROM predictions WHERE profile_id = ?1 AND language = ?2 AND word LIKE ?3", + )?; + let rows = stmt.query_map(params![profile_id, language, pattern], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) + })?; + for row in rows { + let (word, freq) = row?; + profile_usage.insert(word, i64::from(freq)); + } + } + + let mut ranked: Vec<(String, i64, i64)> = pack_freq + .keys() + .chain(profile_usage.keys()) + .cloned() + .collect::>() + .into_iter() + .map(|word| { + let usage = profile_usage.get(&word).copied().unwrap_or(0); + let pack = pack_freq.get(&word).copied().unwrap_or(0); + (word, usage, pack) + }) + .collect(); + ranked.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| b.2.cmp(&a.2)) + .then_with(|| a.0.cmp(&b.0)) + }); + ranked.truncate(limit.max(0) as usize); + + Ok(ranked + .into_iter() + .map(|(word, usage, pack)| PredictionEntry { + word, + language: language.to_string(), + frequency: (usage.saturating_mul(1000).saturating_add(pack)) + .min(i64::from(i32::MAX)) as i32, + }) + .collect()) + } + + pub fn list_installed_packs(&self) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; let mut stmt = conn.prepare( - "SELECT word, language, frequency FROM predictions WHERE profile_id = ?1 AND language = ?2 AND word LIKE ?3 ORDER BY frequency DESC LIMIT ?4", + "SELECT language, version FROM installed_packs ORDER BY language", )?; - let rows = stmt.query_map(params![profile_id, language, pattern, limit], |row| { - Ok(PredictionEntry { - word: row.get(0)?, - language: row.get(1)?, - frequency: row.get(2)?, - }) - })?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; rows.collect::, _>>().map_err(Into::into) } + pub fn is_pack_installed(&self, language: &str) -> Result { + let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM installed_packs WHERE language = ?1", + params![language], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn import_word_pack( + &self, + language: &str, + version: i32, + words: &[(String, i32)], + ) -> Result<()> { + let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM pack_words WHERE language = ?1", params![language])?; + { + let mut stmt = tx.prepare( + "INSERT INTO pack_words (language, word, frequency) VALUES (?1,?2,?3)", + )?; + for (word, freq) in words { + stmt.execute(params![language, word, freq])?; + } + } + tx.execute( + "INSERT INTO installed_packs (language, version, installed_at) VALUES (?1,?2,?3) + ON CONFLICT(language) DO UPDATE SET version = excluded.version, installed_at = excluded.installed_at", + params![language, version, Utc::now().to_rfc3339()], + )?; + tx.commit()?; + Ok(()) + } + + pub fn uninstall_word_pack(&self, language: &str) -> Result<()> { + let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM pack_words WHERE language = ?1", params![language])?; + tx.execute( + "DELETE FROM installed_packs WHERE language = ?1", + params![language], + )?; + tx.commit()?; + Ok(()) + } + pub fn record_word_usage(&self, profile_id: &str, word: &str, language: &str) -> Result<()> { let conn = self.conn.lock().map_err(|_| anyhow!("DB lock poisoned"))?; let updated = conn.execute( diff --git a/src-tauri/src/icons.rs b/src-tauri/src/icons.rs index d525727..69ecff7 100644 --- a/src-tauri/src/icons.rs +++ b/src-tauri/src/icons.rs @@ -1,17 +1,40 @@ //! Resolve and extract icons for Quick Actions (Windows app icons). +use std::collections::hash_map::DefaultHasher; +use std::fs; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; -/// Returns a `data:image/png;base64,...` URL for an app target, or `None` if unavailable. -pub fn app_icon_data_url(target: &str) -> Option { +/// Returns a filesystem path to a cached PNG icon for `convertFileSrc`, or `None`. +pub fn app_icon_cached_path(app: &AppHandle, target: &str) -> Option { #[cfg(windows)] { let path = resolve_app_path(target)?; - extract_file_icon_png_data_url(&path).ok() + let cache_dir = icon_cache_dir(app).ok()?; + let key = icon_cache_key(&path); + let cache_file = cache_dir.join(format!("{key}.png")); + if cache_file.is_file() { + return Some(cache_file.to_string_lossy().into_owned()); + } + let png = extract_file_icon_png(&path).ok()?; + let tmp = cache_dir.join(format!( + "{key}.{}.tmp", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + fs::write(&tmp, &png).ok()?; + if fs::rename(&tmp, &cache_file).is_err() { + let _ = fs::remove_file(&tmp); + return None; + } + Some(cache_file.to_string_lossy().into_owned()) } #[cfg(not(windows))] { - let _ = target; + let _ = (app, target); None } } @@ -29,6 +52,66 @@ pub fn is_app_installed(target: &str) -> bool { } } +/// Resolve a quick-action app target to an existing `.exe` path when possible. +pub fn resolve_launch_app_path(target: &str) -> Option { + #[cfg(windows)] + { + resolve_app_path(target) + } + #[cfg(not(windows))] + { + let _ = target; + None + } +} + +const ICON_CACHE_RETENTION: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60); + +fn icon_cache_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("icon-cache"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + prune_icon_cache(&dir); + Ok(dir) +} + +fn prune_icon_cache(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { + continue; + }; + let Ok(age) = now.duration_since(modified) else { + continue; + }; + if age > ICON_CACHE_RETENTION { + let _ = fs::remove_file(path); + } + } +} + +fn icon_cache_key(path: &Path) -> String { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + if let Ok(meta) = fs::metadata(path) { + if let Ok(modified) = meta.modified() { + modified.hash(&mut hasher); + } + meta.len().hash(&mut hasher); + } + format!("{:016x}", hasher.finish()) +} + #[cfg(windows)] fn resolve_app_path(target: &str) -> Option { let trimmed = target.trim(); @@ -167,8 +250,7 @@ fn resolve_from_app_paths_registry(exe_name: &str) -> Option { } #[cfg(windows)] -fn extract_file_icon_png_data_url(path: &Path) -> Result { - use base64::Engine; +fn extract_file_icon_png(path: &Path) -> Result, String> { use image::{ImageBuffer, ImageFormat, Rgba}; use std::io::Cursor; use std::os::windows::ffi::OsStrExt; @@ -260,7 +342,6 @@ fn extract_file_icon_png_data_url(path: &Path) -> Result { let mut png_bytes = Cursor::new(Vec::new()); img.write_to(&mut png_bytes, ImageFormat::Png) .map_err(|e| e.to_string())?; - let encoded = base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner()); - Ok(format!("data:image/png;base64,{encoded}")) + Ok(png_bytes.into_inner()) } } diff --git a/src-tauri/src/input/keyboard.rs b/src-tauri/src/input/keyboard.rs index 4d015ac..4706392 100644 --- a/src-tauri/src/input/keyboard.rs +++ b/src-tauri/src/input/keyboard.rs @@ -427,12 +427,13 @@ fn lang_id_display_name(lang_id: u32) -> String { lang_id_to_iso_tag(lang_id).to_uppercase() } -/// App chrome locales only (`en` / `el`); unsupported → `en`. +/// App chrome locales only (`en` / `el` / `de` / `fr` / `it` / `es` / `pt`); unsupported → `en`. pub fn windows_ui_language() -> String { use windows::Win32::Globalization::GetUserDefaultUILanguage; let lang_id = unsafe { GetUserDefaultUILanguage() } as u32; - match lang_id_to_iso_tag(lang_id).as_str() { - "el" => "el".to_string(), + let tag = lang_id_to_iso_tag(lang_id); + match tag.as_str() { + "el" | "de" | "fr" | "it" | "es" | "pt" => tag, _ => "en".to_string(), } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 931bccc..3c97d83 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,7 +22,10 @@ use input::{ }; #[cfg(target_os = "windows")] use input::focus_target; -use prediction::{get_installed_languages, get_suggestions, record_usage}; +use prediction::{ + ensure_english_pack, get_installed_languages, get_suggestions, install_word_pack, + list_word_packs, record_usage, uninstall_word_pack, WordPackInfo, +}; use serde::{Deserialize, Serialize}; use std::sync::Mutex; use tauri::{AppHandle, Manager, State}; @@ -525,8 +528,10 @@ fn pick_background_image(app: &tauri::AppHandle) -> Result, Strin } #[tauri::command] -fn cmd_pick_background_image(app: tauri::AppHandle) -> Result, String> { - pick_background_image(&app) +async fn cmd_pick_background_image(app: tauri::AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || pick_background_image(&app)) + .await + .map_err(|e| e.to_string())? } fn pick_music_song_file(app: &tauri::AppHandle) -> Result, String> { @@ -550,7 +555,10 @@ fn pick_music_song_file(app: &tauri::AppHandle) -> Result, String .set_parent(&window) .pick_file(); - Ok(file.map(|p| p.to_string_lossy().into_owned())) + Ok(file.map(|p| { + music::allow_music_read_path(&p); + p.to_string_lossy().into_owned() + })) } #[derive(Debug, Serialize)] @@ -561,13 +569,17 @@ struct MusicFilePayload { } #[tauri::command] -fn cmd_pick_music_song_file(app: tauri::AppHandle) -> Result, String> { - pick_music_song_file(&app) +async fn cmd_pick_music_song_file(app: tauri::AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || pick_music_song_file(&app)) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -fn cmd_list_installed_apps() -> Result, String> { - installed_apps::list_installed_apps() +async fn cmd_list_installed_apps() -> Result, String> { + tokio::task::spawn_blocking(installed_apps::list_installed_apps) + .await + .map_err(|e| e.to_string())? } fn pick_app_executable(app: &tauri::AppHandle) -> Result, String> { @@ -589,13 +601,15 @@ fn pick_app_executable(app: &tauri::AppHandle) -> Result, String> } #[tauri::command] -fn cmd_pick_app_executable(app: tauri::AppHandle) -> Result, String> { - pick_app_executable(&app) +async fn cmd_pick_app_executable(app: tauri::AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || pick_app_executable(&app)) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -fn cmd_read_music_file(path: String) -> Result { - let bytes = music::read_music_file_bytes(&path)?; +fn cmd_read_music_file(app: tauri::AppHandle, path: String) -> Result { + let bytes = music::read_music_file_bytes(&app, &path)?; Ok(MusicFilePayload { path, content_base64: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes), @@ -654,6 +668,120 @@ fn cmd_delete_quick_action(id: String, state: State) -> Result<(), Str state.db.delete_quick_action(&id).map_err(|e| e.to_string()) } +fn validate_quick_action_url(target: &str) -> Result { + let trimmed = target.trim(); + if trimmed.is_empty() { + return Err("URL target is empty".to_string()); + } + if trimmed.contains('\0') { + return Err("URL target is invalid".to_string()); + } + + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("https://") + || lower.starts_with("http://") + || lower.starts_with("mailto:") + || lower.starts_with("ms-settings:") + { + return Ok(trimmed.to_string()); + } + + // Bare hosts from the editor (e.g. "youtube.com") — open as https. + if is_host_shaped_url_target(trimmed) { + return Ok(format!("https://{trimmed}")); + } + + Err("URL scheme not allowed".to_string()) +} + +/// Accepts host-shaped targets like `example.com`, `example.com:443/path?q=1`. +fn is_host_shaped_url_target(trimmed: &str) -> bool { + if trimmed.contains('\\') || trimmed.contains("://") { + return false; + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".-:_/?#=&%+".contains(c)) + { + return false; + } + + let host_port = trimmed + .split(['/', '?', '#']) + .next() + .unwrap_or(trimmed); + if host_port.is_empty() { + return false; + } + + let host = if let Some((name, port)) = host_port.rsplit_once(':') { + if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) { + return false; + } + name + } else { + host_port + }; + + let labels: Vec<&str> = host.split('.').collect(); + labels.len() >= 2 + && labels.iter().all(|label| { + !label.is_empty() + && label + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + }) +} + +fn validate_quick_action_app(target: &str) -> Result { + let trimmed = target.trim(); + if trimmed.is_empty() { + return Err("App target is empty".to_string()); + } + if trimmed.contains('\0') || trimmed.contains("..") { + return Err("App target is invalid".to_string()); + } + + let lower = trimmed.to_ascii_lowercase(); + const BLOCKED_SUFFIXES: &[&str] = &[ + ".bat", ".cmd", ".ps1", ".vbs", ".js", ".jse", ".wsf", ".msi", ".scr", ".com", ".lnk", + ]; + for suffix in BLOCKED_SUFFIXES { + if lower.ends_with(suffix) { + return Err("Executable type not allowed".to_string()); + } + } + + if trimmed.contains('\\') || trimmed.contains('/') { + if !lower.ends_with(".exe") { + return Err("Only .exe app paths are allowed".to_string()); + } + let path = std::path::PathBuf::from(trimmed); + if !path.is_file() { + return Err("Application not found".to_string()); + } + return Ok(trimmed.to_string()); + } + + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') + { + return Err("Invalid app name".to_string()); + } + + if let Some(resolved) = icons::resolve_launch_app_path(trimmed) { + return Ok(resolved.to_string_lossy().into_owned()); + } + + let path = if lower.ends_with(".exe") { + trimmed.to_string() + } else { + format!("{trimmed}.exe") + }; + Err(format!("Application not found: {path}")) +} + #[tauri::command] fn cmd_launch_quick_action( app: tauri::AppHandle, @@ -661,24 +789,27 @@ fn cmd_launch_quick_action( target: String, ) -> Result<(), String> { match action_type.as_str() { - "url" => app.opener().open_url(&target, None::<&str>).map_err(|e| e.to_string())?, + "url" => { + let url = validate_quick_action_url(&target)?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|e| e.to_string())?; + } "app" => { - let path = if target.contains('\\') || target.contains('/') { - target - } else { - format!("{target}.exe") - }; - app.opener().open_path(&path, None::<&str>).map_err(|e| e.to_string())?; + let path = validate_quick_action_app(&target)?; + app.opener() + .open_path(&path, None::<&str>) + .map_err(|e| e.to_string())?; } _ => return Err("Unknown action type".to_string()), } Ok(()) } -/// Returns a PNG data-URL for an installed app icon, or null when unavailable. +/// Returns a cached PNG path for an installed app icon (for `convertFileSrc`), or null. #[tauri::command] -fn cmd_get_app_icon(target: String) -> Option { - icons::app_icon_data_url(&target) +fn cmd_get_app_icon(app: tauri::AppHandle, target: String) -> Option { + icons::app_icon_cached_path(&app, &target) } #[tauri::command] @@ -704,45 +835,55 @@ fn cmd_get_phrase_categories(profile_id: String, state: State) -> Resu } #[tauri::command] -fn cmd_use_phrase( +async fn cmd_use_phrase( text: String, action: String, language: String, - _state: State, + _state: State<'_, AppState>, ) -> Result<(), String> { - if action == "type" || action == "both" { - type_text(&text).map_err(|e| e.to_string())?; - } - if action == "speak" || action == "both" { + tokio::task::spawn_blocking(move || { + if action == "type" || action == "both" { + type_text(&text).map_err(|e| e.to_string())?; + } + if action == "speak" || action == "both" { + speak_text( + &text, + TtsSettings { + rate: 0, + volume: 100, + language, + }, + ) + .map_err(|e| e.to_string())?; + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn cmd_speak(text: String, rate: i32, volume: u16, language: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { speak_text( &text, TtsSettings { - rate: 0, - volume: 100, + rate, + volume, language, }, ) - .map_err(|e| e.to_string())?; - } - Ok(()) -} - -#[tauri::command] -fn cmd_speak(text: String, rate: i32, volume: u16, language: String) -> Result<(), String> { - speak_text( - &text, - TtsSettings { - rate, - volume, - language, - }, - ) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -fn cmd_stop_speaking() -> Result<(), String> { - stop_speaking().map_err(|e| e.to_string()) +async fn cmd_stop_speaking() -> Result<(), String> { + tokio::task::spawn_blocking(|| stop_speaking().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -761,17 +902,23 @@ fn cmd_validate_tts() -> Result<(), String> { } #[tauri::command] -fn cmd_start_dictation( +async fn cmd_start_dictation( language: String, groq_api_key: Option, app: AppHandle, ) -> Result<(), String> { - start_dictation(&language, groq_api_key.as_deref(), app).map_err(|e| e.to_string()) + tokio::task::spawn_blocking(move || { + start_dictation(&language, groq_api_key.as_deref(), app).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -fn cmd_stop_dictation() -> Result<(), String> { - stop_dictation().map_err(|e| e.to_string()) +async fn cmd_stop_dictation() -> Result<(), String> { + tokio::task::spawn_blocking(|| stop_dictation().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -828,6 +975,34 @@ fn cmd_record_word(profile_id: String, word: String, language: String, state: St Ok(()) } +#[tauri::command] +fn cmd_list_word_packs(state: State) -> Result, String> { + list_word_packs(&state.db).map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn cmd_install_word_pack( + language: String, + app: tauri::AppHandle, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + install_word_pack(&app, &state.db, &language).map_err(|e| e.to_string())?; + list_word_packs(&state.db).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +fn cmd_uninstall_word_pack( + language: String, + state: State, +) -> Result, String> { + uninstall_word_pack(&state.db, &language).map_err(|e| e.to_string())?; + list_word_packs(&state.db).map_err(|e| e.to_string()) +} + #[tauri::command] fn cmd_get_languages() -> Vec { get_installed_languages() @@ -918,19 +1093,27 @@ pub fn run() { let app_data_dir = app .path() .app_data_dir() - .expect("failed to resolve app data dir"); - let db = Database::new(app_data_dir.clone()).expect("failed to initialize database"); - let profiles = - ProfileStore::new(&app_data_dir).expect("failed to initialize profile store"); + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + let db = Database::new(app_data_dir.clone()) + .map_err(|e| format!("failed to initialize database: {e}"))?; + let profiles = ProfileStore::new(&app_data_dir) + .map_err(|e| format!("failed to initialize profile store: {e}"))?; profiles .ensure_default_profile_file(&db) - .expect("failed to load default profile file"); + .map_err(|e| format!("failed to load default profile file: {e}"))?; app.manage(AppState { db, profiles, last_error: Mutex::new(None), }); + { + let state = app.state::(); + if let Err(e) = ensure_english_pack(app.handle(), &state.db) { + eprintln!("Failed to install bundled English word pack: {e}"); + } + } + stt::init(&app_data_dir, app.handle().clone()); if let Some(window) = app.get_webview_window("main") { @@ -1006,6 +1189,9 @@ pub fn run() { cmd_open_windows_settings, cmd_get_suggestions, cmd_record_word, + cmd_list_word_packs, + cmd_install_word_pack, + cmd_uninstall_word_pack, cmd_get_languages, cmd_get_macros, cmd_get_macro_steps, diff --git a/src-tauri/src/macros/mod.rs b/src-tauri/src/macros/mod.rs index e3a1d87..ec9a849 100644 --- a/src-tauri/src/macros/mod.rs +++ b/src-tauri/src/macros/mod.rs @@ -3,7 +3,6 @@ use crate::input::{press_combo, press_key, type_text, KeyPressRequest}; use crate::tts::{speak_text, TtsSettings}; use anyhow::Result; use serde_json::Value; -use std::thread; use std::time::Duration; use tauri_plugin_opener::OpenerExt; @@ -25,6 +24,16 @@ fn profile_language(db: &Database, profile_id: &str) -> String { .unwrap_or_else(|| "en".to_string()) } +async fn run_blocking(f: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| anyhow::anyhow!("blocking task join failed: {e}"))? +} + pub async fn run_macro( app: &tauri::AppHandle, db: &Database, @@ -40,7 +49,8 @@ pub async fn run_macro( match step.action_type.as_str() { "type_text" => { if let Some(text) = payload.get("text").and_then(|v| v.as_str()) { - type_text(text)?; + let text = text.to_string(); + run_blocking(move || type_text(&text)).await?; } } "key_press" => { @@ -54,10 +64,14 @@ pub async fn run_macro( .collect() }) .unwrap_or_default(); - press_key(KeyPressRequest { - key: key.to_string(), - modifiers, - })?; + let key = key.to_string(); + run_blocking(move || { + press_key(KeyPressRequest { + key, + modifiers, + }) + }) + .await?; } "key_combo" => { let keys: Vec = payload @@ -69,7 +83,7 @@ pub async fn run_macro( .collect() }) .unwrap_or_default(); - press_combo(keys)?; + run_blocking(move || press_combo(keys)).await?; } "open_program" => { if let Some(target) = payload.get("target").and_then(|v| v.as_str()) { @@ -83,18 +97,23 @@ pub async fn run_macro( } "wait" => { let ms = payload.get("ms").and_then(|v| v.as_u64()).unwrap_or(1000); - thread::sleep(Duration::from_millis(ms)); + tokio::time::sleep(Duration::from_millis(ms)).await; } "speak" => { if let Some(text) = payload.get("text").and_then(|v| v.as_str()) { - speak_text( - text, - TtsSettings { - rate: 0, - volume: 100, - language: language.clone(), - }, - )?; + let text = text.to_string(); + let language = language.clone(); + run_blocking(move || { + speak_text( + &text, + TtsSettings { + rate: 0, + volume: 100, + language, + }, + ) + }) + .await?; } } _ => {} diff --git a/src-tauri/src/music.rs b/src-tauri/src/music.rs index 6004968..75772c8 100644 --- a/src-tauri/src/music.rs +++ b/src-tauri/src/music.rs @@ -1,8 +1,21 @@ use serde_json::Value; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use tauri::{AppHandle, Manager}; +/// Max bytes accepted by `cmd_read_music_file` (JSON / MIDI / MusicXML imports). +const MAX_MUSIC_FILE_BYTES: u64 = 16 * 1024 * 1024; + +const ALLOWED_MUSIC_EXTENSIONS: &[&str] = &["json", "mid", "midi", "xml", "musicxml", "mxl"]; + +/// Paths the user selected via the music file picker (canonical when possible). +fn allowed_read_paths() -> &'static Mutex> { + static ALLOWED_READ_PATHS: OnceLock>> = OnceLock::new(); + ALLOWED_READ_PATHS.get_or_init(|| Mutex::new(HashSet::new())) +} + fn music_dir(app: &AppHandle) -> Result { let dir = app .path() @@ -76,6 +89,80 @@ pub fn delete_imported_song(app: &AppHandle, id: &str) -> Result<(), String> { Ok(()) } -pub fn read_music_file_bytes(path: &str) -> Result, String> { - fs::read(path).map_err(|e| e.to_string()) +/// Record a user-picked import path so a subsequent read is allowed. +pub fn allow_music_read_path(path: &Path) { + let mut guard = allowed_read_paths() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Ok(canonical) = path.canonicalize() { + guard.insert(canonical); + } else { + guard.insert(path.to_path_buf()); + } +} + +fn has_allowed_music_extension(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + let lower = ext.to_ascii_lowercase(); + ALLOWED_MUSIC_EXTENSIONS + .iter() + .any(|allowed| *allowed == lower) + }) + .unwrap_or(false) +} + +fn is_under_dir(path: &Path, root: &Path) -> bool { + path.starts_with(root) +} + +fn path_is_allowed(app: &AppHandle, canonical: &Path, original: &Path) -> Result { + let music_root = music_dir(app)?; + let music_root = music_root + .canonicalize() + .unwrap_or(music_root); + if is_under_dir(canonical, &music_root) || is_under_dir(original, &music_root) { + return Ok(true); + } + let guard = allowed_read_paths() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Ok(guard.contains(canonical) || guard.contains(original)) +} + +/// Read a music import file after allowlist + size checks. +pub fn read_music_file_bytes(app: &AppHandle, path: &str) -> Result, String> { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Music file path is empty".to_string()); + } + if trimmed.contains('\0') { + return Err("Music file path is invalid".to_string()); + } + + let candidate = PathBuf::from(trimmed); + if !has_allowed_music_extension(&candidate) { + return Err("Music file type is not allowed".to_string()); + } + + let canonical = candidate + .canonicalize() + .map_err(|e| format!("Music file not found: {e}"))?; + + if !path_is_allowed(app, &canonical, &candidate)? { + return Err("Music file path is not allowed".to_string()); + } + + let meta = fs::metadata(&canonical).map_err(|e| e.to_string())?; + if !meta.is_file() { + return Err("Music path is not a file".to_string()); + } + if meta.len() > MAX_MUSIC_FILE_BYTES { + return Err(format!( + "Music file exceeds maximum size of {MAX_MUSIC_FILE_BYTES} bytes" + )); + } + + fs::read(&canonical).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 92d69fa..d0d3dfb 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -1,5 +1,28 @@ use crate::db::{Database, PredictionEntry}; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +pub const SUPPORTED_PACK_LANGUAGES: &[&str] = &["en", "el", "de", "fr", "it", "es", "pt"]; +const PACK_DOWNLOAD_BASE: &str = + "https://github.com/pountzas/reach-Panel/releases/download/wordpacks-v1"; + +#[derive(Debug, Clone, serde::Serialize, Deserialize)] +pub struct WordPackInfo { + pub language: String, + pub installed: bool, + pub version: Option, + pub bundled: bool, +} + +#[derive(Debug, Deserialize)] +struct WordPackFile { + language: String, + version: i32, + words: Vec<(String, i32)>, +} pub fn get_suggestions( db: &Database, @@ -21,3 +44,133 @@ pub fn record_usage(db: &Database, profile_id: &str, word: &str, language: &str) pub fn get_installed_languages() -> Vec { crate::input::get_installed_language_tags() } + +pub fn list_word_packs(db: &Database) -> Result> { + let installed = db.list_installed_packs()?; + let installed_map: std::collections::HashMap = installed.into_iter().collect(); + Ok(SUPPORTED_PACK_LANGUAGES + .iter() + .map(|lang| WordPackInfo { + language: (*lang).to_string(), + installed: installed_map.contains_key(*lang), + version: installed_map.get(*lang).copied(), + bundled: *lang == "en", + }) + .collect()) +} + +pub fn ensure_english_pack(app: &AppHandle, db: &Database) -> Result<()> { + if db.is_pack_installed("en")? { + return Ok(()); + } + install_word_pack(app, db, "en") +} + +pub fn install_word_pack(app: &AppHandle, db: &Database, language: &str) -> Result<()> { + validate_language(language)?; + let pack = if language == "en" { + load_bundled_english_pack(app)? + } else { + download_pack(app, language)? + }; + if pack.language != language { + return Err(anyhow!( + "Pack language mismatch: expected {language}, got {}", + pack.language + )); + } + db.import_word_pack(&pack.language, pack.version, &pack.words)?; + Ok(()) +} + +pub fn uninstall_word_pack(db: &Database, language: &str) -> Result<()> { + validate_language(language)?; + if language == "en" { + return Err(anyhow!("English dictionary is required and cannot be removed")); + } + db.uninstall_word_pack(language)?; + Ok(()) +} + +fn validate_language(language: &str) -> Result<()> { + if SUPPORTED_PACK_LANGUAGES.contains(&language) { + Ok(()) + } else { + Err(anyhow!("Unsupported prediction language: {language}")) + } +} + +fn load_bundled_english_pack(app: &AppHandle) -> Result { + let mut candidates: Vec = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join("resources/wordpacks/en.json")); + candidates.push(resource_dir.join("wordpacks/en.json")); + } + candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/wordpacks/en.json")); + + for path in &candidates { + if path.is_file() { + return read_pack_file(path); + } + } + Err(anyhow!( + "Bundled English word pack not found (looked in resource dir and src-tauri/resources)" + )) +} + +fn download_pack(app: &AppHandle, language: &str) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|e| anyhow!("app data dir: {e}"))?; + let dir = app_data.join("wordpacks"); + fs::create_dir_all(&dir)?; + let dest = dir.join(format!("{language}.json")); + + if dest.is_file() { + if let Ok(pack) = read_pack_file(&dest) { + if pack.language == language { + return Ok(pack); + } + } + } + + // Dev convenience: install from local wordpacks-dist without network. + let local_dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("wordpacks-dist") + .join(format!("{language}.json")); + if local_dist.is_file() { + fs::copy(&local_dist, &dest)?; + return read_pack_file(&dest); + } + + let url = format!("{PACK_DOWNLOAD_BASE}/{language}.json"); + let body = http_get_text(&url)?; + let pack: WordPackFile = serde_json::from_str(&body) + .map_err(|e| anyhow!("Invalid word pack JSON from {url}: {e}"))?; + fs::write(&dest, &body)?; + Ok(pack) +} + +fn read_pack_file(path: &Path) -> Result { + let body = fs::read_to_string(path) + .map_err(|e| anyhow!("Failed to read {}: {e}", path.display()))?; + serde_json::from_str(&body).map_err(|e| anyhow!("Invalid word pack {}: {e}", path.display())) +} + +fn http_get_text(url: &str) -> Result { + use std::time::Duration; + + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(15)) + .timeout(Duration::from_secs(60)) + .build(); + let response = agent + .get(url) + .call() + .map_err(|e| anyhow!("Download failed ({url}): {e}"))?; + response + .into_string() + .map_err(|e| anyhow!("Failed reading download body: {e}")) +} diff --git a/src-tauri/src/profiles/mod.rs b/src-tauri/src/profiles/mod.rs index bc913f2..72ad27b 100644 --- a/src-tauri/src/profiles/mod.rs +++ b/src-tauri/src/profiles/mod.rs @@ -59,6 +59,9 @@ pub struct ProfileFile { pub struct ProfileStore { profiles_dir: PathBuf, active_filename: Mutex, + /// Serializes load/save/create/delete/wipe so DB content and active + /// filename cannot diverge while another thread exports to disk. + io_lock: Mutex<()>, } impl ProfileStore { @@ -69,6 +72,7 @@ impl ProfileStore { Ok(Self { profiles_dir, active_filename: Mutex::new(active_filename), + io_lock: Mutex::new(()), }) } @@ -86,13 +90,22 @@ impl ProfileStore { fn write_active_filename(&self, filename: &str) -> Result<()> { let config_path = self.profiles_dir.join(ACTIVE_PROFILE_CONFIG); fs::write(config_path, filename)?; - if let Ok(mut guard) = self.active_filename.lock() { - *guard = filename.to_string(); - } + let mut guard = self + .active_filename + .lock() + .map_err(|_| anyhow!("lock poisoned"))?; + *guard = filename.to_string(); Ok(()) } + fn lock_io(&self) -> Result> { + self.io_lock + .lock() + .map_err(|_| anyhow!("profile IO lock poisoned")) + } + pub fn ensure_default_profile_file(&self, db: &Database) -> Result { + let _guard = self.lock_io()?; let files = self.list_profile_files()?; if !files.is_empty() { let active = self @@ -105,8 +118,7 @@ impl ProfileStore { } else { active }; - self.write_active_filename(&filename)?; - self.load_profile_file(db, &filename)?; + self.load_profile_file_locked(db, &filename)?; return Ok(filename); } @@ -154,21 +166,33 @@ impl ProfileStore { } pub fn load_profile_file(&self, db: &Database, filename: &str) -> Result<()> { + let _guard = self.lock_io()?; + self.load_profile_file_locked(db, filename) + } + + fn load_profile_file_locked(&self, db: &Database, filename: &str) -> Result<()> { let path = self.profiles_dir.join(filename); if !path.exists() { return Err(anyhow!("Profile file not found: {filename}")); } let content = fs::read_to_string(path)?; let file: ProfileFile = serde_json::from_str(&content)?; + // Import then point active at this file under the same IO lock so a + // concurrent save cannot export the new DB into the previous filename. import_profile_into_db(db, INTERNAL_PROFILE_ID, &file)?; self.write_active_filename(filename)?; Ok(()) } pub fn save_active_profile(&self, db: &Database) -> Result<()> { + let _guard = self.lock_io()?; + self.save_active_profile_locked(db) + } + + fn save_active_profile_locked(&self, db: &Database) -> Result<()> { let filename = self.active_filename()?; if filename.is_empty() { - return Ok(()); + return Err(anyhow!("No active profile file to save")); } let file = export_profile_from_db(db, INTERNAL_PROFILE_ID)?; let path = self.profiles_dir.join(&filename); @@ -177,6 +201,7 @@ impl ProfileStore { } pub fn create_profile_file(&self, db: &Database, filename: &str, name: &str) -> Result<()> { + let _guard = self.lock_io()?; let safe_name = if filename.ends_with(".profile.json") { filename.to_string() } else { @@ -189,24 +214,26 @@ impl ProfileStore { let ui_language = windows_ui_language(); db.reset_profile_to_defaults(INTERNAL_PROFILE_ID, name, &ui_language)?; self.write_active_filename(&safe_name)?; - self.save_active_profile(db)?; + self.save_active_profile_locked(db)?; Ok(()) } /// Reset settings + wipe QA/macros/phrases/predictions/HT, keep profile filename/name. pub fn wipe_active_profile(&self, db: &Database) -> Result<()> { + let _guard = self.lock_io()?; let name = db .get_profile_by_id(INTERNAL_PROFILE_ID)? .map(|p| p.name) .unwrap_or_else(|| "Default".to_string()); let ui_language = windows_ui_language(); db.reset_profile_to_defaults(INTERNAL_PROFILE_ID, &name, &ui_language)?; - self.save_active_profile(db)?; + self.save_active_profile_locked(db)?; Ok(()) } /// Delete a profile file. If it was active (or last), recreate fresh `default.profile.json`. pub fn delete_profile_file(&self, db: &Database, filename: &str) -> Result { + let _guard = self.lock_io()?; let path = self.profiles_dir.join(filename); if !path.exists() { return Err(anyhow!("Profile file not found: {filename}")); @@ -219,20 +246,20 @@ impl ProfileStore { if !was_active { let remaining = self.list_profile_files()?; if remaining.is_empty() { - return self.recreate_default_profile(db); + return self.recreate_default_profile_locked(db); } return Ok(active); } - self.recreate_default_profile(db) + self.recreate_default_profile_locked(db) } - fn recreate_default_profile(&self, db: &Database) -> Result { + fn recreate_default_profile_locked(&self, db: &Database) -> Result { let ui_language = windows_ui_language(); db.reset_profile_to_defaults(INTERNAL_PROFILE_ID, "Default", &ui_language)?; let filename = DEFAULT_PROFILE_FILENAME.to_string(); self.write_active_filename(&filename)?; - self.save_active_profile(db)?; + self.save_active_profile_locked(db)?; Ok(filename) } } @@ -348,3 +375,212 @@ fn import_profile_into_db(db: &Database, profile_id: &str, file: &ProfileFile) - )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("reach-panel-profile-test-{nanos}")); + fs::create_dir_all(&dir).expect("temp dir"); + dir + } + + fn write_profile(dir: &Path, filename: &str, name: &str, marker: &str) { + let file = ProfileFile { + name: name.to_string(), + settings: json!({ + "uiLanguage": "en", + "appBgColor": marker, + "groqApiKey": format!("key-{marker}"), + }), + quick_actions: vec![], + phrase_categories: vec![], + phrases: vec![], + macros: vec![], + predictions: vec![], + head_tracking_settings: json!({}), + }; + fs::write( + dir.join("profiles").join(filename), + serde_json::to_string_pretty(&file).unwrap(), + ) + .unwrap(); + } + + #[test] + fn switch_then_save_does_not_clobber_previous_profile() { + let root = temp_dir(); + let db = Database::new(root.clone()).expect("db"); + fs::create_dir_all(root.join("profiles")).unwrap(); + + write_profile(&root, "a.profile.json", "A", "#aaa"); + write_profile(&root, "b.profile.json", "B", "#bbb"); + fs::write(root.join("profiles").join(ACTIVE_PROFILE_CONFIG), "a.profile.json").unwrap(); + + // Re-read active after writing config. + let store = ProfileStore::new(&root).expect("store"); + store + .load_profile_file(&db, "a.profile.json") + .expect("load a"); + + // Simulate the race window: import B into DB, then save while still + // pointing at A would historically overwrite A. With the IO lock, + // load completes (active=B) before any save can run. + store + .load_profile_file(&db, "b.profile.json") + .expect("load b"); + store.save_active_profile(&db).expect("save"); + + let a: ProfileFile = serde_json::from_str( + &fs::read_to_string(root.join("profiles").join("a.profile.json")).unwrap(), + ) + .unwrap(); + let b: ProfileFile = serde_json::from_str( + &fs::read_to_string(root.join("profiles").join("b.profile.json")).unwrap(), + ) + .unwrap(); + + assert_eq!(a.settings.get("appBgColor").and_then(|v| v.as_str()), Some("#aaa")); + assert_eq!( + a.settings.get("groqApiKey").and_then(|v| v.as_str()), + Some("key-#aaa") + ); + assert_eq!(b.settings.get("appBgColor").and_then(|v| v.as_str()), Some("#bbb")); + assert_eq!(store.active_filename().unwrap(), "b.profile.json"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn round_trip_switch_preserves_custom_settings() { + let root = temp_dir(); + let db = Database::new(root.clone()).expect("db"); + fs::create_dir_all(root.join("profiles")).unwrap(); + + write_profile(&root, "a.profile.json", "A", "#aaa"); + write_profile(&root, "b.profile.json", "B", "#bbb"); + fs::write(root.join("profiles").join(ACTIVE_PROFILE_CONFIG), "a.profile.json").unwrap(); + + let store = ProfileStore::new(&root).expect("store"); + store + .load_profile_file(&db, "a.profile.json") + .expect("load a"); + + // Mutate active (A) settings and persist. + db.update_profile_settings( + INTERNAL_PROFILE_ID, + &serde_json::json!({ + "uiLanguage": "en", + "appBgColor": "#custom-a", + "groqApiKey": "secret-a", + }) + .to_string(), + ) + .unwrap(); + store.save_active_profile(&db).expect("save a"); + + store + .load_profile_file(&db, "b.profile.json") + .expect("load b"); + store + .load_profile_file(&db, "a.profile.json") + .expect("load a again"); + + let profile = db.get_profile_by_id(INTERNAL_PROFILE_ID).unwrap().unwrap(); + let settings: serde_json::Value = serde_json::from_str(&profile.settings_json).unwrap(); + assert_eq!(settings.get("appBgColor").and_then(|v| v.as_str()), Some("#custom-a")); + assert_eq!(settings.get("groqApiKey").and_then(|v| v.as_str()), Some("secret-a")); + assert_eq!(store.active_filename().unwrap(), "a.profile.json"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn concurrent_switch_and_save_keeps_profiles_isolated() { + use std::sync::Arc; + use std::thread; + + let root = temp_dir(); + let db = Arc::new(Database::new(root.clone()).expect("db")); + fs::create_dir_all(root.join("profiles")).unwrap(); + write_profile(&root, "a.profile.json", "A", "#aaa"); + write_profile(&root, "b.profile.json", "B", "#bbb"); + fs::write(root.join("profiles").join(ACTIVE_PROFILE_CONFIG), "a.profile.json").unwrap(); + + let store = Arc::new(ProfileStore::new(&root).expect("store")); + store + .load_profile_file(&db, "a.profile.json") + .expect("load a"); + + let mut handles = Vec::new(); + for i in 0..8 { + let store = Arc::clone(&store); + let db = Arc::clone(&db); + handles.push(thread::spawn(move || { + if i % 2 == 0 { + store + .load_profile_file(&db, "b.profile.json") + .expect("load b"); + } else { + store.save_active_profile(&db).expect("save"); + } + })); + } + for handle in handles { + handle.join().expect("thread"); + } + + // Final explicit switch + save should leave both files self-consistent. + store + .load_profile_file(&db, "a.profile.json") + .expect("load a final"); + store.save_active_profile(&db).expect("save a final"); + store + .load_profile_file(&db, "b.profile.json") + .expect("load b final"); + store.save_active_profile(&db).expect("save b final"); + + let a: ProfileFile = serde_json::from_str( + &fs::read_to_string(root.join("profiles").join("a.profile.json")).unwrap(), + ) + .unwrap(); + let b: ProfileFile = serde_json::from_str( + &fs::read_to_string(root.join("profiles").join("b.profile.json")).unwrap(), + ) + .unwrap(); + + let a_color = a.settings.get("appBgColor").and_then(|v| v.as_str()).unwrap(); + let b_color = b.settings.get("appBgColor").and_then(|v| v.as_str()).unwrap(); + // Neither file should contain the other profile's original marker after a + // clean final load/save of each. + assert!( + a_color == "#aaa" || a_color == "#bbb", + "unexpected a color {a_color}" + ); + assert_eq!(b_color, "#bbb"); + // A must not have been permanently replaced with B's identity if we + // finished on a clean load of A then B — B stays B; A stays A's marker + // unless a save raced after loading B into the shared DB *before* active + // was updated. The IO lock forbids that corruption path. + assert_eq!(a_color, "#aaa"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn save_without_active_profile_errors() { + let root = temp_dir(); + let db = Database::new(root.clone()).expect("db"); + let store = ProfileStore::new(&root).expect("store"); + let err = store.save_active_profile(&db).expect_err("should fail"); + assert!(err.to_string().contains("No active profile file to save")); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src-tauri/src/stt/groq.rs b/src-tauri/src/stt/groq.rs index b6da137..8b7b07d 100644 --- a/src-tauri/src/stt/groq.rs +++ b/src-tauri/src/stt/groq.rs @@ -238,17 +238,20 @@ fn worker_loop( while !stop_flag.load(Ordering::SeqCst) { thread::sleep(Duration::from_millis(80)); - let snapshot = match samples.lock() { - Ok(buf) => buf.clone(), + + // Score energy under the lock without cloning the full buffer every tick. + let energy = match samples.lock() { + Ok(buf) => { + if buf.len() <= cursor { + 0.0 + } else { + let window = TARGET_SAMPLE_RATE as usize / 10; // ~100ms + let start = buf.len().saturating_sub(window).max(cursor); + rms(&buf[start..]) + } + } Err(_) => continue, }; - if snapshot.len() <= cursor { - continue; - } - // Score only the newest slice so trailing silence does not dilute RMS. - let window = TARGET_SAMPLE_RATE as usize / 10; // ~100ms - let start = snapshot.len().saturating_sub(window).max(cursor); - let energy = rms(&snapshot[start..]); peak_energy = peak_energy.max(energy); let now = Instant::now(); if energy >= ENERGY_THRESHOLD { @@ -271,19 +274,26 @@ fn worker_loop( if stop_flag.load(Ordering::SeqCst) { break; } - let end = snapshot.len().saturating_sub( - (silence_secs.min(SILENCE_SECS) * TARGET_SAMPLE_RATE as f32) as usize, - ); - let end = end.max(cursor); - let chunk = snapshot[cursor..end].to_vec(); - cursor = end; - // Drop consumed audio so the buffer does not grow forever. - if let Ok(mut buf) = samples.lock() { - if cursor > 0 && cursor <= buf.len() { - buf.drain(0..cursor); - cursor = 0; + let silence_trim = + (silence_secs.min(SILENCE_SECS) * TARGET_SAMPLE_RATE as f32) as usize; + let chunk = match samples.lock() { + Ok(mut buf) => { + if buf.len() <= cursor { + None + } else { + let end = buf.len().saturating_sub(silence_trim).max(cursor); + let chunk = buf[cursor..end].to_vec(); + // Drop consumed audio so the buffer does not grow forever. + buf.drain(0..end); + cursor = 0; + Some(chunk) + } } - } + Err(_) => None, + }; + let Some(chunk) = chunk else { + continue; + }; speech_started = None; let flushed_peak = peak_energy; peak_energy = 0.0; diff --git a/src-tauri/src/stt/route.rs b/src-tauri/src/stt/route.rs index a59b1f5..0e27469 100644 --- a/src-tauri/src/stt/route.rs +++ b/src-tauri/src/stt/route.rs @@ -58,6 +58,11 @@ pub fn can_dictate( pub fn groq_language_hint(language: &str) -> &'static str { match language { "el" => "el", + "de" => "de", + "fr" => "fr", + "it" => "it", + "es" => "es", + "pt" => "pt", _ => "en", } } @@ -66,6 +71,11 @@ pub fn groq_language_hint(language: &str) -> &'static str { pub fn winrt_language_tag(language: &str) -> &'static str { match language { "el" => "el-GR", + "de" => "de-DE", + "fr" => "fr-FR", + "it" => "it-IT", + "es" => "es-ES", + "pt" => "pt-PT", _ => "en-US", } } @@ -152,9 +162,20 @@ mod tests { fn language_mappings() { assert!(prefer_cloud_stt("el")); assert!(!prefer_cloud_stt("en")); + assert!(!prefer_cloud_stt("de")); assert_eq!(groq_language_hint("el"), "el"); assert_eq!(groq_language_hint("en"), "en"); + assert_eq!(groq_language_hint("de"), "de"); + assert_eq!(groq_language_hint("fr"), "fr"); + assert_eq!(groq_language_hint("it"), "it"); + assert_eq!(groq_language_hint("es"), "es"); + assert_eq!(groq_language_hint("pt"), "pt"); assert_eq!(winrt_language_tag("el"), "el-GR"); assert_eq!(winrt_language_tag("en"), "en-US"); + assert_eq!(winrt_language_tag("de"), "de-DE"); + assert_eq!(winrt_language_tag("fr"), "fr-FR"); + assert_eq!(winrt_language_tag("it"), "it-IT"); + assert_eq!(winrt_language_tag("es"), "es-ES"); + assert_eq!(winrt_language_tag("pt"), "pt-PT"); } } diff --git a/src-tauri/src/stt/winrt.rs b/src-tauri/src/stt/winrt.rs index 5a0550d..015bea9 100644 --- a/src-tauri/src/stt/winrt.rs +++ b/src-tauri/src/stt/winrt.rs @@ -24,6 +24,7 @@ struct ActiveSession { struct DictationRuntime { session: Option, app_handle: Option, + starting: bool, } impl Default for DictationRuntime { @@ -31,6 +32,7 @@ impl Default for DictationRuntime { Self { session: None, app_handle: None, + starting: false, } } } @@ -77,20 +79,34 @@ fn require_language_supported(tag: &str) -> Result<()> { )) } +struct StartingFlagGuard; + +impl Drop for StartingFlagGuard { + fn drop(&mut self) { + if let Ok(mut guard) = runtime().lock() { + guard.starting = false; + } + } +} + pub fn start_dictation(language: &str, app: AppHandle) -> Result<()> { ensure_winrt()?; let tag = winrt_language_tag(language); require_language_supported(tag)?; - let mut guard = runtime() - .lock() - .map_err(|_| anyhow!("Dictation runtime lock poisoned"))?; - - if guard.session.is_some() { - return Err(anyhow!("Dictation is already active")); + { + let mut guard = runtime() + .lock() + .map_err(|_| anyhow!("Dictation runtime lock poisoned"))?; + if guard.session.is_some() || guard.starting { + return Err(anyhow!("Dictation is already active")); + } + guard.starting = true; } + let _starting_guard = StartingFlagGuard; + // Build recognizer and await WinRT ops without holding the global mutex. let win_lang = Language::CreateLanguage(&HSTRING::from(tag))?; let recognizer = SpeechRecognizer::Create(&win_lang)?; @@ -99,6 +115,7 @@ pub fn start_dictation(language: &str, app: AppHandle) -> Result<()> { .get() .map_err(|e| anyhow!("Failed to compile speech constraints: {e}"))?; if compilation.Status()? != SpeechRecognitionResultStatus::Success { + let _ = recognizer.Close(); return Err(anyhow!( "Speech recognition constraints failed to compile for '{tag}'" )); @@ -145,9 +162,24 @@ pub fn start_dictation(language: &str, app: AppHandle) -> Result<()> { ))?; let start = session.StartAsync()?; - start - .get() - .map_err(|e| map_stt_error(format!("Failed to start dictation: {e}")))?; + if let Err(e) = start.get() { + let _ = session.RemoveResultGenerated(result_token); + let _ = session.RemoveCompleted(completed_token); + let _ = recognizer.Close(); + return Err(map_stt_error(format!("Failed to start dictation: {e}"))); + } + + let mut guard = runtime() + .lock() + .map_err(|_| anyhow!("Dictation runtime lock poisoned"))?; + if guard.session.is_some() { + drop(guard); + let _ = session.StopAsync().and_then(|op| op.get()); + let _ = session.RemoveResultGenerated(result_token); + let _ = session.RemoveCompleted(completed_token); + let _ = recognizer.Close(); + return Err(anyhow!("Dictation is already active")); + } guard.app_handle = Some(app.clone()); guard.session = Some(ActiveSession { @@ -162,15 +194,19 @@ pub fn start_dictation(language: &str, app: AppHandle) -> Result<()> { } pub fn stop_dictation() -> Result<()> { - let mut guard = runtime() - .lock() - .map_err(|_| anyhow!("Dictation runtime lock poisoned"))?; - - let Some(active) = guard.session.take() else { - return Ok(()); + let (active, app) = { + let mut guard = runtime() + .lock() + .map_err(|_| anyhow!("Dictation runtime lock poisoned"))?; + let active = match guard.session.take() { + Some(active) => active, + None => return Ok(()), + }; + let app = guard.app_handle.clone(); + (active, app) }; - let app = guard.app_handle.clone(); + // Await StopAsync without holding the global mutex. let session = active.recognizer.ContinuousRecognitionSession()?; let stop = session.StopAsync()?; let _ = stop.get(); diff --git a/src-tauri/src/tts/sapi.rs b/src-tauri/src/tts/sapi.rs index 0daedb4..7851548 100644 --- a/src-tauri/src/tts/sapi.rs +++ b/src-tauri/src/tts/sapi.rs @@ -14,6 +14,11 @@ const ONECORE_VOICES: PCWSTR = fn language_lcids(language: &str) -> &'static [&'static str] { match language { "el" => &["408", "0408", "41008"], + "de" => &["407", "0407"], + "fr" => &["40C", "040C"], + "it" => &["410", "0410"], + "es" => &["40A", "040A", "C0A", "0C0A"], + "pt" => &["816", "0816", "416", "0416"], _ => &["409", "0409", "1033"], } } @@ -21,6 +26,11 @@ fn language_lcids(language: &str) -> &'static [&'static str] { fn language_prefix(language: &str) -> &'static str { match language { "el" => "el", + "de" => "de", + "fr" => "fr", + "it" => "it", + "es" => "es", + "pt" => "pt", _ => "en", } } @@ -147,7 +157,15 @@ pub fn speak_text(text: &str, settings: &TtsSettings) -> Result<()> { pub fn list_voices() -> Result> { let mut voices = Vec::new(); - for (lang, label) in [("en", "English"), ("el", "Greek")] { + for (lang, label) in [ + ("en", "English"), + ("el", "Greek"), + ("de", "German"), + ("fr", "French"), + ("it", "Italian"), + ("es", "Spanish"), + ("pt", "Portuguese"), + ] { unsafe { let installed = find_voice_token(lang)?.is_some(); voices.push(format!( diff --git a/src-tauri/src/tts/winrt.rs b/src-tauri/src/tts/winrt.rs index 6608ad0..52c11ce 100644 --- a/src-tauri/src/tts/winrt.rs +++ b/src-tauri/src/tts/winrt.rs @@ -1,5 +1,7 @@ use super::TtsSettings; use anyhow::{anyhow, Result}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::Duration; use windows::core::HSTRING; @@ -9,6 +11,22 @@ use windows::Media::Playback::{MediaPlaybackState, MediaPlayer, MediaPlayerAudio use windows::Media::SpeechSynthesis::{SpeechSynthesizer, VoiceInformation}; use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED}; +struct ActivePlayback { + player: MediaPlayer, + cancel: Arc, +} + +static SYNTHESIZER: OnceLock>> = OnceLock::new(); +static PLAYBACK: OnceLock>> = OnceLock::new(); + +fn synthesizer_slot() -> &'static Mutex> { + SYNTHESIZER.get_or_init(|| Mutex::new(None)) +} + +fn playback_slot() -> &'static Mutex> { + PLAYBACK.get_or_init(|| Mutex::new(None)) +} + fn ensure_winrt() -> Result<()> { unsafe { RoInitialize(RO_INIT_MULTITHREADED).ok(); @@ -16,9 +34,31 @@ fn ensure_winrt() -> Result<()> { Ok(()) } +fn with_synthesizer(f: F) -> Result +where + F: FnOnce(&SpeechSynthesizer) -> Result, +{ + ensure_winrt()?; + let mut guard = synthesizer_slot() + .lock() + .map_err(|_| anyhow!("TTS synthesizer lock poisoned"))?; + if guard.is_none() { + *guard = Some(SpeechSynthesizer::new()?); + } + let synth = guard + .as_ref() + .ok_or_else(|| anyhow!("TTS synthesizer unavailable"))?; + f(synth) +} + fn culture_prefix(language: &str) -> &str { match language { "el" => "el", + "de" => "de", + "fr" => "fr", + "it" => "it", + "es" => "es", + "pt" => "pt", _ => "en", } } @@ -51,10 +91,19 @@ fn map_volume(volume: u16) -> f64 { (volume as f64 / 100.0).clamp(0.0, 1.0) } -fn wait_for_playback(session: &windows::Media::Playback::MediaPlaybackSession) -> Result<()> { +fn wait_for_playback( + session: &windows::Media::Playback::MediaPlaybackSession, + cancel: &AtomicBool, +) -> Result<()> { let mut was_playing = false; - for _ in 0..500 { - let state = session.PlaybackState()?; + loop { + if cancel.load(Ordering::SeqCst) { + return Ok(()); + } + let state = match session.PlaybackState() { + Ok(state) => state, + Err(_) => return Ok(()), + }; if state == MediaPlaybackState::Playing { was_playing = true; } @@ -67,24 +116,35 @@ fn wait_for_playback(session: &windows::Media::Playback::MediaPlaybackSession) - } thread::sleep(Duration::from_millis(100)); } - Ok(()) +} + +fn clear_playback_if_current(cancel: &Arc) { + if let Ok(mut guard) = playback_slot().lock() { + let is_current = guard + .as_ref() + .map(|active| Arc::ptr_eq(&active.cancel, cancel)) + .unwrap_or(false); + if is_current { + *guard = None; + } + } } pub fn speak_text(text: &str, settings: &TtsSettings) -> Result<()> { ensure_winrt()?; + let _ = stop_speaking(); let voice = find_voice(&settings.language)?; - let synthesizer = SpeechSynthesizer::new()?; - synthesizer.SetVoice(&voice)?; - - let options = synthesizer.Options()?; - options.SetAudioVolume(map_volume(settings.volume))?; - options.SetSpeakingRate(map_rate(settings.rate))?; - - let stream_op = synthesizer.SynthesizeTextToStreamAsync(&HSTRING::from(text))?; - let stream = stream_op - .get() - .map_err(|e| anyhow!("Speech synthesis failed: {e}"))?; + let stream = with_synthesizer(|synthesizer| { + synthesizer.SetVoice(&voice)?; + let options = synthesizer.Options()?; + options.SetAudioVolume(map_volume(settings.volume))?; + options.SetSpeakingRate(map_rate(settings.rate))?; + let stream_op = synthesizer.SynthesizeTextToStreamAsync(&HSTRING::from(text))?; + stream_op + .get() + .map_err(|e| anyhow!("Speech synthesis failed: {e}")) + })?; let content_type = stream.ContentType()?; let media_source = MediaSource::CreateFromStream(&stream, &content_type)?; @@ -92,9 +152,23 @@ pub fn speak_text(text: &str, settings: &TtsSettings) -> Result<()> { let player = MediaPlayer::new()?; let _ = player.SetAudioCategory(MediaPlayerAudioCategory::Speech); player.SetSource(&media_source)?; + + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut guard = playback_slot() + .lock() + .map_err(|_| anyhow!("TTS playback lock poisoned"))?; + *guard = Some(ActivePlayback { + player: player.clone(), + cancel: Arc::clone(&cancel), + }); + } + player.Play()?; - wait_for_playback(&player.PlaybackSession()?)?; - Ok(()) + let wait_result = wait_for_playback(&player.PlaybackSession()?, &cancel); + clear_playback_if_current(&cancel); + let _ = player.Close(); + wait_result } pub fn list_voices() -> Result> { @@ -112,9 +186,29 @@ pub fn list_voices() -> Result> { } pub fn get_status() -> Result { - Ok("idle".to_string()) + let speaking = playback_slot() + .lock() + .map(|guard| guard.is_some()) + .unwrap_or(false); + if speaking { + Ok("speaking".to_string()) + } else { + Ok("idle".to_string()) + } } pub fn stop_speaking() -> Result<()> { + let active = { + let mut guard = playback_slot() + .lock() + .map_err(|_| anyhow!("TTS playback lock poisoned"))?; + guard.take() + }; + if let Some(active) = active { + active.cancel.store(true, Ordering::SeqCst); + let _ = active.player.Pause(); + let _ = active.player.SetSource(None); + let _ = active.player.Close(); + } Ok(()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4778b4c..b5f8c0b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,11 +24,17 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://www.google.com; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost; object-src 'none'; base-uri 'self'; form-action 'self'", "assetProtocol": { "enable": true, "scope": [ - "**" + "$APPDATA/**", + "$APPLOCALDATA/**", + "$APPCACHE/**", + "$PICTURE/**", + "$DOWNLOAD/**", + "$DOCUMENT/**", + "$DESKTOP/**" ] } } @@ -37,6 +43,9 @@ "active": true, "targets": "all", "createUpdaterArtifacts": false, + "resources": [ + "resources/wordpacks/en.json" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.tsx b/src/App.tsx index 9112786..876ad89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,49 +17,70 @@ import { } from "./lib/toolWindows"; import { useAppStore } from "./stores/appStore"; +const KEYBOARD_POLL_MS = 100; + function MainApp() { - const { - loadProfileFiles, - loadMonitors, - loadKeyboardLayout, - loadInputMethods, - refreshLayoutKeyLabels, - pollKeyboardState, - checkForUpdates, - setDictationState, - refreshSttCapability, - appendTyped, - loadSuggestions, - setLastError, - loadImportedSongs, - } = useAppStore(); + const loadProfileFiles = useAppStore((s) => s.loadProfileFiles); + const loadMonitors = useAppStore((s) => s.loadMonitors); + const loadKeyboardLayout = useAppStore((s) => s.loadKeyboardLayout); + const loadInputMethods = useAppStore((s) => s.loadInputMethods); + const refreshLayoutKeyLabels = useAppStore((s) => s.refreshLayoutKeyLabels); + const pollKeyboardState = useAppStore((s) => s.pollKeyboardState); + const checkForUpdates = useAppStore((s) => s.checkForUpdates); + const setDictationState = useAppStore((s) => s.setDictationState); + const refreshSttCapability = useAppStore((s) => s.refreshSttCapability); + const appendTyped = useAppStore((s) => s.appendTyped); + const loadSuggestions = useAppStore((s) => s.loadSuggestions); + const setLastError = useAppStore((s) => s.setLastError); + const loadImportedSongs = useAppStore((s) => s.loadImportedSongs); useEffect(() => { + let cancelled = false; + let pollId: number | null = null; const init = async () => { - await loadProfileFiles(); - await loadImportedSongs(); - await loadMonitors(); - const { settings } = useAppStore.getState(); - await invoke("cmd_apply_window_layout", { - monitorId: settings.accessibilityMonitorId, - collapsed: settings.collapsed, - collapsedDictation: - settings.collapsed && settings.dictationVisible !== false, - heightRatio: computeContentHeightRatio({ - quickActions: settings.quickActionsVisible, - phrases: settings.phrasesVisible, - }), - }); - await loadKeyboardLayout(); - await loadInputMethods(); - await refreshLayoutKeyLabels(); - await pollKeyboardState(); - await invoke("cmd_set_always_on_top", { enabled: true }); - await invoke("cmd_set_window_focusable", { focusable: false }); - void checkForUpdates(); - await refreshSttCapability(); + try { + await loadProfileFiles(); + if (cancelled) return; + await loadImportedSongs(); + await loadMonitors(); + const { settings } = useAppStore.getState(); + await invoke("cmd_apply_window_layout", { + monitorId: settings.accessibilityMonitorId, + collapsed: settings.collapsed, + collapsedDictation: + settings.collapsed && settings.dictationVisible !== false, + heightRatio: computeContentHeightRatio({ + quickActions: settings.quickActionsVisible, + phrases: settings.phrasesVisible, + }), + }); + await loadKeyboardLayout(); + await loadInputMethods(); + await refreshLayoutKeyLabels(); + await pollKeyboardState(); + await invoke("cmd_set_always_on_top", { enabled: true }); + await invoke("cmd_set_window_focusable", { focusable: false }); + void checkForUpdates(); + await refreshSttCapability(); + } catch (error) { + if (!cancelled) { + setLastError(error instanceof Error ? error.message : String(error)); + } + } finally { + if (cancelled) return; + // Start polling even if earlier setup steps failed so keyboard state + // stays fresh; profile hydration errors are surfaced via setLastError. + pollId = window.setInterval(() => { + if (document.hidden) return; + void pollKeyboardState(); + }, KEYBOARD_POLL_MS); + } + }; + void init(); + return () => { + cancelled = true; + if (pollId !== null) window.clearInterval(pollId); }; - init(); }, [ loadProfileFiles, loadImportedSongs, @@ -70,15 +91,9 @@ function MainApp() { pollKeyboardState, checkForUpdates, refreshSttCapability, + setLastError, ]); - useEffect(() => { - const id = window.setInterval(() => { - void pollKeyboardState(); - }, 50); - return () => window.clearInterval(id); - }, [pollKeyboardState]); - useEffect(() => { const unlisteners: Array void>> = []; @@ -158,7 +173,8 @@ function MainApp() { } function ToolApp({ label }: { label: ToolWindowLabel }) { - const { loadProfileFiles, loadMonitors } = useAppStore(); + const loadProfileFiles = useAppStore((s) => s.loadProfileFiles); + const loadMonitors = useAppStore((s) => s.loadMonitors); useEffect(() => { const init = async () => { diff --git a/src/components/head-tracking/HeadTrackingWizard.tsx b/src/components/head-tracking/HeadTrackingWizard.tsx index f574c46..75e0923 100644 --- a/src/components/head-tracking/HeadTrackingWizard.tsx +++ b/src/components/head-tracking/HeadTrackingWizard.tsx @@ -13,9 +13,13 @@ const DEFAULT_HT: HeadTrackingSettings = { calibrated: false, }; +function noopMove(_dx: number, _dy: number) {} + export function HeadTrackingWizard() { - const { settings, updateSettings, saveActiveProfile, setShowHeadTrackingWizard } = - useAppStore(); + const settings = useAppStore((s) => s.settings); + const updateSettings = useAppStore((s) => s.updateSettings); + const saveActiveProfile = useAppStore((s) => s.saveActiveProfile); + const setShowHeadTrackingWizard = useAppStore((s) => s.setShowHeadTrackingWizard); const [htSettings, setHtSettings] = useState(DEFAULT_HT); const [mode, setMode] = useState<"touch" | "head">( settings.headTrackingEnabled ? "head" : "touch", @@ -24,7 +28,7 @@ export function HeadTrackingWizard() { const { videoRef, calibrated, calibrate } = useHeadTracking( mode === "head", htSettings, - () => {}, + noopMove, ); useEffect(() => { diff --git a/src/components/keyboard/Keyboard.tsx b/src/components/keyboard/Keyboard.tsx index f0bdf81..2505bfa 100644 --- a/src/components/keyboard/Keyboard.tsx +++ b/src/components/keyboard/Keyboard.tsx @@ -20,28 +20,27 @@ import { computeKeyMetrics } from "../../lib/keyMetrics"; import type { OnscreenLayout } from "../../lib/types"; export function Keyboard() { - const { - settings, - keyboardLayout, - stickyModifiers, - physicalKeyState, - toggleSticky, - pollKeyboardState, - clearSticky, - clearStickyExceptFn, - appendTyped, - backspaceTyped, - setTypedBuffer, - loadSuggestions, - pollError, - inputMethods, - layoutKeyLabels, - languagePickerOpen, - setLanguagePickerOpen, - selectTypingInputMethod, - loadInputMethods, - updateSettings, - } = useAppStore(); + const settings = useAppStore((s) => s.settings); + const keyboardLayout = useAppStore((s) => s.keyboardLayout); + const stickyModifiers = useAppStore((s) => s.stickyModifiers); + const physicalKeyState = useAppStore((s) => s.physicalKeyState); + const toggleSticky = useAppStore((s) => s.toggleSticky); + const pollKeyboardState = useAppStore((s) => s.pollKeyboardState); + const clearSticky = useAppStore((s) => s.clearSticky); + const clearStickyExceptFn = useAppStore((s) => s.clearStickyExceptFn); + const appendTyped = useAppStore((s) => s.appendTyped); + const backspaceTyped = useAppStore((s) => s.backspaceTyped); + const setTypedBuffer = useAppStore((s) => s.setTypedBuffer); + const loadSuggestions = useAppStore((s) => s.loadSuggestions); + const recordTypedWord = useAppStore((s) => s.recordTypedWord); + const pollError = useAppStore((s) => s.pollError); + const inputMethods = useAppStore((s) => s.inputMethods); + const layoutKeyLabels = useAppStore((s) => s.layoutKeyLabels); + const languagePickerOpen = useAppStore((s) => s.languagePickerOpen); + const setLanguagePickerOpen = useAppStore((s) => s.setLanguagePickerOpen); + const selectTypingInputMethod = useAppStore((s) => s.selectTypingInputMethod); + const loadInputMethods = useAppStore((s) => s.loadInputMethods); + const updateSettings = useAppStore((s) => s.updateSettings); const { ref, height } = useContainerSize(); const { t } = useTranslation(); @@ -61,6 +60,7 @@ export function Keyboard() { ); const { keyHeight, spacing } = computeKeyMetrics(height, rows.length); const fontSize = settings.keyboardFontSize ?? 18; + const typingLocale = settings.typingLanguage || "en"; const clearModifiersAfterKey = (usedFn: boolean) => { if (settings.fnKeyMode === "latched") { @@ -111,6 +111,7 @@ export function Keyboard() { } if (key === "enter") { + await recordTypedWord(); setTypedBuffer(""); await invoke("cmd_press_key", { request: { key: "enter", modifiers: [...activeModifiers] }, @@ -122,6 +123,7 @@ export function Keyboard() { } if (key === "space") { + await recordTypedWord(); appendTyped(" "); await invoke("cmd_press_key", { request: { key: "space", modifiers: [...activeModifiers] }, @@ -133,7 +135,13 @@ export function Keyboard() { } const usedFn = fnActive && isFnMappedKey(keyDef.key); - const output = resolveKeyOutput(keyDef, physicalKeyState.capsLock, shiftActive, fnActive); + const output = resolveKeyOutput( + keyDef, + physicalKeyState.capsLock, + shiftActive, + fnActive, + typingLocale, + ); if (output.length === 1) appendTyped(output); await invoke("cmd_press_key", { request: { key: output, modifiers: [...activeModifiers] }, @@ -160,7 +168,13 @@ export function Keyboard() { return ( s.settings); + const updateSettings = useAppStore((s) => s.updateSettings); const mouseSide = settings.mousePanelSide ?? "right"; const mouseRatio = settings.inputRowRightRatio ?? 0.28; const mouseVisible = settings.mouseVisible; @@ -78,18 +79,16 @@ function contentHeightRatioFromSettings(settings: { } export function AppShell() { - const { - settings, - monitors, - pendingUpdate, - setPendingUpdate, - setShowSettings, - toggleCollapsed, - updateSettings, - applyWindowHeightRatioLive, - isAnimatingWindow, - musicTeachingEnabled, - } = useAppStore(); + const settings = useAppStore((s) => s.settings); + const monitors = useAppStore((s) => s.monitors); + const pendingUpdate = useAppStore((s) => s.pendingUpdate); + const setPendingUpdate = useAppStore((s) => s.setPendingUpdate); + const setShowSettings = useAppStore((s) => s.setShowSettings); + const toggleCollapsed = useAppStore((s) => s.toggleCollapsed); + const updateSettings = useAppStore((s) => s.updateSettings); + const applyWindowHeightRatioLive = useAppStore((s) => s.applyWindowHeightRatioLive); + const isAnimatingWindow = useAppStore((s) => s.isAnimatingWindow); + const musicTeachingEnabled = useAppStore((s) => s.musicTeachingEnabled); const { t } = useTranslation(); const largeHeaders = settings.largeHeaders; const headerHeight = appHeaderHeightPx(largeHeaders); @@ -104,6 +103,7 @@ export function AppShell() { regionHeight: number; latestRatio: number; } | null>(null); + const resizeRafRef = useRef(null); const handleCloseApp = () => { void closeAllToolWindows().finally(() => { @@ -137,13 +137,23 @@ export function AppShell() { drag.startRatio - delta / drag.regionHeight, ); drag.latestRatio = nextRatio; - void applyWindowHeightRatioLive(nextRatio); + if (resizeRafRef.current !== null) return; + resizeRafRef.current = requestAnimationFrame(() => { + resizeRafRef.current = null; + const current = windowResizeRef.current; + if (!current) return; + void applyWindowHeightRatioLive(current.latestRatio); + }); }; const onWindowHeaderPointerUp = (event: ReactPointerEvent) => { const drag = windowResizeRef.current; if (!drag) return; windowResizeRef.current = null; + if (resizeRafRef.current !== null) { + cancelAnimationFrame(resizeRafRef.current); + resizeRafRef.current = null; + } try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { diff --git a/src/components/mouse/NumKeypad.tsx b/src/components/mouse/NumKeypad.tsx index 3cd5f53..e82a0a5 100644 --- a/src/components/mouse/NumKeypad.tsx +++ b/src/components/mouse/NumKeypad.tsx @@ -33,7 +33,8 @@ const NUMPAD_KEYS: NumpadKeyDef[] = [ ]; export function NumKeypad() { - const { settings, pollError } = useAppStore(); + const settings = useAppStore((s) => s.settings); + const pollError = useAppStore((s) => s.pollError); const { ref, height } = useContainerSize(); const { keyHeight, spacing } = computeKeyMetrics(height, NUMPAD_ROW_COUNT); const fontSize = settings.keyboardFontSize ?? 18; diff --git a/src/components/mouse/Trackpad.tsx b/src/components/mouse/Trackpad.tsx index a58e5b3..5b6e327 100644 --- a/src/components/mouse/Trackpad.tsx +++ b/src/components/mouse/Trackpad.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ComponentType } from "react"; +import { useEffect, useRef, useState, type ComponentType } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useAppStore } from "../../stores/appStore"; import { useContainerSize } from "../../hooks/useContainerSize"; @@ -91,7 +91,8 @@ function TrackpadButton({ } export function Trackpad() { - const { settings, pollError } = useAppStore(); + const settings = useAppStore((s) => s.settings); + const pollError = useAppStore((s) => s.pollError); const { ref, width } = useContainerSize(); const compact = settings.inputAreaCompact; const showBottomRow = settings.mouseBottomRowVisible; @@ -100,6 +101,9 @@ export function Trackpad() { const [precision, setPrecision] = useState(false); const lastPos = useRef<{ x: number; y: number } | null>(null); const dragging = useRef(false); + const pendingDelta = useRef({ dx: 0, dy: 0 }); + const rafId = useRef(null); + const ipcInFlight = useRef(false); const keyBgColor = settings.keyboardKeyColor ?? "#ffffff"; const keyTextColor = settings.keyTextColor ?? "#1e293b"; const surface = getSurfaceColors(settings.appBgColor); @@ -109,28 +113,58 @@ export function Trackpad() { (precision || settings.precisionMode ? 0.4 : 1) * (settings.mouseCustomSpeed ?? 1); + useEffect(() => { + return () => { + if (rafId.current !== null) cancelAnimationFrame(rafId.current); + }; + }, []); + + const flushPendingMove = () => { + rafId.current = null; + if (ipcInFlight.current) { + rafId.current = requestAnimationFrame(flushPendingMove); + return; + } + const { dx, dy } = pendingDelta.current; + if (dx === 0 && dy === 0) return; + pendingDelta.current = { dx: 0, dy: 0 }; + ipcInFlight.current = true; + void invoke("cmd_move_cursor_relative", { dx, dy }) + .catch(() => {}) + .finally(() => { + ipcInFlight.current = false; + }); + }; + const onPointerDown = (e: React.PointerEvent) => { (e.target as HTMLElement).setPointerCapture(e.pointerId); lastPos.current = { x: e.clientX, y: e.clientY }; dragging.current = true; + pendingDelta.current = { dx: 0, dy: 0 }; }; - const onPointerMove = async (e: React.PointerEvent) => { + const onPointerMove = (e: React.PointerEvent) => { if (!dragging.current || !lastPos.current) return; const dx = Math.round((e.clientX - lastPos.current.x) * speed); const dy = Math.round((e.clientY - lastPos.current.y) * speed); - if (dx !== 0 || dy !== 0) { - await invoke("cmd_move_cursor_relative", { dx, dy }); - await pollError(); - } lastPos.current = { x: e.clientX, y: e.clientY }; + if (dx === 0 && dy === 0) return; + pendingDelta.current.dx += dx; + pendingDelta.current.dy += dy; + if (rafId.current === null) { + rafId.current = requestAnimationFrame(flushPendingMove); + } }; const onPointerUp = () => { dragging.current = false; lastPos.current = null; + if (rafId.current !== null) { + cancelAnimationFrame(rafId.current); + rafId.current = null; + } + flushPendingMove(); }; - const buttonProps = { iconSize, paddingY, bgColor: keyBgColor, textColor: keyTextColor }; return ( diff --git a/src/components/music/MusicLessonPanel.tsx b/src/components/music/MusicLessonPanel.tsx index def0d59..7361d78 100644 --- a/src/components/music/MusicLessonPanel.tsx +++ b/src/components/music/MusicLessonPanel.tsx @@ -12,6 +12,7 @@ import { resolveSynthStartOctave, } from "../../lib/music/octaveCount"; import { isImportedMusicSong } from "../../lib/music/parseJsonSong"; +import { PartitureView } from "./PartitureView"; /** Fixed slot width so the strip can keep the active note centered. */ const NOTE_SLOT_REM = 2.75; @@ -67,154 +68,165 @@ export function MusicLessonPanel() { color: surface.panelText, }} > -
- {t("musicLesson")} -
- - - + + + {selectedIsImported && ( + + )} +
+
+ +