Skip to content
Open
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
9 changes: 9 additions & 0 deletions deploy/argent-lite-kiosk.desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Argent Lite Kiosk
Comment=Full-screen Argent Lite kiosk UI (walk-up satellite)
Exec=chromium --kiosk --noerrdialogs --disable-infobars http://127.0.0.1:7788
Icon=utilities-terminal
Terminal=false
Categories=Utility;
StartupNotify=false
46 changes: 46 additions & 0 deletions src/cli/kiosk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createKioskHandlers } from "../kiosk/handlers.js";
import { startKioskServer } from "../kiosk/server.js";

export async function runKiosk(
_argv: string[] = process.argv.slice(2),
): Promise<number> {
const handlers = createKioskHandlers({
async startCapture() {
process.stdout.write("[kiosk] mic capture not wired yet\n");
},
async stopCapture() {
process.stdout.write("[kiosk] mic capture not wired yet\n");
return undefined;
},
async cancelSpeech() {
process.stdout.write("[kiosk] speech cancel not wired yet\n");
},
});

const server = await startKioskServer({ handlers, port: 7788 });
process.stdout.write(`listening on http://127.0.0.1:${server.port}\n`);

await new Promise<void>((resolvePromise) => {
const onSig = (): void => {
process.off("SIGINT", onSig);
process.off("SIGTERM", onSig);
resolvePromise();
};
process.on("SIGINT", onSig);
process.on("SIGTERM", onSig);
});

await server.stop();
return 0;
}

