mirror of
https://github.com/Melorise/TamaASR.git
synced 2026-11-07 14:39:32 +00:00
加入 Unix Socket v2 协议、会话状态机、动态快捷键、完整 preedit 更新、结束提交以及断线重连和安全取消。 AI-assisted-by: Codex AI-model: gpt-5.6-sol AI-reasoning-effort: medium Co-authored-by: Codex <codex@openai.com>
407 lines
16 KiB
C++
407 lines
16 KiB
C++
#include <cerrno>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
#include <fcntl.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <fcitx-utils/event.h>
|
|
#include <fcitx-utils/eventloopinterface.h>
|
|
#include <fcitx-utils/capabilityflags.h>
|
|
#include <fcitx-utils/key.h>
|
|
#include <fcitx-utils/keysym.h>
|
|
#include <fcitx-utils/log.h>
|
|
#include <fcitx/addonfactory.h>
|
|
#include <fcitx/addoninstance.h>
|
|
#include <fcitx/addonmanager.h>
|
|
#include <fcitx/event.h>
|
|
#include <fcitx/inputcontext.h>
|
|
#include <fcitx/inputpanel.h>
|
|
#include <fcitx/instance.h>
|
|
#include <fcitx/text.h>
|
|
|
|
#include "protocolsession.h"
|
|
|
|
namespace {
|
|
|
|
constexpr int kProtocolVersion = 2;
|
|
constexpr std::size_t kMaxBufferBytes = 1024 * 1024;
|
|
|
|
class VoiceInputAddon final : public fcitx::AddonInstance {
|
|
public:
|
|
explicit VoiceInputAddon(fcitx::Instance *instance)
|
|
: instance_(instance), trigger_("Control+Shift+space") {
|
|
keyWatcher_ = instance_->watchEvent(
|
|
fcitx::EventType::InputContextKeyEvent,
|
|
// 必须在原输入法之前吞掉快捷键;否则长按产生的重复事件会先触发
|
|
// Fcitx5 自身的输入法切换窗口,并打断网页录音焦点。
|
|
fcitx::EventWatcherPhase::PreInputMethod,
|
|
[this](fcitx::Event &event) {
|
|
handleKeyEvent(static_cast<fcitx::KeyEvent &>(event));
|
|
});
|
|
focusOutWatcher_ = instance_->watchEvent(
|
|
fcitx::EventType::InputContextFocusOut,
|
|
fcitx::EventWatcherPhase::PostInputMethod,
|
|
[this](fcitx::Event &event) {
|
|
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
|
|
});
|
|
focusInWatcher_ = instance_->watchEvent(
|
|
fcitx::EventType::InputContextFocusIn,
|
|
fcitx::EventWatcherPhase::PostInputMethod,
|
|
[this](fcitx::Event &event) {
|
|
focusedInputContext_ =
|
|
static_cast<fcitx::InputContextEvent &>(event).inputContext();
|
|
});
|
|
resetWatcher_ = instance_->watchEvent(
|
|
fcitx::EventType::InputContextReset,
|
|
fcitx::EventWatcherPhase::PostInputMethod,
|
|
[this](fcitx::Event &event) {
|
|
cancelForContext(static_cast<fcitx::InputContextEvent &>(event).inputContext());
|
|
});
|
|
destroyedWatcher_ = instance_->watchEvent(
|
|
fcitx::EventType::InputContextDestroyed,
|
|
fcitx::EventWatcherPhase::PostInputMethod,
|
|
[this](fcitx::Event &event) {
|
|
auto *context = static_cast<fcitx::InputContextEvent &>(event).inputContext();
|
|
if (context == focusedInputContext_) focusedInputContext_ = nullptr;
|
|
if (context == inputContext_) {
|
|
sendControl("request-cancel");
|
|
resetLocalState(false, false);
|
|
}
|
|
});
|
|
reconnectTimer_ = instance_->eventLoop().addTimeEvent(
|
|
CLOCK_MONOTONIC, fcitx::now(CLOCK_MONOTONIC) + 100000, 100000,
|
|
[this](fcitx::EventSourceTime *source, uint64_t) {
|
|
if (socketFd_ < 0) ensureConnected();
|
|
source->setNextInterval(1000000);
|
|
// 时间事件触发后会退出 one-shot 状态;重新启用才能持续探测
|
|
// Electron 的重启和冷启动。
|
|
source->setOneShot();
|
|
return true;
|
|
});
|
|
}
|
|
|
|
~VoiceInputAddon() override {
|
|
if (inputContext_) {
|
|
clearPreedit();
|
|
restorePreeditState();
|
|
}
|
|
disconnectSocket();
|
|
}
|
|
|
|
private:
|
|
void handleKeyEvent(fcitx::KeyEvent &event) {
|
|
const bool isTriggerPress = !event.isRelease() && event.key().check(trigger_);
|
|
const bool isTriggerRelease = event.isRelease() && held_ &&
|
|
event.rawKey().sym() == trigger_.sym();
|
|
|
|
if (isTriggerPress) {
|
|
event.filterAndAccept();
|
|
if (event.rawKey().states().test(fcitx::KeyState::Repeat)) return;
|
|
FCITX_INFO() << "网页语音输入:快捷键按下 held=" << held_
|
|
<< " awaiting=" << awaitingStart_
|
|
<< " active=" << session_.active();
|
|
if (held_) return;
|
|
if (!ensureConnected()) {
|
|
FCITX_WARN() << "网页语音输入:Electron Socket 尚未就绪";
|
|
return;
|
|
}
|
|
held_ = true;
|
|
if (!prepareInputContext(event.inputContext())) held_ = false;
|
|
return;
|
|
}
|
|
|
|
if (isTriggerRelease) {
|
|
event.filterAndAccept();
|
|
FCITX_INFO() << "网页语音输入:快捷键松开 awaiting=" << awaitingStart_
|
|
<< " active=" << session_.active();
|
|
held_ = false;
|
|
if (awaitingStart_ || session_.active()) sendControl("request-stop");
|
|
return;
|
|
}
|
|
|
|
if (!session_.active() && !awaitingStart_) return;
|
|
if (event.isRelease() || event.key().isModifier()) return;
|
|
|
|
if (event.key().check(FcitxKey_Escape)) {
|
|
event.filterAndAccept();
|
|
sendControl("request-cancel");
|
|
clearPreedit();
|
|
resetLocalState(false);
|
|
return;
|
|
}
|
|
|
|
// 用户开始键盘输入时,立即取消语音会话,并让该按键继续交给原输入法。
|
|
sendControl("request-cancel");
|
|
clearPreedit();
|
|
resetLocalState(false);
|
|
}
|
|
|
|
bool prepareInputContext(fcitx::InputContext *context) {
|
|
if (!context || !context->hasFocus()) return false;
|
|
if (!context->inputPanel().clientPreedit().empty() ||
|
|
!context->inputPanel().preedit().empty()) {
|
|
FCITX_WARN() << "MeloASR:当前输入法存在未提交文本,本轮未启动";
|
|
return false;
|
|
}
|
|
inputContext_ = context;
|
|
preeditWasEnabled_ = inputContext_->isPreeditEnabled();
|
|
preeditStateCaptured_ = true;
|
|
inputContext_->setEnablePreedit(true);
|
|
awaitingStart_ = true;
|
|
sendControl("request-start");
|
|
return true;
|
|
}
|
|
|
|
bool ensureConnected() {
|
|
if (socketFd_ >= 0) {
|
|
char byte;
|
|
const ssize_t probe = ::recv(socketFd_, &byte, sizeof(byte), MSG_PEEK | MSG_DONTWAIT);
|
|
if (probe > 0 || (probe < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) {
|
|
return true;
|
|
}
|
|
FCITX_INFO() << "网页语音输入:检测到失效的 Electron Socket,立即重连";
|
|
handleDisconnect();
|
|
}
|
|
const char *runtimeDir = std::getenv("XDG_RUNTIME_DIR");
|
|
if (!runtimeDir || !*runtimeDir) return false;
|
|
const std::string socketPath = std::string(runtimeDir) + "/meloasr/fcitx5.sock";
|
|
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
|
if (fd < 0) return false;
|
|
sockaddr_un address{};
|
|
if (socketPath.size() >= sizeof(address.sun_path)) {
|
|
::close(fd);
|
|
return false;
|
|
}
|
|
address.sun_family = AF_UNIX;
|
|
std::memcpy(address.sun_path, socketPath.c_str(), socketPath.size() + 1);
|
|
if (::connect(fd, reinterpret_cast<sockaddr *>(&address), sizeof(address)) < 0) {
|
|
::close(fd);
|
|
return false;
|
|
}
|
|
const int flags = ::fcntl(fd, F_GETFL, 0);
|
|
if (flags >= 0) ::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
|
socketFd_ = fd;
|
|
socketWatcher_ = instance_->eventLoop().addIOEvent(
|
|
socketFd_, fcitx::IOEventFlags{fcitx::IOEventFlag::In, fcitx::IOEventFlag::Err,
|
|
fcitx::IOEventFlag::Hup},
|
|
[this](fcitx::EventSourceIO *, int, fcitx::IOEventFlags flags) {
|
|
if (flags.test(fcitx::IOEventFlag::Err) || flags.test(fcitx::IOEventFlag::Hup)) {
|
|
handleDisconnect();
|
|
return false;
|
|
}
|
|
return readMessages();
|
|
});
|
|
return writeJson({{"type", "hello"}, {"protocol", kProtocolVersion}});
|
|
}
|
|
|
|
bool readMessages() {
|
|
char chunk[8192];
|
|
while (true) {
|
|
const ssize_t size = ::recv(socketFd_, chunk, sizeof(chunk), 0);
|
|
if (size > 0) {
|
|
readBuffer_.append(chunk, static_cast<std::size_t>(size));
|
|
if (readBuffer_.size() > kMaxBufferBytes) {
|
|
handleDisconnect();
|
|
return false;
|
|
}
|
|
consumeLines();
|
|
continue;
|
|
}
|
|
if (size == 0) {
|
|
handleDisconnect();
|
|
return false;
|
|
}
|
|
if (errno == EINTR) continue;
|
|
if (errno == EAGAIN || errno == EWOULDBLOCK) return true;
|
|
handleDisconnect();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void consumeLines() {
|
|
std::size_t newline;
|
|
while ((newline = readBuffer_.find('\n')) != std::string::npos) {
|
|
std::string line = readBuffer_.substr(0, newline);
|
|
readBuffer_.erase(0, newline + 1);
|
|
if (line.empty()) continue;
|
|
try {
|
|
handleMessage(nlohmann::json::parse(line));
|
|
} catch (const std::exception &error) {
|
|
FCITX_WARN() << "网页语音输入协议解析失败:" << error.what();
|
|
}
|
|
}
|
|
}
|
|
|
|
void handleMessage(const nlohmann::json &message) {
|
|
if (!message.is_object() || !message.contains("type") || !message["type"].is_string()) return;
|
|
const std::string type = message["type"].get<std::string>();
|
|
if (type == "hello") {
|
|
if (message.value("protocol", 0) != kProtocolVersion) handleDisconnect();
|
|
return;
|
|
}
|
|
if (type == "configure") {
|
|
if (!message.contains("shortcut") || !message["shortcut"].is_string() ||
|
|
awaitingStart_ || session_.active()) return;
|
|
fcitx::Key candidate(message["shortcut"].get<std::string>());
|
|
if (!candidate.isValid() || !candidate.hasModifier()) {
|
|
FCITX_WARN() << "MeloASR:拒绝无效快捷键";
|
|
return;
|
|
}
|
|
trigger_ = std::move(candidate);
|
|
FCITX_INFO() << "MeloASR:快捷键已更新为 " << trigger_.toString();
|
|
return;
|
|
}
|
|
if (type == "activate") {
|
|
if (!awaitingStart_ && !session_.active()) {
|
|
prepareInputContext(focusedInputContext_);
|
|
}
|
|
return;
|
|
}
|
|
if (type == "start") {
|
|
if (!awaitingStart_ || !inputContext_ || !inputContext_->hasFocus()) return;
|
|
if (!message.contains("sessionId") || !message["sessionId"].is_string()) return;
|
|
session_.start(message["sessionId"].get<std::string>());
|
|
awaitingStart_ = false;
|
|
FCITX_INFO() << "网页语音输入:会话开始,clientPreedit="
|
|
<< inputContext_->capabilityFlags().test(fcitx::CapabilityFlag::Preedit);
|
|
setPreedit("");
|
|
return;
|
|
}
|
|
if (type == "cancel") {
|
|
const std::string incoming = message.value("sessionId", std::string());
|
|
if (!incoming.empty() && !session_.matches(incoming)) return;
|
|
clearPreedit();
|
|
resetLocalState(false);
|
|
return;
|
|
}
|
|
if (type == "update" || type == "finish") {
|
|
if (!message.contains("revision") || !message["revision"].is_number_integer() ||
|
|
!message.contains("text") || !message["text"].is_string()) return;
|
|
const std::int64_t incomingRevision = message["revision"].get<std::int64_t>();
|
|
if (!session_.accept(message.value("sessionId", std::string()), incomingRevision)) return;
|
|
currentText_ = message["text"].get<std::string>();
|
|
FCITX_INFO() << "网页语音输入:收到 " << type << " revision="
|
|
<< incomingRevision << " bytes=" << currentText_.size();
|
|
setPreedit(currentText_);
|
|
if (type == "finish") {
|
|
if (inputContext_ && inputContext_->hasFocus() && !currentText_.empty()) {
|
|
inputContext_->commitString(currentText_);
|
|
}
|
|
clearPreedit();
|
|
resetLocalState(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void setPreedit(const std::string &text) {
|
|
if (!inputContext_ || !inputContext_->hasFocus()) return;
|
|
fcitx::Text formatted(text);
|
|
formatted.setCursor(static_cast<int>(text.size()));
|
|
inputContext_->inputPanel().setClientPreedit(formatted);
|
|
inputContext_->updatePreedit();
|
|
}
|
|
|
|
void clearPreedit() {
|
|
if (!inputContext_) return;
|
|
inputContext_->inputPanel().setClientPreedit(fcitx::Text());
|
|
if (inputContext_->hasFocus()) {
|
|
inputContext_->updatePreedit();
|
|
}
|
|
}
|
|
|
|
void cancelForContext(fcitx::InputContext *context) {
|
|
if (context == focusedInputContext_) focusedInputContext_ = nullptr;
|
|
if (context != inputContext_) return;
|
|
sendControl("request-cancel");
|
|
clearPreedit();
|
|
resetLocalState(false);
|
|
}
|
|
|
|
void restorePreeditState() {
|
|
if (inputContext_ && preeditStateCaptured_) {
|
|
inputContext_->setEnablePreedit(preeditWasEnabled_);
|
|
}
|
|
preeditStateCaptured_ = false;
|
|
}
|
|
|
|
void resetLocalState(bool keepContext, bool restorePreedit = true) {
|
|
if (restorePreedit) restorePreeditState();
|
|
held_ = false;
|
|
awaitingStart_ = false;
|
|
session_.reset();
|
|
currentText_.clear();
|
|
if (!keepContext) inputContext_ = nullptr;
|
|
}
|
|
|
|
bool sendControl(const char *type) { return writeJson({{"type", type}}); }
|
|
|
|
bool writeJson(const nlohmann::json &message) {
|
|
if (socketFd_ < 0) return false;
|
|
const std::string line = message.dump() + "\n";
|
|
std::size_t offset = 0;
|
|
while (offset < line.size()) {
|
|
const ssize_t written = ::send(socketFd_, line.data() + offset, line.size() - offset, MSG_NOSIGNAL);
|
|
if (written > 0) {
|
|
offset += static_cast<std::size_t>(written);
|
|
continue;
|
|
}
|
|
if (written < 0 && errno == EINTR) continue;
|
|
handleDisconnect();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void handleDisconnect() {
|
|
clearPreedit();
|
|
resetLocalState(false);
|
|
disconnectSocket();
|
|
}
|
|
|
|
void disconnectSocket() {
|
|
socketWatcher_.reset();
|
|
if (socketFd_ >= 0) ::close(socketFd_);
|
|
socketFd_ = -1;
|
|
readBuffer_.clear();
|
|
}
|
|
|
|
fcitx::Instance *instance_;
|
|
fcitx::Key trigger_;
|
|
fcitx::InputContext *inputContext_ = nullptr;
|
|
fcitx::InputContext *focusedInputContext_ = nullptr;
|
|
bool held_ = false;
|
|
bool awaitingStart_ = false;
|
|
bool preeditStateCaptured_ = false;
|
|
bool preeditWasEnabled_ = true;
|
|
ProtocolSession session_;
|
|
std::string currentText_;
|
|
int socketFd_ = -1;
|
|
std::string readBuffer_;
|
|
std::unique_ptr<fcitx::EventSourceIO> socketWatcher_;
|
|
std::unique_ptr<fcitx::EventSourceTime> reconnectTimer_;
|
|
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> keyWatcher_;
|
|
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> focusOutWatcher_;
|
|
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> focusInWatcher_;
|
|
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> resetWatcher_;
|
|
std::unique_ptr<fcitx::HandlerTableEntry<fcitx::EventHandler>> destroyedWatcher_;
|
|
};
|
|
|
|
class VoiceInputAddonFactory final : public fcitx::AddonFactory {
|
|
public:
|
|
fcitx::AddonInstance *create(fcitx::AddonManager *manager) override {
|
|
return new VoiceInputAddon(manager->instance());
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
FCITX_ADDON_FACTORY(VoiceInputAddonFactory);
|