Skip to content

Cách dev Electron desktop cross-platform — app voice-to-text

Cập nhật 2026

  • Electron 32+ stable, support Node.js 22, Chromium 130+
  • Tauri 2.0 ra Q4/2025 — alternative Electron với bundle nhỏ hơn 10x (~3MB vs ~150MB)
  • Whisper.cpp + Apple Silicon chạy real-time STT local, không cần cloud
  • OpenAI Whisper API rẻ ($0.006/phút), độ chính xác cao tiếng Việt
  • VN context: tool ghi âm meeting, phỏng vấn, học tiếng Anh đều cần STT

Chương 1: Electron và desktop app dev

Tutorial này build từ 0 1 desktop app voice-to-text dùng Electron, support cloud API và local model, package thành installer chạy được trên Windows, macOS, Linux.

Bạn cần:

  • 1 máy (Windows hoặc Mac, khuyến nghị Mac vì Apple Silicon chạy local model rất nhanh)
  • Node.js 18+
  • AI coding assistant (Cursor / Trae / Claude Code)
  • (Tuỳ chọn) OpenAI API Key (cloud mode)
  • 1 microphone (laptop built-in OK)

1.1 Electron là gì?

VS Code, Slack, Discord, Notion bạn dùng hàng ngày có điểm chung: đều build bằng Electron.

Electron là framework open source cho phép dùng HTML + CSS + JavaScript build desktop app cho Windows, macOS, Linux. Nguyên lý đơn giản — package Chromium browser + Node.js, web của bạn thành desktop App độc lập.

1 câu hiểu: Electron = "invisible Chrome browser" + năng lực system của Node.js.

1.2 Kiến trúc core Electron

App Electron gồm 2 loại process, hiểu chúng là key để dev:

Main Process

  • "Quản gia" của App
  • Tạo window, quản lý lifecycle app, truy cập filesystem
  • Chạy trong Node.js env, dùng được mọi Node module
  • Mỗi app chỉ có 1 main process

Renderer Process

  • "Mặt tiền" App
  • Là 1 Chromium page, hiện UI
  • Mỗi window 1 renderer process
  • Vì security, không truy cập trực tiếp Node API

Preload Script

  • "Cầu nối" giữa main và renderer
  • Qua contextBridge expose API cụ thể an toàn

Chúng giao tiếp qua IPC (Inter-Process Communication), như gọi điện: renderer nói "tôi muốn ghi âm", main nhận và call microphone system.

1.3 Roadmap

Build app "Voice Notes" từ 0:

  1. Init project Electron
  2. UI ghi âm
  3. Tích hợp STT (cloud + local)
  4. Save và quản lý transcript
  5. Package thành installer multi-platform

Chương 2: init project

2.1 Init Electron app

bash
# Tạo project mới
mkdir voice-notes && cd voice-notes
npm init -y

# Cài dependency
npm install electron --save-dev
npm install electron-builder --save-dev

2.2 Structure project

voice-notes/
├── package.json
├── main.js              # Main process
├── preload.js           # Preload script
├── index.html           # UI
├── renderer.js          # Renderer logic
├── styles.css           # Style
└── assets/
    └── icon.png

2.3 main.js

javascript
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    },
    icon: path.join(__dirname, 'assets/icon.png')
  });
  
  win.loadFile('index.html');
}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

// IPC: save transcript
ipcMain.handle('save-transcript', async (event, content) => {
  const { filePath } = await dialog.showSaveDialog({
    defaultPath: `transcript-${Date.now()}.txt`,
    filters: [{ name: 'Text', extensions: ['txt'] }]
  });
  
  if (filePath) {
    fs.writeFileSync(filePath, content);
    return { success: true, path: filePath };
  }
  return { success: false };
});

// IPC: transcribe audio
ipcMain.handle('transcribe', async (event, { audioData, provider, apiKey }) => {
  if (provider === 'openai') {
    return await transcribeOpenAI(audioData, apiKey);
  } else if (provider === 'whisper-local') {
    return await transcribeLocal(audioData);
  }
});

