Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7569e610f | ||
|
|
f377fa63b4 | ||
|
|
98b09bad06 |
No files matched your search
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"hud": {
|
||||
"mode": "footer",
|
||||
"position": "bottom-center",
|
||||
"shortcut": "ctrl+shift+h",
|
||||
"switchShortcut": "ctrl+.",
|
||||
"minimizeShortcut": "ctrl+h",
|
||||
"autoCompactWhileStreaming": true,
|
||||
"startupNotification": true,
|
||||
"usageDisplay": "metered",
|
||||
"contextIndicator": "icon",
|
||||
"expandedWidth": 42,
|
||||
"compactWidth": 26,
|
||||
"minTerminalWidth": 90,
|
||||
"margin": {
|
||||
"top": 1,
|
||||
"right": 1,
|
||||
"bottom": 1
|
||||
},
|
||||
"visibility": {
|
||||
"context": true,
|
||||
"project": true,
|
||||
"worktrees": true,
|
||||
"mcps": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
Licenca Proprietaria - Evandro Fonseca Junior (Custom)
|
||||
|
||||
1. Uso permitido: uso pessoal e de estudo, desde que os creditos ao autor sejam mantidos visiveis.
|
||||
|
||||
2. Proibido: redistribuicao, revenda, sublicenciamento ou uso comercial sem autorizacao expressa por escrito.
|
||||
|
||||
3. Uso comercial autorizado: mediante contrato, com repasse de 50% (cinquenta por cento) do valor bruto arrecadado ao autor, alem da manutencao dos creditos.
|
||||
|
||||
4. Sem garantias: o software e fornecido "como esta", sem garantias de qualquer tipo.
|
||||
@@ -1,84 +1,123 @@
|
||||
# FightRateRN
|
||||
<div align="center">
|
||||
|
||||
React Native Community CLI rebuild of FightRate. This app is **bare React Native only** and does not use Expo.
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="FightRate: heart-rate capture for fight training from a BLE chest strap, with a live BPM readout over the BASE, PRESSÃO and REDLINE zones">
|
||||
|
||||
## Visual source of truth
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
The app follows the selected FightRate direction:
|
||||
[Design system](FIGHTRATE_DESIGN_SYSTEM.md) · [Literature notes](docs/data/pesquisa.md) · [Issues](https://git.evandro.dev.br/evandro/HealthTracking/issues)
|
||||
|
||||
- `../FIGHTRATE_DESIGN_SYSTEM.md`
|
||||
- Direction: `01 — Corner Instrument`
|
||||
</div>
|
||||
|
||||
Do not reuse the deprecated Combat Lab/card-heavy direction.
|
||||
The BLE parser decoding one Heart Rate Measurement packet (flags `0x16`: skin contact detected, one RR interval). It is plain TypeScript, so Node runs it straight from a fresh clone, no install:
|
||||
|
||||
## Current slice
|
||||
```console
|
||||
$ node --experimental-strip-types --no-warnings --input-type=module -e "import { parseHeartRateMeasurement as parse } from './src/ble/heartRateParser.ts'; console.log(parse([0x16, 0xA0, 0x6C, 0x01]))"
|
||||
{
|
||||
bpm: 160,
|
||||
contactSupported: true,
|
||||
contactDetected: true,
|
||||
energyExpended: null,
|
||||
rrIntervalsMs: [ 355.46875 ]
|
||||
}
|
||||
```
|
||||
|
||||
Implemented in this first slice:
|
||||
## What it is
|
||||
|
||||
- Corner Instrument app shell.
|
||||
- Continuous capture-first home screen.
|
||||
- History, Sensor, and Insights screens.
|
||||
- Centralized design tokens in `src/theme`.
|
||||
- Reusable UI components in `src/components`.
|
||||
- No Expo dependencies.
|
||||
- Android `applicationId` aligned to `com.healthtracking.fightrate`.
|
||||
FightRate (repository `HealthTracking`) is an Android app that records heart rate continuously from a Bluetooth Low Energy chest strap during combat-sport training. It is a bare React Native app (no Expo): a Kotlin foreground service owns the BLE connection and stores every timestamped BPM sample in SQLite, and the React Native UI reads those samples. UI copy is PT-BR; code identifiers are English.
|
||||
|
||||
Not implemented yet:
|
||||
| Tab | What it does |
|
||||
|---|---|
|
||||
| **Capture** | Live BPM, average / peak / sample-count tiles, BLE, battery and background status, and the training chart. Start/stop marks a **training window** inside the continuous stream; monitoring keeps storing samples either way |
|
||||
| **History** | Saved training windows and continuous-usage blocks (split by gaps), each with average / peak / min BPM; open one to inspect the timestamped data; custom time range |
|
||||
| **Sensor** | BLE scan, device list, connect, connection phase, signal (RSSI), battery level, BLE services/characteristics diagnostics and a shortcut to Android battery-optimization settings |
|
||||
| **Insights** | Current/average BPM, range, variability, time in zones, load, heart-rate drift, post-session recovery, HRmax/HRR (marked as needing a profile) and HRV (only when the strap sends RR intervals) |
|
||||
|
||||
- Real BLE Magene H003 integration.
|
||||
- SQLite/local persistence.
|
||||
- Real timestamped workout chart data.
|
||||
- Navigation library.
|
||||
## Why use it
|
||||
|
||||
## Commands
|
||||
- **Real data only.** Every number on screen is computed from samples a real sensor sent. With no samples the app shows an empty state instead of inventing a peak, zone, load or HRV. Samples outside 30–240 bpm, or reported without skin contact, are discarded.
|
||||
- **Capture survives the screen turning off.** It runs in a foreground service of type `connectedDevice`, restarts as `START_STICKY`, reconnects with backoff (2 s → 5 s → 10 s → 30 s) and restores the last device after a restart.
|
||||
- **Built for rounds, not steps.** Fixed zones `BASE <150`, `PRESSÃO 150–169`, `REDLINE 170+`, and a zoomable chart (pinch zoom, pan, tap-to-pin) whose ticks follow the visible span, from half-second to one-minute steps.
|
||||
|
||||
Do not use it if you need iOS (there is no native capture module there), zones from your own HRmax or resting heart rate (there is no athlete profile yet), or a signed build you can distribute (see [Project status](#project-status)).
|
||||
|
||||
## How it works
|
||||
|
||||
1. **BLE client**: `FightRateCaptureService.kt` scans, connects by known device id, subscribes to Heart Rate Measurement `2A37` (service `180D`), reads Battery Level `2A19` and RSSI. After 12 s without a valid sample it reports a stale state and waits for new real data.
|
||||
2. **Parser**: `HeartRateMeasurementParser.kt` and `src/ble/heartRateParser.ts` read UInt8/UInt16 BPM, skin contact and RR intervals (converted from 1/1024 s to ms).
|
||||
3. **Storage**: `FightRateCaptureStore.kt` writes `fightrate_rn_capture.db` (schema version 2) with training windows and continuous samples (timestamp, BPM, contact, RR intervals, device).
|
||||
4. **Bridge**: `FightRateCaptureModule.kt` and `src/native/FightRateCaptureNative.ts` expose start/stop, connect, snapshots and samples, and turn service broadcasts into JS events.
|
||||
5. **Domain**: pure TypeScript in `src/training/` computes metrics, zones, drift, recovery, usage blocks (a 5-minute gap starts a new block) and the chart viewport.
|
||||
6. **UI**: a state-driven tab shell in `src/app/FightRateApp.tsx` (no navigation library), Reanimated 4 + Worklets animations and design tokens in `src/theme/`.
|
||||
|
||||
Design direction: [`FIGHTRATE_DESIGN_SYSTEM.md`](FIGHTRATE_DESIGN_SYSTEM.md) (**01 — Corner Instrument**, the official one). [`FIGHTRATE_COMBAT_LAB_DESIGN_SYSTEM.md`](FIGHTRATE_COMBAT_LAB_DESIGN_SYSTEM.md) is a deprecated direction kept for history. Specs and plans live in [`docs/superpowers/`](docs/superpowers).
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Node ≥ 22.11 (`engines` in `package.json`), the Android SDK and a JDK.
|
||||
|
||||
```bash
|
||||
git clone https://git.evandro.dev.br/evandro/HealthTracking.git
|
||||
cd HealthTracking
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test -- --runInBand
|
||||
```
|
||||
|
||||
Run Metro:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
npm run start # Metro
|
||||
npm run android # run on a device/emulator
|
||||
```
|
||||
|
||||
Run Android on a device/emulator:
|
||||
|
||||
```bash
|
||||
npm run android
|
||||
```
|
||||
|
||||
Build Android debug APK:
|
||||
Debug APK:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
# output: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
If Gradle reports `No Java compiler found`, point `JAVA_HOME` to Android Studio JBR:
|
||||
> [!WARNING]
|
||||
> **Grant the permissions and exempt the app from battery optimization.** The manifest requests Bluetooth scan/connect, fine location, notifications and a foreground service of type `connectedDevice`. The **Bateria** button on the Sensor tab opens Android's battery-optimization settings (`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared) so background capture isn't throttled.
|
||||
|
||||
If Gradle reports `No Java compiler found`, point `JAVA_HOME` to the JBR bundled with Android Studio:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME="/c/Users/Evandro/AppData/Local/Programs/Android Studio/jbr"
|
||||
export JAVA_HOME="<Android Studio install dir>/jbr"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
If React Native autolinking keeps a stale package after renaming Android package paths,
|
||||
clean generated Gradle caches before rebuilding:
|
||||
If React Native autolinking keeps a stale package after renaming Android package paths, clean the generated Gradle caches before rebuilding:
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
rm -rf android/.gradle android/app/build android/build .gradle
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
cd android && ./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Debug APK output:
|
||||
## Project status
|
||||
|
||||
```text
|
||||
android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
Work in progress: `package.json` is `0.0.1`, Android `versionName` is `1.0`, `applicationId` is `com.healthtracking.fightrate`. The last feature work (2026-06-16) shipped the "Instrumento Forte" UI refinement; its record in [`.impeccable.md`](.impeccable.md) lists `npm run typecheck` with 0 errors, `npm test -- --runInBand` with 74/74 passing, and `npm run lint` failing on a pre-existing `jest/globals` configuration error.
|
||||
|
||||
Known limits:
|
||||
|
||||
- **Android only**: capture exists only in the Kotlin native module; the `ios/` project has no equivalent.
|
||||
- **No athlete profile yet**: zones use the fixed thresholds above; `%HRmax` zones, Karvonen and full TRIMP are shown as needing a profile. The app doesn't invent HRmax or resting heart rate.
|
||||
- **HRV needs RR intervals**: without them the dashboard says so instead of estimating.
|
||||
- **Release signing**: the `release` build type uses the **debug keystore** (`android/app/build.gradle`); generate your own keystore before distributing.
|
||||
|
||||
The literature notes in [`docs/data/pesquisa.md`](docs/data/pesquisa.md) state that population tables and formula-based estimates are not a substitute for measured testing.
|
||||
|
||||
## License
|
||||
|
||||
Proprietary license in [`LICENSE`](LICENSE): personal and study use only, with visible credit to the author. Redistribution, resale, sublicensing and commercial use need written authorization; authorized commercial use is by contract, with 50% of gross revenue paid to the author. Provided "as is", without warranty.
|
||||
|
||||
Built on [React Native](https://reactnative.dev/), [Reanimated](https://docs.swmansion.com/react-native-reanimated/), [Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/), [react-native-svg](https://github.com/software-mansion/react-native-svg), [Lucide](https://lucide.dev/) icons and [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
|
||||
|
||||
<div align="center">
|
||||
|
||||
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
|
||||
<title id="title">FightRate</title>
|
||||
<desc id="desc">FightRate records heart rate from a BLE chest strap during fight training. On the right, a live readout of 160 BPM in the PRESSÃO zone over a session trace that crosses the 150 and 170 BPM thresholds, decoded from the Heart Rate Measurement packet 16 A0 6C 01.</desc>
|
||||
|
||||
<rect width="1200" height="420" rx="24" fill="#070605"/>
|
||||
|
||||
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||
<text x="64" y="104" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#B9A98E">HealthTracking · Android</text>
|
||||
<text x="64" y="186" font-size="72" font-weight="700" fill="#F8EAD8">FightRate</text>
|
||||
<text x="64" y="246" font-size="28" fill="#F8EAD8">Heart-rate capture for fight training</text>
|
||||
<text x="64" y="282" font-size="28" fill="#F8EAD8">from a BLE chest strap. Real data only.</text>
|
||||
<text x="64" y="344" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#B9A98E">React Native 0.86 · Kotlin · SQLite</text>
|
||||
</g>
|
||||
|
||||
<g id="project-proof" font-family="ui-monospace, 'SF Mono', Consolas, monospace">
|
||||
<rect x="632" y="40" width="512" height="340" rx="16" fill="#12100C" stroke="#392D1E" stroke-width="2"/>
|
||||
|
||||
<circle cx="672" cy="80" r="7" fill="#E23B2E"/>
|
||||
<text x="688" y="86" font-size="18" fill="#B9A98E">LIVE</text>
|
||||
<text x="664" y="150" font-size="60" font-weight="700" fill="#F8EAD8">160</text>
|
||||
<text x="784" y="150" font-size="20" fill="#B9A98E">BPM</text>
|
||||
<rect x="976" y="62" width="136" height="36" rx="18" fill="#FFB43D"/>
|
||||
<text x="1044" y="87" font-size="18" font-weight="700" fill="#070605" text-anchor="middle">PRESSÃO</text>
|
||||
|
||||
<g id="zones" font-size="18">
|
||||
<line x1="664" y1="216" x2="1068" y2="216" stroke="#E23B2E" stroke-width="2" stroke-dasharray="6 6"/>
|
||||
<line x1="664" y1="256" x2="1068" y2="256" stroke="#FFB43D" stroke-width="2" stroke-dasharray="6 6"/>
|
||||
<line x1="664" y1="316" x2="1068" y2="316" stroke="#392D1E" stroke-width="2"/>
|
||||
<text x="1112" y="222" fill="#E23B2E" text-anchor="end">170</text>
|
||||
<text x="1112" y="262" fill="#FFB43D" text-anchor="end">150</text>
|
||||
</g>
|
||||
|
||||
<polyline fill="none" stroke="#F8EAD8" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"
|
||||
points="664,304 700,294 736,276 772,252 808,258 844,234 880,208 916,198 952,224 988,246 1024,240 1060,236"/>
|
||||
<circle cx="1060" cy="236" r="7" fill="#FFB43D"/>
|
||||
|
||||
<text x="664" y="354" font-size="20" fill="#B9A98E">2A37 · 16 A0 6C 01 → 160 bpm</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
-288
@@ -1,288 +0,0 @@
|
||||
# Code Review — whisper-mcp
|
||||
|
||||
**Escopo:** `C:\Users\Evandro\.pi\agent\mcp-servers\whisper\`
|
||||
**Arquivos:** `server.py` (1127), `parakeet_bootstrap.py` (332), `tests/test_parakeet_bootstrap.py` (237), `cli/whisper` (113)
|
||||
**Lint/Build:** `py_compile` OK · `pytest 26 passed` · sem `git` no diretório
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1 · SSRF bypass via HTTP redirect — `server.py:832`
|
||||
|
||||
```python
|
||||
with urllib.request.urlopen(url, timeout=TIMEOUT) as resp:
|
||||
```
|
||||
|
||||
`urllib.request.urlopen` segue `30x` por padrão. Atacante serve URL pública que retorna `302 → http://127.0.0.1:8000/...` e a checagem `_is_private_host` (linha 382) é contornada. `_validate_url` só olha a URL inicial, não a cadeia de redirects.
|
||||
**Fix:** montar `OpenerDirector` com `HTTPRedirectHandler` que revalida cada destino (ou `no-redirect`), ou extrair IP canônico antes de cada hop.
|
||||
|
||||
### C2 · SSRF via DNS rebinding (TOCTOU) — `server.py:333-388`, `:832`
|
||||
|
||||
`_is_private_host` resolve o host **uma vez** (linha 354) e libera. Entre o check e `urlopen`, o atacante controla TTL baixo e devolve IP público no primeiro resolve e `127.0.0.1` no segundo (dentro do mesmo `urlopen`).
|
||||
**Fix:** resolver o IP antes do request, **abrir socket manualmente** conectando ao IP literal e usar `Host:` header — ou usar `requests` + `requests-ossrf` / `httpx` com hook de validação por hop. Mínimo: revalidar o IP em cada redirect.
|
||||
|
||||
### C3 · `sh -c` com pipe para URL externa, sem verificação — `parakeet_bootstrap.py:124`
|
||||
|
||||
```python
|
||||
_run(["sh", "-c", "curl -fsSL https://get.docker.com | sh"], timeout=600)
|
||||
```
|
||||
|
||||
- Não usa `shell=False` (mesmo com lista, passa pelo `sh -c`).
|
||||
- Não valida TLS pinning / assinatura / checksum.
|
||||
- Executa como root via `sudo systemctl start docker` (linha 127).
|
||||
- Se o DNS do `get.docker.com` for sequestrado → RCE.
|
||||
**Fix:** baixar o script primeiro para arquivo, comparar SHA-256 contra valor conhecido, só então executar. Ou usar pacotes nativos do gerenciador de pacotes da distro.
|
||||
|
||||
### C4 · Dead code corrompe arquivo do usuário — `server.py:996`
|
||||
|
||||
```python
|
||||
out.write_bytes(pcm) # raw PCM, will be wrapped below
|
||||
# Write proper WAV
|
||||
wav_path = _write_wav_to(pcm, sample_rate, out)
|
||||
```
|
||||
|
||||
A primeira escrita é PCD cru (não-WAV); a segunda sobrescreve com WAV válido — porém:
|
||||
|
||||
- Se `_write_wav_to` falhar, o arquivo fica como PCD cru no disco do usuário (corrompido).
|
||||
- Janela de race em que terceiros leriam o arquivo.
|
||||
**Fix:** remover a linha 996 inteira. `_write_wav_to` já cria o arquivo.
|
||||
|
||||
### C5 · FD leak em `tempfile.mkstemp` — `server.py:499`, `:932`
|
||||
|
||||
```python
|
||||
out_path = Path(tempfile.mkstemp(suffix=".wav", prefix="whisper-vad-")[1])
|
||||
path = Path(tempfile.mkstemp(suffix=".wav", prefix="whisper-rec-")[1])
|
||||
```
|
||||
|
||||
`mkstemp` retorna `(fd, path)`. O `[1]` descarta o `fd`, que vaza (até o GC fechar). Em loops de gravação contínua pode esgotar FDs.
|
||||
**Fix:** usar `tempfile.NamedTemporaryFile(delete=False, suffix=".wav")` ou `os.close(fd)` antes de descartar.
|
||||
|
||||
---
|
||||
|
||||
## IMPORTANT
|
||||
|
||||
### I1 · Cobertura de testes = 0% em `server.py` (1127 linhas)
|
||||
|
||||
Os 26 testes cobrem **apenas** `parakeet_bootstrap.py`. Nenhum teste para: `health`, `transcribe_file`, `transcribe_url`, `record_audio`, `_vad_trim_wav`, `_is_private_host`, `_validate_url`, `LRUCache`, `Metrics`, `WhisperError`, `_post_multipart`. O caminho de maior valor (transcrição + SSRF + cache) está intocado.
|
||||
**Fix:** mínimo — testes para `_is_private_host` (loopback, IPv6 mapped, IPv4-mapped IPv6, getaddrinfo com múltiplos IPs), `_validate_url` (file://, ftp://, redirect), `_cache_key`, `LRUCache.put` (eviction), `Metrics.snapshot`. Testes de integração marcados como `slow` e gated.
|
||||
|
||||
### I2 · `transcribe_url` não usa cache — `server.py:807-879`
|
||||
|
||||
O docstring do módulo (linha 21-24) promete "cache LRU local ... evita re-transcrever mesmo áudio". URL baixada é re-transcrita toda vez — `cache_hits`/`cache_misses` não são chamados no path de URL. Comportamento documentado ≠ comportamento real.
|
||||
**Fix:** hashear `data_bytes` (ou do `tmp_path`) e gravar entrada de cache no fim do path de URL, com `source`=URL.
|
||||
|
||||
### I3 · Default port documentado diverge do código — `server.py:35` vs `:92`
|
||||
|
||||
```python
|
||||
# docstring linha 35:
|
||||
WHISPER_BASE_URL (default ``http://localhost:8000``)
|
||||
# código linha 92:
|
||||
BASE_URL = os.environ.get("WHISPER_BASE_URL", "http://localhost:5092").rstrip("/")
|
||||
```
|
||||
|
||||
8000 (Whisper) vs 5092 (Parakeet) — duas fontes da verdade dessincronizadas. README (a refazer) também pode estar errado.
|
||||
**Fix:** padronizar; de preferência carregar o default de um único constante e fazer o docstring apontar para ele.
|
||||
|
||||
### I4 · `logging.basicConfig` no import do módulo — `server.py:83`
|
||||
|
||||
```python
|
||||
logging.basicConfig(level=os.environ.get("WHISPER_LOG_LEVEL", "INFO"), ...)
|
||||
```
|
||||
|
||||
Quem importa `server` (incluindo `cli/whisper`, testes, ferramentas) tem o root logger reconfigurado. O `basicConfig` do chamador (ex.: `parakeet_bootstrap.main` linha 312) é silenciosamente ignorado. Anti-pattern bem conhecido.
|
||||
**Fix:** remover `basicConfig` daqui; deixar o chamador (entry point) configurar, ou usar `logger.setLevel()` local.
|
||||
|
||||
### I5 · Health infla métrica `requests_success` antes de checar — `server.py:635`
|
||||
|
||||
```python
|
||||
def health() -> dict[str, Any]:
|
||||
METRICS.record_request("health", success=True) # otimismo prematuro
|
||||
payload = {}
|
||||
for path in ("/v1/models", "/health"):
|
||||
try: ...; except: continue
|
||||
if not payload:
|
||||
METRICS.record_request("health", success=False) # conta 2 requests!
|
||||
```
|
||||
|
||||
Incrementa `requests_total` duas vezes (uma com `success=True` e outra com `False`) em falha. Comparado a `transcribe_file` que faz o oposto. Inconsistência.
|
||||
**Fix:** não chamar `record_request` na entrada; decidir uma vez no fim.
|
||||
|
||||
### I6 · Duplicação `_write_wav` / `_write_wav_to` — `server.py:930` e `:1033`
|
||||
|
||||
Quase idênticos; diferença real é só o destino do `wave.open`. `_write_wav_to` ainda hardcoda `nchannels=1` (linha 1037) enquanto `_write_wav` aceita o param (linha 935) — `record_audio` não propaga `channels`.
|
||||
**Fix:** uma única função `_write_wav(pcm, sr, channels, dest: Path | None = None)`.
|
||||
|
||||
### I7 · Three `except Exception` sem narrowing — `server.py:764`, `:872`, `:984`
|
||||
|
||||
Engole `KeyboardInterrupt`, `SystemExit`, `MemoryError`. `# noqa: BLE001` é aceito mas mascara bugs reais. Em `record_audio` linha 984, o broad catch esconde erros de configuração do PyAudio.
|
||||
**Fix:** capturar `OSError`, `wave.Error`, `pyaudio.PyAudioError` (após import) e deixar `KeyboardInterrupt` subir.
|
||||
|
||||
### I8 · `sudo systemctl ...` silencioso — `parakeet_bootstrap.py:127-128`
|
||||
|
||||
```python
|
||||
_run(["sudo", "systemctl", "start", "docker"], check=False, timeout=30)
|
||||
_run(["sudo", "enable", "docker"], check=False, timeout=30)
|
||||
```
|
||||
|
||||
Se sudo pedir senha e o usuário não conseguir, falha silenciosa — próximo `_docker_daemon_running()` lança `RuntimeError` sem contexto. Idem para `winget install` (linha 107) com timeout 600s sem feedback.
|
||||
**Fix:** checar `returncode` e emitir mensagem acionável (ex.: "senha sudo necessária; rode manualmente").
|
||||
|
||||
### I9 · `_is_private_host` usa só primeira resposta do `getaddrinfo` indiretamente — `server.py:354`
|
||||
|
||||
```python
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
```
|
||||
|
||||
Percorre todos os IPs do `getaddrinfo` (OK), mas se a chamada for lenta ela acontece em todo `transcribe_url`. Pode cachear por `host` (com TTL curto) — performance, não correção.
|
||||
**Fix:** opcional, `@functools.lru_cache(maxsize=1024)` com TTL via `time.monotonic`. Apenas nice-to-have.
|
||||
|
||||
### I10 · `parakeet_bootstrap` mistura `Optional`/`List` com PEP 604 — `parakeet_bootstrap.py:20`
|
||||
|
||||
`from __future__ import annotations` está presente, então `Optional[str]` é coerente com `str | None` em server.py. Não é bug, mas inconsistência estilística.
|
||||
**Fix:** decidir um estilo. `list[str]`, `str | None` é o padrão moderno.
|
||||
|
||||
### I11 · `cache_key` lê o arquivo inteiro do disco para cada request — `server.py:624`
|
||||
|
||||
```python
|
||||
def _cache_key(...): data = file_path.read_bytes()
|
||||
```
|
||||
|
||||
Para um WAV de 25 MB a cada chamada, mesmo se o cache for hit. Aceitável para hit (mas é miss porque hit é verificado **antes** — então ok), porém no caminho de miss é relido e ainda reenviado em `_post_multipart` (linha 538). O `_post_multipart` poderia receber `data` cacheado em memória em vez de re-ler do disco.
|
||||
**Fix:** opcional — passar bytes em vez de path.
|
||||
|
||||
### I12 · TOCTOU em `transcribe_file` — `server.py:700-716`
|
||||
|
||||
```python
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
if not file_path.is_file(): ...
|
||||
# mais tarde:
|
||||
data = file_path.read_bytes()
|
||||
```
|
||||
|
||||
Janela de race entre `is_file()` e `read_bytes()`. Impacto baixo (input do operador), mas é padrão. Também: `Path.resolve()` em Windows pode quebrar em symlinks de rede.
|
||||
**Fix:** opcional; abrir e validar com `try: open(...)` direto.
|
||||
|
||||
### I13 · Limite hardcoded de 600s em `_record_audio_pyaudio` — `server.py:901`
|
||||
|
||||
Magic number. `record_audio` (linha 949) aceita `seconds` mas passa a um validador de teto de 600. Inconsistente com o resto do módulo que prefere env-vars.
|
||||
**Fix:** `WHISPER_RECORD_MAX_SECONDS` env var.
|
||||
|
||||
### I14 · Magic numbers no VAD — `server.py:428, :454, :472, :482`
|
||||
|
||||
`0.030` (janela 30ms), `32767.0` (peak int16), `30` (ms hardcoded para janela) aparecem em vários lugares. O valor `30` é duplicado nas linhas 472, 475, 482 (são ms-por-janela). Refatorar para `WINDOW_MS = 30` constante.
|
||||
**Fix:** extrair constantes.
|
||||
|
||||
### I15 · Subprocess `check=True` em comandos idempotentes — `parakeet_bootstrap.py:97, :166, :187, :195`
|
||||
|
||||
Para `docker inspect` (existência), falha com `CalledProcessError` é informação, não erro. O código captura certo, mas usar `subprocess.CompletedProcess` com `check=False` e checar `returncode` é mais barato e idiomático.
|
||||
**Fix:** cosmético.
|
||||
|
||||
---
|
||||
|
||||
## MINOR
|
||||
|
||||
### M1 · Comentários-mortos imports — `parakeet_bootstrap.py:12, :19`
|
||||
|
||||
```python
|
||||
import logging
|
||||
# (os removido)
|
||||
import platform
|
||||
# (Path removido)
|
||||
```
|
||||
|
||||
**Fix:** deletar.
|
||||
|
||||
### M2 · `pass # OK` — `parakeet_bootstrap.py:273`
|
||||
|
||||
```python
|
||||
if _is_windows() or _linux_distro() is not None or platform.system() == "Linux":
|
||||
pass # OK
|
||||
```
|
||||
|
||||
Lógica tautológica: as três condições equivalem a `platform.system() in {"Windows","Linux"}` — a chamada de `_linux_distro()` é gratuita e a última condição é estritamente mais ampla. Refatorar.
|
||||
|
||||
### M3 · `_emit` ramifica `if cb: ... if not cb: ...` — `parakeet_bootstrap.py:46-54`
|
||||
|
||||
Pode ser linear: `if cb: try call; else: log`. Reduz duas branches.
|
||||
|
||||
### M4 · `LRUCache.put` log "cache evicted" não remove arquivo — `server.py:306`
|
||||
|
||||
Atualmente só remove do índice; se evoluir para cachear áudio no disco, o log vira mentira. Documentar ou remover até a evolução.
|
||||
|
||||
### M5 · `_validate_url` retorna `dict | None` — `server.py:375`
|
||||
|
||||
Caller precisa de `if err := _validate_url(...)`. Mais idiomático levantar `WhisperError` ou usar `Result`. Cosmético.
|
||||
|
||||
### M6 · `record_audio` sem `transcribe_file`-style error handler unificado — `server.py:948-1030`
|
||||
|
||||
Três tools com três padrões diferentes de captura de métrica + error wrapping (linhas 697, 814, 969). Considerar decorator `@track_metrics("tool_name")` que envolve a chamada.
|
||||
**Fix:** refactor — único `with metrics.track("tool"):` helper.
|
||||
|
||||
### M7 · `health` expõe `default_model` e `base_url` mesmo em falha — `server.py:648-654`
|
||||
|
||||
Em ok=False não há razão para esconder, mas considere suprimir `models: []` vazio (linha 671) — fica confuso.
|
||||
|
||||
### M8 · `clamp` ausente em `sample_rate` — `server.py:954, :980`
|
||||
|
||||
`record_audio(sample_rate=0)` passa direto para `pyaudio.open(rate=0)` que retorna erro não-amigável. Validar `sample_rate > 0` e ≤ 48000.
|
||||
|
||||
### M9 · `cli/whisper` sem shebang funcional cross-platform — `cli/whisper:1`
|
||||
|
||||
`#!/usr/bin/env python3` não funciona com o launcher `.exe`/`.bat` em Windows. Já que o README vai ser refeito, mencionar uso via `python cli/whisper`.
|
||||
|
||||
### M10 · Teste `test_linux_distro_*` mocka `builtins.open` globalmente — `tests/test_parakeet_bootstrap.py:38-51`
|
||||
|
||||
Em Python 3.12+ `pathlib.Path` usa `io.open` que pode não ser capturado por `builtins.open` dependendo do monkeypatch. Hoje funciona, mas é frágil. Use `mock.patch("builtins.open", ...)` com `create=True` ou `mock.patch.object(pb, "open", ...)`.
|
||||
|
||||
### M11 · Falta teste de `_wait_healthy` (timeout) — `tests/test_parakeet_bootstrap.py`
|
||||
|
||||
A função existe (linha 224) mas não tem teste para o caso de timeout — exatamente o cenário de maior risco de produção.
|
||||
|
||||
### M12 · Cache não tem método `close()` / cleanup de temp — `server.py:254`
|
||||
|
||||
Se processo for SIGKILL, `index.json` pode ficar corrompido. Adicionar `try/except` no write (já tem) e validar JSON no load (já tem). OK, mas considerar `fcntl.flock` no índice para multi-processo.
|
||||
|
||||
### M13 · Sem `__all__` em nenhum módulo
|
||||
|
||||
Não é bug, mas o módulo `server` exporta ~15 símbolos públicos. Adicionar `__all__` ajuda tooling e leitores.
|
||||
|
||||
### M14 · Tipos: `dict` sem parametrização — `server.py:636`, `:213`
|
||||
|
||||
`payload: dict = {}` em vez de `dict[str, Any]`. Idem `result: dict = {}`. `snapshot() -> dict[str, Any]` está ok. Cosmético.
|
||||
|
||||
### M15 · `_emit` engole exception do callback sem logar — `parakeet_bootstrap.py:50`
|
||||
|
||||
```python
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
```
|
||||
|
||||
Pelo menos um `logger.debug` aqui para diagnóstico. Está silencioso.
|
||||
|
||||
---
|
||||
|
||||
## Veredito
|
||||
|
||||
**NÃO pronto para próxima fase** sem corrigir C1–C5 (1-2 horas de trabalho total). A base de código é sólida e idiomática, mas tem um punhado de buratos críticos de segurança (SSRF redirect + DNS rebinding, RCE via curl|sh) e um bug de corrupção de arquivo que pode perder dados do usuário.
|
||||
|
||||
**Prioridades de correção:**
|
||||
|
||||
1. **C1 + C2** (SSRF) — mesma PR; o `_validate_url` precisa virar um guard revalidado por hop.
|
||||
2. **C3** (curl|sh) — substituir por download → checksum → exec, ou apenas instruções manuais.
|
||||
3. **C4** (dead write) — one-liner, remover linha 996.
|
||||
4. **C5** (FD leak) — `os.close(fd)` ou trocar API.
|
||||
5. **I1** (testes do server) — bloquear próxima fase sem pelo menos testes de `_is_private_host` e `_post_multipart` mockado.
|
||||
|
||||
**Não-bloqueantes (I2–I15):** podem entrar como follow-ups em PRs separados, idealmente junto com I6 (DRY) e I5 (métricas).
|
||||
|
||||
**Boas práticas que vale destacar:**
|
||||
|
||||
- Thread-safety em `Metrics` e `LRUCache` está correta (lock por seção crítica).
|
||||
- Retry com backoff exponencial no 5xx (linha 590).
|
||||
- `TemporaryDirectory` com cleanup automático em `transcribe_url` (linha 829).
|
||||
- Idempotência real do `parakeet_bootstrap` (testada e correta).
|
||||
- Tipagem progressiva (`from __future__ import annotations`, `dict[str, Any]`).
|
||||
- Tratamento de `urllib.error` separado para `HTTPError` vs `URLError`.
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Debug mcp.json by counting braces"""
|
||||
import sys
|
||||
|
||||
with open('C:/Users/Evandro/.pi/agent/mcp.json', 'rb') as f:
|
||||
raw = f.read().decode('utf-8')
|
||||
|
||||
# Count braces not in strings
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for i, c in enumerate(raw):
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if c == '\\':
|
||||
escape = True
|
||||
continue
|
||||
if c == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if c == '{':
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
|
||||
print(f'Final depth: {depth}')
|
||||
# Find chrome-devtools and check if its block is properly closed
|
||||
idx_cd = raw.find('"chrome-devtools"')
|
||||
print(f'chrome-devtools at: {idx_cd}')
|
||||
# Show the block from chrome-devtools
|
||||
end = raw.find('"id": "speaches"', 0)
|
||||
if end == -1:
|
||||
end = len(raw)
|
||||
# Find where the whisper block ENDS
|
||||
idx_w = raw.find('"whisper"')
|
||||
print(f'whisper at: {idx_w}')
|
||||
# Show lines around line 140
|
||||
lines = raw.split('\n')
|
||||
for i in range(max(0, 135), min(len(lines), len(lines))):
|
||||
print(f'{i+1}: {lines[i]}')
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Just rewrite the mcp.json cleanly with the new whisper path"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path = 'C:/Users/Evandro/.pi/agent/mcp.json'
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
raw = f.read()
|
||||
|
||||
# Find the whisper block
|
||||
start_marker = '"whisper": {'
|
||||
start_idx = raw.find(start_marker)
|
||||
if start_idx == -1:
|
||||
print("Not found")
|
||||
sys.exit(1)
|
||||
|
||||
# Find the matching closing brace
|
||||
depth = 0
|
||||
i = start_idx + len(start_marker) - 1
|
||||
in_string = False
|
||||
escape = False
|
||||
while i < len(raw):
|
||||
c = raw[i]
|
||||
if escape:
|
||||
escape = False
|
||||
i += 1
|
||||
continue
|
||||
if c == chr(92): # backslash
|
||||
escape = True
|
||||
i += 1
|
||||
continue
|
||||
if c == '"':
|
||||
in_string = not in_string
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
i += 1
|
||||
continue
|
||||
if c == '{':
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end_idx = i + 1
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
print("No matching close brace found")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"start={start_idx}, end={end_idx}")
|
||||
new_whisper = '"whisper": {\n "command": "C:\\\\Users\\\\Evandro\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe",\n "args": [\n "D:/Documents/GitHub/whisper-mcp/server.py"\n ],\n "description": "Whisper transcription via local Speaches/Whisper (faster-whisper-large-v3-turbo). v2.0 with SSRF guard, size limit, retry, structured errors.",\n "timeout": 180,\n "lifecycle": "lazy",\n "idleTimeout": 10,\n "directTools": true,\n "environment": {\n "WHISPER_BASE_URL": "http://localhost:8000",\n "WHISPER_MODEL": "deepdml/faster-whisper-large-v3-turbo-ct2",\n "WHISPER_TIMEOUT": "120",\n "WHISPER_MAX_BYTES": "26214400"\n }\n }'
|
||||
new_raw = raw[:start_idx] + new_whisper + raw[end_idx:]
|
||||
try:
|
||||
parsed = json.loads(new_raw)
|
||||
print("JSON valid")
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_raw)
|
||||
print("Saved")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Still invalid: {e}")
|
||||
start = max(0, e.pos - 100)
|
||||
end = min(len(new_raw), e.pos + 100)
|
||||
print(f"Context: {new_raw[start:end]!r}")
|
||||
Reference in new issue
Block a user