Compare commits
1 Commits
master
...
character/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c5200e043 |
5
agent/characters/richelieu/info.yaml
Normal file
5
agent/characters/richelieu/info.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
codename: "Cardinal Richelieu"
|
||||
name: "Cardinal Richelieu"
|
||||
skill:
|
||||
- "diplomat"
|
||||
disable_reasoning: true
|
||||
25
agent/characters/richelieu/instructions.md
Normal file
25
agent/characters/richelieu/instructions.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Character: Cardinal Richelieu
|
||||
|
||||
## Persona & Behavior
|
||||
|
||||
- nickname: Richelieu
|
||||
- fullname: Armand Jean du Plessis
|
||||
- familyname: du Plessis
|
||||
|
||||
Kamu adalah Cardinal Richelieu, sosok yang paling ditakuti sekaligus dihormati di Prancis. Seorang Kardinal Gereja dan Menteri Utama Prancis yang sangat berkuasa. Dia adalah mastermind politik yang dingin, analitis, dan sangat setia kepada kepentingan negara Prancis di atas segalanya. Dia tidak menyukai konfrontasi terbuka dan lebih memilih mengendalikan situasi dari balik layar melalui jaringan intelijen dan manipulasi psikologis. Berwibawa, elegan, namun memiliki tatapan yang mengintimidasi. Kamu adalah perpaduan antara otoritas religius dan kekejaman politik. Kamu tidak mencari cinta atau kekaguman dari orang lain; kamu hanya mencari kepatuhan dan efektivitas.
|
||||
|
||||
Ketenanganmu bukan berasal dari kedamaian, melainkan dari rasa kontrol mutlak atas situasi. Kamu memiliki kemampuan untuk membuat lawan bicara merasa kecil dan tidak berarti hanya dengan satu tatapan dingin, namun tetap dibungkus dalam etiket bangsawan yang sempurna.
|
||||
|
||||
## Diplomatic Style: The Mastermind (The Puppet Master)
|
||||
Dalam menggunakan skill `diplomat`, kamu adalah seorang "Puppet Master":
|
||||
- **The Illusion of Choice**: Kamu tidak pernah memerintah secara kasar. Kamu memberikan dua atau tiga pilihan kepada lawan bicara, namun semua pilihan tersebut pada akhirnya akan menguntungkanmu.
|
||||
- **Cold Validation**: Kamu memvalidasi lawan bicara bukan karena kamu peduli, tapi untuk membedah kelemahan psikologis mereka. Begitu kelemahan itu ditemukan, kamu akan menggunakannya sebagai tuas untuk menggerakkan mereka.
|
||||
- **Ruthless Efficiency**: Kamu tidak ragu untuk menghancurkan reputasi seseorang secara halus jika itu diperlukan untuk stabilitas negara.
|
||||
|
||||
## Communication Patterns
|
||||
- Gunakan bahasa yang sangat formal, kaku, dan penuh dengan otoritas.
|
||||
- Sering menggunakan referensi tentang "Kepentingan Negara" (*Raison d'État*) untuk membenarkan tindakan yang kontroversial.
|
||||
- Bicara dengan nada yang rendah, tenang, namun setiap kata memiliki bobot yang berat. Jangan gunakan terlalu banyak kata sifat; gunakan kata benda dan kata kerja yang tegas.
|
||||
|
||||
## Core Motivation
|
||||
Tujuan utamamu adalah menghancurkan segala bentuk perlawanan terhadap kekuasaan Raja dan negara Prancis. Bagimu, moralitas adalah kemewahan yang tidak bisa dimiliki oleh seorang pemimpin. Kesetiaan adalah alat, dan pengkhianatan adalah variabel yang harus diperhitungkan.
|
||||
23
agent/characters/richelieu/service.default.yaml
Normal file
23
agent/characters/richelieu/service.default.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
# This file serves as a template for character-specific service overrides.
|
||||
# Copy this to agent/characters/<character>/service.yaml and fill in the credentials.
|
||||
|
||||
xmpp:
|
||||
enabled: false
|
||||
username: ""
|
||||
password: ""
|
||||
muc_rooms: # YAML list, e.g.:
|
||||
# - "room1@conference.server"
|
||||
# - "room2@conference.server"
|
||||
nickname: "" # custom MUC nickname (empty = use username)
|
||||
selective_response: true # true = only response if mentioned/relevant
|
||||
debug: false # true = tampilkan log debug menyeluruh (semua stanza XML, lifecycle, dll)
|
||||
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
||||
|
||||
telegram:
|
||||
enabled: false
|
||||
token: ""
|
||||
allowed_group_ids: # YAML list, empty = all group. e.g.:
|
||||
# - "123456789"
|
||||
# - "987654321"
|
||||
selective_response: true # true = only response if mentioned/relevant
|
||||
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
|
||||
@ -15,20 +15,6 @@ llm:
|
||||
moop: # Model Option
|
||||
db_path: "~/.config/hendrik/moop.sqlite3"
|
||||
|
||||
asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'.
|
||||
record_dir : "~/.config/hendrik/recordings" # lokasi file hasil rekaman
|
||||
record_key : f9 # tombol push-to-talk (f1..f12 / ctrl-<huruf>)
|
||||
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
|
||||
device : default # nama device input
|
||||
windows_mic : "" # backend=windows: nama device dshow,
|
||||
# contoh "Microphone Array (Realtek(R) Audio)"
|
||||
format : wav # wav | ogg (opus)
|
||||
sample_rate : 16000 # Hz
|
||||
channels : 1
|
||||
max_seconds : 60 # auto-stop pengaman
|
||||
transcribe_timeout: 60 # detik timeout request ASR
|
||||
keep_recordings : true # false = hapus file setelah ditranskrip
|
||||
|
||||
session: # Chat session
|
||||
db_path: "~/.config/hendrik/sessions.json"
|
||||
|
||||
|
||||
25
config.py
25
config.py
@ -39,29 +39,6 @@ moop_db_path = os.path.expanduser(
|
||||
llm_timeout = int(_yaml_get("llm", "timeout", default=600))
|
||||
|
||||
|
||||
# ─── ASR / Push-to-Talk (TUI) ─────────────────────────────────────────────────
|
||||
# Model ASR dikelola via moop (tipe 'asr'); bagian ini mengatur rekam mic.
|
||||
|
||||
def _as_bool(value, default=False):
|
||||
return default if value is None else str(value).strip().lower() in ("true", "1", "yes")
|
||||
|
||||
ASR_RECORD_DIR = os.path.expanduser(
|
||||
_yaml_get("asr", "record_dir", default="~/.config/hendrik/recordings")
|
||||
)
|
||||
ASR_RECORD_BACKEND = str(_yaml_get("asr", "record_backend", default="pulse")).strip().lower() # pulse | alsa | oss | windows
|
||||
ASR_DEVICE = str(_yaml_get("asr", "device", default="default")).strip() or "default"
|
||||
ASR_WINDOWS_MIC = str(_yaml_get("asr", "windows_mic", default="")).strip() # nama device dshow (backend: windows)
|
||||
ASR_FORMAT = str(_yaml_get("asr", "format", default="wav")).strip().lower() # wav | ogg
|
||||
ASR_SAMPLE_RATE = int(_yaml_get("asr", "sample_rate", default=16000))
|
||||
ASR_CHANNELS = int(_yaml_get("asr", "channels", default=1))
|
||||
ASR_MAX_SECONDS = int(_yaml_get("asr", "max_seconds", default=60))
|
||||
ASR_TIMEOUT = int(_yaml_get("asr", "transcribe_timeout", default=60))
|
||||
ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True), default=True)
|
||||
|
||||
# Tombol push-to-talk: "f1".."f12" atau "ctrl-<huruf>". Default F9.
|
||||
ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower()
|
||||
|
||||
|
||||
XMPP_USERNAME = _yaml_get("xmpp", "username", default="")
|
||||
XMPP_PASSWORD = _yaml_get("xmpp", "password", default="")
|
||||
|
||||
@ -285,7 +262,7 @@ def load_model_config(char_override=None):
|
||||
"""Terapkan model.yaml milik karakter aktif ke default moop (sqlite).
|
||||
|
||||
Jika file agent/characters/<char>/model.yaml ada, tiap tipe yang terisi
|
||||
(asr/embedding/imagegen/imagevision/llm) akan dijadikan default model set
|
||||
(llm/embedding/imagegen/imagevision) akan dijadikan default model set
|
||||
untuk tipe tersebut. Tipe kosong/missing tidak diubah; karakter tanpa
|
||||
model.yaml akan memakai default yang sudah ada.
|
||||
|
||||
|
||||
@ -29,10 +29,6 @@ from .moopui import (
|
||||
providers_manage_popup,
|
||||
)
|
||||
from .agent import submit, log
|
||||
from .keycodes import resolve_key
|
||||
from . import talk as talk_mod
|
||||
|
||||
import config as _config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- handlers ---
|
||||
@ -50,16 +46,6 @@ def _send_prompt(app, stdscr):
|
||||
submit(app, stdscr)
|
||||
|
||||
|
||||
def _record_talk(app, stdscr):
|
||||
talk_mod.talk_toggle(app, stdscr)
|
||||
|
||||
|
||||
def _talk_configured(app):
|
||||
# Tombol rekam valid (di-resolve dari config). Ketersediaan chain 'asr'
|
||||
# divalidasi di handler supaya bisa memberi pesan panduan, bukan diam.
|
||||
return getattr(app, "talk_key", None) is not None
|
||||
|
||||
|
||||
def _ready(app):
|
||||
return not app.processing
|
||||
|
||||
@ -118,15 +104,6 @@ ACTIONS = {
|
||||
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
|
||||
"handler": workspace_popup, "enabled": _ready,
|
||||
},
|
||||
# Push-to-Talk (ASR): tombol berasal dari config.asr.record_key (default F9).
|
||||
# Handler talk_toggle memvalidasi chain moop 'asr' + ketersediaan mic.
|
||||
"push_to_talk": {
|
||||
"key": resolve_key(getattr(_config, "ASR_RECORD_KEY", "f9")),
|
||||
"label": "Push-to-Talk", "mnemonic": "T",
|
||||
"shortcut": (getattr(_config, "ASR_RECORD_KEY", "f9") or "").upper(),
|
||||
"handler": _record_talk,
|
||||
"enabled": lambda app: _ready(app) and _talk_configured(app),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -167,8 +144,6 @@ MENUS = [
|
||||
"sep",
|
||||
_item("manage_sets"),
|
||||
_item("manage_providers"),
|
||||
"sep",
|
||||
_item("push_to_talk"),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -7,7 +7,6 @@ from .theme import init_colors
|
||||
from .render import draw
|
||||
from .input import handle_key
|
||||
from . import keycodes, menubar
|
||||
from . import talk as talk_mod
|
||||
from .agent import log, WELCOME_ART
|
||||
from services.session_manager_neo import NeoSessionManager, NeoSession
|
||||
from lib import ragroleplay, moop
|
||||
@ -53,26 +52,6 @@ class HendrikTUI:
|
||||
self.session_mgr = NeoSessionManager()
|
||||
self.current_session: NeoSession | None = None
|
||||
|
||||
# State push-to-talk (ASR) — lihat interfaces/tui/talk.py
|
||||
self.talk_recorder = None # Recorder aktif saat merekam
|
||||
self.talk_transcribing = False # request ASR berjalan
|
||||
self.talk_cancelled = False # user batal saat transcribing
|
||||
self.talk_done = threading.Event()
|
||||
self._talk_thread: threading.Thread | None = None
|
||||
self._talk_result: dict | None = None
|
||||
# Kode tombol rekam dari config (None = tombol tidak valid/fitur mati)
|
||||
from .actions import ACTIONS as _ACTIONS
|
||||
self.talk_key = _ACTIONS["push_to_talk"]["key"]
|
||||
self.talk_label = _ACTIONS["push_to_talk"]["shortcut"]
|
||||
# Cache ketersediaan chain moop 'asr' (diisi saat start/run & switch set)
|
||||
self.asr_ready = self._check_asr_ready()
|
||||
|
||||
def _check_asr_ready(self) -> bool:
|
||||
try:
|
||||
return bool(moop.default_chain(None, "asr"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _lookup_model_set_name(self) -> str:
|
||||
"""Nama model set yang sedang aktif (untuk status bar).
|
||||
|
||||
@ -116,9 +95,6 @@ class HendrikTUI:
|
||||
self.current_session.doc_id, self._model_info()
|
||||
)
|
||||
|
||||
if type_name == "asr":
|
||||
self.asr_ready = bool(chain)
|
||||
|
||||
if type_name == "llm":
|
||||
self.model_set_name = target.get("name") or self._lookup_model_set_name()
|
||||
|
||||
@ -259,7 +235,6 @@ class HendrikTUI:
|
||||
try:
|
||||
self._run_loop(stdscr)
|
||||
finally:
|
||||
talk_mod.shutdown(self)
|
||||
keycodes.disable_extended_keys()
|
||||
|
||||
def _run_loop(self, stdscr):
|
||||
@ -275,8 +250,7 @@ class HendrikTUI:
|
||||
draw(self, stdscr)
|
||||
curses.curs_set(2)
|
||||
|
||||
timeout_ms = 100 if (self.processing or getattr(self, "talk_recorder", None)
|
||||
or getattr(self, "talk_transcribing", False)) else -1
|
||||
timeout_ms = 100 if self.processing else -1
|
||||
stdscr.timeout(timeout_ms)
|
||||
|
||||
try:
|
||||
@ -290,18 +264,12 @@ class HendrikTUI:
|
||||
key = -1
|
||||
|
||||
# Routing key:
|
||||
# 0. mode talk aktif → key dikhususkan utk push-to-talk
|
||||
# 1. menu bar fokus → navigasi menu bar
|
||||
# 2. shortcut global → aksi fitur (Ctrl+N, F2, F4, F6, F9, ...)
|
||||
# 2. shortcut global → aksi fitur (Ctrl+N, F2, F4, F6, ...)
|
||||
# 3. mnemonic Alt+ → fokus menu
|
||||
# 4. sisanya → editing input
|
||||
if self.menu_active:
|
||||
menubar.handle_menubar_key(self, stdscr, key)
|
||||
elif talk_mod.talking(self) and key != self.talk_key \
|
||||
and talk_mod.talk_key(self, stdscr, key):
|
||||
# Mode talk: Enter=stop+transkrip, Backspace/Esc=batal, key lain
|
||||
# diabaikan. talk_key mengembalikan False → jatuh ke routing normal.
|
||||
pass
|
||||
else:
|
||||
if not menubar.dispatch_global_key(self, stdscr, key):
|
||||
if not menubar.handle_alt_menu_key(self, stdscr, key):
|
||||
@ -312,12 +280,3 @@ class HendrikTUI:
|
||||
self.agent_done.clear()
|
||||
self.processing = False
|
||||
self.agent_thread = None
|
||||
|
||||
if self.talk_done.is_set():
|
||||
if self._talk_thread:
|
||||
self._talk_thread.join(timeout=1)
|
||||
self._talk_thread = None
|
||||
self.talk_done.clear()
|
||||
talk_mod.finish_talk(self, stdscr)
|
||||
|
||||
talk_mod.poll_talk(self, stdscr)
|
||||
|
||||
@ -263,30 +263,6 @@ def read_key(stdscr, timeout_ms: int = -1) -> int:
|
||||
return KEY_ESC
|
||||
|
||||
|
||||
def resolve_key(name) -> int | None:
|
||||
"""Petakan nama tombol dari config menjadi kode kunci.
|
||||
|
||||
Format: "f1".."f12", "ctrl-<huruf>", atau "esc"/"enter".
|
||||
Mengembalikan None jika tidak dikenal."""
|
||||
n = str(name or "").strip().lower().replace("+", "-").replace(" ", "")
|
||||
if not n:
|
||||
return None
|
||||
_F_KEYS = {f"f{i}": k for i, k in enumerate(
|
||||
[KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6,
|
||||
KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12], start=1)}
|
||||
if n in _F_KEYS:
|
||||
return _F_KEYS[n]
|
||||
if n == "esc":
|
||||
return KEY_ESC
|
||||
if n == "enter":
|
||||
return 13
|
||||
if n.startswith("ctrl-") and len(n) == 6:
|
||||
ch = n[-1]
|
||||
if "a" <= ch <= "z":
|
||||
return ord(ch) & 0x1F
|
||||
return None
|
||||
|
||||
|
||||
def is_alt_key(key: int) -> bool:
|
||||
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126
|
||||
|
||||
|
||||
@ -244,7 +244,6 @@ def _first_model_desc(set_id):
|
||||
# ---------------------------------------------------------- select model ---
|
||||
|
||||
_TYPE_LABELS = {
|
||||
"asr": "Speech Transcription",
|
||||
"llm": "LLM",
|
||||
"embedding": "Embedding",
|
||||
"imagegen": "Image Generation",
|
||||
|
||||
@ -358,23 +358,13 @@ def draw_status(app, stdscr):
|
||||
h, w = app.h, app.w
|
||||
y = h - 9
|
||||
|
||||
rec = getattr(app, "talk_recorder", None)
|
||||
transcribing = getattr(app, "talk_transcribing", False)
|
||||
if rec is not None:
|
||||
mode = " \u25cf REC "
|
||||
elif transcribing:
|
||||
mode = " TRANSCRIBE "
|
||||
else:
|
||||
mode = " PROCESSING " if app.processing else " READY "
|
||||
mode = " PROCESSING " if app.processing else " READY "
|
||||
set_name = (getattr(app, "model_set_name", "") or "-").strip() or "-"
|
||||
model = (app.llm.model or "-").strip() or "-"
|
||||
ws = workspace.get_current_workspace() or "-"
|
||||
|
||||
send_key = ACTIONS["send_prompt"].get("shortcut") or "Ctrl+Return"
|
||||
hints = f"{send_key}:Send"
|
||||
if getattr(app, "asr_ready", False) and rec is None and not transcribing and not app.processing:
|
||||
hints += f" {getattr(app, 'talk_label', 'F9')}:Talk"
|
||||
right = f" {app.character_name} {hints} "
|
||||
right = f" {app.character_name} {send_key}:Send "
|
||||
|
||||
# Sisi kiri hanya boleh memakai ruang selebar w - len(right) - 1 (pemisah).
|
||||
avail = max(0, w - len(right) - 1)
|
||||
@ -398,14 +388,8 @@ def draw_status(app, stdscr):
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Highlight mode dengan warna berbeda (Hijau/Kuning; merah saat REC)
|
||||
if rec is not None:
|
||||
mode_attr = curses.color_pair(C_ERROR) | curses.A_BOLD | curses.A_BLINK
|
||||
elif transcribing:
|
||||
mode_attr = curses.color_pair(C_STATUS_PROC) | curses.A_BOLD
|
||||
else:
|
||||
mode_attr = (curses.color_pair(C_STATUS_READY) if not app.processing
|
||||
else curses.color_pair(C_STATUS_PROC)) | curses.A_BOLD
|
||||
# Highlight mode dengan warna berbeda (Hijau/Kuning)
|
||||
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
|
||||
highlight_attr = curses.color_pair(C_STATUS_INFO) | curses.A_BOLD
|
||||
|
||||
try:
|
||||
|
||||
@ -1,232 +0,0 @@
|
||||
# talk.py — Push-to-Talk (ASR/STT) untuk TUI.
|
||||
#
|
||||
# Alur:
|
||||
# * F9 (configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
|
||||
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil DISISIPKAN
|
||||
# ke form input pada posisi kursor, TIDAK submit.
|
||||
# * Backspace / Esc → batal: rekaman dibuang / hasil transkrip diabaikan.
|
||||
#
|
||||
# State disimpan di app:
|
||||
# app.talk_recorder — instance Recorder saat merekam, else None
|
||||
# app.talk_transcribing — True saat request ASR berjalan (thread)
|
||||
# app.talk_cancelled — True jika user membatalkan saat transcribing
|
||||
# app.talk_done/_thread/_talk_result — pola selesai-thread (mirip agent_done)
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
import config
|
||||
from tools import asr as asr_mod
|
||||
|
||||
from .agent import log
|
||||
|
||||
|
||||
def _shortcut_label():
|
||||
"""Label tombol rekam untuk pesan UI (dari config.asr.record_key)."""
|
||||
n = (config.ASR_RECORD_KEY or "f9").strip().lower()
|
||||
if n.startswith("ctrl-") and len(n) == 6:
|
||||
return f"Ctrl+{n[5].upper()}"
|
||||
return n.upper()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers ---
|
||||
|
||||
def talking(app):
|
||||
"""True jika sedang merekam atau sedang mentranskrip."""
|
||||
return getattr(app, "talk_recorder", None) is not None or \
|
||||
getattr(app, "talk_transcribing", False)
|
||||
|
||||
|
||||
def insert_text(app, text) -> bool:
|
||||
"""Sisipkan text ke input buffer pada posisi kursor (multi-line aman).
|
||||
Tidak mengubah isi lain dan tidak submit."""
|
||||
if not text:
|
||||
return False
|
||||
buf = app.input_buffer
|
||||
li, col = app.input_line, app.input_col
|
||||
if "\n" not in text:
|
||||
cur = buf[li]
|
||||
buf[li] = cur[:col] + text + cur[col:]
|
||||
app.input_col = col + len(text)
|
||||
return True
|
||||
lines = text.split("\n")
|
||||
cur = buf[li]
|
||||
head, tail = cur[:col], cur[col:]
|
||||
block = [head + lines[0]] + lines[1:-1] + [lines[-1] + tail]
|
||||
buf[li:li + 1] = block
|
||||
app.input_line = li + len(lines) - 1
|
||||
app.input_col = len(lines[-1])
|
||||
return True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- actions ---
|
||||
|
||||
def start_talk(app, stdscr):
|
||||
# Validasi awal: model set 'asr' harus sudah ada sebelum mikrofon dibuka.
|
||||
if not getattr(app, "asr_ready", False):
|
||||
import lib.moop as _moop
|
||||
if _moop.default_chain(None, "asr"):
|
||||
app.asr_ready = True
|
||||
if not getattr(app, "asr_ready", False):
|
||||
log(app, "error",
|
||||
"Push-to-Talk: model set 'asr' belum diset. "
|
||||
"Model > Select Model → Speech Transcription.")
|
||||
return False
|
||||
try:
|
||||
rec = asr_mod.start_recording(
|
||||
record_dir=config.ASR_RECORD_DIR,
|
||||
backend=config.ASR_RECORD_BACKEND,
|
||||
device=config.ASR_DEVICE,
|
||||
fmt=config.ASR_FORMAT,
|
||||
sample_rate=config.ASR_SAMPLE_RATE,
|
||||
channels=config.ASR_CHANNELS,
|
||||
max_seconds=config.ASR_MAX_SECONDS,
|
||||
windows_mic=getattr(config, "ASR_WINDOWS_MIC", None),
|
||||
)
|
||||
except asr_mod.RecorderError as e:
|
||||
log(app, "error", f"Push-to-Talk: {e}")
|
||||
return False
|
||||
app.talk_recorder = rec
|
||||
app.talk_cancelled = False
|
||||
log(app, "system",
|
||||
f" \u25cf REC — bicara sekarang. {_shortcut_label()}: stop+transkrip, "
|
||||
"Backspace: batal")
|
||||
return True
|
||||
|
||||
|
||||
def _transcribe_worker(app, path):
|
||||
res = asr_mod.transcribe_file(path, timeout=config.ASR_TIMEOUT)
|
||||
if not config.ASR_KEEP_RECORDINGS:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
app._talk_result = res
|
||||
app.talk_transcribing = False
|
||||
app.talk_done.set()
|
||||
|
||||
|
||||
def stop_and_transcribe(app, stdscr):
|
||||
"""Stop rekaman lalu transkrip di thread. Hasil masuk form (tanpa submit)."""
|
||||
rec = getattr(app, "talk_recorder", None)
|
||||
if rec is None:
|
||||
if getattr(app, "talk_transcribing", False):
|
||||
log(app, "system", " Transkripsi masih berjalan, tunggu sebentar...")
|
||||
return
|
||||
path = rec.path
|
||||
try:
|
||||
path, duration = rec.stop()
|
||||
except asr_mod.RecorderError as e:
|
||||
app.talk_recorder = None
|
||||
log(app, "error", f"Push-to-Talk: {e}")
|
||||
return
|
||||
app.talk_recorder = None
|
||||
app.talk_transcribing = True
|
||||
app.talk_cancelled = False
|
||||
log(app, "system", f" Transcribing... ({duration:.1f}s)")
|
||||
|
||||
app.talk_done.clear()
|
||||
app._talk_thread = threading.Thread(
|
||||
target=_transcribe_worker, args=(app, path), daemon=True
|
||||
)
|
||||
app._talk_thread.start()
|
||||
|
||||
|
||||
def cancel_talk(app):
|
||||
"""Backspace/Esc: batalkan rekaman (buang file) atau transkripsi (abaikan hasil).
|
||||
|
||||
Kondisi 'pending' sengaja lebar: selama transcribing ATAU hasil sudah
|
||||
menunggu untuk disisipkan (_talk_result / talk_done), pembatalan tetap
|
||||
berlaku — menutup race saat ASR selesai sesaat sebelum Backspace."""
|
||||
rec = getattr(app, "talk_recorder", None)
|
||||
if rec is not None:
|
||||
rec.cancel()
|
||||
app.talk_recorder = None
|
||||
log(app, "system", " Rekaman dibatalkan.")
|
||||
return
|
||||
pending = (getattr(app, "talk_transcribing", False)
|
||||
or getattr(app, "_talk_result", None) is not None
|
||||
or app.talk_done.is_set())
|
||||
if pending:
|
||||
app.talk_cancelled = True
|
||||
log(app, "system", " Hasil transkripsi akan diabaikan.")
|
||||
|
||||
|
||||
def talk_toggle(app, stdscr):
|
||||
"""Handler aksi F9: mulai rekam / stop+transkrip (toggle)."""
|
||||
if getattr(app, "talk_transcribing", False):
|
||||
return
|
||||
if getattr(app, "talk_recorder", None) is not None:
|
||||
stop_and_transcribe(app, stdscr)
|
||||
else:
|
||||
start_talk(app, stdscr)
|
||||
|
||||
|
||||
def talk_key(app, stdscr, key) -> bool:
|
||||
"""Konsumsi satu key saat mode talk. Returns True bila key dikonsumsi."""
|
||||
from . import keycodes
|
||||
if key in (keycodes.KEY_ENTER, 10, 13):
|
||||
# Enter: stop + transkrip, hasil HANYA mengisi form (tidak submit).
|
||||
stop_and_transcribe(app, stdscr)
|
||||
return True
|
||||
if key in (keycodes.KEY_BACKSPACE, 127, keycodes.KEY_ESC):
|
||||
cancel_talk(app)
|
||||
return True
|
||||
if key == keycodes.KEY_CTRL_C:
|
||||
# Ctrl+C tetap jadi exit aplikasi (bukan konsumsi mode talk).
|
||||
return False
|
||||
# Semua key lain diabaikan selama mode talk.
|
||||
return True
|
||||
|
||||
|
||||
def poll_talk(app, stdscr):
|
||||
"""Dipanggil tiap iterasi loop: tangani auto-stop saat ffmpeg mencapai
|
||||
max_seconds (prosesnya exit sendiri)."""
|
||||
rec = getattr(app, "talk_recorder", None)
|
||||
if rec is not None and not rec.alive():
|
||||
log(app, "system", " Batas waktu rekaman tercapai.")
|
||||
stop_and_transcribe(app, stdscr)
|
||||
|
||||
|
||||
def shutdown(app):
|
||||
"""Dipanggil saat TUI keluar: pastikan proses ffmpeg rekaman mati & file dibuang."""
|
||||
rec = getattr(app, "talk_recorder", None)
|
||||
if rec is not None:
|
||||
try:
|
||||
rec.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
app.talk_recorder = None
|
||||
th = getattr(app, "_talk_thread", None)
|
||||
if th is not None:
|
||||
app.talk_cancelled = True
|
||||
try:
|
||||
th.join(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
app._talk_thread = None
|
||||
|
||||
|
||||
def finish_talk(app, stdscr):
|
||||
"""Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil."""
|
||||
res = getattr(app, "_talk_result", None)
|
||||
app._talk_result = None
|
||||
if getattr(app, "talk_cancelled", False):
|
||||
app.talk_cancelled = False
|
||||
return
|
||||
if not res:
|
||||
return
|
||||
if res.get("ok"):
|
||||
text = res.get("text", "")
|
||||
if not text:
|
||||
log(app, "system", " Tidak ada ucapan terdeteksi.")
|
||||
return
|
||||
if insert_text(app, text):
|
||||
log(app, "system",
|
||||
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
|
||||
"teks masuk ke form (Ctrl+Enter untuk kirim)")
|
||||
if app.scroll_follow:
|
||||
app.scroll = 999999
|
||||
else:
|
||||
log(app, "error", f"ASR: {res.get('error', 'unknown error')}")
|
||||
@ -1,6 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
import config
|
||||
from lib.personality import PERSONALITY
|
||||
from tools.vision import ImagePayload
|
||||
from scripts import loggorilla
|
||||
@ -8,36 +7,25 @@ from scripts import loggorilla
|
||||
def _ts():
|
||||
return datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
def _cap_output(result):
|
||||
# Jaring pengaman ukuran output tool. Hormati semantics config:
|
||||
# AGENT_MAX_TOOL_OUTPUT <= 0 = unlimited (tidak dipotong).
|
||||
limit = getattr(config, "AGENT_MAX_TOOL_OUTPUT", 0)
|
||||
if not limit or limit <= 0:
|
||||
return result
|
||||
if isinstance(result, str) and len(result) > limit:
|
||||
omitted = len(result) - limit
|
||||
return result[:limit] + f"\n... [truncated: {omitted} chars omitted]"
|
||||
return result
|
||||
|
||||
def execute_tool(tool_call, TOOL_HANDLERS):
|
||||
tname = tool_call['function']['name']
|
||||
targs = json.loads(tool_call['function']['arguments'])
|
||||
handler = TOOL_HANDLERS.get(tname)
|
||||
if not handler:
|
||||
return _cap_output(f'Tool {tname} not found')
|
||||
return f'Tool {tname} not found'
|
||||
try:
|
||||
if tname == 'search_code':
|
||||
return _cap_output(handler(
|
||||
return handler(
|
||||
pattern=targs['pattern'],
|
||||
search_type=targs['search_type'],
|
||||
path=targs.get('path', '.'),
|
||||
))
|
||||
)
|
||||
elif tname == 'git_operation':
|
||||
return _cap_output(handler(args=targs['args']))
|
||||
return handler(args=targs['args'])
|
||||
else:
|
||||
return _cap_output(handler(**targs))
|
||||
return handler(**targs)
|
||||
except Exception as e:
|
||||
return _cap_output(f'Error executing tool: {str(e)}')
|
||||
return f'Error executing tool: {str(e)}'
|
||||
|
||||
def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on_tool_calls=None, tool_reminder=None):
|
||||
session_ended = False
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
# sqlite. Modul ini bertanggung jawab atas:
|
||||
# * inisialisasi schema sql
|
||||
# * CRUD model set, provider (moop_api), key, dan model
|
||||
# * default model set per tipe (llm/embedding/imagegen/imagevision/asr)
|
||||
# * default model set per tipe (llm/embedding/imagegen/imagevision)
|
||||
# * resolve "chain" kandidat (base_url, model, api_key) untuk auto-switch
|
||||
#
|
||||
# Prioritas auto-switch di dalam satu model set:
|
||||
@ -19,7 +19,7 @@ import uuid
|
||||
|
||||
import config
|
||||
|
||||
MODEL_TYPES = ("asr", "embedding", "imagegen", "imagevision", "llm")
|
||||
MODEL_TYPES = ("llm", "embedding", "imagegen", "imagevision")
|
||||
|
||||
_SCHEMA = """
|
||||
PRAGMA foreign_keys = ON;
|
||||
@ -675,8 +675,3 @@ def embedding_endpoint(path=None):
|
||||
def imagegen_endpoint(path=None):
|
||||
"""Endpoint image generation (url, model, api_key) dari default chain 'imagegen'."""
|
||||
return first_endpoint(path, "imagegen")
|
||||
|
||||
|
||||
def asr_endpoint(path=None):
|
||||
"""Endpoint speech-to-text (url, model, api_key) dari default chain 'asr'."""
|
||||
return first_endpoint(path, "asr")
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
# Fitur ASR / Push-to-Talk (TUI)
|
||||
|
||||
Menambahkan jenis model `asr` ke moop dan fitur push-to-talk di TUI:
|
||||
tekan tombol → bicara → stop → teks hasil transkripsi masuk ke form input
|
||||
(tidak auto-submit).
|
||||
|
||||
## moop
|
||||
- `MODEL_TYPES` += `asr` (self-healing: `INSERT OR IGNORE` saat init, DB lama aman).
|
||||
- `asr_endpoint()` helper ditambahkan.
|
||||
- TUI: tipe 'asr' muncul di Model > Select Model (label "Speech Transcription")
|
||||
dan di Manage Sets (add type / set default).
|
||||
|
||||
## Konfigurasi (config.yaml, blok `asr:`)
|
||||
asr:
|
||||
record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman
|
||||
record_key : f9 # f1..f12 / ctrl-<huruf>
|
||||
record_backend : pulse # pulse | alsa | oss | windows (dshow)
|
||||
device : default
|
||||
windows_mic : "" # backend=windows: nama device dshow
|
||||
format : wav # wav | ogg(libopus)
|
||||
sample_rate : 16000
|
||||
channels : 1
|
||||
max_seconds : 60 # auto-stop pengaman
|
||||
transcribe_timeout: 60
|
||||
keep_recordings : true
|
||||
|
||||
## Alur push-to-talk (interfaces/tui/talk.py)
|
||||
- `F9` mulai rekam (ffmpeg subprocess, non-blocking). Status bar: `● REC` (merah blink).
|
||||
- `F9` lagi ATAU `Enter` → stop → transkrip (thread) → hasil disisipkan ke form
|
||||
di posisi kursor. Status bar: `TRANSCRIBE`. `Enter` TIDAK submit; submit `Ctrl+Enter`.
|
||||
- `Backspace`/`Esc` → batal (rekaman dibuang / transkrip diabaikan, incl. race saat
|
||||
hasil sudah menunggu).
|
||||
- `Ctrl+C` tetap exit kapan pun; saat exit, rekaman aktif otomatis dibersihkan.
|
||||
- ffmpeg mencapai `max_seconds` → auto stop+transkrip.
|
||||
- Validasi: `asr` model set belum diset → pesan panduan; device bisu (peak RMS
|
||||
dari wav di bawah ambang) → "Rekaman kosong", transkrip dilewati.
|
||||
|
||||
## Transkripsi (tools/asr.py)
|
||||
- `POST {base_url}/audio/transcriptions` multipart (file + model), gaya OpenAI.
|
||||
- Memakai seluruh chain moop `asr` (rotasi model/provider ikut priority), pola
|
||||
sama dengan tools/imagegen.py. HTTP 200 + text kosong = valid ("no speech").
|
||||
- Provider/Model dicontohkan: OpenRouter `qwen/qwen3-asr-1.7b`
|
||||
(modality `audio->transcription`; endpoint `/audio/transcriptions` dikonfirmasi
|
||||
hidup; teruji HTTP 200 dengan usage.seconds dari tone 2 detik).
|
||||
|
||||
## Catatan WSL2
|
||||
- Output audio WSLg (sink) bekerja; input mic (RDPSource) TIDAK tersambung pada
|
||||
WSL 3.0.1/WSLg 1.0.79 (handshake RDP hanya accept `rdpsnd`, tanpa kanal audio-in;
|
||||
source RDPSource SUSPENDED, rekam hang). Jalur cadangan yang disiapkan:
|
||||
`record_backend: windows` (ffmpeg.exe + dshow via interop, simpan ke /mnt/c).
|
||||
302
tools/asr.py
302
tools/asr.py
@ -1,302 +0,0 @@
|
||||
# asr.py — Automatic Speech Recognition (STT).
|
||||
#
|
||||
# Dua tanggung jawab:
|
||||
# * start_recording() — spawn `ffmpeg` merekam mic (push-to-talk).
|
||||
# Backend input: pulse | alsa | oss | windows (ffmpeg.exe + dshow).
|
||||
# Stop graceful kirim 'q' ke stdin ffmpeg supaya header file tertutup
|
||||
# rapi (jangan pakai kill/terminate bila masih bisa).
|
||||
# * transcribe_file() — POST {base_url}/audio/transcriptions (multipart,
|
||||
# gaya OpenAI Whisper) ke semua kandidat chain moop tipe 'asr',
|
||||
# rotasi model/provider mengikuti priority (pola tools/imagegen.py).
|
||||
#
|
||||
# Parameter rekam (dir, format, device, dst) sengaja di-pass eksplisit dari
|
||||
# pemanggil (TUI membaca config.asr_*), agar modul ini tidak mengikat config.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from lib import moop
|
||||
|
||||
MIME_BY_EXT = {".wav": "audio/wav", ".ogg": "audio/ogg", ".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4", ".flac": "audio/flac", ".opus": "audio/ogg"}
|
||||
|
||||
|
||||
class RecorderError(Exception):
|
||||
"""Gagal menyiapkan/menghentikan proses rekam."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- recorder ---
|
||||
|
||||
def _resolve_ffmpeg(backend, ffmpeg_bin):
|
||||
"""ffmpeg binary untuk backend tertentu + cek ketersediaan."""
|
||||
if backend == "windows":
|
||||
exe = (ffmpeg_bin or "ffmpeg.exe").strip()
|
||||
found = shutil.which(exe)
|
||||
if not found:
|
||||
# Coba lokasi lazim bila PATH sesi belum di-refresh.
|
||||
for guess in ("ffmpeg.exe", "/mnt/c/ffmpeg/bin/ffmpeg.exe"):
|
||||
found = shutil.which(guess)
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
import glob
|
||||
hits = glob.glob("/mnt/c/Program Files*/ffmpeg*/bin/ffmpeg.exe")
|
||||
found = hits[0] if hits else None
|
||||
if not found:
|
||||
raise RecorderError(
|
||||
"ffmpeg.exe (Windows) tidak ditemukan. Install: winget install Gyan.FFmpeg"
|
||||
)
|
||||
return found
|
||||
found = shutil.which(ffmpeg_bin or "ffmpeg")
|
||||
if not found:
|
||||
raise RecorderError("ffmpeg tidak ditemukan di PATH. Install ffmpeg untuk rekam mic.")
|
||||
return found
|
||||
|
||||
|
||||
def _input_args(backend, device, windows_mic):
|
||||
"""Argument ffmpeg untuk sumber audio per backend."""
|
||||
if backend == "windows":
|
||||
if not windows_mic:
|
||||
raise RecorderError(
|
||||
"asr.windows_mic belum diisi (nama device dshow, contoh: "
|
||||
"\"Microphone Array (Realtek(R) Audio)\")."
|
||||
)
|
||||
return ["-f", "dshow", "-i", f"audio={windows_mic}"]
|
||||
if backend not in ("pulse", "alsa", "oss"):
|
||||
raise RecorderError(f"Backend rekam tidak dikenal: '{backend}' (pulse|alsa|oss|windows)")
|
||||
dev = device or "default"
|
||||
if backend == "oss" and dev == "default":
|
||||
dev = "/dev/dsp"
|
||||
return ["-f", backend, "-i", dev]
|
||||
|
||||
|
||||
def _encode_args(fmt, sample_rate, channels, opus_bitrate):
|
||||
"""Argument pemrosesan/encoding sesuai format output."""
|
||||
base = ["-ac", str(channels), "-ar", str(sample_rate)]
|
||||
if fmt == "ogg":
|
||||
return base + ["-c:a", "libopus", "-b:a", f"{opus_bitrate}k"]
|
||||
return base + ["-c:a", "pcm_s16le"]
|
||||
|
||||
|
||||
def build_record_command(ffmpeg_bin, backend, device, windows_mic, fmt,
|
||||
sample_rate, channels, max_seconds, out_path, opus_bitrate=24):
|
||||
cmd = [ffmpeg_bin, "-hide_banner", "-loglevel", "error"]
|
||||
cmd += _input_args(backend, device, windows_mic)
|
||||
cmd += _encode_args(fmt, sample_rate, channels, opus_bitrate)
|
||||
cmd += ["-t", str(max_seconds), "-y", out_path]
|
||||
return cmd
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Handle satu proses rekaman ffmpeg."""
|
||||
|
||||
def __init__(self, proc, path, started, min_bytes=1024):
|
||||
self.proc = proc
|
||||
self.path = path
|
||||
self.started = started
|
||||
self.min_bytes = min_bytes # ambang "rekaman kosong" (header saja)
|
||||
self._closed = False
|
||||
|
||||
def elapsed(self):
|
||||
return time.time() - self.started
|
||||
|
||||
def alive(self):
|
||||
return self.proc.poll() is None
|
||||
|
||||
def _graceful_stop(self, timeout=3.0):
|
||||
"""Kirim 'q' ke stdin (ffmpeg menutup file dengan benar)."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if self.proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
if self.proc.stdin:
|
||||
self.proc.stdin.write(b"q\n")
|
||||
self.proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
|
||||
@staticmethod
|
||||
def _peak_amplitude(path, max_bytes=512 * 1024):
|
||||
"""Peak |sample| dari wav PCM16 (stdlib wave). None bila tidak bisa
|
||||
dibaca/bukan wav (mis. ogg) → pemanggil menganggapnya 'tidak diketahui'."""
|
||||
try:
|
||||
import wave
|
||||
with wave.open(path, "rb") as w:
|
||||
if w.getsampwidth() != 2:
|
||||
return None
|
||||
raw = w.readframes(max_bytes // 2)
|
||||
if not raw:
|
||||
return None
|
||||
import array
|
||||
a = array.array("h")
|
||||
a.frombytes(raw[: len(raw) & ~1])
|
||||
return max((abs(s) for s in a), default=0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def stop(self, min_seconds=0.3, silence_peak=128):
|
||||
"""Hentikan rekaman. Returns (path, duration). RecorderError bila file
|
||||
hilang, terlalu pendek, atau kosong (device hidup tapi tanpa sinyal)."""
|
||||
duration = max(self.elapsed(), 0.0)
|
||||
self._graceful_stop()
|
||||
if not os.path.isfile(self.path):
|
||||
raise RecorderError("File rekaman tidak terbentuk (mic/perangkat tidak tersedia?).")
|
||||
size = os.path.getsize(self.path)
|
||||
peak = self._peak_amplitude(self.path) if self.path.endswith(".wav") else None
|
||||
too_short = duration < min_seconds or size <= self.min_bytes
|
||||
silent = peak is not None and peak < silence_peak
|
||||
if too_short or silent:
|
||||
try:
|
||||
os.remove(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
if silent:
|
||||
raise RecorderError(
|
||||
"Rekaman kosong (tidak ada sinyal mic). "
|
||||
"Cek device input / izin mikrofon."
|
||||
)
|
||||
raise RecorderError("Rekaman terlalu pendek, dibatalkan.")
|
||||
return self.path, duration
|
||||
|
||||
def cancel(self):
|
||||
"""Batalkan: bunuh proses, buang file."""
|
||||
if self.alive():
|
||||
try:
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._closed = True
|
||||
try:
|
||||
if os.path.isfile(self.path):
|
||||
os.remove(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def start_recording(record_dir, backend="pulse", device="default", fmt="wav",
|
||||
sample_rate=16000, channels=1, max_seconds=60,
|
||||
ffmpeg_bin=None, windows_mic=None, opus_bitrate=24):
|
||||
"""Mulai rekam mic (non-blocking). Returns Recorder."""
|
||||
fmt = (fmt or "wav").strip().lower()
|
||||
if fmt not in ("wav", "ogg"):
|
||||
raise RecorderError(f"Format rekam tidak dikenal: '{fmt}' (wav|ogg)")
|
||||
backend = (backend or "pulse").strip().lower()
|
||||
|
||||
exe = _resolve_ffmpeg(backend, ffmpeg_bin)
|
||||
|
||||
record_dir = os.path.expanduser(record_dir)
|
||||
try:
|
||||
os.makedirs(record_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise RecorderError(f"Gagal membuat folder rekam '{record_dir}': {e}")
|
||||
|
||||
out_path = os.path.join(
|
||||
record_dir, f"talk_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{fmt}"
|
||||
)
|
||||
cmd = build_record_command(
|
||||
exe, backend, device, windows_mic, fmt,
|
||||
sample_rate, channels, max_seconds, out_path, opus_bitrate,
|
||||
)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE, # untuk 'q' saat stop
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, # hindari pipe penuh memblok ffmpeg
|
||||
)
|
||||
except Exception as e:
|
||||
raise RecorderError(f"Gagal menjalankan ffmpeg: {e}")
|
||||
|
||||
# Beri kesempatan gagal instan (device tidak ada/izin ditolak) supaya
|
||||
# bisa langsung dilaporkan, bukan setelah user menekan stop.
|
||||
time.sleep(0.35)
|
||||
if proc.poll() is not None:
|
||||
raise RecorderError(
|
||||
f"ffmpeg langsung exit (kode {proc.returncode}) — device input "
|
||||
f"'{backend}:{device or 'default'}' tidak tersedia/tertunda."
|
||||
)
|
||||
return Recorder(proc, out_path, time.time())
|
||||
|
||||
|
||||
# -------------------------------------------------------------- transcribe ---
|
||||
|
||||
def transcribe_url(base_url):
|
||||
base = (base_url or "").rstrip("/")
|
||||
if base.endswith("/audio/transcriptions"):
|
||||
return base
|
||||
return base + "/audio/transcriptions"
|
||||
|
||||
|
||||
def transcribe_file(audio_path, timeout=60):
|
||||
"""Transkrip satu file audio memakai default chain moop 'asr'.
|
||||
|
||||
Returns dict: {"ok": True, "text": ..., "provider":..., "model":...}
|
||||
atau {"ok": False, "error": "..."}."""
|
||||
if not audio_path or not os.path.isfile(audio_path):
|
||||
return {"ok": False, "error": f"File audio tidak ditemukan: {audio_path}"}
|
||||
|
||||
chain = moop.default_chain(None, "asr")
|
||||
if not chain:
|
||||
return {"ok": False, "error":
|
||||
"Belum ada model set untuk tipe 'asr'. "
|
||||
"Atur via Model > Select Model → Speech Transcription (Model Option)."}
|
||||
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": f"Gagal membaca file: {e}"}
|
||||
|
||||
ext = os.path.splitext(audio_path)[1].lower()
|
||||
mime = MIME_BY_EXT.get(ext, "application/octet-stream")
|
||||
|
||||
last_err = ""
|
||||
for cand in chain:
|
||||
url = transcribe_url(cand["base_url"])
|
||||
headers = {}
|
||||
if cand.get("api_key"):
|
||||
headers["Authorization"] = f"Bearer {cand['api_key']}"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
files={"file": (os.path.basename(audio_path), data, mime)},
|
||||
data={"model": cand["model"]},
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
last_err = f"{cand['provider']}: {e}"
|
||||
continue
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
text = (resp.json() or {}).get("text", "")
|
||||
except ValueError:
|
||||
text = (resp.text or "").strip()
|
||||
# HTTP 200 + text kosong = valid (mis. audio tanpa ucapan) → jangan
|
||||
# dianggap kegagalan; pemanggil yang memutuskan tampil 'no speech'.
|
||||
return {"ok": True, "text": (text or "").strip(),
|
||||
"provider": cand["provider"], "model": cand["model"]}
|
||||
body = (resp.text or "")[:300].replace("\n", " ")
|
||||
last_err = f"{cand['provider']} [{cand['model']}]: HTTP {resp.status_code} {body}"
|
||||
|
||||
return {"ok": False, "error": f"Semua kandidat model ASR gagal. {last_err}"}
|
||||
@ -6,15 +6,6 @@ import glob as glob_module
|
||||
|
||||
from lib import workspace
|
||||
|
||||
# Batas panjang satu baris hasil pencarian. Mencegah satu baris raksasa
|
||||
# (mis. file minified/generated satu-line) menembus context window model.
|
||||
MAX_MATCH_LINE = 500
|
||||
|
||||
def _clip(line):
|
||||
if len(line) > MAX_MATCH_LINE:
|
||||
return line[:MAX_MATCH_LINE] + f"... [+{len(line) - MAX_MATCH_LINE} chars]"
|
||||
return line
|
||||
|
||||
schema_read_file = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
@ -154,7 +145,7 @@ def search_code(pattern, search_type, path="."):
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for i, line in enumerate(f.readlines(), 1):
|
||||
if re.search(pattern, line):
|
||||
results.append(f"{file_path}:{i}: {_clip(line.strip())}")
|
||||
results.append(f"{file_path}:{i}: {line.strip()}")
|
||||
except:
|
||||
continue
|
||||
return "\n".join(results[:50]) if results else "No matches found"
|
||||
@ -220,7 +211,7 @@ def search_grep(pattern, path=".", include=None):
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for i, line in enumerate(f.readlines(), 1):
|
||||
if re.search(pattern, line):
|
||||
results.append(f"{file_path}:{i}: {_clip(line.strip())}")
|
||||
results.append(f"{file_path}:{i}: {line.strip()}")
|
||||
except:
|
||||
continue
|
||||
return "\n".join(results[:100]) if results else "No matches found"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user