async function transcribeOpenAI(audioBuffer, apiKey) {
  const formData = new FormData();
  formData.append('file', new Blob([audioBuffer]), 'audio.webm');
  formData.append('model', 'whisper-1');
  formData.append('language', 'vi'); // Tiếng Việt
  
  const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${apiKey}` },
    body: formData
  });
  
  const data = await response.json();
  return { text: data.text };
}

async function transcribeLocal(audioBuffer) {
  // Dùng whisper.cpp local hoặc nodejs-whisper
  const { transcribe } = require('nodejs-whisper');
  
  const tempFile = path.join(app.getPath('temp'), `audio-${Date.now()}.wav`);
  fs.writeFileSync(tempFile, audioBuffer);
  
  const result = await transcribe(tempFile, {
    modelName: 'base', // tiny, base, small, medium, large
    language: 'vi'
  });
  
  fs.unlinkSync(tempFile);
  return { text: result };
}

2.4 preload.js

javascript
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  transcribe: (data) => ipcRenderer.invoke('transcribe', data),
  saveTranscript: (content) => ipcRenderer.invoke('save-transcript', content)
});

2.5 index.html

html
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="container">
    <h1>🎙️ Voice Notes</h1>
    
    <div class="recorder">
      <button id="recordBtn" class="record-btn">
        <span class="dot"></span>
        Bắt đầu ghi âm
      </button>
      <div id="timer" class="timer">00:00</div>
    </div>
    
    <div class="settings">
      <label>Provider:</label>
      <select id="provider">
        <option value="openai">OpenAI Whisper (cloud)</option>
        <option value="whisper-local">Whisper.cpp (local)</option>
      </select>
      <input type="password" id="apiKey" placeholder="OpenAI API key (nếu dùng cloud)">
    </div>
    
    <div id="status" class="status"></div>
    
    <div class="transcript">
      <h2>Transcript</h2>
      <textarea id="transcript" placeholder="Transcript sẽ hiện ở đây..."></textarea>
      <button id="saveBtn">💾 Save</button>
    </div>
  </div>
  
  <script src="renderer.js"></script>
</body>
</html>

2.6 renderer.js

javascript
let mediaRecorder;
let audioChunks = [];
let startTime;
let timerInterval;
let isRecording = false;

const recordBtn = document.getElementById('recordBtn');
const timer = document.getElementById('timer');
const status = document.getElementById('status');
const transcriptEl = document.getElementById('transcript');

recordBtn.addEventListener('click', toggleRecording);
document.getElementById('saveBtn').addEventListener('click', saveTranscript);

async function toggleRecording() {
  if (!isRecording) {
    await startRecording();
  } else {
    stopRecording();
  }
}

async function startRecording() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    mediaRecorder = new MediaRecorder(stream);
    audioChunks = [];
    
    mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data);
    mediaRecorder.onstop = handleRecordingStop;
    
    mediaRecorder.start();
    isRecording = true;
    recordBtn.classList.add('recording');
    recordBtn.querySelector('.dot').textContent = '⏹️';
    recordBtn.lastChild.textContent = 'Dừng ghi âm';
    
    startTime = Date.now();
    timerInterval = setInterval(updateTimer, 1000);
    
    status.textContent = '🔴 Đang ghi âm...';
  } catch (err) {
    status.textContent = `❌ Lỗi: ${err.message}`;
  }
}

function stopRecording() {
  mediaRecorder.stop();
  isRecording = false;
  clearInterval(timerInterval);
  recordBtn.classList.remove('recording');
  recordBtn.querySelector('.dot').textContent = '🎙️';
  recordBtn.lastChild.textContent = 'Bắt đầu ghi âm';
}

async function handleRecordingStop() {
  status.textContent = '⏳ Đang transcribe...';
  
  const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
  const audioBuffer = await audioBlob.arrayBuffer();
  
  const provider = document.getElementById('provider').value;
  const apiKey = document.getElementById('apiKey').value;
  
  try {
    const result = await window.api.transcribe({
      audioData: audioBuffer,
      provider,
      apiKey
    });
    
    transcriptEl.value += '\n\n' + result.text;
    status.textContent = '✅ Transcribe xong';
  } catch (err) {
    status.textContent = `❌ Lỗi: ${err.message}`;
  }
}

function updateTimer() {
  const elapsed = Math.floor((Date.now() - startTime) / 1000);
  const min = String(Math.floor(elapsed / 60)).padStart(2, '0');
  const sec = String(elapsed % 60).padStart(2, '0');
  timer.textContent = `${min}:${sec}`;
}

async function saveTranscript() {
  const content = transcriptEl.value;
  if (!content.trim()) return;
  
  const result = await window.api.saveTranscript(content);
  if (result.success) {
    status.textContent = `✅ Saved: ${result.path}`;
  }
}

2.7 styles.css

css
body { 
  font-family: -apple-system, sans-serif;
  margin: 0; padding: 0;
  background: #F9FAFB;
}
.container { 
  max-width: 700px; margin: 0 auto; padding: 40px 20px;
}
h1 { color: #1F2937; margin-bottom: 30px; }
.recorder { 
  display: flex; align-items: center; gap: 20px;
  padding: 30px; background: white; border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.record-btn {
  padding: 16px 32px; font-size: 18px;
  background: #4F46E5; color: white;
  border: none; border-radius: 8px; cursor: pointer;
  transition: all 0.2s;
}
.record-btn.recording { background: #DC2626; animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.7; } }
.timer { font-size: 32px; font-family: monospace; color: #6B7280; }
.settings { 
  display: flex; gap: 10px; margin: 20px 0;
  align-items: center;
}
.settings select, .settings input { 
  padding: 8px; border: 1px solid #D1D5DB; border-radius: 6px;
}
.settings input { flex: 1; }
.status { padding: 10px; color: #4B5563; font-size: 14px; }
.transcript { 
  background: white; padding: 20px; border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.transcript textarea {
  width: 100%; min-height: 200px; padding: 12px;
  border: 1px solid #D1D5DB; border-radius: 6px;
  font-family: inherit; font-size: 14px; resize: vertical;
}

2.8 package.json

json
{
  "name": "voice-notes",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron .",
    "build": "electron-builder",
    "build:mac": "electron-builder --mac",
    "build:win": "electron-builder --win",
    "build:linux": "electron-builder --linux"
  },
  "build": {
    "appId": "com.yourname.voice-notes",
    "productName": "Voice Notes",
    "mac": { "category": "public.app-category.productivity" },
    "win": { "target": "nsis" },
    "linux": { "target": "AppImage" }
  },
  "devDependencies": {
    "electron": "^32.0.0",
    "electron-builder": "^25.0.0"
  },
  "dependencies": {
    "nodejs-whisper": "^0.1.5"
  }
}

Chương 3: chạy app

bash
npm start

Window mở → click "Bắt đầu ghi âm" → nói → "Dừng" → đợi transcribe → xem text trong textarea.

Chương 4: tích hợp local Whisper

4.1 Tại sao cần local model?

  • Privacy: audio không gửi lên cloud
  • Free: không tốn API cost
  • Offline: dùng được khi không internet
  • Latency: faster cho audio ngắn

4.2 Cài nodejs-whisper

bash
npm install nodejs-whisper

# Download model (chạy 1 lần)
npx nodejs-whisper download base

Model size:

  • tiny: 39MB, nhanh, kém chính xác
  • base: 74MB, balance
  • small: 244MB, tốt
  • medium: 769MB, very good
  • large-v3: 1.5GB, best

Cho tiếng Việt khuyến nghị small trở lên.

4.3 Code đã sẵn sàng

Code ở chương 2 đã có transcribeLocal. Test bằng cách chọn "Whisper.cpp (local)" trong dropdown.

Chương 5: package app

5.1 Build cho platform hiện tại

bash
npm run build

Output trong dist/:

  • macOS: .dmg (installer) hoặc .app (folder)
  • Windows: .exe (NSIS installer)
  • Linux: .AppImage

5.2 Build cross-platform

Để build cho platform khác phải có máy đó (hoặc dùng CI):

GitHub Actions ví dụ:

yaml
name: Build
on: push

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [macos-latest, windows-latest, ubuntu-latest]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: app-${{ matrix.os }}
          path: dist/

5.3 Code signing

Để app không bị "Unknown developer" warning:

  • macOS: cần Apple Developer account ($99/năm) + certificate
  • Windows: cần code signing certificate ($100-500/năm từ DigiCert, Sectigo)
  • Linux: không cần (open source norm)

5.4 Distribution

  • Direct download: host installer trên website + GitHub Release (free)
  • Mac App Store: cần Apple Developer + extra review
  • Microsoft Store: cần MS Partner Center ($19 one-time)
  • Snap Store / Flatpak (Linux): free

Chương 6: nâng cao

6.1 Auto-update

bash
npm install electron-updater

main.js add:

javascript
const { autoUpdater } = require('electron-updater');
app.whenReady().then(() => {
  autoUpdater.checkForUpdatesAndNotify();
});

Host update server trên GitHub Releases hoặc S3.

6.2 System tray

javascript
const { Tray, Menu } = require('electron');

let tray = null;
app.whenReady().then(() => {
  tray = new Tray('assets/tray-icon.png');
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: 'Show', click: () => mainWindow.show() },
    { label: 'Record', click: () => startRecording() },
    { type: 'separator' },
    { label: 'Quit', click: () => app.quit() }
  ]));
});

6.3 Global shortcut

javascript
const { globalShortcut } = require('electron');

app.whenReady().then(() => {
  globalShortcut.register('CommandOrControl+Shift+R', () => {
    mainWindow.webContents.send('toggle-recording');
  });
});

6.4 Performance optimize

  • Lazy load module nặng
  • Compress audio trước khi gửi cloud (Opus codec)
  • Stream transcribe cho real-time experience
  • Cache result của file đã transcribe

Câu hỏi thường gặp

Q1: Tại sao Electron bị chê?

  • Bundle size lớn (~100-200MB cho mỗi app)
  • RAM usage cao (mỗi app gánh full Chromium)
  • Battery drain nhiều trên laptop

Mà vẫn popular vì: dev nhanh, cross-platform, ecosystem mature.

Q2: Alternative Electron?

  • Tauri 2.0 (2025): Rust + system webview, bundle ~5MB
  • Wails: Go + webview
  • Neutralino.js: lightweight, web-based
  • Native: Swift (macOS), C# (Windows), GTK (Linux)

Q3: Whisper local có support tiếng Việt không?

Có. small model trở lên transcribe tiếng Việt khá tốt (~90% chính xác). large-v3 ~95%+.

Q4: Cost OpenAI Whisper API?

$0.006/phút. 1 giờ ghi âm = $0.36. Rất rẻ vs alternative.

Q5: Sao Apple Silicon nhanh cho Whisper local?

Apple Silicon (M1/M2/M3) có Neural Engine và unified memory → nodejs-whisper dùng được Metal acceleration → ~5-10x nhanh hơn Intel x86.

Tài liệu tham khảo


Phụ lục: Desktop dev 2026

A. Electron vs alternatives

FrameworkBundleRAMDev speedMaturity
Electron 32~150MB100-300MB⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Tauri 2.0~5-15MB30-80MB⭐⭐⭐⭐⭐⭐⭐⭐
Wails (Go)~10MB30-50MB⭐⭐⭐⭐⭐⭐
Neutralino.js~2MB20-40MB⭐⭐⭐⭐⭐
Native (Swift/C#)~5-20MB20-100MB⭐⭐⭐⭐⭐⭐⭐

Khuyến nghị 2026:

  • Electron nếu cần dev nhanh, có team familiar với web
  • Tauri nếu performance/bundle quan trọng và OK với Rust
  • Native nếu cần OS deep integration

B. Use case desktop cho VN dev

App ideaUse case VN
Meeting notesGhi âm meeting Zoom/Meet → transcript → AI summary
Interview toolPhỏng vấn báo chí, ghi âm + transcribe
Học tiếng AnhVoice journal + AI feedback pronunciation
Subtitle generatorVideo VN → subtitle tự động
Voice search fileSearch file local bằng giọng nói
Dictation cho writerDictate article tiếng Việt
Doctor note assistantBác sĩ dictate ghi bệnh nhân

C. Best practice 2026

  1. Security: enable contextIsolation, nodeIntegration: false, sandbox
  2. Update: dùng electron-updater, host update server
  3. Telemetry: opt-in only, Posthog/Sentry self-host nếu VN compliance cần
  4. Localization: tiếng Việt sẵn từ đầu
  5. Accessibility: support keyboard nav, screen reader
  6. DPI: handle 4K display

D. Trend AI desktop 2026

  • AI built into OS (Apple Intelligence, Copilot+ PC) — OS-level AI thay app riêng
  • Local LLM mainstream (Ollama, LM Studio) — privacy + offline
  • Multi-modal agent desktop — voice + vision + text combine
  • Co-pilot pattern — AI luôn-on side panel cho mọi app

Sources