if (import.meta.url === `file://${process.argv[1]}`) {
runKiosk().then(
(code) => process.exit(code),
(err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[argent-lite-kiosk] ${msg}\n`);
process.exit(1);
},
);
}
127 changes: 127 additions & 0 deletions src/kiosk/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
export type KioskState = "idle" | "listening" | "thinking" | "speaking";

export interface KioskStatus {
state: KioskState;
statusText: string;
}

export interface KioskTalkStartResult {
ok: boolean;
error?: string;
}

export interface KioskTalkEndResult {
ok: boolean;
transcript?: string;
error?: string;
}

export interface KioskInterruptResult {
ok: boolean;
error?: string;
}

export interface KioskHandlers {
status(): Promise<KioskStatus>;
talkStart(): Promise<KioskTalkStartResult>;
talkEnd(): Promise<KioskTalkEndResult>;
interrupt(): Promise<KioskInterruptResult>;
}

export interface KioskHandlerOptions {
startCapture?: () => Promise<void>;
stopCapture?: () => Promise<string | undefined>;
cancelSpeech?: () => Promise<void>;
getState?: () => KioskState;
}

const STATUS_TEXT: Record<KioskState, string> = {
idle: "Tap to talk",
listening: "Listening...",
thinking: "Thinking...",
speaking: "Speaking...",
};

function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

export function createKioskHandlers(
opts: KioskHandlerOptions = {},
): KioskHandlers {
let state: KioskState = "idle";

const startCapture =
opts.startCapture ??
(async (): Promise<void> => {
process.stdout.write("[kiosk] mic capture not wired yet\n");
});
const stopCapture =
opts.stopCapture ??
(async (): Promise<string | undefined> => {
process.stdout.write("[kiosk] mic capture not wired yet\n");
return undefined;
});
const cancelSpeech =
opts.cancelSpeech ??
(async (): Promise<void> => {
process.stdout.write("[kiosk] speech cancel not wired yet\n");
});

function currentState(): KioskState {
return opts.getState ? opts.getState() : state;
}

return {
async status() {
const s = currentState();
return { state: s, statusText: STATUS_TEXT[s] };
},

async talkStart() {
if (currentState() !== "idle") {
return { ok: false, error: `cannot start from state ${currentState()}` };
}
try {
state = "listening";
await startCapture();
return { ok: true };
} catch (err) {
state = "idle";
return { ok: false, error: errorMessage(err) };
}
},

async talkEnd() {
if (currentState() !== "listening") {
return {
ok: false,
error: `cannot end from state ${currentState()}`,
};
}
state = "thinking";
try {
const transcript = await stopCapture();
if (transcript && transcript.length > 0) {
state = "speaking";
return { ok: true, transcript };
}
state = "idle";
return { ok: true };
} catch (err) {
state = "idle";
return { ok: false, error: errorMessage(err) };
}
},

async interrupt() {
try {
await cancelSpeech();
state = "idle";
return { ok: true };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
},
};
}
197 changes: 197 additions & 0 deletions src/kiosk/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argent Lite Kiosk</title>
<style>
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: #0b0d10;
color: #e7ecf1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Ubuntu, sans-serif;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
#kiosk {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 48px;
}
#gear {
position: absolute;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: #1a1f25;
color: #8a96a4;
font-size: 22px;
cursor: pointer;
}
#gear:hover {
background: #232a32;
color: #e7ecf1;
}
#disc {
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(
circle at 50% 45%,
#6ab7ff 0%,
#3a78c7 28%,
#1c3a66 60%,
#0b0d10 85%
);
box-shadow:
0 0 80px rgba(106, 183, 255, 0.25),
inset 0 0 60px rgba(11, 13, 16, 0.6);
transform-origin: center;
animation: breath 2.4s ease-in-out infinite;
}
#disc.state-idle {
animation: breath 2.4s ease-in-out infinite;
}
#disc.state-listening {
animation: spin 6s linear infinite;
}
#disc.state-thinking {
animation: spin 1.4s linear infinite;
}
#disc.state-speaking {
animation: pulse 0.6s ease-in-out infinite;
}
@keyframes breath {
0%, 100% {
transform: scale(1);
opacity: 0.85;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
#status {
font-size: 28px;
font-weight: 300;
letter-spacing: 0.02em;
min-height: 36px;
text-align: center;
}
#talk {
width: 280px;
height: 100px;
border-radius: 20px;
border: none;
background: #6ab7ff;
color: #0b0d10;
font-size: 24px;
font-weight: 600;
cursor: pointer;
touch-action: manipulation;
}
#talk:active,
#talk.pressed {
background: #3a78c7;
color: #e7ecf1;
}
</style>
</head>
<body>
<main id="kiosk">
<button id="gear" type="button" aria-label="settings">⚙</button>
<div id="disc" class="state-idle" role="img" aria-label="assistant"></div>
<div id="status">Tap to talk</div>
<button id="talk" type="button">Tap to Talk</button>
</main>
<script>
(function () {
var disc = document.getElementById("disc");
var statusEl = document.getElementById("status");
var talk = document.getElementById("talk");
var gear = document.getElementById("gear");

function applyStatus(s) {
if (!s || !s.state) return;
disc.className = "state-" + s.state;
statusEl.textContent = s.statusText || "";
}

function post(path) {
return fetch(path, { method: "POST" }).then(function (r) {
return r.json();
});
}

talk.addEventListener("pointerdown", function (e) {
e.preventDefault();
talk.classList.add("pressed");
post("/api/talk/start").catch(function () {});
});
function release() {
if (!talk.classList.contains("pressed")) return;
talk.classList.remove("pressed");
post("/api/talk/end").catch(function () {});
}
talk.addEventListener("pointerup", release);
talk.addEventListener("pointercancel", release);
talk.addEventListener("pointerleave", release);

gear.addEventListener("click", function () {
alert("settings panel ships cycle-22");
});

fetch("/api/status")
.then(function (r) {
return r.json();
})
.then(applyStatus)
.catch(function () {});

try {
var es = new EventSource("/events");
es.addEventListener("status", function (ev) {
try {
applyStatus(JSON.parse(ev.data));
} catch (_) {}
});
} catch (_) {}
})();
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions src/kiosk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export {
createKioskHandlers,
type KioskHandlers,
type KioskHandlerOptions,
type KioskState,
type KioskStatus,
type KioskTalkStartResult,
type KioskTalkEndResult,
type KioskInterruptResult,
} from "./handlers.js";
export {
startKioskServer,
type RunningKioskServer,
type StartKioskServerOptions,
} from "./server.js";
Loading
Loading