Files
nexus/internal/api/leviathan.go
T
2026-06-16 11:51:07 +01:00

908 lines
25 KiB
Go

package api
import (
"archive/zip"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/andydixon/nexus/internal/leviathan/analysis"
"github.com/andydixon/nexus/internal/leviathan/binsnoop"
"github.com/andydixon/nexus/internal/leviathan/disasm"
"github.com/andydixon/nexus/internal/leviathan/document"
"github.com/andydixon/nexus/internal/leviathan/parsers"
"github.com/andydixon/nexus/internal/leviathan/patch"
"github.com/andydixon/nexus/internal/leviathan/pdf"
"github.com/andydixon/nexus/internal/leviathan/spectrogram"
"github.com/andydixon/nexus/internal/leviathan/squashfs"
"github.com/andydixon/nexus/internal/leviathan/stego"
"github.com/andydixon/nexus/internal/leviathan/transforms"
)
type leviathanState struct {
mu sync.Mutex
doc *document.Document
cmp *document.Document
}
type leviathanDocState struct {
Name string `json:"name"`
Size int64 `json:"size"`
Dirty bool `json:"dirty"`
UndoLen int `json:"undoLen"`
RedoLen int `json:"redoLen"`
Compare *leviathanDocState `json:"compare,omitempty"`
PreviewHex []map[string]any `json:"previewHex"`
}
func (s *Service) leviathanEngine() *leviathanState {
if s.leviathan == nil {
s.leviathan = &leviathanState{}
}
return s.leviathan
}
// LeviathanOpenFile prompts for a binary and loads it into the Leviathan workbench.
func (s *Service) LeviathanOpenFile() (map[string]any, error) {
if s.ctx == nil {
return nil, fmt.Errorf("application context not set")
}
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Open Binary",
})
if err != nil || path == "" {
return nil, err
}
return s.LeviathanLoadFile(path, false)
}
// LeviathanOpenCompare prompts for a binary to compare against the working file.
func (s *Service) LeviathanOpenCompare() (map[string]any, error) {
if s.ctx == nil {
return nil, fmt.Errorf("application context not set")
}
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Open Compare Binary",
})
if err != nil || path == "" {
return nil, err
}
return s.LeviathanLoadFile(path, true)
}
// LeviathanLoadFile loads a path supplied by drag/drop or the native file picker.
func (s *Service) LeviathanLoadFile(path string, compare bool) (map[string]any, error) {
d, err := document.Open(path)
if err != nil {
return nil, err
}
engine := s.leviathanEngine()
engine.mu.Lock()
if compare {
engine.cmp = d
} else {
engine.doc = d
}
state := engine.stateLocked()
engine.mu.Unlock()
return map[string]any{"state": state}, nil
}
// LeviathanState returns the current binary workbench state.
func (s *Service) LeviathanState() (leviathanDocState, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
defer engine.mu.Unlock()
return engine.stateLocked(), nil
}
// LeviathanRunTool runs one Leviathan analysis/editing tool.
func (s *Service) LeviathanRunTool(tool string, params map[string]any) (map[string]any, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
if engine.doc == nil {
engine.mu.Unlock()
return nil, errors.New("open a file first")
}
if tool == "spectrogram-encode" || tool == "spectrogram-insert" || tool == "spectrogram-view" || tool == "case" {
doc := engine.doc
engine.mu.Unlock()
switch tool {
case "spectrogram-encode":
return s.leviathanSpectrogramEncode(doc, params)
case "spectrogram-insert":
return s.leviathanSpectrogramInsert(doc, params)
case "case":
return s.leviathanExportCase(doc)
default:
return leviathanSpectrogramView(doc, params)
}
}
defer engine.mu.Unlock()
return engine.runToolLocked(tool, params)
}
// LeviathanSetByte patches one byte in the working document.
func (s *Service) LeviathanSetByte(offset int64, value string) (map[string]any, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
defer engine.mu.Unlock()
if engine.doc == nil {
return nil, errors.New("open a file first")
}
value = strings.TrimSpace(value)
value = strings.TrimPrefix(strings.TrimPrefix(value, "0x"), "0X")
if len(value) == 1 {
value = "0" + value
}
if len(value) != 2 {
return nil, errors.New("byte value must be two hex digits")
}
b, err := hex.DecodeString(value)
if err != nil {
return nil, err
}
if _, err := engine.doc.Set(offset, b[0]); err != nil {
return nil, err
}
return map[string]any{"state": engine.stateLocked()}, nil
}
// LeviathanUndo reverts the latest byte edit.
func (s *Service) LeviathanUndo() (leviathanDocState, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
defer engine.mu.Unlock()
if engine.doc == nil {
return leviathanDocState{}, errors.New("open a file first")
}
engine.doc.Undo()
return engine.stateLocked(), nil
}
// LeviathanRedo reapplies the latest undone byte edit.
func (s *Service) LeviathanRedo() (leviathanDocState, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
defer engine.mu.Unlock()
if engine.doc == nil {
return leviathanDocState{}, errors.New("open a file first")
}
engine.doc.Redo()
return engine.stateLocked(), nil
}
// LeviathanRevert clears all working edits.
func (s *Service) LeviathanRevert() (leviathanDocState, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
defer engine.mu.Unlock()
if engine.doc == nil {
return leviathanDocState{}, errors.New("open a file first")
}
engine.doc.Revert()
return engine.stateLocked(), nil
}
// LeviathanSaveWorking prompts for a destination and writes the patched bytes.
func (s *Service) LeviathanSaveWorking() (string, error) {
engine := s.leviathanEngine()
engine.mu.Lock()
if engine.doc == nil {
engine.mu.Unlock()
return "", errors.New("open a file first")
}
doc := engine.doc
engine.mu.Unlock()
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Save Working Copy",
DefaultFilename: doc.Name() + ".patched",
})
if err != nil || path == "" {
return "", err
}
return path, writeLeviathanDocumentFile(path, doc)
}
// LeviathanExportJSON writes a tool result to disk.
func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Export JSON",
DefaultFilename: safeExportName(name, "leviathan-result.json"),
})
if err != nil || path == "" {
return "", err
}
b, err := json.MarshalIndent(value, "", " ")
if err != nil {
return "", err
}
return path, os.WriteFile(path, b, 0600)
}
func (l *leviathanState) stateLocked() leviathanDocState {
state := leviathanDocState{}
if l.doc != nil {
state = leviathanDocStateFor(l.doc, true)
}
if l.cmp != nil {
cmp := leviathanDocStateFor(l.cmp, false)
state.Compare = &cmp
}
return state
}
func leviathanDocStateFor(d *document.Document, preview bool) leviathanDocState {
s := leviathanDocState{
Name: d.Name(),
Size: d.Size(),
Dirty: d.IsDirty(),
UndoLen: d.HistoryLen(),
RedoLen: d.RedoLen(),
}
if preview {
s.PreviewHex = leviathanHexRows(d, 0, 512, 8)
}
return s
}
func (l *leviathanState) runToolLocked(tool string, params map[string]any) (map[string]any, error) {
start := int64Param(params, "start", 0)
length := int64Param(params, "length", -1)
switch tool {
case "overview":
return analysis.Overview(l.doc)
case "hashes":
return analysis.Hashes(l.doc, start, length)
case "entropy":
return analysis.Entropy(l.doc, start, length, int64Param(params, "window", 4096), int64Param(params, "step", 4096))
case "strings":
items, err := analysis.ExtractStrings(l.doc, start, length, intParam(params, "min", 4), []string{"ascii", "utf16le", "utf16be"}, intParam(params, "max", 5000))
return map[string]any{"items": items}, err
case "iocs":
return analysis.ExtractIOCs(l.doc, start, length, intParam(params, "min", 5))
case "carve":
items, err := analysis.Carve(l.doc, start, length, intParam(params, "max", 256))
return map[string]any{"items": items}, err
case "keys":
items, err := analysis.ScanKeys(l.doc)
return map[string]any{"items": items}, err
case "timestamps":
return analysis.ScanTimestamps(l.doc, start, length, intParam(params, "max", 1000))
case "search":
return l.runSearch(params, start, length)
case "multi-search":
return analysis.MultiSearch(l.doc, start, length, stringParam(params, "spec", ""), intParam(params, "max", 1000))
case "parse":
name, tree, err := parseLeviathanDocument(l.doc, stringParam(params, "kind", "auto"))
if err != nil {
return nil, err
}
return map[string]any{"parser": name, "tree": tree.ToMap()}, nil
case "disasm":
return l.runDisasm(params, start, length)
case "diff":
if l.cmp == nil {
return nil, errors.New("open a compare file first")
}
return analysis.Diff(l.doc, l.cmp, start, length)
case "stego":
out, err := stego.AnalyseAppended(l.doc)
if err != nil {
return nil, err
}
meta, _ := stego.ScanMetadata(l.doc)
out["metadata"] = meta
return out, nil
case "metadata":
items, err := stego.ScanMetadata(l.doc)
return map[string]any{"items": items}, err
case "pdf":
return pdf.Parse(l.doc)
case "squashfs":
fs, err := squashfs.Open(l.doc)
if err != nil {
return nil, err
}
entries, err := fs.Walk()
return map[string]any{"superblock": fs.Superblock(), "compression": fs.CompressionName(), "entries": entries}, err
case "binsnoop":
buf, err := l.rangeBytes(0, min64(l.doc.Size(), int64Param(params, "maxBytes", 64<<20)))
if err != nil {
return nil, err
}
scanner := binsnoop.New()
return map[string]any{"results": scanner.AnalyzeBytes(buf, l.doc.Name(), false), "signatures": binsnoop.ListSignatures()}, nil
case "rules":
rules, err := analysis.ParseRules(stringParam(params, "source", ""))
if err != nil {
return nil, err
}
items, err := analysis.RunRules(l.doc, start, length, rules, intParam(params, "max", 1000))
return map[string]any{"items": items}, err
case "patch-plan":
steps, err := patch.Parse(stringParam(params, "source", ""))
if err != nil {
return nil, err
}
plan, err := patch.Plan(l.doc, steps)
return map[string]any{"plan": plan}, err
case "patch-apply":
steps, err := patch.Parse(stringParam(params, "source", ""))
if err != nil {
return nil, err
}
if _, err := patch.Plan(l.doc, steps); err != nil {
return nil, err
}
return map[string]any{"state": l.stateLocked()}, patch.Apply(l.doc, steps)
case "transform":
return l.runTransform(params, start, length)
case "hex":
return map[string]any{"rows": leviathanHexRows(l.doc, start, int64Param(params, "bytes", 1024), intParam(params, "width", 16))}, nil
case "case":
return nil, errors.New("case export must run through the service")
default:
return nil, errors.New("unknown tool: " + tool)
}
}
func (l *leviathanState) runSearch(params map[string]any, start, length int64) (map[string]any, error) {
mode := stringParam(params, "mode", "text")
query := stringParam(params, "query", "")
max := intParam(params, "max", 1000)
switch mode {
case "hex":
b, err := hex.DecodeString(strings.ReplaceAll(query, " ", ""))
if err != nil {
return nil, err
}
hits, err := analysis.SearchBytes(l.doc, start, length, b, max)
return map[string]any{"hits": analysis.HitsToMaps(hits)}, err
case "regex":
hits, err := analysis.SearchRegex(l.doc, start, length, query, max)
return map[string]any{"hits": analysis.HitsToMaps(hits)}, err
default:
hits, err := analysis.SearchBytes(l.doc, start, length, []byte(query), max)
return map[string]any{"hits": analysis.HitsToMaps(hits)}, err
}
}
func (l *leviathanState) runDisasm(params map[string]any, start, length int64) (map[string]any, error) {
if length < 0 || length > 65536 {
length = 4096
}
buf, err := l.rangeBytes(start, length)
if err != nil {
return nil, err
}
return map[string]any{"items": disasm.Disassemble(buf, start, stringParam(params, "arch", "x86_64"), intParam(params, "max", 1000))}, nil
}
func (l *leviathanState) runTransform(params map[string]any, start, length int64) (map[string]any, error) {
if length < 0 || length > 8<<20 {
length = min64(l.doc.Size()-start, 1<<20)
}
buf, err := l.rangeBytes(start, length)
if err != nil {
return nil, err
}
steps := parseLeviathanTransformPipeline(stringParam(params, "pipeline", "hex.encode"))
results, err := transforms.ApplyPipeline(buf, steps)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(results))
for i, r := range results {
out = append(out, map[string]any{"step": i, "length": len(r), "preview": previewLeviathanBytes(r)})
}
return map[string]any{"steps": steps, "results": out}, nil
}
func leviathanSpectrogramView(doc *document.Document, params map[string]any) (map[string]any, error) {
buf, err := leviathanDocumentBytes(doc, spectrogram.MaxAudioBytes, "audio")
if err != nil {
return nil, err
}
pngBytes, summary, err := spectrogram.AudioSpectrogramPNG(buf, doc.Name(), leviathanSpectrogramViewOptions(params))
if err != nil {
return nil, err
}
return map[string]any{
"mime": "image/png",
"pngBase64": base64.StdEncoding.EncodeToString(pngBytes),
"summary": summary,
}, nil
}
func (s *Service) leviathanExportCase(doc *document.Document) (map[string]any, error) {
if s.ctx == nil {
return nil, fmt.Errorf("application context not set")
}
filename := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".case.zip"
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Export Leviathan Case",
DefaultFilename: filename,
})
if err != nil || path == "" {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return nil, err
}
defer f.Close()
zw := zip.NewWriter(f)
add := func(name string, data []byte) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
hashes, _ := analysis.Hashes(doc, 0, -1)
overview, _ := analysis.Overview(doc)
manifest, _ := json.MarshalIndent(map[string]any{"name": doc.Name(), "size": doc.Size(), "exportedAt": time.Now().Format(time.RFC3339), "hashes": hashes}, "", " ")
overviewJSON, _ := json.MarshalIndent(overview, "", " ")
patchesJSON, _ := json.MarshalIndent(doc.Patches(), "", " ")
if err := add("manifest.json", manifest); err != nil {
return nil, err
}
if err := addDocument("working.bin", doc, zw); err != nil {
return nil, err
}
if err := add("overview.json", overviewJSON); err != nil {
return nil, err
}
if err := add("patches.json", patchesJSON); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
if err := f.Sync(); err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
return nil, err
}
return map[string]any{"path": path, "bytes": info.Size()}, nil
}
func (s *Service) leviathanSpectrogramEncode(doc *document.Document, params map[string]any) (map[string]any, error) {
if s.ctx == nil {
return nil, fmt.Errorf("application context not set")
}
imageBytes, err := leviathanDocumentBytes(doc, spectrogram.MaxImageBytes, "image")
if err != nil {
return nil, err
}
wavBytes, summary, err := spectrogram.ImageToWAV(imageBytes, leviathanSpectrogramEncodeOptions(params))
if err != nil {
return nil, err
}
defaultName := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".spectrogram.wav"
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Save Spectrogram WAV",
DefaultFilename: safeExportName(defaultName, "spectrogram.wav"),
Filters: []wailsRuntime.FileFilter{
{DisplayName: "WAV Audio (*.wav)", Pattern: "*.wav"},
},
})
if err != nil || path == "" {
return nil, err
}
if filepath.Ext(strings.ToLower(path)) != ".wav" {
path += ".wav"
}
if err := os.WriteFile(path, wavBytes, 0600); err != nil {
return nil, err
}
summary["path"] = path
return map[string]any{"path": path, "summary": summary}, nil
}
func (s *Service) leviathanSpectrogramInsert(doc *document.Document, params map[string]any) (map[string]any, error) {
if s.ctx == nil {
return nil, fmt.Errorf("application context not set")
}
imagePath := strings.TrimSpace(stringParam(params, "imagePath", ""))
if imagePath == "" {
return nil, errors.New("select an image to insert")
}
imageBytes, err := readLeviathanSmallFile(imagePath, spectrogram.MaxImageBytes, "image")
if err != nil {
return nil, fmt.Errorf("read image: %w", err)
}
audioBytes, err := leviathanDocumentBytes(doc, spectrogram.MaxAudioBytes, "audio")
if err != nil {
return nil, err
}
wavBytes, summary, err := spectrogram.InsertImageIntoAudio(imageBytes, audioBytes, doc.Name(), leviathanSpectrogramInsertOptions(params))
if err != nil {
return nil, err
}
defaultName := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".with-spectrogram.wav"
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Save Audio With Spectrogram Image",
DefaultFilename: safeExportName(defaultName, "with-spectrogram.wav"),
Filters: []wailsRuntime.FileFilter{
{DisplayName: "WAV Audio (*.wav)", Pattern: "*.wav"},
},
})
if err != nil || path == "" {
return nil, err
}
if filepath.Ext(strings.ToLower(path)) != ".wav" {
path += ".wav"
}
if err := os.WriteFile(path, wavBytes, 0600); err != nil {
return nil, err
}
summary["path"] = path
summary["imagePath"] = imagePath
return map[string]any{"path": path, "summary": summary}, nil
}
func writeLeviathanDocumentFile(path string, doc *document.Document) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer f.Close()
if err := doc.ForEachChunk(0, doc.Size(), func(_ int64, chunk []byte) error {
_, err := f.Write(chunk)
return err
}); err != nil {
return err
}
return f.Sync()
}
func addDocument(name string, doc *document.Document, zw *zip.Writer) error {
w, err := zw.Create(name)
if err != nil {
return err
}
return doc.ForEachChunk(0, doc.Size(), func(_ int64, chunk []byte) error {
_, err := w.Write(chunk)
return err
})
}
func leviathanDocumentBytes(doc *document.Document, maxBytes int64, label string) ([]byte, error) {
size := doc.Size()
if maxBytes > 0 && size > maxBytes {
return nil, fmt.Errorf("%s file is too large for this operation: %s exceeds %s", label, humanBytes(size), humanBytes(maxBytes))
}
return doc.Range(0, size)
}
func readLeviathanSmallFile(path string, maxBytes int64, label string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.IsDir() {
return nil, fmt.Errorf("%s path is a directory", label)
}
if maxBytes > 0 && info.Size() > maxBytes {
return nil, fmt.Errorf("%s file is too large for this operation: %s exceeds %s", label, humanBytes(info.Size()), humanBytes(maxBytes))
}
return os.ReadFile(path)
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
value := float64(n)
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
value /= unit
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
}
return fmt.Sprintf("%.1f PiB", value/unit)
}
func (l *leviathanState) rangeBytes(start, length int64) ([]byte, error) {
if start < 0 {
start = 0
}
if length < 0 || start+length > l.doc.Size() {
length = l.doc.Size() - start
}
if length < 0 {
length = 0
}
return l.doc.Range(start, length)
}
func parseLeviathanDocument(d *document.Document, kind string) (string, *parsers.Field, error) {
kind = strings.ToLower(strings.TrimSpace(kind))
if kind == "" || kind == "auto" {
return parsers.DetectAndParse(d)
}
var tree *parsers.Field
var err error
switch kind {
case "png":
tree, err = parsers.ParsePNG(d)
case "jpeg", "jpg":
tree, err = parsers.ParseJPEG(d)
kind = "jpeg"
case "gif":
tree, err = parsers.ParseGIF(d)
case "bmp":
tree, err = parsers.ParseBMP(d)
case "zip":
tree, err = parsers.ParseZIP(d)
case "elf":
tree, err = parsers.ParseELF(d)
case "pe":
tree, err = parsers.ParsePE(d)
case "macho":
tree, err = parsers.ParseMachO(d)
case "riff":
tree, err = parsers.ParseRIFF(d)
case "asn1":
tree, err = parsers.ParseASN1(d, 0, d.Size())
case "cert":
tree, err = parsers.ParseCerts(d)
default:
return "", nil, errors.New("unknown parser: " + kind)
}
return kind, tree, err
}
func leviathanHexRows(d *document.Document, start, length int64, width int) []map[string]any {
if width <= 0 {
width = 16
}
if length < 0 || length > 8192 {
length = 8192
}
rows := []map[string]any{}
for off := start; off < start+length && off < d.Size(); off += int64(width) {
n := int64(width)
if off+n > d.Size() {
n = d.Size() - off
}
buf, err := d.Range(off, n)
if err != nil {
break
}
rows = append(rows, map[string]any{"offset": off, "hex": spacedLeviathanHex(buf), "bytes": byteHexCells(buf), "ascii": asciiLeviathanPreview(buf)})
}
return rows
}
func parseLeviathanTransformPipeline(src string) []transforms.Step {
parts := strings.Split(src, "|")
steps := []transforms.Step{}
for _, part := range parts {
fields := strings.Fields(strings.TrimSpace(part))
if len(fields) == 0 {
continue
}
op := fields[0]
arg := ""
if len(fields) > 1 {
arg = strings.Join(fields[1:], " ")
}
switch op {
case "hex":
op = "hex.encode"
case "unhex":
op = "hex.decode"
case "base64", "b64":
op = "base64.encode"
case "unbase64", "unb64":
op = "base64.decode"
case "url":
op = "url.encode"
case "unurl":
op = "url.decode"
}
steps = append(steps, transforms.Step{Op: op, Arg: arg})
}
return steps
}
func spacedLeviathanHex(buf []byte) string {
return strings.Join(byteHexCells(buf), " ")
}
func byteHexCells(buf []byte) []string {
parts := make([]string, len(buf))
for i, b := range buf {
parts[i] = fmt.Sprintf("%02x", b)
}
return parts
}
func asciiLeviathanPreview(buf []byte) string {
var b strings.Builder
for _, c := range buf {
if c >= 32 && c < 127 {
b.WriteByte(c)
} else {
b.WriteByte('.')
}
}
return b.String()
}
func previewLeviathanBytes(buf []byte) string {
if len(buf) > 2048 {
buf = buf[:2048]
}
if isMostlyLeviathanText(buf) {
return string(buf)
}
return spacedLeviathanHex(buf)
}
func isMostlyLeviathanText(buf []byte) bool {
if len(buf) == 0 {
return true
}
printable := 0
for _, b := range buf {
if b == '\n' || b == '\r' || b == '\t' || (b >= 32 && b < 127) {
printable++
}
}
return printable*100/len(buf) > 85
}
func intParam(m map[string]any, key string, def int) int {
return int(int64Param(m, key, int64(def)))
}
func int64Param(m map[string]any, key string, def int64) int64 {
v, ok := m[key]
if !ok || v == nil {
return def
}
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
case string:
if n == "" {
return def
}
if parsed, err := strconv.ParseInt(n, 0, 64); err == nil {
return parsed
}
}
return def
}
func stringParam(m map[string]any, key, def string) string {
if v, ok := m[key]; ok && v != nil {
return fmt.Sprint(v)
}
return def
}
func floatParam(m map[string]any, key string, def float64) float64 {
v, ok := m[key]
if !ok || v == nil {
return def
}
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
case string:
if n == "" {
return def
}
if parsed, err := strconv.ParseFloat(n, 64); err == nil {
return parsed
}
}
return def
}
func boolParam(m map[string]any, key string, def bool) bool {
v, ok := m[key]
if !ok || v == nil {
return def
}
switch b := v.(type) {
case bool:
return b
case string:
if b == "" {
return def
}
if parsed, err := strconv.ParseBool(b); err == nil {
return parsed
}
}
return def
}
func leviathanSpectrogramEncodeOptions(params map[string]any) spectrogram.EncodeOptions {
return spectrogram.EncodeOptions{
DurationSec: floatParam(params, "durationSec", 5),
SampleRate: intParam(params, "sampleRate", 44100),
MinHz: floatParam(params, "minHz", 200),
MaxHz: floatParam(params, "maxHz", 20000),
Bins: intParam(params, "bins", 160),
Columns: intParam(params, "columns", 768),
Invert: boolParam(params, "invert", true),
PreserveAR: boolParam(params, "preserveAspectRatio", true),
Gain: floatParam(params, "gain", 0.85),
}
}
func leviathanSpectrogramInsertOptions(params map[string]any) spectrogram.InsertOptions {
return spectrogram.InsertOptions{
EncodeOptions: leviathanSpectrogramEncodeOptions(params),
StartSec: floatParam(params, "startSec", 0),
MixLevel: floatParam(params, "mixLevel", 0.35),
}
}
func leviathanSpectrogramViewOptions(params map[string]any) spectrogram.ViewOptions {
return spectrogram.ViewOptions{
FFTSize: intParam(params, "fftSize", 2048),
Height: intParam(params, "height", 1024),
MaxWidth: intParam(params, "maxWidth", 1400),
Aspect: floatParam(params, "aspectRatio", 1),
MinHz: floatParam(params, "minHz", 0),
MaxHz: floatParam(params, "maxHz", 0),
Brightness: floatParam(params, "brightness", 1),
Contrast: floatParam(params, "contrast", 1),
}
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func safeExportName(name, fallback string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || strings.ContainsRune(name, 0) {
return fallback
}
return name
}