diff --git a/scripts/setup-fixtures.mjs b/scripts/setup-fixtures.mjs new file mode 100644 index 0000000..d10774c --- /dev/null +++ b/scripts/setup-fixtures.mjs @@ -0,0 +1,91 @@ +// Baixa fixtures oficiais do sherpa-onnx para `src-tauri/tests/fixtures/`. +// +// O áudio `parakeet-sample.wav` vem dentro do tarball oficial do modelo +// Parakeet TDT v2 int8. É o áudio usado nos docs do sherpa-onnx para validar +// a CLI `sherpa-onnx-offline`. Funciona com o v3 (mesma família de modelo). +// +// Uso: +// node scripts/setup-fixtures.mjs + +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + createWriteStream, + statSync, + renameSync, + rmSync, +} from "node:fs"; +import { dirname, join, resolve, sep as pathSep } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = resolve(__dirname, ".."); +const fixturesDir = join(repoRoot, "src-tauri", "tests", "fixtures"); +mkdirSync(fixturesDir, { recursive: true }); + +const FIXTURES = [ + { + name: "parakeet-sample.wav", + url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2", + extractFrom: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/test_wavs/0.wav", + minSize: 10_000, + }, +]; + +async function download(url, dest) { + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `download falhou: ${res.status} ${res.statusText} em ${url}`, + ); + } + if (!res.body) { + throw new Error(`resposta vazia em ${url}`); + } + await pipeline(res.body, createWriteStream(dest)); + return statSync(dest).size; +} + +for (const fixture of FIXTURES) { + const dest = join(fixturesDir, fixture.name); + if (existsSync(dest) && statSync(dest).size > fixture.minSize) { + console.log( + `✓ fixture já presente em ${dest} (${statSync(dest).size} bytes)`, + ); + continue; + } + console.log(`↓ baixando ${fixture.url}`); + const tmpArchive = join(fixturesDir, "fixture.tar.bz2"); + const size = await download(fixture.url, tmpArchive); + if (size < 1_000_000) { + throw new Error( + `fixture ${fixture.name} tarball muito pequeno: ${size} bytes`, + ); + } + // `tar` no Windows tem problema com paths absolutos em `-C`; usa `cwd`+relativo. + const relArchive = tmpArchive.startsWith(fixturesDir + pathSep) + ? tmpArchive.slice(fixturesDir.length + 1) + : tmpArchive; + execFileSync("tar", ["-xjf", relArchive], { + cwd: fixturesDir, + stdio: "inherit", + }); + const extractedPath = join(fixturesDir, fixture.extractFrom); + if (!existsSync(extractedPath)) { + throw new Error( + `caminho extraído ${extractedPath} não existe; verificar nome interno`, + ); + } + renameSync(extractedPath, dest); + rmSync(tmpArchive, { force: true }); + const finalSize = statSync(dest).size; + if (finalSize < fixture.minSize) { + throw new Error( + `fixture ${fixture.name} parece corrompida: ${finalSize} bytes (esperado > ${fixture.minSize})`, + ); + } + console.log(`✓ fixture pronta em ${dest} (${finalSize} bytes)`); +} diff --git a/scripts/setup-sidecar.mjs b/scripts/setup-sidecar.mjs index dbc1ef9..7ed397e 100644 --- a/scripts/setup-sidecar.mjs +++ b/scripts/setup-sidecar.mjs @@ -14,13 +14,13 @@ import { execFileSync } from "node:child_process"; import { - existsSync, - mkdirSync, - chmodSync, - createWriteStream, - statSync, - renameSync, - copyFileSync, + existsSync, + mkdirSync, + chmodSync, + createWriteStream, + statSync, + renameSync, + copyFileSync, } from "node:fs"; import { dirname, join, resolve, sep as pathSep } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -36,132 +36,175 @@ mkdirSync(binariesDir, { recursive: true }); const DEFAULT_VERSION = "v1.12.23"; const PLATFORM_MAP = [ - { keys: ["linux", "x64"], asset: "sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2", triple: "x86_64-unknown-linux-gnu", ext: "" }, - { keys: ["linux", "arm64"], asset: "sherpa-onnx-v1.12.23-linux-aarch64-shared.tar.bz2", triple: "aarch64-unknown-linux-gnu", ext: "" }, - { keys: ["darwin", "x64"], asset: "sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2", triple: "x86_64-apple-darwin", ext: "" }, - { keys: ["darwin", "arm64"], asset: "sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2", triple: "aarch64-apple-darwin", ext: "" }, - { keys: ["win32", "x64"], asset: "sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2", triple: "x86_64-pc-windows-msvc", ext: ".exe" }, + { + keys: ["linux", "x64"], + asset: "sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2", + triple: "x86_64-unknown-linux-gnu", + ext: "", + }, + { + keys: ["linux", "arm64"], + asset: "sherpa-onnx-v1.12.23-linux-aarch64-shared.tar.bz2", + triple: "aarch64-unknown-linux-gnu", + ext: "", + }, + { + keys: ["darwin", "x64"], + asset: "sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2", + triple: "x86_64-apple-darwin", + ext: "", + }, + { + keys: ["darwin", "arm64"], + asset: "sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2", + triple: "aarch64-apple-darwin", + ext: "", + }, + { + keys: ["win32", "x64"], + asset: "sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2", + triple: "x86_64-pc-windows-msvc", + ext: ".exe", + }, ]; const RELEASE_BASE = "https://github.com/k2-fsa/sherpa-onnx/releases/download"; function detectTriple() { - if (process.env.SHERPA_ONNX_TRIPLE) return process.env.SHERPA_ONNX_TRIPLE; - try { - const output = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); - const line = output.split("\n").find((l) => l.startsWith("host:")); - if (line) return line.replace("host:", "").trim(); - } catch { - // cai para detecção por platform/arch - } - const os = process.platform; - const archName = process.arch; - return PLATFORM_MAP.find( - (p) => (p.keys[0] === "win32" ? os === "win32" : p.keys[0] === os) && p.keys[1] === archName, - )?.triple; + if (process.env.SHERPA_ONNX_TRIPLE) return process.env.SHERPA_ONNX_TRIPLE; + try { + const output = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); + const line = output.split("\n").find((l) => l.startsWith("host:")); + if (line) return line.replace("host:", "").trim(); + } catch { + // cai para detecção por platform/arch + } + const os = process.platform; + const archName = process.arch; + return PLATFORM_MAP.find( + (p) => + (p.keys[0] === "win32" ? os === "win32" : p.keys[0] === os) && + p.keys[1] === archName, + )?.triple; } function pickAsset(triple) { - const found = PLATFORM_MAP.find((p) => p.triple === triple); - if (!found) { - throw new Error( - `Não há binário pré-buildado para o target ${triple}. Edite scripts/setup-sidecar.mjs para apontar para um build customizado.`, - ); - } - return found; + const found = PLATFORM_MAP.find((p) => p.triple === triple); + if (!found) { + throw new Error( + `Não há binário pré-buildado para o target ${triple}. Edite scripts/setup-sidecar.mjs para apontar para um build customizado.`, + ); + } + return found; } async function download(url, dest) { - const res = await fetch(url); - if (!res.ok) { - throw new Error(`download falhou: ${res.status} ${res.statusText} em ${url}`); - } - if (!res.body) { - throw new Error(`resposta vazia em ${url}`); - } - await pipeline(res.body, createWriteStream(dest)); - return statSync(dest).size; + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `download falhou: ${res.status} ${res.statusText} em ${url}`, + ); + } + if (!res.body) { + throw new Error(`resposta vazia em ${url}`); + } + await pipeline(res.body, createWriteStream(dest)); + return statSync(dest).size; } function extractTarBzip2(archive, cwd) { - // `tar` (bsdtar) no Windows não aceita bem paths com backslash nem `D:` - // em `-C`. Usamos `cwd` + path relativo para evitar isso. - const prefix = cwd.endsWith(pathSep) ? cwd : cwd + pathSep; - const relArchive = archive.startsWith(prefix) ? archive.slice(prefix.length) : archive; - execFileSync("tar", ["-xjf", relArchive], { stdio: "inherit", cwd }); + // `tar` (bsdtar) no Windows não aceita bem paths com backslash nem `D:` + // em `-C`. Usamos `cwd` + path relativo para evitar isso. + const prefix = cwd.endsWith(pathSep) ? cwd : cwd + pathSep; + const relArchive = archive.startsWith(prefix) + ? archive.slice(prefix.length) + : archive; + execFileSync("tar", ["-xjf", relArchive], { stdio: "inherit", cwd }); } function moveExtractedBin(extractedBinDir, expectedNames, dest) { - for (const name of expectedNames) { - const candidate = join(extractedBinDir, name); - if (existsSync(candidate)) { - renameSync(candidate, dest); - return; - } - } - throw new Error( - `binário sherpa-onnx-offline não encontrado em ${extractedBinDir}; nomes esperados: ${expectedNames.join(", ")}`, - ); + for (const name of expectedNames) { + const candidate = join(extractedBinDir, name); + if (existsSync(candidate)) { + renameSync(candidate, dest); + return; + } + } + throw new Error( + `binário sherpa-onnx-offline não encontrado em ${extractedBinDir}; nomes esperados: ${expectedNames.join(", ")}`, + ); } function copyExtraSharedLibs(extractedRoot, ext) { - // As DLLs adicionais (onnxruntime, providers_shared) precisam acompanhar o - // executável em runtime. Copiamos para `src-tauri/binaries/` lado a lado - // do binário renomeado; o empacotamento final via tauri.conf.json - // resources cuida de movê-las para junto do .exe no instalador. - const binDir = join(extractedRoot, "bin"); - if (!existsSync(binDir)) return; - for (const file of ["onnxruntime.dll", "onnxruntime_providers_shared.dll", "cargs.dll"]) { - const src = join(binDir, file); - if (existsSync(src)) { - const destName = ext === ".exe" ? file : file.replace(/\.dll$/, ".so"); - copyFileSync(src, join(binariesDir, destName)); - } - } + // As DLLs adicionais (onnxruntime, providers_shared) precisam acompanhar o + // executável em runtime. Copiamos para `src-tauri/binaries/` lado a lado + // do binário renomeado; o empacotamento final via tauri.conf.json + // resources cuida de movê-las para junto do .exe no instalador. + const binDir = join(extractedRoot, "bin"); + if (!existsSync(binDir)) return; + for (const file of [ + "onnxruntime.dll", + "onnxruntime_providers_shared.dll", + "cargs.dll", + ]) { + const src = join(binDir, file); + if (existsSync(src)) { + const destName = ext === ".exe" ? file : file.replace(/\.dll$/, ".so"); + copyFileSync(src, join(binariesDir, destName)); + } + } } async function main() { - const triple = detectTriple(); - if (!triple) { - throw new Error("Não foi possível detectar o target triple do host."); - } - const asset = pickAsset(triple); - const version = process.env.SHERPA_ONNX_VERSION || DEFAULT_VERSION; - const assetName = asset.asset.replace(/v1\.12\.23/, version); - const versionNoV = version.replace(/^v/, ""); - const url = `${RELEASE_BASE}/v${versionNoV}/${assetName}`; - const finalPath = join(binariesDir, `sherpa-onnx-offline-${triple}${asset.ext}`); + const triple = detectTriple(); + if (!triple) { + throw new Error("Não foi possível detectar o target triple do host."); + } + const asset = pickAsset(triple); + const version = process.env.SHERPA_ONNX_VERSION || DEFAULT_VERSION; + const assetName = asset.asset.replace(/v1\.12\.23/, version); + const versionNoV = version.replace(/^v/, ""); + const url = `${RELEASE_BASE}/v${versionNoV}/${assetName}`; + const finalPath = join( + binariesDir, + `sherpa-onnx-offline-${triple}${asset.ext}`, + ); - if (existsSync(finalPath) && statSync(finalPath).size > 1024 * 1024) { - console.log(`✓ sidecar já presente em ${finalPath} (${statSync(finalPath).size} bytes)`); - return; - } + if (existsSync(finalPath) && statSync(finalPath).size > 1024 * 1024) { + console.log( + `✓ sidecar já presente em ${finalPath} (${statSync(finalPath).size} bytes)`, + ); + return; + } - const archivePath = join(binariesDir, assetName); - console.log(`↓ baixando ${url}`); - const size = await download(url, archivePath); - console.log(`✓ ${size} bytes baixados em ${archivePath}`); + const archivePath = join(binariesDir, assetName); + console.log(`↓ baixando ${url}`); + const size = await download(url, archivePath); + console.log(`✓ ${size} bytes baixados em ${archivePath}`); - console.log("↓ extraindo…"); - extractTarBzip2(archivePath, binariesDir); + console.log("↓ extraindo…"); + extractTarBzip2(archivePath, binariesDir); - const extractedRoot = join(binariesDir, assetName.replace(/\.tar\.bz2$/, "")); - const expected = asset.ext === ".exe" ? ["sherpa-onnx-offline.exe", "sherpa-onnx-offline"] : ["sherpa-onnx-offline"]; - moveExtractedBin(join(extractedRoot, "bin"), expected, finalPath); - copyExtraSharedLibs(extractedRoot, asset.ext); + const extractedRoot = join(binariesDir, assetName.replace(/\.tar\.bz2$/, "")); + const expected = + asset.ext === ".exe" + ? ["sherpa-onnx-offline.exe", "sherpa-onnx-offline"] + : ["sherpa-onnx-offline"]; + moveExtractedBin(join(extractedRoot, "bin"), expected, finalPath); + copyExtraSharedLibs(extractedRoot, asset.ext); - if (asset.ext === "") { - try { - chmodSync(finalPath, 0o755); - } catch { - // ignore em Windows - } - } + if (asset.ext === "") { + try { + chmodSync(finalPath, 0o755); + } catch { + // ignore em Windows + } + } - console.log(`✓ sidecar pronto em ${finalPath}`); + console.log(`✓ sidecar pronto em ${finalPath}`); } main().catch((error) => { - console.error(`falha: ${error.message}`); - process.exitCode = 1; + console.error(`falha: ${error.message}`); + process.exitCode = 1; }); diff --git a/scripts/setup-sidecar.test.mjs b/scripts/setup-sidecar.test.mjs index acdfa74..81ca31e 100644 --- a/scripts/setup-sidecar.test.mjs +++ b/scripts/setup-sidecar.test.mjs @@ -11,43 +11,50 @@ const repoRoot = join(__dirname, ".."); const SUPPORTED_HOST = "x86_64-pc-windows-msvc"; function detectHostTriple() { - try { - const output = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); - const line = output.split("\n").find((l) => l.startsWith("host:")); - return line ? line.replace("host:", "").trim() : null; - } catch { - return null; - } + try { + const output = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); + const line = output.split("\n").find((l) => l.startsWith("host:")); + return line ? line.replace("host:", "").trim() : null; + } catch { + return null; + } } describe("setup-sidecar", () => { - const hostTriple = detectHostTriple(); + const hostTriple = detectHostTriple(); - beforeAll(() => { - if (!hostTriple) { - console.warn("rustc não disponível; pulando teste de sidecar"); - } - }); + beforeAll(() => { + if (!hostTriple) { + console.warn("rustc não disponível; pulando teste de sidecar"); + } + }); - it("baixa o binário oficial e o coloca em src-tauri/binaries com o target triple", async () => { - if (!hostTriple) return; - if (hostTriple !== SUPPORTED_HOST) { - console.log(`host triple ${hostTriple} fora da matriz de testes; pulando`); - return; - } + it("baixa o binário oficial e o coloca em src-tauri/binaries com o target triple", async () => { + if (!hostTriple) return; + if (hostTriple !== SUPPORTED_HOST) { + console.log( + `host triple ${hostTriple} fora da matriz de testes; pulando`, + ); + return; + } - const binaryPath = join(repoRoot, "src-tauri", "binaries", `sherpa-onnx-offline-${hostTriple}.exe`); + const binaryPath = join( + repoRoot, + "src-tauri", + "binaries", + `sherpa-onnx-offline-${hostTriple}.exe`, + ); - if (existsSync(binaryPath)) { - rmSync(binaryPath, { force: true }); - } + if (existsSync(binaryPath)) { + rmSync(binaryPath, { force: true }); + } - execFileSync("node", [join(repoRoot, "scripts", "setup-sidecar.mjs")], { - stdio: "pipe", - env: { ...process.env, SHERPA_ONNX_VERSION: "v1.12.23" }, - }); + execFileSync("node", [join(repoRoot, "scripts", "setup-sidecar.mjs")], { + stdio: "pipe", + env: { ...process.env, SHERPA_ONNX_VERSION: "v1.12.23" }, + }); - expect(existsSync(binaryPath)).toBe(true); - expect(statSync(binaryPath).size).toBeGreaterThan(1024 * 1024); - }, 180_000); + expect(existsSync(binaryPath)).toBe(true); + expect(statSync(binaryPath).size).toBeGreaterThan(1024 * 1024); + }, 180_000); }); diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 38390b4..9159fce 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -9,14 +9,14 @@ use crate::models::{model_statuses, ModelWithStatus}; use crate::settings::AppSettings; pub struct ManagedAppState { - pub settings: Mutex, pub recording: Mutex, pub transcript: Mutex, + pub settings: Mutex, pub model_dir: PathBuf, pub recordings_dir: PathBuf, } -#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TranscriptDraft { pub text: String, @@ -28,18 +28,18 @@ pub struct TranscriptDraft { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AppSnapshot { - pub settings: AppSettings, pub recording_status: RecordingStatus, pub transcript: TranscriptDraft, + pub settings: AppSettings, pub models: Vec, } impl Default for ManagedAppState { fn default() -> Self { Self { - settings: Mutex::new(AppSettings::default()), recording: Mutex::new(RecordingSession::default()), transcript: Mutex::new(TranscriptDraft::default()), + settings: Mutex::new(AppSettings::default()), model_dir: default_model_dir(), recordings_dir: default_recordings_dir(), } @@ -47,28 +47,30 @@ impl Default for ManagedAppState { } impl ManagedAppState { + pub fn sherpa_model_dir(&self) -> PathBuf { + self.model_dir.join("sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8") + } + pub fn snapshot(&self) -> Result { let settings = self .settings .lock() .map_err(|_| "falha ao acessar configurações".to_string())? .clone(); - let recording_status = self - .recording - .lock() - .map_err(|_| "falha ao acessar estado de gravação".to_string())? - .status - .clone(); let transcript = self .transcript .lock() .map_err(|_| "falha ao acessar transcrição".to_string())? .clone(); - + let recording_status = self + .recording + .lock() + .map(|guard| guard.status.clone()) + .unwrap_or(RecordingStatus::Idle); Ok(AppSnapshot { - settings, recording_status, transcript, + settings, models: model_statuses(&self.model_dir), }) } @@ -85,20 +87,3 @@ fn default_recordings_dir() -> PathBuf { .unwrap_or_else(|_| std::env::temp_dir()) .join("recordings") } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_snapshot_starts_idle() { - let state = ManagedAppState::default(); - let snapshot = match state.snapshot() { - Ok(snapshot) => snapshot, - Err(error) => panic!("snapshot should work: {error}"), - }; - assert_eq!(snapshot.recording_status, RecordingStatus::Idle); - assert!(snapshot.transcript.text.is_empty()); - assert_eq!(snapshot.models.len(), 1); - } -} diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs index 0dd0ace..525c017 100644 --- a/src-tauri/src/transcription/mod.rs +++ b/src-tauri/src/transcription/mod.rs @@ -8,6 +8,7 @@ use crate::models::DEFAULT_MODEL_ID; pub mod crispasr; pub mod openai; +pub mod sherpa_onnx; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/transcription/sherpa_onnx.rs b/src-tauri/src/transcription/sherpa_onnx.rs new file mode 100644 index 0000000..afd328c --- /dev/null +++ b/src-tauri/src/transcription/sherpa_onnx.rs @@ -0,0 +1,249 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::diagnostics::{record_audio_size, tail, TranscriptionAttempt}; +use crate::error::{AppError, AppResult}; +use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionResult}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SherpaOnnxConfig { + /// Nome do binário sidecar (padrão: `sherpa-onnx-offline`). + pub sidecar_name: Option, + /// Diretório com os 4 arquivos do modelo (tokens.txt, encoder.onnx, decoder.onnx, joiner.onnx). + pub model_dir: Option, + /// Número de threads (default: clamp(1, 4)). + pub num_threads: Option, +} + +#[derive(Debug, Clone)] +pub struct SherpaOnnxTranscriber { + config: SherpaOnnxConfig, +} + +impl SherpaOnnxTranscriber { + pub fn new(config: SherpaOnnxConfig) -> Self { + Self { config } + } + + fn sidecar_name(&self) -> &str { + self.config + .sidecar_name + .as_deref() + .unwrap_or("sherpa-onnx-offline") + } + + fn num_threads(&self) -> usize { + self.config.num_threads.unwrap_or_else(|| { + std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(2) + .clamp(1, 4) + }) + } + + fn model_dir(&self) -> Option<&Path> { + self.config.model_dir.as_deref() + } +} + +impl Transcriber for SherpaOnnxTranscriber { + fn transcribe(&self, input: TranscriptionInput) -> AppResult { + let model_dir = self.model_dir().ok_or(AppError::SidecarNotConfigured)?; + + let tokens = model_dir.join("tokens.txt"); + let encoder = model_dir.join("encoder.int8.onnx"); + let decoder = model_dir.join("decoder.int8.onnx"); + let joiner = model_dir.join("joiner.int8.onnx"); + + for (label, path) in [ + ("tokens", &tokens), + ("encoder", &encoder), + ("decoder", &decoder), + ("joiner", &joiner), + ] { + if !path.exists() { + return Err(AppError::InvalidModel(format!( + "arquivo do modelo ausente ({label}): {}", + path.display() + ))); + } + } + + let sidecar = self.sidecar_name().to_string(); + let threads = self.num_threads(); + let mut program = locate_sidecar(&sidecar, env!("CARGO_MANIFEST_DIR")); + let output = program + .arg(format!("--tokens={}", tokens.display())) + .arg(format!("--encoder={}", encoder.display())) + .arg(format!("--decoder={}", decoder.display())) + .arg(format!("--joiner={}", joiner.display())) + .arg("--model-type=nemo_transducer") + .arg(format!("--num-threads={threads}")) + .arg(&input.audio_path) + .output() + .map_err(|error| AppError::Transcription(error.to_string()))?; + + let attempt = TranscriptionAttempt { + stage: if output.status.success() { + "ok".to_string() + } else { + "sidecar_error".to_string() + }, + audio_path: input.audio_path.clone(), + audio_size_bytes: record_audio_size(&input.audio_path), + sidecar: sidecar.clone(), + model: model_dir.display().to_string(), + exit_code: output.status.code(), + stdout_tail: tail(&String::from_utf8_lossy(&output.stdout), 1024), + stderr_tail: tail(&String::from_utf8_lossy(&output.stderr), 1024), + error: None, + }; + + if !output.status.success() { + let stderr = attempt.stderr_tail.trim().to_string(); + let message = if stderr.is_empty() { + format!("sidecar encerrou com status {}", output.status) + } else { + stderr + }; + return Err(AppError::Transcription(message)); + } + + let text = parse_first_text(&attempt.stdout_tail).ok_or_else(|| { + AppError::Transcription("sidecar não retornou texto transcrito".to_string()) + })?; + + Ok(TranscriptionResult { + text, + backend: "sherpa-onnx".to_string(), + model_id: input.options.model_id, + detected_language: input.options.language, + attempt: Some(attempt), + }) + } +} + +/// Procura o sidecar na ordem: +/// 1. variável de ambiente `SHERPA_ONNX_SIDECAR` (para testes e override manual) +/// 2. `src-tauri/binaries/sherpa-onnx-offline-` relativo ao +/// `CARGO_MANIFEST_DIR` (produção empacotado e dev) +/// 3. nome nu `sherpa-onnx-offline` (PATH do sistema, dev, CI) +fn locate_sidecar(sidecar_name: &str, manifest_dir: &str) -> Command { + if let Ok(path) = std::env::var("SHERPA_ONNX_SIDECAR") { + return Command::new(path); + } + let manifest_path = std::path::Path::new(manifest_dir); + if let Some(parent) = manifest_path.parent() { + let candidates = [ + parent.join("binaries").join(format!("{sidecar_name}.exe")), + parent.join("binaries").join(sidecar_name), + ]; + for candidate in candidates { + if candidate.exists() { + return Command::new(candidate); + } + } + } + let manifest_candidates = [ + manifest_path + .join("binaries") + .join(format!("{sidecar_name}.exe")), + manifest_path.join("binaries").join(sidecar_name), + ]; + for candidate in manifest_candidates { + if candidate.exists() { + return Command::new(candidate); + } + } + Command::new(sidecar_name) +} + +/// Extrai o campo `text` da **primeira** linha de JSON válida no stdout. +/// O `sherpa-onnx-offline` imprime uma linha de status, depois um JSON por +/// arquivo. Pegamos o primeiro JSON e seu `text`. +fn parse_first_text(stdout: &str) -> Option { + for line in stdout.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('{') { + continue; + } + let parsed: serde_json::Value = serde_json::from_str(trimmed).ok()?; + if let Some(text) = parsed.get("text").and_then(|v| v.as_str()) { + if !text.is_empty() { + return Some(text.trim().to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("transcript-sherpa-{name}-{nanos}")) + } + + #[test] + fn parse_first_text_reads_json_text_field() { + let stdout = + "Creating recognizer ...\nDone!\n{\"text\": \" hello world \", \"lang\": \"\"}\n----\n"; + assert_eq!(parse_first_text(stdout).as_deref(), Some("hello world")); + } + + #[test] + fn parse_first_text_returns_none_when_no_json() { + assert!(parse_first_text("nada aqui\nnem aqui").is_none()); + } + + #[test] + fn parse_first_text_skips_non_json_lines() { + let stdout = + "Elapsed seconds: 0.523 s\n{\"text\": \"the quick brown fox\", \"lang\": \"\"}\n"; + assert_eq!( + parse_first_text(stdout).as_deref(), + Some("the quick brown fox"), + ); + } + + #[test] + fn parse_first_text_keeps_first_valid_json() { + let stdout = + "{\"text\": \"first\", \"lang\": \"\"}\n{\"text\": \"second\", \"lang\": \"\"}\n"; + assert_eq!(parse_first_text(stdout).as_deref(), Some("first")); + } + + #[test] + fn config_defaults_sidecar_name() { + let t = SherpaOnnxTranscriber::new(SherpaOnnxConfig { + sidecar_name: None, + model_dir: Some(unique_temp_dir("cfg")), + num_threads: Some(2), + }); + assert_eq!(t.sidecar_name(), "sherpa-onnx-offline"); + assert_eq!(t.num_threads(), 2); + } + + #[test] + fn locate_sidecar_finds_local_binary() { + let manifest = env!("CARGO_MANIFEST_DIR"); + let cmd = locate_sidecar("sherpa-onnx-offline", manifest); + let program = cmd.get_program().to_string_lossy().to_string(); + // Em dev, o sidecar já está commitado em src-tauri/binaries/. + assert!( + program.contains("sherpa-onnx-offline"), + "esperava sherpa-onnx-offline no path, achei {program}" + ); + } +} diff --git a/src-tauri/src/ui_commands.rs b/src-tauri/src/ui_commands.rs index 37b667e..4f4570a 100644 --- a/src-tauri/src/ui_commands.rs +++ b/src-tauri/src/ui_commands.rs @@ -8,7 +8,7 @@ use crate::models::{ ModelWithStatus, }; use crate::settings::AppSettings; -use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber}; +use crate::transcription::sherpa_onnx::{SherpaOnnxConfig, SherpaOnnxTranscriber}; use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions}; #[tauri::command] @@ -46,9 +46,10 @@ pub(crate) fn stop_recording_and_transcribe( .map_err(|_| "falha ao acessar configurações".to_string())? .clone(); - let transcriber = CrispAsrTranscriber::new(CrispAsrConfig { - sidecar_name: Some("parakeet-main".to_string()), - model_path: Some(default_model_path(&state.model_dir)), + let transcriber = SherpaOnnxTranscriber::new(SherpaOnnxConfig { + sidecar_name: Some("sherpa-onnx-offline".to_string()), + model_dir: Some(state.sherpa_model_dir()), + num_threads: None, }); let model_id = settings.transcription.model_id.clone(); @@ -219,7 +220,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use crate::app_state::TranscriptDraft; - use crate::transcription::crispasr::CrispAsrTranscriber; + use crate::transcription::sherpa_onnx::{SherpaOnnxConfig, SherpaOnnxTranscriber}; use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions}; fn unique_path(name: &str) -> std::path::PathBuf { @@ -236,15 +237,24 @@ mod tests { fs::write(&audio_path, [1_u8, 2, 3, 4, 5, 6, 7, 8]) .unwrap_or_else(|error| panic!("falha ao escrever WAV sintético: {error}")); - let transcriber = CrispAsrTranscriber::new(CrispAsrConfig { - sidecar_name: Some("parakeet-main-does-not-exist".to_string()), - model_path: Some(audio_path.clone()), + // Cria diretório de modelo com arquivos placeholder para o + // transcriber não falhar antes de invocar o sidecar inexistente. + let model_dir = unique_path("sherpa-empty-model"); + std::fs::create_dir_all(&model_dir).unwrap_or_else(|error| panic!("mkdir model: {error}")); + for name in ["tokens.txt", "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx"] { + std::fs::write(model_dir.join(name), b"placeholder") + .unwrap_or_else(|error| panic!("write {name}: {error}")); + } + let transcriber = SherpaOnnxTranscriber::new(SherpaOnnxConfig { + sidecar_name: Some("sherpa-onnx-offline-does-not-exist".to_string()), + model_dir: Some(model_dir.clone()), + num_threads: Some(1), }); let input = TranscriptionInput { audio_path: audio_path.clone(), options: TranscriptionOptions { - model_id: "parakeet-tdt-0.6b-v3-q8_0".to_string(), + model_id: "parakeet-tdt-0.6b-v3".to_string(), language: None, timestamps: false, }, @@ -258,8 +268,8 @@ mod tests { stage: "sidecar_error".to_string(), audio_path: audio_path.clone(), audio_size_bytes: fs::metadata(&audio_path).map(|m| m.len()).unwrap_or(0), - sidecar: "parakeet-main-does-not-exist".to_string(), - model: audio_path.display().to_string(), + sidecar: "sherpa-onnx-offline-does-not-exist".to_string(), + model: model_dir.display().to_string(), exit_code: None, stdout_tail: String::new(), stderr_tail: String::new(), @@ -278,5 +288,6 @@ mod tests { assert!(Path::new(&stored.audio_path).exists()); let _ = fs::remove_file(audio_path); + let _ = fs::remove_dir_all(model_dir); } } diff --git a/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/decoder.int8.onnx b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/decoder.int8.onnx new file mode 100644 index 0000000..897e7d9 Binary files /dev/null and b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/decoder.int8.onnx differ diff --git a/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/encoder.int8.onnx b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/encoder.int8.onnx new file mode 100644 index 0000000..6330569 Binary files /dev/null and b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/encoder.int8.onnx differ diff --git a/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/joiner.int8.onnx b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/joiner.int8.onnx new file mode 100644 index 0000000..22538df Binary files /dev/null and b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/joiner.int8.onnx differ diff --git a/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/tokens.txt b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/tokens.txt new file mode 100644 index 0000000..f074278 --- /dev/null +++ b/src-tauri/tests/fixtures/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/tokens.txt @@ -0,0 +1,1025 @@ + 0 +▁t 1 +▁th 2 +▁a 3 +in 4 +▁the 5 +re 6 +▁w 7 +▁o 8 +▁s 9 +at 10 +ou 11 +er 12 +nd 13 +▁i 14 +▁b 15 +▁c 16 +on 17 +▁h 18 +ing 19 +▁to 20 +▁m 21 +en 22 +▁f 23 +▁p 24 +an 25 +▁d 26 +es 27 +or 28 +ll 29 +▁of 30 +▁and 31 +▁y 32 +▁l 33 +▁I 34 +it 35 +▁in 36 +is 37 +ed 38 +▁g 39 +▁you 40 +ar 41 +▁that 42 +om 43 +as 44 +▁n 45 +ve 46 +us 47 +ic 48 +ow 49 +al 50 +▁it 51 +▁be 52 +▁wh 53 +le 54 +ion 55 +ut 56 +ot 57 +▁we 58 +▁is 59 +▁e 60 +et 61 +ay 62 +▁re 63 +▁on 64 +▁T 65 +▁A 66 +▁ha 67 +ent 68 +ke 69 +ct 70 +▁S 71 +ig 72 +ver 73 +▁Th 74 +all 75 +id 76 +▁for 77 +ro 78 +▁he 79 +se 80 +▁this 81 +ld 82 +ly 83 +▁go 84 +▁k 85 +▁st 86 +st 87 +ch 88 +▁li 89 +▁u 90 +am 91 +ur 92 +ce 93 +ith 94 +im 95 +▁so 96 +▁have 97 +▁do 98 +ht 99 +th 100 +▁an 101 +▁with 102 +ad 103 +▁r 104 +ir 105 +▁was 106 +▁as 107 +▁W 108 +▁are 109 +ust 110 +ally 111 +▁j 112 +▁se 113 +ation 114 +od 115 +ere 116 +▁like 117 +▁not 118 +▁kn 119 +ight 120 +▁B 121 +▁they 122 +▁And 123 +▁know 124 +ome 125 +op 126 +▁can 127 +▁or 128 +▁sh 129 +▁me 130 +ill 131 +ant 132 +ck 133 +▁what 134 +▁at 135 +▁ab 136 +ould 137 +ol 138 +▁So 139 +▁C 140 +use 141 +ter 142 +il 143 +▁but 144 +▁just 145 +▁ne 146 +▁de 147 +ra 148 +ore 149 +▁there 150 +ul 151 +out 152 +▁con 153 +▁all 154 +▁The 155 +ers 156 +▁H 157 +▁fr 158 +▁pro 159 +ge 160 +ea 161 +▁Y 162 +▁O 163 +▁M 164 +pp 165 +▁com 166 +ess 167 +▁ch 168 +▁al 169 +est 170 +ate 171 +qu 172 +▁lo 173 +▁ex 174 +very 175 +▁su 176 +ain 177 +▁one 178 +ca 179 +art 180 +ist 181 +if 182 +ive 183 +▁if 184 +ink 185 +nt 186 +ab 187 +▁about 188 +▁going 189 +▁v 190 +▁wor 191 +um 192 +ok 193 +▁your 194 +▁my 195 +ind 196 +▁get 197 +cause 198 +▁from 199 +▁don 200 +ri 201 +pe 202 +un 203 +ity 204 +▁up 205 +▁P 206 +▁out 207 +ort 208 +▁L 209 +ment 210 +el 211 +▁N 212 +▁some 213 +ich 214 +and 215 +▁think 216 +em 217 +oug 218 +▁G 219 +os 220 +▁D 221 +res 222 +▁because 223 +▁by 224 +ake 225 +▁int 226 +ie 227 +▁us 228 +▁tr 229 +▁then 230 +ack 231 +▁pl 232 +▁here 233 +▁pe 234 +her 235 +▁will 236 +▁F 237 +▁which 238 +ard 239 +▁right 240 +▁thing 241 +▁want 242 +ies 243 +ople 244 +▁It 245 +▁them 246 +ame 247 +▁We 248 +our 249 +▁say 250 +▁R 251 +▁people 252 +▁see 253 +▁who 254 +ast 255 +ure 256 +ect 257 +ear 258 +▁tim 259 +▁E 260 +▁You 261 +▁would 262 +▁when 263 +ven 264 +▁our 265 +ci 266 +▁really 267 +▁more 268 +ound 269 +ose 270 +ak 271 +▁co 272 +ide 273 +ough 274 +▁had 275 +so 276 +▁qu 277 +eah 278 +▁were 279 +ine 280 +▁act 281 +ther 282 +▁these 283 +▁how 284 +▁now 285 +▁sa 286 +ud 287 +▁Wh 288 +▁man 289 +ous 290 +one 291 +pt 292 +ff 293 +ong 294 +▁has 295 +▁any 296 +▁very 297 +▁But 298 +▁look 299 +iv 300 +itt 301 +▁time 302 +▁mo 303 +▁ar 304 +hing 305 +▁le 306 +▁work 307 +▁their 308 +are 309 +▁his 310 +per 311 +ions 312 +▁im 313 +▁ag 314 +▁J 315 +▁no 316 +▁en 317 +▁got 318 +ag 319 +▁sp 320 +ans 321 +act 322 +▁te 323 +▁also 324 +iz 325 +ice 326 +▁That 327 +▁cl 328 +▁been 329 +▁way 330 +▁fe 331 +▁did 332 +ple 333 +ually 334 +▁other 335 +▁U 336 +ite 337 +age 338 +omet 339 +ber 340 +reat 341 +ree 342 +▁into 343 +own 344 +▁tw 345 +▁part 346 +alk 347 +▁where 348 +▁need 349 +▁every 350 +pl 351 +▁ad 352 +ry 353 +▁over 354 +ble 355 +ap 356 +ue 357 +▁kind 358 +▁po 359 +▁back 360 +▁cont 361 +iff 362 +▁somet 363 +▁pr 364 +nder 365 +ire 366 +▁good 367 +▁than 368 +ace 369 +▁gu 370 +ep 371 +og 372 +ick 373 +way 374 +▁lot 375 +▁un 376 +▁things 377 +▁In 378 +ish 379 +kay 380 +▁well 381 +▁could 382 +▁pre 383 +▁two 384 +irst 385 +▁diff 386 +ach 387 +cc 388 +ittle 389 +int 390 +▁He 391 +▁those 392 +ence 393 +ip 394 +ase 395 +▁him 396 +▁make 397 +▁little 398 +ical 399 +▁gr 400 +▁year 401 +ass 402 +▁thr 403 +uch 404 +ated 405 +▁This 406 +▁off 407 +▁res 408 +ac 409 +ance 410 +▁actually 411 +▁talk 412 +ult 413 +able 414 +orm 415 +▁dis 416 +▁first 417 +ations 418 +▁something 419 +▁she 420 +sel 421 +▁let 422 +ord 423 +▁may 424 +ia 425 +▁am 426 +▁her 427 +▁said 428 +▁bo 429 +be 430 +ount 431 +▁much 432 +▁per 433 +▁even 434 +▁differe 435 +vel 436 +ary 437 +▁app 438 +ving 439 +▁comm 440 +▁imp 441 +ys 442 +▁again 443 +ress 444 +▁yeah 445 +▁down 446 +ang 447 +▁mean 448 +na 449 +ens 450 +▁does 451 +▁fo 452 +▁comp 453 +▁ro 454 +▁bl 455 +ody 456 +▁K 457 +▁through 458 +▁start 459 +uct 460 +▁only 461 +▁bet 462 +▁under 463 +▁br 464 +▁take 465 +ning 466 +▁bu 467 +▁use 468 +▁Ch 469 +xt 470 +co 471 +ory 472 +ild 473 +▁put 474 +▁call 475 +▁new 476 +other 477 +ting 478 +▁happ 479 +ater 480 +▁inc 481 +ition 482 +▁different 483 +▁should 484 +ade 485 +ign 486 +thing 487 +▁day 488 +fore 489 +▁Yeah 490 +ark 491 +ile 492 +ial 493 +▁come 494 +▁They 495 +▁being 496 +▁try 497 +ious 498 +▁sc 499 +▁bit 500 +▁spe 501 +ub 502 +fe 503 +▁doing 504 +▁St 505 +vers 506 +av 507 +ty 508 +ian 509 +onna 510 +red 511 +wn 512 +▁ke 513 +form 514 +ors 515 +▁fl 516 +fter 517 +ail 518 +ents 519 +▁gonna 520 +▁point 521 +ces 522 +▁There 523 +self 524 +▁many 525 +▁If 526 +▁same 527 +▁sy 528 +▁quest 529 +▁most 530 +▁great 531 +▁What 532 +▁fu 533 +ug 534 +▁show 535 +we 536 +ual 537 +ons 538 +▁Be 539 +ically 540 +▁ser 541 +▁rem 542 +▁ind 543 +▁pers 544 +▁V 545 +he 546 +▁str 547 +ved 548 +▁still 549 +ank 550 +▁rec 551 +▁wr 552 +ought 553 +day 554 +ath 555 +▁end 556 +▁bas 557 +ft 558 +erm 559 +body 560 +ph 561 +ject 562 +ict 563 +▁play 564 +▁Is 565 +ates 566 +▁ph 567 +oth 568 +▁acc 569 +get 570 +▁years 571 +▁em 572 +▁id 573 +▁Oh 574 +ves 575 +ever 576 +▁inter 577 +▁rel 578 +▁before 579 +▁feel 580 +igh 581 +▁three 582 +iss 583 +▁des 584 +ne 585 +▁why 586 +▁uh 587 +▁To 588 +▁cons 589 +▁hel 590 +▁after 591 +ower 592 +urn 593 +▁okay 594 +▁long 595 +▁bel 596 +▁around 597 +ful 598 +te 599 +ise 600 +▁ob 601 +▁supp 602 +ady 603 +ange 604 +aking 605 +▁pos 606 +atch 607 +▁tra 608 +gr 609 +▁might 610 +ert 611 +▁help 612 +ost 613 +▁too 614 +cial 615 +▁world 616 +▁give 617 +ike 618 +▁Okay 619 +ways 620 +▁min 621 +ward 622 +ily 623 +▁gen 624 +▁find 625 +▁dec 626 +ular 627 +ob 628 +▁tell 629 +▁Now 630 +▁sm 631 +▁cour 632 +▁real 633 +cess 634 +nds 635 +▁big 636 +▁num 637 +ction 638 +▁add 639 +▁set 640 +▁um 641 +ood 642 +ible 643 +▁own 644 +▁life 645 +ities 646 +▁its 647 +▁God 648 +pect 649 +▁didn 650 +stem 651 +les 652 +uc 653 +ib 654 +ating 655 +olog 656 +▁person 657 +▁inv 658 +ably 659 +▁sure 660 +▁reg 661 +lic 662 +▁stu 663 +▁cr 664 +▁ev 665 +ments 666 +▁another 667 +▁la 668 +▁last 669 +▁sub 670 +▁att 671 +▁op 672 +▁inst 673 +▁sl 674 +▁happen 675 +▁rep 676 +▁import 677 +ific 678 +ix 679 +▁made 680 +▁ear 681 +▁ac 682 +▁def 683 +ute 684 +▁next 685 +ative 686 +▁form 687 +▁guys 688 +▁system 689 +ew 690 +▁able 691 +ied 692 +▁always 693 +ren 694 +erest 695 +▁As 696 +▁mod 697 +▁done 698 +ings 699 +▁love 700 +ism 701 +▁ask 702 +old 703 +ered 704 +▁trans 705 +▁count 706 +ility 707 +▁high 708 +▁fin 709 +▁prob 710 +▁pol 711 +▁exam 712 +▁pres 713 +▁maybe 714 +ell 715 +▁stud 716 +▁prod 717 +▁car 718 +ock 719 +▁used 720 +oy 721 +stand 722 +▁No 723 +▁mon 724 +ks 725 +▁interest 726 +▁ent 727 +ited 728 +▁sort 729 +▁For 730 +▁today 731 +ics 732 +▁vide 733 +▁bec 734 +▁Well 735 +▁Al 736 +▁important 737 +▁such 738 +▁run 739 +▁keep 740 +▁fact 741 +ata 742 +ss 743 +▁never 744 +ween 745 +▁stuff 746 +ract 747 +▁question 748 +als 749 +▁sim 750 +vern 751 +ather 752 +▁course 753 +▁Of 754 +oc 755 +ness 756 +arch 757 +ize 758 +▁All 759 +ense 760 +blem 761 +▁probably 762 +hip 763 +▁number 764 +ention 765 +▁saying 766 +▁commun 767 +▁An 768 +akes 769 +▁belie 770 +▁between 771 +▁better 772 +cus 773 +▁place 774 +▁gener 775 +▁ca 776 +▁ins 777 +▁ass 778 +cond 779 +cept 780 +ull 781 +▁understand 782 +▁fun 783 +▁thought 784 +gan 785 +iew 786 +cy 787 +ution 788 +ope 789 +ason 790 +▁problem 791 +▁doesn 792 +ational 793 +▁read 794 +▁trying 795 +▁sch 796 +▁el 797 +ah 798 +atter 799 +▁exper 800 +▁four 801 +▁ele 802 +▁cou 803 +ont 804 +▁called 805 +▁partic 806 +▁open 807 +▁gl 808 +▁everything 809 +▁eff 810 +▁getting 811 +▁ty 812 +▁Am 813 +▁Because 814 +ave 815 +▁met 816 +▁Like 817 +oney 818 +▁ 819 +e 820 +t 821 +o 822 +a 823 +n 824 +i 825 +s 826 +h 827 +r 828 +l 829 +d 830 +u 831 +c 832 +y 833 +m 834 +g 835 +w 836 +f 837 +p 838 +, 839 +b 840 +. 841 +k 842 +v 843 +' 844 +I 845 +T 846 +A 847 +S 848 +j 849 +x 850 +W 851 +B 852 +C 853 +? 854 +0 855 +O 856 +- 857 +M 858 +H 859 +Y 860 +q 861 +1 862 +P 863 +z 864 +L 865 +D 866 +N 867 +G 868 +F 869 +R 870 +E 871 +2 872 +J 873 +U 874 +: 875 +5 876 +9 877 +3 878 +K 879 +4 880 +V 881 +8 882 +6 883 +7 884 +! 885 +% 886 +Q 887 +$ 888 +Z 889 +X 890 +é 891 +/ 892 +í 893 +á 894 +£ 895 +ó 896 +ā 897 +ü 898 +ñ 899 +ö 900 +è 901 +ç 902 +à 903 +¿ 904 +μ 905 +π 906 +ä 907 +ú 908 +θ 909 +ã 910 +φ 911 +ī 912 +σ 913 +ê 914 +ρ 915 +â 916 +ô 917 +^ 918 +€ 919 +É 920 +ū 921 +Δ 922 +λ 923 +α 924 +τ 925 +æ 926 +а 927 +о 928 +ν 929 +î 930 +γ 931 +ψ 932 +ē 933 +т 934 +ß 935 +ω 936 +ï 937 +ć 938 +č 939 +ε 940 +е 941 +и 942 +ò 943 +р 944 +β 945 +ø 946 +ł 947 +δ 948 +η 949 +п 950 +ë 951 +н 952 +с 953 +š 954 +Ü 955 +å 956 +ń 957 +ś 958 +я 959 +đ 960 +л 961 +м 962 +Ö 963 +û 964 +ș 965 +в 966 +Á 967 +Ø 968 +ù 969 +ο 970 +ч 971 +ь 972 +ž 973 +Φ 974 +у 975 +ę 976 +ι 977 +б 978 +г 979 +к 980 +ő 981 +Ś 982 +Ω 983 +κ 984 +υ 985 +ì 986 +Č 987 +έ 988 +х 989 +ы 990 +Å 991 +Ç 992 +ż 993 +ί 994 +ζ 995 +χ 996 +э 997 +Æ 998 +Í 999 +õ 1000 +ě 1001 +ħ 1002 +Ł 1003 +œ 1004 +Ž 1005 +ț 1006 +Γ 1007 +П 1008 +д 1009 +з 1010 +ф 1011 +¡ 1012 +À 1013 +Î 1014 +Ā 1015 +ė 1016 +Š 1017 +ź 1018 +Κ 1019 +Ψ 1020 +ά 1021 +ξ 1022 +ό 1023 + 1024