Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,22 @@ The browser component is also published as
https://github.com/TencentCloud/octop-browser
Copyright (c) 2026 Octop
Licensed under the MIT License.

Jonathan Ai voice is adapted from Octop's voice manager: an active
speech-to-text provider, an active text-to-speech provider, and a single
place that dispatches a recording or a spoken reply. Jonathan Ai does not
vendor Octop's browser, Edge, Tencent, or MiMo presets. The default
speech-to-text engine is local faster-whisper and the default voice is
local Piper. OpenAI and xAI are optional and run only after they are
selected. Microphones and speakers, including Bluetooth headsets, are
chosen from the Windows WASAPI devices or the Linux PulseAudio and
PipeWire devices. Push-to-talk is the default. The wake word is off until
the user turns it on, and that check stays on this machine. Model files
are fetched from Hugging Face or the official Piper and Whisper sources
and checked with SHA-256. The optional install extra is voice
(pip install "clawd-codex[voice]").

Octop voice
https://github.com/TencentCloud/Octop
Copyright (c) 2026 Octop
Licensed under the MIT License.
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ dev = [
browser = [
"websocket-client>=1.7.0",
]
voice = [
"faster-whisper>=1.0.0",
"sounddevice>=0.4.6",
"piper-tts>=1.2.0",
]

[project.scripts]
clawd = "src.cli:main"
Expand Down
15 changes: 15 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def main():
clawd memory delete <id> Delete one local memory
clawd cron list List local scheduled jobs
clawd cron run <id> Run one scheduled job now
clawd voice settings Show local speech settings
clawd Start interactive REPL
"""
)
Expand Down Expand Up @@ -106,6 +107,9 @@ def main():
cron_logs = cron_sub.add_parser('logs', help='Show scheduler logs')
cron_logs.add_argument('job_id', nargs='?')

voice_parser = subparsers.add_parser('voice', help='Local speech, push-to-talk, and devices')
voice_parser.add_argument('words', nargs='*', help='settings, devices, stt, tts, ptt, wake, say')

args = parser.parse_args()

# Handle --version
Expand Down Expand Up @@ -183,6 +187,8 @@ def main():
return handle_memory(args)
elif args.command == 'cron':
return handle_cron(args)
elif args.command == 'voice':
return handle_voice(args)

# Default: start REPL
return start_repl(stream=args.stream)
Expand Down Expand Up @@ -293,6 +299,15 @@ def handle_cron(args) -> int:
return 0


def handle_voice(args) -> int:
"""Show or change speech settings stored on this machine."""
from src.voice.service import format_voice_command

words = getattr(args, "words", None) or []
print(format_voice_command(" ".join(words)))
return 0


def start_repl(stream: bool = False):
"""Start interactive REPL."""
from src.config import get_default_provider
Expand Down
2 changes: 2 additions & 0 deletions src/command_system/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
CRON_COMMAND,
INIT_COMMAND,
MEMORY_COMMAND,
VOICE_COMMAND,
SKILLS_COMMAND,
execute_command_async,
execute_command_sync,
Expand Down Expand Up @@ -94,6 +95,7 @@
"COMPACT_COMMAND",
"CRON_COMMAND",
"MEMORY_COMMAND",
"VOICE_COMMAND",
"INIT_COMMAND",
"get_builtin_commands",
"register_builtin_commands",
Expand Down
20 changes: 20 additions & 0 deletions src/command_system/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,22 @@ def cron_command_call(args: str, context: CommandContext) -> LocalCommandResult:

return LocalCommandResult(type="text", value=format_cron_command(args))


VOICE_COMMAND = LocalCommand(
name="voice",
description="Listen and speak on this machine, or choose a cloud voice",
argument_hint="settings | devices | stt local|openai|xai | tts local|openai|xai | ptt on|off | wake on|off | say <text>",
supports_non_interactive=True,
)


def voice_command_call(args: str, context: CommandContext) -> LocalCommandResult:
"""Show or change local speech settings. Audio stays here unless a cloud provider is selected."""
del context
from ..voice.service import format_voice_command

return LocalCommandResult(type="text", value=format_voice_command(args))

INIT_COMMAND = PromptCommand(
name="init",
description="Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation",
Expand Down Expand Up @@ -684,6 +700,8 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t
result = memory_command_call(args, context)
elif cmd is CRON_COMMAND:
result = cron_command_call(args, context)
elif cmd is VOICE_COMMAND:
result = voice_command_call(args, context)
else:
return False, None, f"Command not implemented for sync execution: {cmd_name}"

Expand All @@ -702,6 +720,7 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t
COMPACT_COMMAND.set_call(compact_command_call)
MEMORY_COMMAND.set_call(memory_command_call)
CRON_COMMAND.set_call(cron_command_call)
VOICE_COMMAND.set_call(voice_command_call)


def get_builtin_commands() -> list[Command]:
Expand All @@ -716,6 +735,7 @@ def get_builtin_commands() -> list[Command]:
COMPACT_COMMAND,
MEMORY_COMMAND,
CRON_COMMAND,
VOICE_COMMAND,
INIT_COMMAND,
]

Expand Down
1 change: 1 addition & 0 deletions src/desktop/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,7 @@ def _format_palette(self) -> str:
- `/memory` — list, recall, or delete local memories (`/memory delete <id>`)
- `/cron` — list, run, enable, disable, or delete local schedules (`/cron run <id>`)
- Browser tool — local Chrome over CDP. Logins stay in the profile. Submits, purchases, and logins need approval.
- `/voice` — local speech and speakers (`/voice settings`, `/voice say hello`)

Type a message to run the existing agent loop. Destructive and network tools
prompt for approval before they run.
Expand Down
12 changes: 12 additions & 0 deletions src/desktop/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body:
return 200, runtime.update_status(refresh=True)
if path == "/api/update/apply" and method == "POST":
return 200, runtime.apply_update()
if path == "/api/voice" and method == "GET":
from src.voice.service import voice_status

return 200, voice_status()
if path == "/api/voice" and method == "POST":
from src.voice.service import apply_voice_settings

return 200, apply_voice_settings(body)
if path == "/api/voice/ptt" and method == "POST":
from src.voice.service import push_to_talk_action

return 200, push_to_talk_action(str(body.get("action") or ""))
return 404, {"error": f"unknown route: {method} {path}"}

def serve_forever(self) -> None:
Expand Down
85 changes: 85 additions & 0 deletions src/desktop/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ function applyRoute() {
const showSetup = route === "setup" || (route === "chat" && Boolean(state.status?.needs_setup));
$("setup-modal")?.classList.toggle("hidden", !showSetup);
if (route === "library") refreshLibrary().catch(reportError);
if (route === "settings") loadVoiceSettings().catch(reportError);
}

async function refreshLibrary() {
Expand Down Expand Up @@ -775,7 +776,90 @@ function renderScheduleList(items) {
}
}

function fillVoiceDevices(select, devices, kind, selected) {
select.innerHTML = "";
const fallback = document.createElement("option");
fallback.value = "";
fallback.textContent = kind === "input" ? "Default microphone" : "Default speaker";
select.appendChild(fallback);
for (const device of devices.filter((item) => item.kind === kind)) {
const option = document.createElement("option");
option.value = device.id;
const marks = [device.host_api];
if (device.bluetooth) marks.push("Bluetooth");
if (device.default) marks.push("default");
option.textContent = `${device.name} (${marks.join(", ")})`;
select.appendChild(option);
}
select.value = selected || "";
}

async function loadVoiceSettings() {
try {
const data = await api("/api/voice");
const settings = data.settings || {};
$("voice-stt").value = settings.stt_provider || "local";
$("voice-tts").value = settings.tts_provider || "local";
$("voice-ptt-enabled").checked = settings.push_to_talk !== false;
$("voice-wake").checked = Boolean(settings.wake_word_enabled);
$("voice-wake-phrase").value = settings.wake_word || "jonathan";
fillVoiceDevices($("voice-input"), data.devices || [], "input", settings.input_device);
fillVoiceDevices($("voice-output"), data.devices || [], "output", settings.output_device);
$("voice-note").textContent = settings.note || data.device_error || "";
} catch (err) {
$("voice-note").textContent = err.message;
}
}

async function saveVoiceSettings() {
const data = await api("/api/voice", {
method: "POST",
body: JSON.stringify({
stt_provider: $("voice-stt").value,
tts_provider: $("voice-tts").value,
input_device: $("voice-input").value,
output_device: $("voice-output").value,
push_to_talk: $("voice-ptt-enabled").checked,
wake_word_enabled: $("voice-wake").checked,
wake_word: $("voice-wake-phrase").value,
}),
});
$("voice-note").textContent = data.settings?.note || "";
}

function bindVoiceTalk() {
const button = $("voice-ptt");
if (!button) return;
const start = async (event) => {
event.preventDefault();
button.classList.add("holding");
try {
await api("/api/voice/ptt", { method: "POST", body: JSON.stringify({ action: "start" }) });
} catch (err) {
button.classList.remove("holding");
addBubble("system", err.message);
}
};
const stop = async () => {
if (!button.classList.contains("holding")) return;
button.classList.remove("holding");
try {
const data = await api("/api/voice/ptt", { method: "POST", body: JSON.stringify({ action: "stop" }) });
if (data.text) {
const box = $("prompt");
box.value = `${box.value ? `${box.value} ` : ""}${data.text}`;
}
} catch (err) {
addBubble("system", err.message);
}
};
button.addEventListener("pointerdown", start);
button.addEventListener("pointerup", stop);
button.addEventListener("pointerleave", stop);
}

function bindUi() {
bindVoiceTalk();
$("send").addEventListener("click", () => sendMessage("/api/chat"));
$("workers")?.addEventListener("click", () => sendMessage("/api/multi-agent"));
$("nav-toggle")?.addEventListener("click", () => {
Expand Down Expand Up @@ -919,6 +1003,7 @@ function bindUi() {
});
}
$("settings-key").value = "";
await saveVoiceSettings();
go("chat");
await refreshStatus();
} catch (err) {
Expand Down
29 changes: 29 additions & 0 deletions src/desktop/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ <h1 id="chat-title">New chat</h1>
<button id="attach-files" class="ghost" title="Attach files">Attach</button>
<button id="attach-clipboard" class="ghost" title="Attach current clipboard text">Clipboard</button>
<button id="attach-screenshot" class="ghost hidden" title="Capture the current screen">Screenshot</button>
<button id="voice-ptt" class="ghost" type="button" title="Hold to talk through the selected microphone">Hold to talk</button>
<input id="file-input" type="file" multiple hidden />
</div>
<button id="workers" class="ghost" title="Plan this into isolated internal workers">Workers</button>
Expand Down Expand Up @@ -165,6 +166,34 @@ <h2>Provider &amp; model</h2>
</div>
<div id="settings-local-results" class="scan-list"></div>
</div>
<h3>Voice</h3>
<p class="muted">Speech stays on this machine unless you choose OpenAI or xAI. Install the extra with <code>pip install "clawd-codex[voice]"</code>. Push-to-talk uses the selected microphone and speaker, including Bluetooth on Windows WASAPI and Linux PulseAudio or PipeWire.</p>
<label>Speech to text
<select id="voice-stt">
<option value="local">Local Whisper</option>
<option value="openai">OpenAI</option>
<option value="xai">xAI</option>
</select>
</label>
<label>Text to speech
<select id="voice-tts">
<option value="local">Local Piper</option>
<option value="openai">OpenAI</option>
<option value="xai">xAI</option>
</select>
</label>
<label>Microphone
<select id="voice-input"></select>
</label>
<label>Speaker
<select id="voice-output"></select>
</label>
<label class="check"><input id="voice-ptt-enabled" type="checkbox" checked /> Push to talk</label>
<label class="check"><input id="voice-wake" type="checkbox" /> Wake word (optional, stays on this machine)</label>
<label>Wake phrase
<input id="voice-wake-phrase" type="text" value="jonathan" />
</label>
<p id="voice-note" class="muted"></p>
<p class="muted">Source and updates: <code>https://github.com/GoDeskio/Clawd-Code</code>. Default local tree: <code>~/Jonathan/Jonathan-Ai</code>.</p>
<div class="row end">
<button id="settings-close" class="ghost">Close</button>
Expand Down
3 changes: 3 additions & 0 deletions src/desktop/web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ button:disabled { opacity: 0.55; cursor: default; }
padding: 8px 10px;
}
.composer-row { justify-content: space-between; margin-top: 6px; }
#voice-ptt.holding { outline: 2px solid var(--accent); }
label.check { display: flex; align-items: center; }
.dialog input[type="checkbox"] { width: auto; min-height: 0; margin: 0 8px 0 0; }
.end { justify-content: flex-end; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
.chip {
Expand Down
Loading
Loading