Merge feat/macos-app: full macOS app with Notitie editor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reads Design/tokens.json and writes docs/design/tokens.md and docs/design/tokens-preview.html.
|
||||||
|
Also checks WCAG contrast for every text pairing; exits 1 if a text pair is below 4.5:1."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
tokens = json.loads((ROOT / "Design" / "tokens.json").read_text())
|
||||||
|
HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
def lum(hexcolor):
|
||||||
|
h = hexcolor.lstrip("#")
|
||||||
|
out = []
|
||||||
|
for i in (0, 2, 4):
|
||||||
|
c = int(h[i:i + 2], 16) / 255
|
||||||
|
out.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
|
||||||
|
return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2]
|
||||||
|
|
||||||
|
|
||||||
|
def contrast(a, b):
|
||||||
|
la, lb = sorted((lum(a), lum(b)), reverse=True)
|
||||||
|
return (la + 0.05) / (lb + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
# --- validation -------------------------------------------------------------
|
||||||
|
errors = []
|
||||||
|
for name, t in tokens["color"].items():
|
||||||
|
for theme in ("light", "dark"):
|
||||||
|
if not HEX.match(t.get(theme, "")):
|
||||||
|
errors.append(f"color.{name}.{theme} is not a #RRGGBB value")
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# --- contrast ---------------------------------------------------------------
|
||||||
|
TEXT_PAIRS = [
|
||||||
|
("ink.primary", "surface.chassis"), ("ink.primary", "surface.panel"), ("ink.primary", "surface.paper"),
|
||||||
|
("ink.primary", "surface.well"), ("ink.primary", "surface.selection"), ("ink.onMeter", "surface.meterFace"),
|
||||||
|
("ink.primary", "surface.keyHigh"), ("ink.primary", "surface.keyLow"),
|
||||||
|
("ink.secondary", "surface.chassis"), ("ink.secondary", "surface.panel"), ("ink.secondary", "surface.paper"),
|
||||||
|
("ink.tertiary", "surface.chassis"), ("ink.tertiary", "surface.panel"), ("ink.tertiary", "surface.paper"),
|
||||||
|
("ink.tertiary", "surface.metalHigh"), ("ink.tertiary", "surface.metalLow"),
|
||||||
|
("ink.inverse", "accent.record"),
|
||||||
|
("level.amber", "surface.display"),
|
||||||
|
]
|
||||||
|
rows, failed = [], False
|
||||||
|
for fg, bg in TEXT_PAIRS:
|
||||||
|
for theme in ("light", "dark"):
|
||||||
|
r = contrast(tokens["color"][fg][theme], tokens["color"][bg][theme])
|
||||||
|
ok = r >= 4.5
|
||||||
|
failed |= not ok
|
||||||
|
rows.append((fg, bg, theme, r, ok))
|
||||||
|
|
||||||
|
print(f"{'text':16} {'on':20} {'finish':6} ratio")
|
||||||
|
for fg, bg, theme, r, ok in rows:
|
||||||
|
print(f"{fg:16} {bg:20} {theme:6} {r:5.2f} {'ok' if ok else 'FAIL'}")
|
||||||
|
|
||||||
|
# --- swift ------------------------------------------------------------------
|
||||||
|
def camel(name):
|
||||||
|
parts = name.replace("-", ".").split(".")
|
||||||
|
return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def num(v):
|
||||||
|
return repr(float(v)) if isinstance(v, float) else str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def doc(use):
|
||||||
|
return " /// " + use.replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def swift_source(t):
|
||||||
|
o = []
|
||||||
|
add = o.append
|
||||||
|
add("// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand.")
|
||||||
|
add("import SwiftUI\n")
|
||||||
|
|
||||||
|
add("public enum Palette {")
|
||||||
|
for n, c in t["color"].items():
|
||||||
|
add(doc(c["use"]))
|
||||||
|
add(f" public static let {camel(n)} = Color.dynamic(light: 0x{c['light'][1:].upper()}, dark: 0x{c['dark'][1:].upper()})")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Opacity {")
|
||||||
|
for n, v in t["opacity"].items():
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let {camel(n)}: Double = {num(v['value'])}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum FontFamily: String {")
|
||||||
|
for n, f in t["font"].items():
|
||||||
|
add(f" case {n.replace('family.', '')} = \"{f['value']}\"")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Typography {")
|
||||||
|
for n, s in t["type"].items():
|
||||||
|
add(doc(s["use"]))
|
||||||
|
add(f" public static let {camel(n)} = TextStyle(family: .{s['family']}, size: {num(s['size'])}, weight: {s['weight']}, "
|
||||||
|
f"tracking: {num(s['tracking'])}, line: {num(s['line'])}, caps: {'true' if s['caps'] else 'false'}, "
|
||||||
|
f"tabular: {'true' if s.get('tabular') else 'false'})")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Segment {")
|
||||||
|
for n, v in t["segment"].items():
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Space {")
|
||||||
|
for n, v in t["space"].items():
|
||||||
|
add(f" public static let s{n}: CGFloat = {v}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
for enum, group in (("Layout", "layout"), ("Size", "size"), ("Radius", "radius"), ("Border", "border")):
|
||||||
|
add(f"public enum {enum} {{")
|
||||||
|
for n, v in t[group].items():
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Shadows {")
|
||||||
|
for n, s in t["shadow"].items():
|
||||||
|
add(doc(s["use"]))
|
||||||
|
add(f" public static let {camel(n)}: [ShadowLayer] = [")
|
||||||
|
for l in s["layers"]:
|
||||||
|
add(f" ShadowLayer(inset: {'true' if l['inset'] else 'false'}, x: {l['x']}, y: {l['y']}, blur: {l['blur']}, spread: {l['spread']}, "
|
||||||
|
f"hex: 0x{l['color'][1:].upper()}, alphaLight: {num(l['alpha']['light'])}, alphaDark: {num(l['alpha']['dark'])}),")
|
||||||
|
add(" ]")
|
||||||
|
if "translateY" in s:
|
||||||
|
add(f" public static let {camel(n)}Offset: CGFloat = {s['translateY']}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Texture {")
|
||||||
|
for n, v in t["texture"].items():
|
||||||
|
add(f" public static let {camel(n)}GrainLight: Double = {num(v['grain']['light'])}")
|
||||||
|
add(f" public static let {camel(n)}GrainDark: Double = {num(v['grain']['dark'])}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum MeterTokens {")
|
||||||
|
for n, v in t["meter"].items():
|
||||||
|
val = v["value"]
|
||||||
|
if n == "vu.ticks":
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let vuTicks: [Int] = [{', '.join(str(int(x)) for x in val.split())}]")
|
||||||
|
elif n == "led.segmentSize":
|
||||||
|
w, h = val.split("x")
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let ledSegmentWidth: CGFloat = {w}")
|
||||||
|
add(f" public static let ledSegmentHeight: CGFloat = {h}")
|
||||||
|
elif n == "peak.holdMs":
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let peakHoldSeconds: Double = {num(val / 1000)}")
|
||||||
|
elif n == "led.segments":
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let ledSegments: Int = {val}")
|
||||||
|
else:
|
||||||
|
add(doc(v["use"]))
|
||||||
|
add(f" public static let {camel(n)}: Double = {num(val)}")
|
||||||
|
add("}\n")
|
||||||
|
|
||||||
|
add("public enum Motion {")
|
||||||
|
for n, m in t["motion"].items():
|
||||||
|
if n == "reducedMotion":
|
||||||
|
continue
|
||||||
|
add(doc(m["use"]))
|
||||||
|
if m.get("unit") == "ms":
|
||||||
|
add(f" public static let {camel(n)}: Double = {num(m['value'] / 1000)}")
|
||||||
|
elif m.get("unit") in ("s", "ratio", "Hz"):
|
||||||
|
add(f" public static let {camel(n)}: Double = {num(m['value'])}")
|
||||||
|
elif isinstance(m["value"], str) and m["value"].startswith("cubic-bezier"):
|
||||||
|
pts = [x.strip() for x in m["value"][m["value"].index("(") + 1:-1].split(",")]
|
||||||
|
add(f" public static let {camel(n)}: (Double, Double, Double, Double) = ({', '.join(pts)})")
|
||||||
|
add("}")
|
||||||
|
return "\n".join(o) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
swift_text = swift_source(tokens)
|
||||||
|
swift_dir = ROOT / "Sources" / "SstUI"
|
||||||
|
swift_path = swift_dir / "DesignTokens.swift"
|
||||||
|
|
||||||
|
if "--check" in sys.argv:
|
||||||
|
in_sync = swift_path.exists() and swift_path.read_text() == swift_text
|
||||||
|
print("DesignTokens.swift is " + ("in sync with tokens.json" if in_sync else "OUT OF DATE: run python3 Design/build_tokens.py"))
|
||||||
|
sys.exit(0 if (in_sync and not failed) else 1)
|
||||||
|
|
||||||
|
if swift_dir.exists():
|
||||||
|
swift_path.write_text(swift_text)
|
||||||
|
print("wrote Sources/SstUI/DesignTokens.swift")
|
||||||
|
|
||||||
|
# --- markdown ---------------------------------------------------------------
|
||||||
|
md = []
|
||||||
|
add = md.append
|
||||||
|
m = tokens["meta"]
|
||||||
|
add(f"# {m['name']} — v{m['version']}\n")
|
||||||
|
add(f"**Status:** {m['status']}\n")
|
||||||
|
add(f"{m['concept']}\n")
|
||||||
|
add("Bron: `Design/tokens.json`. Dit bestand en `tokens-preview.html` worden gegenereerd met `python3 Design/build_tokens.py`; pas nooit alleen deze bestanden aan.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def table(headers, body):
|
||||||
|
add("| " + " | ".join(headers) + " |")
|
||||||
|
add("|" + "|".join("---" for _ in headers) + "|")
|
||||||
|
for r in body:
|
||||||
|
add("| " + " | ".join(str(c) for c in r) + " |")
|
||||||
|
add("")
|
||||||
|
|
||||||
|
|
||||||
|
add("## Kleur\n")
|
||||||
|
add("Verwijs ernaar als `color.<naam>`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donker).\n")
|
||||||
|
table(["Token", "Aluminium", "Graphite", "Gebruik"],
|
||||||
|
[(f"`color.{n}`", f"`{t['light']}`", f"`{t['dark']}`", t["use"]) for n, t in tokens["color"].items()])
|
||||||
|
add("**Regel:** `accent.record` is het enige accent en wordt nooit voor lopende tekst gebruikt. Amber en groen zijn alleen voor niveau-indicatoren en markeringen.\n")
|
||||||
|
add("### Contrast (berekend)\n")
|
||||||
|
table(["Tekst", "Op", "Afwerking", "Verhouding", ""],
|
||||||
|
[(f"`{a}`", f"`{b}`", th, f"{r:.2f}:1", "ok" if ok else "**te laag**") for a, b, th, r, ok in rows])
|
||||||
|
|
||||||
|
add("## Opaciteit\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`opacity.{n}`", t["value"], t["use"]) for n, t in tokens["opacity"].items()])
|
||||||
|
|
||||||
|
add("## Lettertypen\n")
|
||||||
|
table(["Token", "Familie", "Terugval", "Gebruik"],
|
||||||
|
[(f"`font.{n}`", t["value"], t["fallback"], t["use"]) for n, t in tokens["font"].items()])
|
||||||
|
add("## Tekststijlen\n")
|
||||||
|
table(["Token", "Familie", "Grootte", "Gewicht", "Spatiëring", "Regelhoogte", "Hoofdletters", "Gebruik"],
|
||||||
|
[(f"`type.{n}`", t["family"], f"{t['size']} pt", t["weight"], f"{t['tracking']} em", f"{t['line']} pt",
|
||||||
|
"ja" if t["caps"] else "nee", t["use"]) for n, t in tokens["type"].items()])
|
||||||
|
add("## Segmentcijfers (getekend, geen lettertype)\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`segment.{n}`", t["value"], t["use"]) for n, t in tokens["segment"].items()])
|
||||||
|
|
||||||
|
add("## Ruimte\n")
|
||||||
|
table(["Token", "Waarde"], [(f"`space.{n}`", f"{v} pt") for n, v in tokens["space"].items()])
|
||||||
|
add("## Indeling\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`layout.{n}`", t["value"], t["use"]) for n, t in tokens["layout"].items()])
|
||||||
|
add("## Afmetingen\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`size.{n}`", t["value"], t["use"]) for n, t in tokens["size"].items()])
|
||||||
|
add("## Hoekradius\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`radius.{n}`", t["value"], t["use"]) for n, t in tokens["radius"].items()])
|
||||||
|
add("## Randen\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`border.{n}`", f"{t['value']} pt", t["use"]) for n, t in tokens["border"].items()])
|
||||||
|
|
||||||
|
add("## Schaduw en diepte\n")
|
||||||
|
for n, t in tokens["shadow"].items():
|
||||||
|
add(f"### `shadow.{n}`\n\n{t['use']}" + (f" Dop verschuift {t['translateY']} pt omlaag." if "translateY" in t else "") + "\n")
|
||||||
|
table(["Laag", "Inset", "x", "y", "blur", "spread", "kleur", "alpha aluminium", "alpha graphite"],
|
||||||
|
[(i + 1, "ja" if l["inset"] else "nee", l["x"], l["y"], l["blur"], l["spread"], f"`{l['color']}`",
|
||||||
|
l["alpha"]["light"], l["alpha"]["dark"]) for i, l in enumerate(t["layers"])])
|
||||||
|
add("## Textuur\n")
|
||||||
|
for n, t in tokens["texture"].items():
|
||||||
|
add(f"**`texture.{n}`** — {t['use']}. Richting {t['direction']}; korrel {t['grain']['light']} (aluminium) / {t['grain']['dark']} (graphite); {t['gradient']}.\n")
|
||||||
|
add("## Meters\n")
|
||||||
|
table(["Token", "Waarde", "Gebruik"], [(f"`meter.{n}`", t["value"], t["use"]) for n, t in tokens["meter"].items()])
|
||||||
|
add("## Beweging\n")
|
||||||
|
table(["Token", "Waarde", "Eenheid", "Gebruik"],
|
||||||
|
[(f"`motion.{n}`", t["value"], t.get("unit", ""), t["use"]) for n, t in tokens["motion"].items()])
|
||||||
|
|
||||||
|
add("## Wat bewust ontbreekt\n")
|
||||||
|
add("Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten. Lampjes mogen een kleine gloed hebben (`shadow.lamp.on`); tekst nooit.\n")
|
||||||
|
|
||||||
|
out_dir = ROOT / "docs" / "design"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(out_dir / "tokens.md").write_text("\n".join(md))
|
||||||
|
|
||||||
|
# --- preview ----------------------------------------------------------------
|
||||||
|
template = (ROOT / "Design" / "preview.template.html").read_text()
|
||||||
|
blob = json.dumps(tokens, ensure_ascii=False).replace("</", "<\\/")
|
||||||
|
(out_dir / "tokens-preview.html").write_text(template.replace("/*TOKENS_JSON*/", blob))
|
||||||
|
print("wrote docs/design/tokens.md and docs/design/tokens-preview.html")
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sst Ontwerp-tokens</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; }
|
||||||
|
body { background: var(--c-surface-chassis); color: var(--c-ink-primary); font: 13px/18px var(--f-text); min-height: 100vh; }
|
||||||
|
|
||||||
|
.wrap { max-width: 1180px; margin: 0 auto; padding: var(--s-7) var(--s-5) 96px; }
|
||||||
|
.brushed { background-image: var(--tex-brushed), linear-gradient(180deg, var(--c-surface-metalHigh), var(--c-surface-metalLow)); }
|
||||||
|
|
||||||
|
/* silkscreen */
|
||||||
|
.silk { font: 500 10px/12px var(--f-chrome); letter-spacing: .09em; text-transform: uppercase; color: var(--c-ink-tertiary); }
|
||||||
|
.silk-s { font: 500 9px/11px var(--f-chrome); letter-spacing: .10em; text-transform: uppercase; color: var(--c-ink-tertiary); }
|
||||||
|
.head { font: 500 12px/16px var(--f-chrome); letter-spacing: .12em; text-transform: uppercase; color: var(--c-ink-primary); }
|
||||||
|
.mono { font: 500 12px/16px var(--f-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* masthead */
|
||||||
|
.mast { display: flex; align-items: stretch; margin-bottom: var(--s-6); background: var(--c-surface-panel); box-shadow: var(--sh-panel); border-top: 1px solid var(--c-line-seamDark); }
|
||||||
|
.mast .id { padding: var(--s-5); flex: 1; }
|
||||||
|
.mast h1 { font: 500 22px/26px var(--f-chrome); letter-spacing: .12em; text-transform: uppercase; margin-top: var(--s-2); }
|
||||||
|
.mast p { color: var(--c-ink-secondary); max-width: 62ch; margin-top: var(--s-3); }
|
||||||
|
.mast .stamp { padding: var(--s-5); border-left: 2px solid var(--c-line-seamDark); box-shadow: -1px 0 0 var(--c-line-seamLight); display: flex; flex-direction: column; justify-content: center; gap: var(--s-3); min-width: 220px; }
|
||||||
|
.stamp .row { display: flex; align-items: center; gap: var(--s-3); }
|
||||||
|
|
||||||
|
section { margin-top: var(--s-7); }
|
||||||
|
section > .sec-head { display: flex; align-items: baseline; gap: var(--s-4); margin-bottom: var(--s-4); padding-bottom: var(--s-3); border-bottom: 1px solid var(--c-line-seamDark); box-shadow: 0 1px 0 var(--c-line-seamLight); }
|
||||||
|
.sec-head .no { font: 500 12px var(--f-mono); color: var(--c-ink-tertiary); }
|
||||||
|
.sec-head .tk { margin-left: auto; font: 11px var(--f-mono); color: var(--c-ink-tertiary); }
|
||||||
|
|
||||||
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); }
|
||||||
|
@media (max-width: 860px) { .two { grid-template-columns: 1fr; } .mast { flex-direction: column; } .mast .stamp { border-left: 0; border-top: 2px solid var(--c-line-seamDark); } }
|
||||||
|
|
||||||
|
/* a finish is a complete, scoped set of variables */
|
||||||
|
.finish { background: var(--c-surface-chassis); color: var(--c-ink-primary); padding: var(--s-5); border: 1px solid var(--c-line-seamDark); }
|
||||||
|
.finish.brushed { background-image: var(--tex-brushed), linear-gradient(180deg, var(--c-surface-metalHigh), var(--c-surface-metalLow)); }
|
||||||
|
.finish > .silk.top { display: flex; justify-content: space-between; margin-bottom: var(--s-4); }
|
||||||
|
|
||||||
|
/* swatches */
|
||||||
|
.sw-group { margin-bottom: var(--s-4); }
|
||||||
|
.sw-group .silk { margin-bottom: var(--s-2); display: block; }
|
||||||
|
.sw { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: var(--s-3); padding: 3px 0; border-bottom: 1px solid var(--c-line-hairline); }
|
||||||
|
.sw i { width: 28px; height: 18px; border: 1px solid var(--c-line-seamDark); display: block; }
|
||||||
|
.sw b { font: 500 11px var(--f-mono); }
|
||||||
|
.sw span { font: 11px var(--f-mono); color: var(--c-ink-tertiary); }
|
||||||
|
|
||||||
|
/* type */
|
||||||
|
.ty { display: grid; grid-template-columns: 150px 1fr; gap: var(--s-4); padding: var(--s-3) 0; border-bottom: 1px solid var(--c-line-hairline); align-items: baseline; }
|
||||||
|
.ty .spec { font: 11px/15px var(--f-mono); color: var(--c-ink-tertiary); }
|
||||||
|
.ty .spec b { color: var(--c-ink-primary); display: block; font-weight: 500; }
|
||||||
|
|
||||||
|
/* spacing / radius / borders */
|
||||||
|
.spc { display: flex; align-items: flex-end; gap: var(--s-5); flex-wrap: wrap; }
|
||||||
|
.spc div { text-align: center; } .spc i { display: block; background: var(--c-ink-primary); height: 14px; margin-bottom: var(--s-2); }
|
||||||
|
.rad { display: flex; gap: var(--s-5); flex-wrap: wrap; }
|
||||||
|
.rad div { text-align: center; } .rad i { display: block; width: 56px; height: 56px; background: var(--c-surface-panel); border: 1px solid var(--c-line-seamDark); margin-bottom: var(--s-2); }
|
||||||
|
|
||||||
|
/* parts */
|
||||||
|
.panel { background: var(--c-surface-panel); box-shadow: var(--sh-panel); border-top: 1px solid var(--c-line-seamDark); padding: var(--s-5); }
|
||||||
|
.well { background: var(--c-surface-well); box-shadow: var(--sh-well); padding: var(--s-4); }
|
||||||
|
.seam { border-top: 1px solid var(--c-line-seamDark); border-bottom: 1px solid var(--c-line-seamLight); height: 2px; }
|
||||||
|
.key { display: inline-flex; align-items: center; justify-content: center; gap: var(--s-3); height: var(--z-control-height); min-width: var(--z-key-minWidth); padding: 0 var(--s-4);
|
||||||
|
font: 500 11px/14px var(--f-chrome); letter-spacing: .06em; text-transform: uppercase; color: var(--c-ink-primary); cursor: pointer; user-select: none;
|
||||||
|
background: linear-gradient(180deg, var(--c-surface-keyHigh), var(--c-surface-keyLow)); border: 1px solid var(--c-line-seamDark); border-radius: var(--r-sm);
|
||||||
|
box-shadow: var(--sh-key-raised); transition: transform var(--m-key-release) var(--e-mechanical), box-shadow var(--m-key-release) var(--e-mechanical), background var(--m-key-release) var(--e-mechanical); }
|
||||||
|
.key:active, .key.down { background: var(--c-surface-keyPressed); box-shadow: var(--sh-key-pressed); transform: translateY(1px); transition-duration: var(--m-key-press); }
|
||||||
|
.key.rec { min-width: var(--z-key-recWidth); height: var(--z-key-recHeight); background: linear-gradient(180deg, var(--c-accent-record), var(--c-accent-record)); color: var(--c-ink-inverse); }
|
||||||
|
.key.rec.down { filter: brightness(.88); }
|
||||||
|
.key:focus-visible { outline: var(--b-focus) solid var(--c-line-focus); outline-offset: 2px; }
|
||||||
|
.lamp { width: var(--z-lamp-diameter); height: var(--z-lamp-diameter); border-radius: var(--r-lamp); background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.55), rgba(255,255,255,0) 38%), var(--c-accent-recordUnlit);
|
||||||
|
border: 1px solid var(--c-line-seamDark); transition: background var(--m-lamp-off), box-shadow var(--m-lamp-off); flex: none; }
|
||||||
|
.lamp.on { background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.6), rgba(255,255,255,0) 38%), var(--c-accent-record); box-shadow: var(--sh-lamp-on); transition-duration: var(--m-lamp-on); }
|
||||||
|
.lamp.big { width: var(--z-lamp-large); height: var(--z-lamp-large); }
|
||||||
|
.lamp.amber.on { background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.6), rgba(255,255,255,0) 38%), var(--c-level-amber); box-shadow: none; }
|
||||||
|
.lamp.amber { background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.3), rgba(255,255,255,0) 38%), var(--c-level-amberUnlit); }
|
||||||
|
.stack { display: flex; gap: var(--s-4); flex-wrap: wrap; align-items: center; }
|
||||||
|
.stack.col { flex-direction: column; align-items: stretch; }
|
||||||
|
.cap { margin-top: var(--s-2); }
|
||||||
|
|
||||||
|
/* paper list */
|
||||||
|
.paper { background: var(--c-surface-paper); box-shadow: var(--sh-well); }
|
||||||
|
.paper .r { display: grid; grid-template-columns: 76px 1fr; gap: var(--s-4); padding: var(--s-3) var(--s-4); min-height: var(--z-row-height); border-bottom: 1px solid var(--c-line-hairline); align-items: baseline; }
|
||||||
|
.paper .r.sel { background: var(--c-surface-selection); }
|
||||||
|
.paper .t { font: 500 12px/16px var(--f-mono); color: var(--c-ink-secondary); font-variant-numeric: tabular-nums; }
|
||||||
|
.paper .x { font: 15px/22px var(--f-text); }
|
||||||
|
mark { background: color-mix(in srgb, var(--c-level-amber) calc(var(--o-mark) * 100%), transparent); color: inherit; padding: 0 1px; }
|
||||||
|
mark s { color: var(--c-ink-tertiary); text-decoration-thickness: 1px; margin-right: 4px; }
|
||||||
|
|
||||||
|
/* instruments */
|
||||||
|
.rig { display: grid; grid-template-columns: auto 1fr; gap: var(--s-5); align-items: start; }
|
||||||
|
@media (max-width: 640px) { .rig { grid-template-columns: 1fr; } }
|
||||||
|
.vu { width: var(--m-vu-width); max-width: 100%; display: block; border-radius: var(--r-bezel); border: var(--b-bezel) solid var(--c-line-seamDark); box-shadow: var(--sh-well), 0 1px 0 var(--c-line-seamLight); background: var(--c-surface-meterFace); }
|
||||||
|
.display { background: var(--c-surface-display); box-shadow: var(--sh-well); padding: var(--s-3) var(--s-4); display: inline-block; }
|
||||||
|
.leds { display: flex; gap: var(--m-led-gap); }
|
||||||
|
.leds i { width: 6px; height: 14px; background: var(--c-level-greenUnlit); border-radius: var(--r-sharp); display: block; }
|
||||||
|
.leds i.g.on { background: var(--c-level-green); } .leds i.a { background: var(--c-level-amberUnlit); } .leds i.a.on { background: var(--c-level-amber); }
|
||||||
|
.leds i.r { background: var(--c-accent-recordUnlit); } .leds i.r.on { background: var(--c-accent-record); }
|
||||||
|
|
||||||
|
ul.rules { list-style: none; padding: 0; display: grid; gap: var(--s-3); max-width: 70ch; }
|
||||||
|
ul.rules li { padding-left: var(--s-5); position: relative; color: var(--c-ink-secondary); }
|
||||||
|
ul.rules li::before { content: ""; position: absolute; left: 0; top: 8px; width: 8px; height: 1px; background: var(--c-ink-primary); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0ms !important; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script id="tokens" type="application/json">/*TOKENS_JSON*/</script>
|
||||||
|
|
||||||
|
<div class="wrap" id="root">
|
||||||
|
|
||||||
|
<header class="mast">
|
||||||
|
<div class="id">
|
||||||
|
<div class="silk">Ontwerpsysteem · Sst</div>
|
||||||
|
<h1>Ontwerp-tokens</h1>
|
||||||
|
<p id="concept"></p>
|
||||||
|
</div>
|
||||||
|
<div class="stamp">
|
||||||
|
<div class="row"><span class="lamp amber on"></span><span class="silk">Ter goedkeuring</span></div>
|
||||||
|
<div class="mono" id="ver"></div>
|
||||||
|
<div class="silk-s">Bron · Design/tokens.json</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">01</span><span class="head">Instrumenten in gebruik</span><span class="tk">meter.* · motion.needle.* · segment.*</span></div>
|
||||||
|
<div class="two">
|
||||||
|
<div class="finish" data-finish="light"><div class="silk top"><span>Afwerking · Aluminium</span><span>licht</span></div><div class="rig-host"></div></div>
|
||||||
|
<div class="finish" data-finish="dark"><div class="silk top"><span>Afwerking · Graphite</span><span>donker</span></div><div class="rig-host"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">02</span><span class="head">Kleur</span><span class="tk">color.*</span></div>
|
||||||
|
<div class="two" id="colors"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">03</span><span class="head">Diepte en materiaal</span><span class="tk">shadow.* · border.* · texture.*</span></div>
|
||||||
|
<div class="two">
|
||||||
|
<div class="finish brushed" data-finish="light" data-depth></div>
|
||||||
|
<div class="finish brushed" data-finish="dark" data-depth></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">04</span><span class="head">Typografie</span><span class="tk">font.* · type.*</span></div>
|
||||||
|
<div id="type" class="panel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">05</span><span class="head">Ruimte, hoeken, randen</span><span class="tk">space.* · radius.* · border.*</span></div>
|
||||||
|
<div class="panel stack col" style="gap:24px">
|
||||||
|
<div><div class="silk cap">Ruimte (pt)</div><div class="spc" id="spc"></div></div>
|
||||||
|
<div class="seam"></div>
|
||||||
|
<div><div class="silk cap">Hoekradius</div><div class="rad" id="rad"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">06</span><span class="head">Beweging</span><span class="tk">motion.*</span></div>
|
||||||
|
<div class="two">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="silk cap">Toets en lampje</div>
|
||||||
|
<div class="stack" style="margin-top:12px"><button class="key" type="button">Stop</button><button class="key" type="button" id="lampbtn">Lampje</button><span class="lamp big" id="lampdemo"></span></div>
|
||||||
|
<p style="margin-top:12px;color:var(--c-ink-secondary)">Toets: 50 ms in, 90 ms uit, zonder veer. Lampje: 30 ms aan, 200 ms uit — dooft als een gloeidraad.</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="silk cap">Wijzer-ballistiek</div>
|
||||||
|
<div class="stack" style="margin-top:12px"><button class="key" type="button" id="step">Stap naar 0 VU</button><button class="key" type="button" id="stepHigh">Stap naar +3 VU</button></div>
|
||||||
|
<p style="margin-top:12px;color:var(--c-ink-secondary)">Veer: 300 ms tot 99 %, demping 0,80 → ongeveer 1,5 % overschot. De meters bij 01 gebruiken dezelfde waarden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><span class="no">07</span><span class="head">Wat het niet is</span></div>
|
||||||
|
<ul class="rules">
|
||||||
|
<li>Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten.</li>
|
||||||
|
<li>Eén accent: het rode opnamelampje. Amber en groen alleen als niveau-indicator of markering, nooit als versiering.</li>
|
||||||
|
<li>Lampjes mogen een kleine gloed hebben. Tekst nooit.</li>
|
||||||
|
<li>Geen eenmalige waarden in componenten: elke afmeting, kleur en tijd komt uit deze tokens.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const T = JSON.parse(document.getElementById('tokens').textContent);
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
||||||
|
const kebab = n => n.replace(/\./g, '-');
|
||||||
|
const rgba = (hex, a) => { const h = hex.slice(1); return `rgba(${parseInt(h.slice(0,2),16)},${parseInt(h.slice(2,4),16)},${parseInt(h.slice(4,6),16)},${a})`; };
|
||||||
|
const shadow = (tok, th) => tok.layers.map(l => `${l.inset ? 'inset ' : ''}${l.x}px ${l.y}px ${l.blur}px ${l.spread}px ${rgba(l.color, l.alpha[th])}`).join(', ');
|
||||||
|
const grain = th => {
|
||||||
|
const g = T.texture.brushed.grain[th];
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='320' height='320'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.004 0.85' numOctaves='2' seed='7'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1.4 -0.2'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='${g * 9}'/></svg>`;
|
||||||
|
return `url("data:image/svg+xml;utf8,${svg}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function vars(th) {
|
||||||
|
const v = {};
|
||||||
|
for (const [n, c] of Object.entries(T.color)) v['--c-' + kebab(n)] = c[th];
|
||||||
|
for (const [n, s] of Object.entries(T.shadow)) v['--sh-' + kebab(n)] = shadow(s, th);
|
||||||
|
for (const [n, o] of Object.entries(T.opacity)) v['--o-' + n] = o.value;
|
||||||
|
for (const [n, s] of Object.entries(T.space)) v['--s-' + n] = s + 'px';
|
||||||
|
for (const [n, r] of Object.entries(T.radius)) v['--r-' + n] = r.value + 'px';
|
||||||
|
for (const [n, b] of Object.entries(T.border)) v['--b-' + n] = b.value + 'px';
|
||||||
|
for (const [n, s] of Object.entries(T.size)) v['--z-' + kebab(n)] = s.value + 'px';
|
||||||
|
for (const [n, f] of Object.entries(T.font)) v['--f-' + n.replace('family.', '')] = `"${f.value}", ${f.fallback}`;
|
||||||
|
for (const [n, m] of Object.entries(T.motion)) {
|
||||||
|
if (m.unit === 'ms') v['--m-' + kebab(n)] = m.value + 'ms'; else if (typeof m.value === 'string' && m.value.startsWith('cubic')) v['--e-' + n.replace('easing.', '')] = m.value;
|
||||||
|
}
|
||||||
|
v['--m-vu-width'] = T.meter['vu.width'].value + 'px';
|
||||||
|
v['--m-led-gap'] = T.meter['led.gap'].value + 'px';
|
||||||
|
v['--tex-brushed'] = grain(th);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
function apply(el, th) { for (const [k, val] of Object.entries(vars(th))) el.style.setProperty(k, val); el.dataset.finish = th; }
|
||||||
|
apply(document.body, 'light');
|
||||||
|
$$('.finish').forEach(el => apply(el, el.dataset.finish));
|
||||||
|
|
||||||
|
$('#concept').textContent = T.meta.concept;
|
||||||
|
$('#ver').textContent = 'v' + T.meta.version;
|
||||||
|
|
||||||
|
/* ---- 02 colour swatches ---- */
|
||||||
|
const groups = {};
|
||||||
|
for (const [n, c] of Object.entries(T.color)) (groups[n.split('.')[0]] ??= []).push([n, c]);
|
||||||
|
const groupLabel = { surface: 'Oppervlak', ink: 'Inkt', accent: 'Accent', level: 'Niveau', line: 'Lijnen', display: 'Display' };
|
||||||
|
for (const th of ['light', 'dark']) {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'finish'; apply(box, th);
|
||||||
|
box.innerHTML = `<div class="silk top"><span>Afwerking · ${th === 'light' ? 'Aluminium' : 'Graphite'}</span><span>${th === 'light' ? 'licht' : 'donker'}</span></div>` +
|
||||||
|
Object.entries(groups).map(([g, items]) => `<div class="sw-group"><span class="silk">${groupLabel[g] || g}</span>` +
|
||||||
|
items.map(([n, c]) => `<div class="sw" title="${c.use}"><i style="background:${c[th]}"></i><b>${n}</b><span>${c[th]}</span></div>`).join('') + '</div>').join('');
|
||||||
|
$('#colors').appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 03 depth ---- */
|
||||||
|
$$('[data-depth]').forEach(el => {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="silk top"><span>Afwerking · ${el.dataset.finish === 'light' ? 'Aluminium' : 'Graphite'}</span><span>geborsteld metaal</span></div>
|
||||||
|
<div class="panel stack col" style="gap:20px">
|
||||||
|
<div class="stack"><button class="key" type="button">Toets</button><button class="key down" type="button">Ingedrukt</button><button class="key rec" type="button"><span class="lamp on"></span>Rec</button><button class="key rec down" type="button">Rec</button></div>
|
||||||
|
<div class="seam"></div>
|
||||||
|
<div class="stack" style="align-items:stretch"><div class="well" style="flex:1"><div class="silk">Verzonken vlak</div><div class="mono" style="margin-top:6px">well</div></div><div class="stack col" style="gap:8px;justify-content:center"><div class="stack"><span class="lamp big on"></span><span class="silk-s">Lamp aan</span></div><div class="stack"><span class="lamp big"></span><span class="silk-s">Lamp uit</span></div></div></div>
|
||||||
|
<div class="paper"><div class="r"><span class="t">14:02:11</span><span class="x">Ik wil de meeting verplaatsen naar tomorrow morning.</span></div>
|
||||||
|
<div class="r sel"><span class="t">14:03:40</span><span class="x">Hij stuurt het door naar <mark><s>cloud code</s>Claude Code</mark> vandaag.</span></div></div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- 04 type ---- */
|
||||||
|
const fam = f => `'${T.font['family.' + f].value}', ${T.font['family.' + f].fallback}`;
|
||||||
|
const samples = { label: 'Opnameniveau', labelSmall: 'VU · dB', control: 'Start opname', heading: 'Transcripties', body: 'Doorzoek je eerdere transcripties en woordenboek.', reading: 'Ik wil de meeting verplaatsen naar tomorrow morning, want de deadline komt eraan.', meta: 'Gisteren, 14:02 · 8,4 s', counter: '00:12.4', counterSmall: '14:02:11', code: 'cloud code => Claude Code' };
|
||||||
|
$('#type').innerHTML = Object.entries(T.type).map(([n, t]) => `
|
||||||
|
<div class="ty"><div class="spec"><b>type.${n}</b>${t.family} ${t.size}/${t.line} · ${t.weight}${t.caps ? ' · CAPS' : ''}${t.tracking ? ' · +' + t.tracking + 'em' : ''}</div>
|
||||||
|
<div style="font:${t.weight} ${t.size}px/${t.line}px ${fam(t.family)};letter-spacing:${t.tracking}em;${t.caps ? 'text-transform:uppercase;' : ''}${t.tabular ? 'font-variant-numeric:tabular-nums;' : ''}">${samples[n] || n}</div></div>`).join('');
|
||||||
|
|
||||||
|
/* ---- 05 spacing / radius ---- */
|
||||||
|
$('#spc').innerHTML = Object.entries(T.space).map(([n, v]) => `<div><i style="width:${v || 1}px"></i><span class="mono">${n} · ${v}</span></div>`).join('');
|
||||||
|
$('#rad').innerHTML = Object.entries(T.radius).map(([n, r]) => `<div><i style="border-radius:${Math.min(r.value, 28)}px"></i><span class="mono">${n} · ${r.value >= 999 ? 'rond' : r.value}</span></div>`).join('');
|
||||||
|
|
||||||
|
/* ---- instruments ---- */
|
||||||
|
const VU = T.meter;
|
||||||
|
const vuMin = VU['vu.min'].value, vuMax = VU['vu.max'].value, arc = VU['vu.arcDegrees'].value;
|
||||||
|
const W = VU['vu.width'].value, H = VU['vu.height'].value;
|
||||||
|
const pivot = { x: W / 2, y: H + VU['vu.pivotOffset'].value };
|
||||||
|
const frac = v => Math.max(0, Math.min(1.03, (v - vuMin) / (vuMax - vuMin)));
|
||||||
|
const ang = f => (-arc / 2 + arc * f) * Math.PI / 180;
|
||||||
|
const pt = (r, a) => [pivot.x + r * Math.sin(a), pivot.y - r * Math.cos(a)];
|
||||||
|
const f2 = n => n.toFixed(2);
|
||||||
|
|
||||||
|
function meterSvg() {
|
||||||
|
const R = VU['vu.scaleRadius'].value;
|
||||||
|
let s = `<svg class="vu" viewBox="0 0 ${W} ${H}" role="img" aria-label="VU-meter" style="aspect-ratio:${W}/${H}">`;
|
||||||
|
s += `<rect width="${W}" height="${H}" fill="var(--c-surface-meterFace)"/>`;
|
||||||
|
const redFrom = frac(0);
|
||||||
|
const arcPath = (r0, f0, f1) => { const [x0, y0] = pt(r0, ang(f0)), [x1, y1] = pt(r0, ang(f1)); return `M${f2(x0)} ${f2(y0)} A${r0} ${r0} 0 0 1 ${f2(x1)} ${f2(y1)}`; };
|
||||||
|
s += `<path d="${arcPath(R, 0, redFrom)}" stroke="var(--c-ink-onMeter)" stroke-width="1" fill="none"/>`;
|
||||||
|
s += `<path d="${arcPath(R - 2, redFrom, 1)}" stroke="var(--c-accent-record)" stroke-width="${VU['vu.redBandWidth'].value}" fill="none"/>`;
|
||||||
|
for (let v = vuMin; v <= vuMax; v++) {
|
||||||
|
const major = [-20, -10, -7, -5, -3, 0, 3].includes(v), a = ang(frac(v));
|
||||||
|
const [x0, y0] = pt(R, a), [x1, y1] = pt(R - (major ? VU['vu.tickMajor'].value : VU['vu.tickMinor'].value), a);
|
||||||
|
s += `<line x1="${f2(x0)}" y1="${f2(y0)}" x2="${f2(x1)}" y2="${f2(y1)}" stroke="${v > 0 ? 'var(--c-accent-record)' : 'var(--c-ink-onMeter)'}" stroke-width="${major ? 1.4 : 1}"/>`;
|
||||||
|
if (major) { const [tx, ty] = pt(R - VU['vu.labelInset'].value, a); s += `<text x="${f2(tx)}" y="${f2(ty + 3)}" text-anchor="middle" font-family="${fam('chrome')}" font-weight="500" font-size="9" letter-spacing=".5" fill="${v > 0 ? 'var(--c-accent-record)' : 'var(--c-ink-onMeter)'}">${v > 0 ? '+' + v : v}</text>`; }
|
||||||
|
}
|
||||||
|
s += `<text x="${W / 2}" y="${H - 30}" text-anchor="middle" font-family="${fam('chrome')}" font-weight="500" font-size="15" letter-spacing="3" fill="var(--c-ink-onMeter)">VU</text>`;
|
||||||
|
s += `<text x="14" y="${H - 10}" font-family="${fam('chrome')}" font-weight="500" font-size="8" letter-spacing="1.2" fill="var(--c-ink-onMeter)" opacity=".75">SST · FIELD RECORDER</text>`;
|
||||||
|
s += `<line class="needle" x1="${pivot.x}" y1="${pivot.y}" x2="${pivot.x}" y2="${pivot.y - VU['vu.needleLength'].value}" stroke="var(--c-ink-onMeter)" stroke-width="${VU['vu.needleWidth'].value}" stroke-linecap="butt"/>`;
|
||||||
|
s += `<circle cx="${W - 18}" cy="18" r="5" class="peak" fill="var(--c-accent-recordUnlit)" stroke="var(--c-ink-onMeter)" stroke-width=".8"/><text x="${W - 18}" y="34" text-anchor="middle" font-family="${fam('chrome')}" font-size="7" font-weight="500" letter-spacing=".8" fill="var(--c-ink-onMeter)">PEAK</text>`;
|
||||||
|
s += `</svg>`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEG = T.segment, DW = SEG.digitWidth.value, DH = SEG.digitHeight.value, TH = SEG.thickness.value, GP = SEG.gap.value;
|
||||||
|
const MAP = { 0: 'abcdef', 1: 'bc', 2: 'abdeg', 3: 'abcdg', 4: 'bcfg', 5: 'acdfg', 6: 'acdefg', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg' };
|
||||||
|
function hbar(x0, x1, cy) { const h = TH / 2; return `${x0},${cy} ${x0 + h},${cy - h} ${x1 - h},${cy - h} ${x1},${cy} ${x1 - h},${cy + h} ${x0 + h},${cy + h}`; }
|
||||||
|
function vbar(cx, y0, y1) { const h = TH / 2; return `${cx},${y0} ${cx + h},${y0 + h} ${cx + h},${y1 - h} ${cx},${y1} ${cx - h},${y1 - h} ${cx - h},${y0 + h}`; }
|
||||||
|
function digit(ch, ox, lit) {
|
||||||
|
const h = TH / 2, L = h, Rr = DW - h, mid = DH / 2, g = GP;
|
||||||
|
const P = { a: hbar(L + h + g, Rr - h - g, h), g: hbar(L + h + g, Rr - h - g, mid), d: hbar(L + h + g, Rr - h - g, DH - h),
|
||||||
|
f: vbar(L, h + h + g, mid - h - g), b: vbar(Rr, h + h + g, mid - h - g), e: vbar(L, mid + h + g, DH - h - h - g), c: vbar(Rr, mid + h + g, DH - h - h - g) };
|
||||||
|
const on = MAP[ch] || '';
|
||||||
|
return `<g transform="translate(${ox},0)">` + Object.entries(P).map(([k, pts]) => `<polygon points="${pts}" fill="${lit && on.includes(k) ? 'var(--c-level-amber)' : 'var(--c-display-segmentUnlit)'}"/>`).join('') + `</g>`;
|
||||||
|
}
|
||||||
|
function counterSvg(text) {
|
||||||
|
const adv = SEG.advance.value; let x = 0, out = '';
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === ':' || ch === '.') { out += `<rect x="${x + 2}" y="${ch === ':' ? DH * .28 : DH - 5}" width="4" height="4" fill="var(--c-level-amber)"/>` + (ch === ':' ? `<rect x="${x + 2}" y="${DH * .64}" width="4" height="4" fill="var(--c-level-amber)"/>` : ''); x += SEG.colonWidth.value; }
|
||||||
|
else { out += digit(ch, x, true); x += adv; }
|
||||||
|
}
|
||||||
|
return `<svg width="${x}" height="${DH}" viewBox="0 0 ${x} ${DH}" role="img" aria-label="Teller ${text}">${out}</svg>`;
|
||||||
|
}
|
||||||
|
const ghost8 = () => counterSvg('88:88.8').replace(/var\(--c-level-amber\)/g, 'var(--c-display-segmentUnlit)');
|
||||||
|
|
||||||
|
const rigs = [];
|
||||||
|
$$('.rig-host').forEach(host => {
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="rig">
|
||||||
|
<div>${meterSvg()}<div class="silk-s" style="margin-top:6px">Niveau · 0 VU = ${VU['vu.referenceDbfs'].value} dBFS</div></div>
|
||||||
|
<div class="stack col" style="gap:14px">
|
||||||
|
<div><div class="silk-s" style="margin-bottom:6px">Tijd</div>
|
||||||
|
<div class="display"><div style="position:relative"><div class="ghost" style="position:absolute;inset:0">${ghost8()}</div><div class="live" style="position:relative"></div></div></div></div>
|
||||||
|
<div><div class="silk-s" style="margin-bottom:6px">Niveau</div><div class="leds">${Array.from({length: 12}, (_, i) => `<i class="${i < 8 ? 'g' : i < 11 ? 'a' : 'r'}"></i>`).join('')}</div></div>
|
||||||
|
<div class="stack"><button class="key rec down" type="button" data-rec><span class="lamp on"></span>Rec</button><button class="key" type="button" data-stop>Stop</button></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
rigs.push({ host, needle: $('.needle', host), peak: $('.peak', host), live: $('.live', host), leds: $$('.leds i', host), rec: $('[data-rec]', host), stop: $('[data-stop]', host), lamp: $('[data-rec] .lamp', host), x: 0, v: 0, target: -40, peakUntil: 0, running: true, t0: performance.now(), mode: 'speech' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* spring: critically-parametrised second-order system, 99% settling = needle.response */
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const zeta = reduce ? 1 : T.motion['needle.damping'].value, resp = T.motion['needle.response'].value;
|
||||||
|
const wn = 4.6 / (zeta * resp);
|
||||||
|
function envelope(t) {
|
||||||
|
const cyc = t % 9;
|
||||||
|
if (cyc > 6.2) return -40;
|
||||||
|
const syl = Math.floor(t * 4.2);
|
||||||
|
const r = Math.sin(syl * 12.9898) * 43758.5453; const rnd = r - Math.floor(r);
|
||||||
|
const gap = Math.sin(t * 4.2 * Math.PI) > -0.55 ? 0 : -25;
|
||||||
|
return rnd > .12 ? (-15 + rnd * 16.5 + gap) : -40;
|
||||||
|
}
|
||||||
|
let last = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
const dt = Math.min(.05, (now - last) / 1000); last = now;
|
||||||
|
for (const r of rigs) {
|
||||||
|
const t = (now - r.t0) / 1000;
|
||||||
|
if (r.mode === 'speech') r.target = r.running ? envelope(t) : -40;
|
||||||
|
const tgt = frac(r.target);
|
||||||
|
const a = wn * wn * (tgt - r.x) - 2 * zeta * wn * r.v;
|
||||||
|
r.v += a * dt; r.x += r.v * dt;
|
||||||
|
const ang_ = ang(Math.max(-0.02, Math.min(1.05, r.x)));
|
||||||
|
const [nx, ny] = pt(VU['vu.needleLength'].value, ang_);
|
||||||
|
r.needle.setAttribute('x2', f2(nx)); r.needle.setAttribute('y2', f2(ny));
|
||||||
|
const vuNow = vuMin + (vuMax - vuMin) * r.x;
|
||||||
|
if (vuNow > 0 && r.running) r.peakUntil = now + VU['peak.holdMs'].value;
|
||||||
|
r.peak.setAttribute('fill', now < r.peakUntil ? 'var(--c-accent-record)' : 'var(--c-accent-recordUnlit)');
|
||||||
|
const lit = Math.round(Math.max(0, Math.min(1, (vuNow - vuMin) / (vuMax - vuMin))) * 12);
|
||||||
|
r.leds.forEach((l, i) => l.classList.toggle('on', i < lit));
|
||||||
|
const secs = r.running ? t : (r.frozen ?? 0); if (r.running) r.frozen = t;
|
||||||
|
const mm = String(Math.floor(secs / 60) % 100).padStart(2, '0'), ss = String(Math.floor(secs % 60)).padStart(2, '0'), d = Math.floor((secs * 10) % 10);
|
||||||
|
const txt = `${mm}:${ss}.${d}`;
|
||||||
|
if (r.txt !== txt) { r.live.innerHTML = counterSvg(txt); r.txt = txt; }
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
rigs.forEach(r => {
|
||||||
|
r.stop.onclick = () => { r.running = false; r.rec.classList.remove('down'); r.lamp.classList.remove('on'); };
|
||||||
|
r.rec.onclick = () => { if (!r.running) { r.running = true; r.t0 = performance.now(); r.rec.classList.add('down'); r.lamp.classList.add('on'); } };
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- 06 motion demos ---- */
|
||||||
|
$('#lampbtn').onclick = () => $('#lampdemo').classList.toggle('on');
|
||||||
|
const stepTo = v => rigs.forEach(r => { r.mode = 'manual'; r.running = false; r.target = v; setTimeout(() => { r.target = -40; setTimeout(() => { r.mode = 'speech'; r.running = true; r.t0 = performance.now(); r.rec.classList.add('down'); r.lamp.classList.add('on'); }, 1800); }, 1800); r.rec.classList.remove('down'); r.lamp.classList.remove('on'); });
|
||||||
|
$('#step').onclick = () => stepTo(0);
|
||||||
|
$('#stepHigh').onclick = () => stepTo(3);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"name": "Sst Design Tokens",
|
||||||
|
"version": "0.1.0-draft",
|
||||||
|
"status": "Ter goedkeuring — nog niet gebruikt door de app",
|
||||||
|
"concept": "Draagbare veldrecorder / cassettedeck uit de jaren 80 (Sony TC-D5, Marantz PMD, Nakamichi, Braun). Twee afwerkingen: ALUMINIUM (licht) en GRAPHITE (donker). Eén accent: het rode opnamelampje. Amber en groen alleen voor niveau-indicatoren."
|
||||||
|
},
|
||||||
|
|
||||||
|
"color": {
|
||||||
|
"surface.chassis": { "light": "#CBC8C0", "dark": "#1C1B19", "use": "Venster-achtergrond; het 'huis' van het apparaat" },
|
||||||
|
"surface.metalHigh": { "light": "#E3E1DB", "dark": "#3B3A36", "use": "Geborsteld metaal, bovenkant van de verloop" },
|
||||||
|
"surface.metalLow": { "light": "#BDBAB2", "dark": "#262522", "use": "Geborsteld metaal, onderkant van de verloop" },
|
||||||
|
"surface.panel": { "light": "#E2DFD5", "dark": "#2B2A27", "use": "Frontplaat van mat plastic; zijbalk en bedieningspanelen" },
|
||||||
|
"surface.well": { "light": "#B4B1A8", "dark": "#131211", "use": "Verzonken vlak: invoervelden, display-vensters" },
|
||||||
|
"surface.paper": { "light": "#EEE9D9", "dark": "#232220", "use": "Cassette-label crème: lijsten met transcripties en woordenboek" },
|
||||||
|
"surface.keyHigh": { "light": "#E6E3DA", "dark": "#44433F", "use": "Toetsdop, bovenkant" },
|
||||||
|
"surface.keyLow": { "light": "#CBC8BF", "dark": "#33322F", "use": "Toetsdop, onderkant" },
|
||||||
|
"surface.keyPressed": { "light": "#BEBBB2", "dark": "#2A2927", "use": "Ingedrukte toets" },
|
||||||
|
"surface.meterFace": { "light": "#EFE6C8", "dark": "#D8CEA6", "use": "Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen)" },
|
||||||
|
"surface.selection": { "light": "#D4CDB2", "dark": "#3A3828", "use": "Geselecteerde rij in een lijst" },
|
||||||
|
"surface.display": { "light": "#1B1A18", "dark": "#0E0D0C", "use": "Displayglas van de teller; donker in beide afwerkingen" },
|
||||||
|
"display.segmentUnlit": { "light": "#3A3320", "dark": "#2A2415", "use": "Gedoofd segment op het displayglas" },
|
||||||
|
|
||||||
|
"ink.primary": { "light": "#1E1D1B", "dark": "#ECE8DC", "use": "Hoofdtekst en wijzer" },
|
||||||
|
"ink.secondary": { "light": "#403D38", "dark": "#C4BFB2", "use": "Secundaire tekst" },
|
||||||
|
"ink.tertiary": { "light": "#4D4A44", "dark": "#ABA79B", "use": "Zeefdruk-labels en hulptekst" },
|
||||||
|
"ink.disabled": { "light": "#8F8B82", "dark": "#6B685F", "use": "Uitgeschakeld; nooit voor essentiële tekst" },
|
||||||
|
"ink.inverse": { "light": "#F4EFE3", "dark": "#F4EFE3", "use": "Tekst op rode of donkere vulling; crème in beide afwerkingen" },
|
||||||
|
"ink.onMeter": { "light": "#1E1D1B", "dark": "#1E1D1B", "use": "Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen" },
|
||||||
|
|
||||||
|
"accent.record": { "light": "#C8281E", "dark": "#CC2A20", "use": "ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst" },
|
||||||
|
"accent.recordUnlit": { "light": "#7A2E28", "dark": "#4A1F1B", "use": "Lampje uit" },
|
||||||
|
|
||||||
|
"level.green": { "light": "#5E9E48", "dark": "#6DB556", "use": "Niveau-segment, brandend" },
|
||||||
|
"level.amber": { "light": "#D9A023", "dark": "#E5B032", "use": "Niveau-segment, waarschuwing, gecorrigeerd woord" },
|
||||||
|
"level.greenUnlit": { "light": "#A8B79C", "dark": "#26331F", "use": "Groen segment, uit" },
|
||||||
|
"level.amberUnlit": { "light": "#CDBA8A", "dark": "#3D3418", "use": "Amber segment, uit" },
|
||||||
|
|
||||||
|
"line.seamDark": { "light": "#8C8981", "dark": "#0A0A09", "use": "Naad tussen panelen, donkere helft" },
|
||||||
|
"line.seamLight": { "light": "#F3F1EB", "dark": "#45433F", "use": "Naad tussen panelen, lichte helft" },
|
||||||
|
"line.hairline": { "light": "#A9A69D", "dark": "#3A3834", "use": "Fijne scheidingslijn, zeefdruk-kader" },
|
||||||
|
"line.focus": { "light": "#1E1D1B", "dark": "#ECE8DC", "use": "Toetsenbord-focus" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"opacity": {
|
||||||
|
"mark": { "value": 0.30, "use": "Amber markering achter een gecorrigeerd woord" },
|
||||||
|
"disabled": { "value": 0.45, "use": "Uitgeschakelde bediening" },
|
||||||
|
"unlit": { "value": 1.00, "use": "Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie" },
|
||||||
|
"pressedRecord": { "value": 0.85, "use": "Ingedrukte REC-toets: het rood dooft licht" },
|
||||||
|
"specularOn": { "value": 0.60, "use": "Lichtvlekje op een brandend lampje" },
|
||||||
|
"specularOff": { "value": 0.30, "use": "Lichtvlekje op een gedoofd lampje" },
|
||||||
|
"caption": { "value": 0.75, "use": "Kleine opschriften op de wijzerplaat" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"font": {
|
||||||
|
"family.chrome": { "value": "Helvetica Neue", "fallback": "Helvetica, Arial", "use": "Zeefdruk, toetsen, koppen: neutrale grotesk" },
|
||||||
|
"family.text": { "value": "SF Pro Text", "fallback": "system-ui", "use": "Leesbare tekst: transcripties, woordenboek" },
|
||||||
|
"family.mono": { "value": "SF Mono", "fallback": "Menlo, monospace", "use": "Teller, tijden, woordenboekbestand" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"type": {
|
||||||
|
"label": { "family": "chrome", "size": 10, "weight": 500, "tracking": 0.09, "line": 12, "caps": true, "use": "Zeefdruk-label" },
|
||||||
|
"labelSmall": { "family": "chrome", "size": 9, "weight": 500, "tracking": 0.10, "line": 11, "caps": true, "use": "Schaalmarkeringen en eenheden" },
|
||||||
|
"control": { "family": "chrome", "size": 11, "weight": 500, "tracking": 0.06, "line": 14, "caps": true, "use": "Opschrift op toetsen" },
|
||||||
|
"heading": { "family": "chrome", "size": 12, "weight": 500, "tracking": 0.12, "line": 16, "caps": true, "use": "Paneelkop" },
|
||||||
|
"body": { "family": "text", "size": 13, "weight": 400, "tracking": 0, "line": 18, "caps": false, "use": "Standaardtekst" },
|
||||||
|
"reading": { "family": "text", "size": 15, "weight": 400, "tracking": 0, "line": 22, "caps": false, "use": "Transcript" },
|
||||||
|
"meta": { "family": "text", "size": 11, "weight": 400, "tracking": 0, "line": 14, "caps": false, "use": "Bijschriften" },
|
||||||
|
"counter": { "family": "mono", "size": 22, "weight": 500, "tracking": 0, "line": 26, "caps": false, "tabular": true, "use": "Teller" },
|
||||||
|
"counterSmall": { "family": "mono", "size": 12, "weight": 500, "tracking": 0, "line": 16, "caps": false, "tabular": true, "use": "Tijdstip in een rij" },
|
||||||
|
"code": { "family": "mono", "size": 12, "weight": 400, "tracking": 0, "line": 18, "caps": false, "use": "Woordenboekregels" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"segment": {
|
||||||
|
"digitHeight": { "value": 36, "use": "Hoogte van een getekend segmentcijfer" },
|
||||||
|
"digitWidth": { "value": 20, "use": "Breedte van een segmentcijfer" },
|
||||||
|
"thickness": { "value": 4, "use": "Dikte van één segment" },
|
||||||
|
"gap": { "value": 1, "use": "Ruimte tussen segmenten" },
|
||||||
|
"slant": { "value": 0, "use": "Schuinte in graden; 0 = rechtop" },
|
||||||
|
"advance": { "value": 26, "use": "Afstand van cijfer tot cijfer" },
|
||||||
|
"colonWidth": { "value": 10, "use": "Breedte van een dubbele punt of punt" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"space": {
|
||||||
|
"0": 0, "1": 2, "2": 4, "3": 8, "4": 12, "5": 16, "6": 24, "7": 32, "8": 48
|
||||||
|
},
|
||||||
|
|
||||||
|
"layout": {
|
||||||
|
"window.minWidth": { "value": 800, "use": "Minimale vensterbreedte" },
|
||||||
|
"window.minHeight": { "value": 560, "use": "Minimale vensterhoogte" },
|
||||||
|
"window.defaultWidth": { "value": 1080, "use": "Standaard vensterbreedte" },
|
||||||
|
"window.defaultHeight":{ "value": 700, "use": "Standaard vensterhoogte" },
|
||||||
|
"sidebar.width": { "value": 232, "use": "Breedte zijbalk" },
|
||||||
|
"settings.width": { "value": 560, "use": "Breedte instellingenvenster" },
|
||||||
|
"panel.padding": { "value": 16, "use": "Binnenmarge van een paneel (= space.5)" },
|
||||||
|
"panel.gap": { "value": 12, "use": "Afstand tussen panelen (= space.4)" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"size": {
|
||||||
|
"control.height": { "value": 28, "use": "Standaardhoogte van toets of veld" },
|
||||||
|
"key.minWidth": { "value": 56, "use": "Minimale breedte van een toets" },
|
||||||
|
"key.recWidth": { "value": 64, "use": "REC-toets, breedte" },
|
||||||
|
"key.recHeight": { "value": 44, "use": "REC-toets, hoogte" },
|
||||||
|
"lamp.diameter": { "value": 10, "use": "Standaard lampje" },
|
||||||
|
"lamp.large": { "value": 14, "use": "Opnamelampje" },
|
||||||
|
"switch.width": { "value": 40, "use": "Tuimelschakelaar, breedte" },
|
||||||
|
"switch.height": { "value": 22, "use": "Tuimelschakelaar, hoogte" },
|
||||||
|
"row.height": { "value": 40, "use": "Rij in transcriptielijst" },
|
||||||
|
"icon.small": { "value": 16, "use": "Klein pictogram" },
|
||||||
|
"icon.medium": { "value": 20, "use": "Normaal pictogram" },
|
||||||
|
"hit.min": { "value": 24, "use": "Minimaal klikdoel" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"radius": {
|
||||||
|
"none": { "value": 0, "use": "Panelen en velden: scherpe randen" },
|
||||||
|
"sharp": { "value": 1, "use": "Toetsranden: bijna scherp" },
|
||||||
|
"sm": { "value": 2, "use": "Toetsen, velden" },
|
||||||
|
"md": { "value": 4, "use": "Grote panelen" },
|
||||||
|
"bezel": { "value": 6, "use": "Rand van de VU-meter" },
|
||||||
|
"lamp": { "value": 999, "use": "Lampjes: rond" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"border": {
|
||||||
|
"hairline": { "value": 1, "use": "Fijne lijn" },
|
||||||
|
"seam": { "value": 2, "use": "Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef" },
|
||||||
|
"bezel": { "value": 3, "use": "Rand om de VU-meter" },
|
||||||
|
"focus": { "value": 2, "use": "Focusring, 2 pt offset" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"shadow": {
|
||||||
|
"key.raised": {
|
||||||
|
"use": "Toets in rust: lichtvang boven, donkere onderrand, valschaduw",
|
||||||
|
"layers": [
|
||||||
|
{ "inset": true, "x": 0, "y": 1, "blur": 0, "spread": 0, "color": "#FFFFFF", "alpha": { "light": 0.65, "dark": 0.10 } },
|
||||||
|
{ "inset": true, "x": 0, "y": -1, "blur": 0, "spread": 0, "color": "#000000", "alpha": { "light": 0.25, "dark": 0.55 } },
|
||||||
|
{ "inset": false, "x": 0, "y": 2, "blur": 2, "spread": 0, "color": "#000000", "alpha": { "light": 0.35, "dark": 0.60 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"key.pressed": {
|
||||||
|
"use": "Ingedrukte toets: dop 1 pt omlaag, geen valschaduw",
|
||||||
|
"layers": [
|
||||||
|
{ "inset": true, "x": 0, "y": 2, "blur": 4, "spread": 0, "color": "#000000", "alpha": { "light": 0.50, "dark": 0.70 } },
|
||||||
|
{ "inset": true, "x": 0, "y": 0, "blur": 0, "spread": 1, "color": "#000000", "alpha": { "light": 0.35, "dark": 0.60 } }
|
||||||
|
],
|
||||||
|
"translateY": 1
|
||||||
|
},
|
||||||
|
"well": {
|
||||||
|
"use": "Verzonken vlak met lichtvang op de onderlip",
|
||||||
|
"layers": [
|
||||||
|
{ "inset": true, "x": 0, "y": 1, "blur": 3, "spread": 0, "color": "#000000", "alpha": { "light": 0.40, "dark": 0.70 } },
|
||||||
|
{ "inset": false, "x": 0, "y": 1, "blur": 0, "spread": 0, "color": "#FFFFFF", "alpha": { "light": 0.55, "dark": 0.07 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"use": "Frontplaat boven het chassis",
|
||||||
|
"layers": [
|
||||||
|
{ "inset": true, "x": 0, "y": 1, "blur": 0, "spread": 0, "color": "#FFFFFF", "alpha": { "light": 0.60, "dark": 0.08 } },
|
||||||
|
{ "inset": false, "x": 0, "y": 3, "blur": 8, "spread": 0, "color": "#000000", "alpha": { "light": 0.22, "dark": 0.55 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lamp.on": {
|
||||||
|
"use": "Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst",
|
||||||
|
"layers": [
|
||||||
|
{ "inset": false, "x": 0, "y": 0, "blur": 6, "spread": 1, "color": "#CC2A20", "alpha": { "light": 0.45, "dark": 0.55 } },
|
||||||
|
{ "inset": true, "x": 0, "y": -1, "blur": 1, "spread": 0, "color": "#000000", "alpha": { "light": 0.35, "dark": 0.45 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"texture": {
|
||||||
|
"brushed": {
|
||||||
|
"use": "Geborsteld aluminium voor chassis en zijbalk",
|
||||||
|
"direction": "horizontaal",
|
||||||
|
"grain": { "light": 0.035, "dark": 0.05 },
|
||||||
|
"gradient": "surface.metalHigh naar surface.metalLow, van boven naar beneden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"meter": {
|
||||||
|
"vu.referenceDbfs": { "value": -20, "use": "0 VU = -20 dBFS (RMS)" },
|
||||||
|
"vu.min": { "value": -20, "use": "Onderkant van de schaal in VU" },
|
||||||
|
"vu.max": { "value": 3, "use": "Bovenkant van de schaal in VU; vanaf 0 VU rood" },
|
||||||
|
"vu.arcDegrees": { "value": 90, "use": "Hoek tussen minimale en maximale stand" },
|
||||||
|
"vu.width": { "value": 260, "use": "Breedte van de meter" },
|
||||||
|
"vu.height": { "value": 150, "use": "Hoogte van de meter" },
|
||||||
|
"vu.needleWidth": { "value": 1.5, "use": "Dikte van de wijzer" },
|
||||||
|
"vu.ticks": { "value": "-20 -10 -7 -5 -3 0 +3", "use": "Genummerde schaalmarkeringen" },
|
||||||
|
"vu.pivotOffset": { "value": 22, "use": "Draaipunt van de wijzer, onder de onderrand van de wijzerplaat" },
|
||||||
|
"vu.scaleRadius": { "value": 142, "use": "Straal van de schaalboog vanaf het draaipunt" },
|
||||||
|
"vu.needleLength": { "value": 146, "use": "Lengte van de wijzer" },
|
||||||
|
"vu.tickMajor": { "value": 11, "use": "Lengte van een genummerde streep" },
|
||||||
|
"vu.tickMinor": { "value": 6, "use": "Lengte van een gewone streep" },
|
||||||
|
"vu.labelInset": { "value": 24, "use": "Afstand van de schaalboog tot de cijfers" },
|
||||||
|
"vu.redBandWidth": { "value": 5, "use": "Dikte van de rode band vanaf 0 VU" },
|
||||||
|
"led.segments": { "value": 12, "use": "Aantal segmenten: 8 groen, 3 amber, 1 rood" },
|
||||||
|
"led.segmentSize": { "value": "6x14", "use": "Segment in pt (breedte x hoogte)" },
|
||||||
|
"led.gap": { "value": 2, "use": "Ruimte tussen segmenten" },
|
||||||
|
"peak.holdMs": { "value": 1000, "use": "Piek-LED blijft zo lang branden" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"motion": {
|
||||||
|
"duration.instant": { "value": 0, "unit": "ms", "use": "Geen overgang" },
|
||||||
|
"duration.fast": { "value": 60, "unit": "ms", "use": "Toets in/uit" },
|
||||||
|
"duration.base": { "value": 120, "unit": "ms", "use": "Standaardovergang" },
|
||||||
|
"duration.slow": { "value": 220, "unit": "ms", "use": "Paneel of venster" },
|
||||||
|
"easing.mechanical":{ "value": "cubic-bezier(0.2, 0, 0, 1)", "use": "Toetsen: direct, zonder veer" },
|
||||||
|
"easing.standard": { "value": "cubic-bezier(0.4, 0, 0.2, 1)", "use": "Overige overgangen" },
|
||||||
|
"key.press": { "value": 50, "unit": "ms", "use": "Toets indrukken" },
|
||||||
|
"key.release": { "value": 90, "unit": "ms", "use": "Toets loslaten" },
|
||||||
|
"lamp.on": { "value": 30, "unit": "ms", "use": "Lampje aan" },
|
||||||
|
"lamp.off": { "value": 200, "unit": "ms", "use": "Lampje uit: dooft langzaam, als een gloeidraad" },
|
||||||
|
"needle.response": { "value": 0.30, "unit": "s", "use": "VU-ballistiek: 300 ms om de stand te bereiken" },
|
||||||
|
"needle.damping": { "value": 0.80, "unit": "ratio", "use": "Veerdemping; geeft ongeveer 1,5 % overschot" },
|
||||||
|
"needle.updateHz": { "value": 60, "unit": "Hz", "use": "Verversing van de wijzer" },
|
||||||
|
"reducedMotion": { "value": "Overgangen 0 ms; wijzer blijft bewegen maar met demping 1.0 (geen overschot)", "use": "Systeeminstelling 'Verminder beweging'" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -9,6 +9,8 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "SstCore"),
|
.target(name: "SstCore"),
|
||||||
|
.target(name: "SstUI"),
|
||||||
|
.target(name: "SstData", dependencies: ["SstCore"]),
|
||||||
.target(
|
.target(
|
||||||
name: "SstWhisper",
|
name: "SstWhisper",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -16,9 +18,14 @@ let package = Package(
|
|||||||
.product(name: "WhisperKit", package: "WhisperKit"),
|
.product(name: "WhisperKit", package: "WhisperKit"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper", "SstData", "SstUI"]),
|
||||||
|
.executableTarget(name: "SstIcon", dependencies: ["SstUI"]),
|
||||||
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
||||||
|
.executableTarget(name: "SstGallery", dependencies: ["SstUI"]),
|
||||||
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
||||||
|
.testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]),
|
||||||
|
.testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]),
|
||||||
|
.testTarget(name: "SstUITests", dependencies: ["SstUI"]),
|
||||||
],
|
],
|
||||||
swiftLanguageModes: [.v5]
|
swiftLanguageModes: [.v5]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AppCommands: Commands {
|
||||||
|
@ObservedObject var model: AppModel
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
|
var body: some Commands {
|
||||||
|
CommandGroup(after: .pasteboard) {
|
||||||
|
Divider()
|
||||||
|
Button(Strings.clearHistory) {
|
||||||
|
openWindow(id: "main")
|
||||||
|
model.confirmingClearHistory = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandMenu(Strings.recordingMenu) {
|
||||||
|
Button(model.isRecording ? Strings.stopRecording : Strings.startRecording) {
|
||||||
|
model.toggleWindowRecording()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("r", modifiers: [.command, .option])
|
||||||
|
Divider()
|
||||||
|
Button(Strings.transcripts) { show(.transcripts) }
|
||||||
|
.keyboardShortcut("1", modifiers: .command)
|
||||||
|
Button(Strings.dictionary) { show(.dictionary) }
|
||||||
|
.keyboardShortcut("2", modifiers: .command)
|
||||||
|
Button(Strings.note) { show(.note) }
|
||||||
|
.keyboardShortcut("3", modifiers: .command)
|
||||||
|
Button(Strings.search) {
|
||||||
|
show(model.section)
|
||||||
|
model.focusSearch()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("f", modifiers: .command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func show(_ section: AppModel.Section) {
|
||||||
|
openWindow(id: "main")
|
||||||
|
model.show(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The one place that knows the app's name, so renaming it later is a one-file change.
|
||||||
|
enum AppIdentity {
|
||||||
|
static let displayName = "Sst"
|
||||||
|
static let supportFolderName = "Sst"
|
||||||
|
static let brandCaption = "SST · FIELD RECORDER"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppPaths {
|
||||||
|
static var support: URL {
|
||||||
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appending(path: AppIdentity.supportFolderName, directoryHint: .isDirectory)
|
||||||
|
}
|
||||||
|
static var dictionary: URL { support.appending(path: "dictionary.txt") }
|
||||||
|
static var history: URL { support.appending(path: "history.json") }
|
||||||
|
static var note: URL { support.appending(path: "note.txt") }
|
||||||
|
}
|
||||||
+361
-58
@@ -1,57 +1,153 @@
|
|||||||
|
import AppKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import ApplicationServices
|
import Combine
|
||||||
import SwiftUI
|
|
||||||
import SstCore
|
import SstCore
|
||||||
|
import SstData
|
||||||
import SstWhisper
|
import SstWhisper
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Decides at insertion time where dictated text goes.
|
||||||
|
/// Sst in front: into the Notitie editor if (and only if) it has the cursor, otherwise nowhere (it would land in Sst's own
|
||||||
|
/// search field). Another app in front: pasted there, except for recordings started in the window.
|
||||||
|
final class InsertionPolicy {
|
||||||
|
var windowSession = false
|
||||||
|
|
||||||
|
func route() -> InsertionRoute {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
let sstInFront = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier
|
||||||
|
if sstInFront { return NoteEditorRegistry.shared.active != nil ? .editor : .none }
|
||||||
|
return windowSession ? .none : .system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppModel: ObservableObject {
|
final class AppModel: ObservableObject {
|
||||||
@Published var state: DictationState = .idle
|
enum Section: String, CaseIterable, Identifiable {
|
||||||
@Published var modelStatus = "Model laden…"
|
case transcripts, dictionary, note
|
||||||
@Published var modelReady = false
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModelStatus: Equatable {
|
||||||
|
case loading(String)
|
||||||
|
case downloading(String, Double)
|
||||||
|
case ready
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services
|
||||||
|
let settings: AppSettings
|
||||||
|
let permissions = PermissionMonitor()
|
||||||
|
let levelSource = LevelSource()
|
||||||
|
let history: HistoryStore
|
||||||
|
let dictionary: DictionaryStore
|
||||||
|
let storage = ModelStorage()
|
||||||
|
let note: NoteStore
|
||||||
|
let historyList: HistoryViewModel
|
||||||
|
let dictionaryList: DictionaryViewModel
|
||||||
|
|
||||||
|
// Observable state
|
||||||
|
@Published private(set) var state: DictationState = .idle
|
||||||
|
@Published private(set) var modelStatus: ModelStatus = .loading(ModelCatalog.defaultVariant)
|
||||||
|
@Published private(set) var activeVariant: String?
|
||||||
|
@Published private(set) var installedVariants: Set<String> = []
|
||||||
|
@Published private(set) var recordingStartedAt: Date?
|
||||||
|
@Published private(set) var lastDuration: TimeInterval?
|
||||||
|
@Published private(set) var loadingVariant: String?
|
||||||
|
/// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh.
|
||||||
|
@Published private(set) var historyRevision = 0
|
||||||
|
@Published private(set) var dictionaryRevision = 0
|
||||||
|
@Published private(set) var searchFocusTick = 0
|
||||||
@Published var lastError: String?
|
@Published var lastError: String?
|
||||||
@Published var micGranted = false
|
@Published var section: Section = .transcripts
|
||||||
@Published var accessibilityGranted = false
|
@Published var confirmingClearHistory = false
|
||||||
@Published var inputMonitoringGranted = false
|
|
||||||
|
|
||||||
private let transcriber = WhisperKitTranscriber()
|
private let transcriber = SwappableTranscriber()
|
||||||
|
private let corrector: DictionaryCorrector
|
||||||
private let hotkey = HotkeyMonitor()
|
private let hotkey = HotkeyMonitor()
|
||||||
|
private let policy: InsertionPolicy
|
||||||
private let controller: DictationController
|
private let controller: DictationController
|
||||||
|
private var modelTask: Task<Void, Never>?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init() {
|
convenience init() {
|
||||||
controller = DictationController(
|
self.init(preview: false)
|
||||||
recorder: AudioRecorder(),
|
}
|
||||||
|
|
||||||
|
/// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey.
|
||||||
|
init(preview: Bool) {
|
||||||
|
let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings()
|
||||||
|
let policy = InsertionPolicy()
|
||||||
|
let knowledge = SystemWordKnowledge()
|
||||||
|
let corrector = DictionaryCorrector(knowledge: knowledge)
|
||||||
|
let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||||
|
let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary)
|
||||||
|
let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history)
|
||||||
|
let note = NoteStore(fileURL: preview ? folder.appending(path: "note.txt") : AppPaths.note)
|
||||||
|
let levelSource = self.levelSource
|
||||||
|
let recorder = AudioRecorder()
|
||||||
|
recorder.onLevel = { levelSource.update($0) }
|
||||||
|
|
||||||
|
self.settings = settings
|
||||||
|
self.policy = policy
|
||||||
|
self.corrector = corrector
|
||||||
|
self.dictionary = dictionary
|
||||||
|
self.history = history
|
||||||
|
self.note = note
|
||||||
|
self.historyList = HistoryViewModel(store: history)
|
||||||
|
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
|
||||||
|
self.controller = DictationController(
|
||||||
|
recorder: recorder,
|
||||||
transcriber: transcriber,
|
transcriber: transcriber,
|
||||||
cleaner: PassthroughCleaner(),
|
cleaner: PassthroughCleaner(),
|
||||||
inserter: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster())
|
inserter: RoutingInserter(
|
||||||
)
|
system: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
|
||||||
controller.onStateChange = { [weak self] in
|
editor: EditorInserter(),
|
||||||
self?.state = $0
|
route: { policy.route() }),
|
||||||
if $0 == .recording { self?.lastError = nil }
|
corrector: corrector,
|
||||||
|
vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] })
|
||||||
|
|
||||||
|
wire()
|
||||||
|
if preview {
|
||||||
|
loadPreviewData()
|
||||||
|
} else {
|
||||||
|
Task { await start() }
|
||||||
}
|
}
|
||||||
controller.onError = { [weak self] in self?.lastError = $0 }
|
}
|
||||||
hotkey.onEvent = { [weak self] event in
|
|
||||||
guard let self, self.modelReady else { return }
|
// MARK: derived
|
||||||
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
|
||||||
self.lastError = "Microfoon-toegang ontbreekt"
|
var isRecording: Bool { state == .recording }
|
||||||
return
|
var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn }
|
||||||
}
|
var modelReady: Bool { modelStatus == .ready }
|
||||||
Task { await self.controller.handle(event) }
|
|
||||||
|
/// Why recording is not possible right now, or nil when it is.
|
||||||
|
var recordingBlocker: String? {
|
||||||
|
if !permissions.allGranted { return Strings.permissionsMissing }
|
||||||
|
switch modelStatus {
|
||||||
|
case .ready: return nil
|
||||||
|
case .loading(let variant): return Strings.modelLoading(title(for: variant))
|
||||||
|
case .downloading(_, let fraction): return Strings.modelDownloading(Int(fraction * 100))
|
||||||
|
case .failed: return Strings.modelFailed
|
||||||
}
|
}
|
||||||
Task { await start() }
|
}
|
||||||
|
|
||||||
|
/// Like `recordingBlocker`, plus: a window recording is only kept when the history is on.
|
||||||
|
var windowRecordingBlocker: String? {
|
||||||
|
recordingBlocker ?? (settings.keepHistory ? nil : Strings.historyNeededForWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusLine: String {
|
var statusLine: String {
|
||||||
guard modelReady else { return modelStatus }
|
if let blocker = recordingBlocker, state == .idle { return blocker }
|
||||||
switch state {
|
switch state {
|
||||||
case .idle: return "Klaar — houd Fn ingedrukt om te dicteren"
|
case .idle: return Strings.holdToDictate(hotkeyBinding.displayName)
|
||||||
case .recording: return "Luistert…"
|
case .recording: return Strings.statusListening
|
||||||
case .transcribing: return "Transcriberen…"
|
case .transcribing: return Strings.statusTranscribing
|
||||||
case .inserting: return "Invoegen…"
|
case .inserting: return Strings.statusInserting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconName: String {
|
var menuIconName: String {
|
||||||
switch state {
|
switch state {
|
||||||
case .idle:
|
case .idle:
|
||||||
if lastError != nil { return "exclamationmark.triangle" }
|
if lastError != nil { return "exclamationmark.triangle" }
|
||||||
@@ -61,43 +157,250 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshPermissions() {
|
func title(for variant: String) -> String {
|
||||||
micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
ModelCatalog.all.first { $0.id == variant }?.title ?? variant
|
||||||
accessibilityGranted = AXIsProcessTrusted()
|
|
||||||
inputMonitoringGranted = CGPreflightListenEventAccess()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryHotkey() {
|
// MARK: lifecycle
|
||||||
refreshPermissions()
|
|
||||||
do {
|
private func wire() {
|
||||||
try hotkey.start()
|
controller.onStateChange = { [weak self] state in
|
||||||
lastError = nil
|
guard let self else { return }
|
||||||
} catch {
|
self.state = state
|
||||||
lastError = error.localizedDescription
|
if state != .recording, let started = self.recordingStartedAt {
|
||||||
|
// The counter shows the recording length, not the time spent transcribing and pasting.
|
||||||
|
self.lastDuration = Date().timeIntervalSince(started)
|
||||||
|
self.recordingStartedAt = nil
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case .recording:
|
||||||
|
self.lastError = nil
|
||||||
|
self.recordingStartedAt = Date()
|
||||||
|
self.levelSource.reset()
|
||||||
|
case .idle:
|
||||||
|
// Also covers a window recording that was ended with the hotkey; the paste decision is already made.
|
||||||
|
self.policy.windowSession = false
|
||||||
|
self.levelSource.reset()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.onError = { [weak self] in self?.lastError = $0 }
|
||||||
|
controller.onResult = { [weak self] result in
|
||||||
|
guard let self, self.settings.keepHistory else { return }
|
||||||
|
self.history.add(HistoryEntry(result: result))
|
||||||
|
}
|
||||||
|
hotkey.onEvent = { [weak self] event in self?.handleHotkey(event) }
|
||||||
|
hotkey.binding = hotkeyBinding
|
||||||
|
|
||||||
|
settings.$hotkeyID
|
||||||
|
.sink { [weak self] id in
|
||||||
|
guard let self else { return }
|
||||||
|
self.hotkey.binding = HotkeyBinding.binding(id: id) ?? .fn
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
permissions.$inputMonitoring
|
||||||
|
.dropFirst()
|
||||||
|
.removeDuplicates()
|
||||||
|
.filter { $0 }
|
||||||
|
.sink { [weak self] _ in self?.retryHotkey() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
settings.$useCorrection
|
||||||
|
.sink { [corrector] in corrector.setEnabled($0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
permissions.objectWillChange
|
||||||
|
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
settings.objectWillChange
|
||||||
|
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
dictionary.onChange = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.corrector.update(entries: self.dictionary.entries)
|
||||||
|
self.dictionaryList.reload()
|
||||||
|
self.dictionaryRevision += 1
|
||||||
|
}
|
||||||
|
dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() }
|
||||||
|
history.onChange = { [weak self] in
|
||||||
|
self?.historyRevision += 1
|
||||||
|
self?.historyList.refresh()
|
||||||
|
}
|
||||||
|
// Backstop for editors that write in place, which the folder watcher can miss.
|
||||||
|
NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() }
|
||||||
|
}
|
||||||
|
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history, note] _ in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
history.flush()
|
||||||
|
note.flush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start() async {
|
private func start() async {
|
||||||
_ = await AVCaptureDevice.requestAccess(for: .audio)
|
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
||||||
if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() }
|
dictionary.startWatching()
|
||||||
if !AXIsProcessTrusted() {
|
note.load()
|
||||||
_ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary)
|
switch history.load() {
|
||||||
|
case .recoveredCorrupt:
|
||||||
|
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
||||||
|
case .unreadable:
|
||||||
|
lastError = Strings.historyUnreadable
|
||||||
|
case .empty, .loaded:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
refreshInstalled()
|
||||||
|
if !ModelCatalog.all.contains(where: { $0.id == settings.modelVariant }) {
|
||||||
|
settings.modelVariant = ModelCatalog.defaultVariant
|
||||||
|
}
|
||||||
|
activateModel(settings.modelVariant) // the download must not wait for the microphone prompt
|
||||||
|
await permissions.requestMissing()
|
||||||
retryHotkey()
|
retryHotkey()
|
||||||
transcriber.onProgress = { [weak self] fraction in
|
}
|
||||||
Task { @MainActor in
|
|
||||||
self?.modelStatus = fraction < 1
|
func retryHotkey() {
|
||||||
? "Model downloaden… \(Int(fraction * 100))%"
|
permissions.refresh()
|
||||||
: "Model laden…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
try await transcriber.load()
|
try hotkey.start()
|
||||||
modelReady = true
|
if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil }
|
||||||
modelStatus = "Klaar"
|
|
||||||
} catch {
|
} catch {
|
||||||
modelStatus = "Model laden mislukt"
|
|
||||||
lastError = error.localizedDescription
|
lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: recording
|
||||||
|
|
||||||
|
private func handleHotkey(_ event: HotkeyEvent) {
|
||||||
|
guard event == .released || modelReady else { return }
|
||||||
|
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
||||||
|
lastError = Strings.microphoneMissing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { await controller.handle(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start/stop from the window or ⌥⌘R. The result goes to the history only; nothing is pasted.
|
||||||
|
func toggleWindowRecording() {
|
||||||
|
if isRecording {
|
||||||
|
stopWindowRecording()
|
||||||
|
} else {
|
||||||
|
startWindowRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startWindowRecording() {
|
||||||
|
guard windowRecordingBlocker == nil, state == .idle else { return }
|
||||||
|
policy.windowSession = true
|
||||||
|
Task {
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
if state != .recording { policy.windowSession = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopWindowRecording() {
|
||||||
|
guard isRecording else { return }
|
||||||
|
Task {
|
||||||
|
await controller.handle(.released)
|
||||||
|
policy.windowSession = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: model
|
||||||
|
|
||||||
|
func refreshInstalled() {
|
||||||
|
installedVariants = Set(ModelCatalog.all.map(\.id).filter { storage.isInstalled($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads (if needed) and loads `variant`. Dictation is off meanwhile; if it fails the previous model stays.
|
||||||
|
func activateModel(_ variant: String) {
|
||||||
|
modelTask?.cancel()
|
||||||
|
let previous = activeVariant
|
||||||
|
loadingVariant = variant
|
||||||
|
modelStatus = .loading(variant)
|
||||||
|
modelTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let candidate = WhisperKitTranscriber(model: variant, storage: storage)
|
||||||
|
candidate.onProgress = { [weak self] fraction in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, self.loadingVariant == variant else { return }
|
||||||
|
self.modelStatus = fraction < 1 ? .downloading(variant, fraction) : .loading(variant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await candidate.load()
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
transcriber.swap(to: candidate)
|
||||||
|
activeVariant = variant
|
||||||
|
settings.modelVariant = variant
|
||||||
|
modelStatus = .ready
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
modelStatus = previous == nil ? .failed(error.localizedDescription) : .ready
|
||||||
|
}
|
||||||
|
if loadingVariant == variant { loadingVariant = nil }
|
||||||
|
refreshInstalled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteModel(_ variant: String) {
|
||||||
|
guard variant != activeVariant, variant != loadingVariant else { return }
|
||||||
|
do {
|
||||||
|
try storage.delete(variant)
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
refreshInstalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: navigation and history
|
||||||
|
|
||||||
|
func show(_ section: Section) {
|
||||||
|
self.section = section
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusSearch() {
|
||||||
|
searchFocusTick += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
history.removeAll()
|
||||||
|
history.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDictionaryFile() {
|
||||||
|
NSWorkspace.shared.open(dictionary.fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: preview
|
||||||
|
|
||||||
|
private func loadPreviewData() {
|
||||||
|
note.text = "Notities voor vrijdag: Supabase-migratie plannen en Claude Code uitproberen."
|
||||||
|
try? dictionary.load()
|
||||||
|
try? dictionary.apply { file in
|
||||||
|
file.append(.term("Anthropic"))
|
||||||
|
file.append(.term("Supabase"))
|
||||||
|
file.append(.correction(heard: "cloud code", wrote: "Claude Code"))
|
||||||
|
file.append(.correction(heard: "cloud", wrote: "Claude"))
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1,
|
||||||
|
rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning."))
|
||||||
|
history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2,
|
||||||
|
rawText: "ik wil de meeting verplaatsen naar tomorrow morning",
|
||||||
|
finalText: "Ik wil de meeting verplaatsen naar tomorrow morning."))
|
||||||
|
history.add(HistoryEntry(
|
||||||
|
date: now.addingTimeInterval(-300), duration: 4.4,
|
||||||
|
rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.",
|
||||||
|
corrections: [
|
||||||
|
AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11),
|
||||||
|
AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8),
|
||||||
|
]))
|
||||||
|
activeVariant = ModelCatalog.defaultVariant
|
||||||
|
modelStatus = .ready
|
||||||
|
lastDuration = 4.4
|
||||||
|
historyList.selection = history.entries.first?.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import SstData
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The transport deck: VU meter, counter, LED bar and the REC/STOP keys. Always visible at the top.
|
||||||
|
struct DeckView: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: Layout.panelGap) {
|
||||||
|
instruments
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
controls
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.background(PanelBackground())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instruments: some View {
|
||||||
|
TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !model.isRecording)) { context in
|
||||||
|
let dbfs = Double(model.levelSource.dbfs)
|
||||||
|
HStack(alignment: .top, spacing: Layout.panelGap) {
|
||||||
|
VUMeterView(levelDbfs: dbfs, isActive: model.isRecording, brand: AppIdentity.brandCaption)
|
||||||
|
.frame(width: MeterTokens.vuWidth)
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel(Strings.time, style: Typography.labelSmall)
|
||||||
|
SegmentCounterView(StopwatchFormat.string(elapsed(at: context.date)))
|
||||||
|
SilkLabel(Strings.level, style: Typography.labelSmall)
|
||||||
|
LEDBarView(fraction: model.isRecording ? VUScale.fraction(forDbfs: dbfs) : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func elapsed(at date: Date) -> TimeInterval {
|
||||||
|
if let started = model.recordingStartedAt { return date.timeIntervalSince(started) }
|
||||||
|
return model.lastDuration ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var controls: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: Layout.panelGap) {
|
||||||
|
HStack(spacing: Layout.panelGap) {
|
||||||
|
Button { model.toggleWindowRecording() } label: {
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
LampView(isOn: model.isRecording)
|
||||||
|
Text(Strings.record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
|
||||||
|
.disabled(!model.isRecording && model.windowRecordingBlocker != nil)
|
||||||
|
.help(model.windowRecordingBlocker ?? "")
|
||||||
|
|
||||||
|
Button(Strings.stop) { model.stopWindowRecording() }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(!model.isRecording)
|
||||||
|
}
|
||||||
|
if let error = model.lastError {
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
LampView(isOn: true, tint: .amber)
|
||||||
|
Text(error)
|
||||||
|
.textStyle(Typography.meta)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(model.statusLine)
|
||||||
|
.textStyle(Typography.meta)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: Layout.sidebarWidth, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import SstData
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DictionaryView: View {
|
||||||
|
enum EditorTarget: Identifiable, Equatable {
|
||||||
|
case new
|
||||||
|
case existing(UUID)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .new: "new"
|
||||||
|
case .existing(let id): id.uuidString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@ObservedObject var list: DictionaryViewModel
|
||||||
|
@State private var selection: UUID?
|
||||||
|
@State private var editing: EditorTarget?
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
@FocusState private var listFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Layout.panelGap) {
|
||||||
|
header
|
||||||
|
ZStack {
|
||||||
|
PaperBackground()
|
||||||
|
table
|
||||||
|
}
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.sheet(item: $editing) { target in
|
||||||
|
EntryEditor(list: list, target: target) { editing = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(spacing: Layout.panelGap) {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
SearchField(text: $list.query, focused: $searchFocused)
|
||||||
|
Button(Strings.add) { editing = .new }.buttonStyle(KeyButtonStyle())
|
||||||
|
Button(Strings.openFile) { model.openDictionaryFile() }.buttonStyle(KeyButtonStyle())
|
||||||
|
}
|
||||||
|
HStack(spacing: Layout.panelGap) {
|
||||||
|
Toggle(isOn: $settings.usePriming) {
|
||||||
|
SilkLabel(Strings.priming)
|
||||||
|
}
|
||||||
|
.toggleStyle(RockerToggleStyle())
|
||||||
|
Toggle(isOn: $settings.useCorrection) {
|
||||||
|
SilkLabel(Strings.correcting)
|
||||||
|
}
|
||||||
|
.toggleStyle(RockerToggleStyle())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var table: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
Color.clear.frame(width: Size.iconMedium, height: Size.iconMedium)
|
||||||
|
SilkLabel(Strings.heard).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
SilkLabel(Strings.wrote).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
if list.visibleRows.isEmpty {
|
||||||
|
Text(list.rows.isEmpty ? Strings.emptyDictionary : Strings.noResults)
|
||||||
|
.textStyle(Typography.body)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
} else {
|
||||||
|
ScrollList {
|
||||||
|
ForEach(list.visibleRows) { row in
|
||||||
|
DictionaryRow(row: row, isSelected: selection == row.id)
|
||||||
|
.onTapGesture(count: 2) { editing = .existing(row.id) }
|
||||||
|
.onTapGesture {
|
||||||
|
selection = row.id
|
||||||
|
listFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
.focused($listFocused)
|
||||||
|
.focusEffectDisabled()
|
||||||
|
.onDeleteCommand {
|
||||||
|
if let id = selection {
|
||||||
|
list.remove(id: id)
|
||||||
|
selection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
||||||
|
.onChange(of: list.rows) { _, rows in
|
||||||
|
if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil }
|
||||||
|
}
|
||||||
|
.onKeyPress(KeyEquivalent("z"), phases: .down) { press in
|
||||||
|
guard press.modifiers == .command, list.canUndo else { return .ignored }
|
||||||
|
list.undo()
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: Layout.panelGap) {
|
||||||
|
SilkLabel(Strings.entries(list.rows.count), style: Typography.labelSmall)
|
||||||
|
if let message = list.errorMessage {
|
||||||
|
Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(Strings.undo) { list.undo() }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(!list.canUndo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DictionaryRow: View {
|
||||||
|
let row: DictionaryViewModel.Row
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
lamp
|
||||||
|
.frame(width: Size.iconMedium, height: Size.iconMedium)
|
||||||
|
if let heard = heard {
|
||||||
|
Text(heard).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
Text("—").textStyle(Typography.code).foregroundStyle(Palette.inkSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
Text(row.entry.wrote).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.frame(minHeight: Size.rowHeight)
|
||||||
|
.background(isSelected ? Palette.surfaceSelection : Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.help(row.warnings.map(\.message).joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heard: String? {
|
||||||
|
if case .correction(let heard, _) = row.entry.kind { return heard }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var lamp: some View {
|
||||||
|
switch row.worst {
|
||||||
|
case .error: LampView(isOn: true)
|
||||||
|
case .warning: LampView(isOn: true, tint: .amber)
|
||||||
|
default: LampView(isOn: false, tint: .amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or edit one entry, with live warnings.
|
||||||
|
private struct EntryEditor: View {
|
||||||
|
@ObservedObject var list: DictionaryViewModel
|
||||||
|
let target: DictionaryView.EditorTarget
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var isCorrection = false
|
||||||
|
@State private var heard = ""
|
||||||
|
@State private var wrote = ""
|
||||||
|
|
||||||
|
private var editingID: UUID? {
|
||||||
|
if case .existing(let id) = target { return id }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var kind: DictionaryEntry.Kind {
|
||||||
|
let write = wrote.trimmingCharacters(in: .whitespaces)
|
||||||
|
return isCorrection ? .correction(heard: heard.trimmingCharacters(in: .whitespaces), wrote: write) : .term(write)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var warnings: [EntryWarning] {
|
||||||
|
let emptySource = isCorrection ? heard.trimmingCharacters(in: .whitespaces).isEmpty : wrote.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
guard !emptySource, !wrote.trimmingCharacters(in: .whitespaces).isEmpty else { return [] }
|
||||||
|
return list.warnings(for: kind, editing: editingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
let hasText = !wrote.trimmingCharacters(in: .whitespaces).isEmpty && (!isCorrection || !heard.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
return hasText && !warnings.contains { $0.level == .error }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
BrushedBackground().ignoresSafeArea()
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
Button(Strings.term) { isCorrection = false }.buttonStyle(KeyButtonStyle(latched: !isCorrection))
|
||||||
|
Button(Strings.correction) { isCorrection = true }.buttonStyle(KeyButtonStyle(latched: isCorrection))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
if isCorrection { field(Strings.heard, text: $heard) }
|
||||||
|
field(Strings.wrote, text: $wrote)
|
||||||
|
ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Space.s3) {
|
||||||
|
LampView(isOn: warning.level != .note, tint: warning.level == .error ? .record : .amber)
|
||||||
|
Text(warning.message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(PanelBackground())
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
Spacer()
|
||||||
|
Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle())
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
}
|
||||||
|
.frame(width: Layout.settingsWidth)
|
||||||
|
.onAppear(perform: load)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(_ title: String, text: Binding<String>) -> some View {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading)
|
||||||
|
TextField("", text: text)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.textStyle(Typography.code)
|
||||||
|
.foregroundStyle(Palette.inkPrimary)
|
||||||
|
.padding(Space.s3)
|
||||||
|
.background(WellBackground())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return }
|
||||||
|
switch row.entry.kind {
|
||||||
|
case .term(let term): (isCorrection, heard, wrote) = (false, "", term)
|
||||||
|
case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if let id = editingID {
|
||||||
|
guard list.rows.contains(where: { $0.id == id }) else {
|
||||||
|
list.errorMessage = Strings.entryGone
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.update(id: id, to: kind)
|
||||||
|
} else {
|
||||||
|
list.add(kind)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import AppKit
|
||||||
|
import SstCore
|
||||||
|
import SstData
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let dutch = Locale(identifier: "nl_NL")
|
||||||
|
|
||||||
|
struct HistoryView: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@ObservedObject var list: HistoryViewModel
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
@FocusState private var listFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Layout.panelGap) {
|
||||||
|
SearchField(text: $list.query, focused: $searchFocused)
|
||||||
|
ZStack {
|
||||||
|
PaperBackground()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
if list.days.isEmpty {
|
||||||
|
Text(emptyMessage)
|
||||||
|
.textStyle(Typography.body)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
} else {
|
||||||
|
ScrollList {
|
||||||
|
ForEach(list.days) { day in
|
||||||
|
Section {
|
||||||
|
ForEach(day.entries) { entry in
|
||||||
|
HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch)))
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Palette.surfacePaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
.focused($listFocused)
|
||||||
|
.focusEffectDisabled()
|
||||||
|
.onChange(of: list.selection) { _, new in if new != nil { listFocused = true } }
|
||||||
|
.onDeleteCommand {
|
||||||
|
if let id = list.selection { list.delete(id: id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyMessage: String {
|
||||||
|
if list.totalCount > 0 { return Strings.noResults }
|
||||||
|
return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HistoryRow: View {
|
||||||
|
let entry: HistoryEntry
|
||||||
|
let isSelected: Bool
|
||||||
|
@ObservedObject var list: HistoryViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
|
||||||
|
Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch)))
|
||||||
|
.textStyle(Typography.counterSmall)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
VStack(alignment: .leading, spacing: Space.s2) {
|
||||||
|
Text(markedText)
|
||||||
|
.textStyle(Typography.reading)
|
||||||
|
.foregroundStyle(Palette.inkPrimary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
if !entry.corrections.isEmpty {
|
||||||
|
SilkLabel(correctionSummary, style: Typography.labelSmall)
|
||||||
|
}
|
||||||
|
if isSelected && entry.rawText != entry.finalText {
|
||||||
|
Text("\(Strings.original): \(entry.rawText)")
|
||||||
|
.textStyle(Typography.meta)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: Space.s4)
|
||||||
|
Button(Strings.copy) { copy(entry.finalText) }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.frame(minHeight: Size.rowHeight)
|
||||||
|
.background(isSelected ? Palette.surfaceSelection : Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { list.selection = entry.id }
|
||||||
|
.contextMenu {
|
||||||
|
Button(Strings.copy) { copy(entry.finalText) }
|
||||||
|
Button(Strings.copyOriginal) { copy(entry.rawText) }
|
||||||
|
Divider()
|
||||||
|
Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final text with every dictionary replacement highlighted.
|
||||||
|
private var markedText: AttributedString {
|
||||||
|
var result = AttributedString()
|
||||||
|
for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) {
|
||||||
|
var piece = AttributedString(segment.text)
|
||||||
|
if segment.correction != nil {
|
||||||
|
piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark)
|
||||||
|
}
|
||||||
|
result += piece
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private var correctionSummary: String {
|
||||||
|
let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ")
|
||||||
|
return "\(Strings.corrections(entry.corrections.count)) · \(changes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copy(_ text: String) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Debug snapshots (`Sst --snapshot`) are drawn by `ImageRenderer`, which cannot draw scroll views or text fields.
|
||||||
|
/// While rendering one, the views below fall back to plain stacks and text.
|
||||||
|
@MainActor
|
||||||
|
enum SnapshotMode {
|
||||||
|
static var isActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scrolling, lazily built list (a plain stack while a snapshot is rendered).
|
||||||
|
struct ScrollList<Content: View>: View {
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if SnapshotMode.isActive {
|
||||||
|
VStack(alignment: .leading, spacing: 0) { content }
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recessed "ZOEK" field.
|
||||||
|
struct SearchField: View {
|
||||||
|
@Binding var text: String
|
||||||
|
var focused: FocusState<Bool>.Binding
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
SilkLabel(Strings.search)
|
||||||
|
Group {
|
||||||
|
if SnapshotMode.isActive {
|
||||||
|
Text(text).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
TextField("", text: $text)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.focused(focused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textStyle(Typography.body)
|
||||||
|
.foregroundStyle(Palette.inkPrimary)
|
||||||
|
.padding(Space.s3)
|
||||||
|
.background(WellBackground())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainWindow: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
BrushedBackground().ignoresSafeArea()
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Sidebar()
|
||||||
|
VStack(spacing: Layout.panelGap) {
|
||||||
|
DeckView()
|
||||||
|
switch model.section {
|
||||||
|
case .transcripts: HistoryView(list: model.historyList)
|
||||||
|
case .dictionary: DictionaryView(list: model.dictionaryList)
|
||||||
|
case .note: NoteView(note: model.note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(Strings.clearHistoryConfirm, isPresented: $model.confirmingClearHistory) {
|
||||||
|
Button(Strings.erase, role: .destructive) { model.clearHistory() }
|
||||||
|
Button(Strings.cancel, role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(Strings.clearHistoryDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The secondary interface: status and shortcut while working in another app.
|
||||||
|
struct MenuBarContent: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(model.statusLine)
|
||||||
|
if let error = model.lastError {
|
||||||
|
Text(error)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(Strings.openApp) {
|
||||||
|
openWindow(id: "main")
|
||||||
|
model.show(model.section)
|
||||||
|
}
|
||||||
|
SettingsLink { Text(Strings.settings) }
|
||||||
|
Divider()
|
||||||
|
Button(Strings.quit) { NSApplication.shared.terminate(nil) }
|
||||||
|
.keyboardShortcut("q")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct MenuContent: View {
|
|
||||||
@ObservedObject var model: AppModel
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(model.statusLine)
|
|
||||||
if let error = model.lastError {
|
|
||||||
Text("Fout: \(error)")
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
permissionRow("Microfoon", granted: model.micGranted, pane: "Privacy_Microphone")
|
|
||||||
permissionRow("Accessibility", granted: model.accessibilityGranted, pane: "Privacy_Accessibility")
|
|
||||||
permissionRow("Input Monitoring", granted: model.inputMonitoringGranted, pane: "Privacy_ListenEvent")
|
|
||||||
Button("Rechten opnieuw controleren") { model.retryHotkey() }
|
|
||||||
Divider()
|
|
||||||
Button("Stop") { NSApplication.shared.terminate(nil) }
|
|
||||||
.keyboardShortcut("q")
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func permissionRow(_ title: String, granted: Bool, pane: String) -> some View {
|
|
||||||
if granted {
|
|
||||||
Text("✓ \(title)")
|
|
||||||
} else {
|
|
||||||
Button("⚠︎ \(title) — open Instellingen") {
|
|
||||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import AppKit
|
||||||
|
import SstCore
|
||||||
|
import SstData
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Text view of the Notitie editor; registers itself while it has the cursor.
|
||||||
|
final class NoteTextView: NSTextView {
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
let accepted = super.becomeFirstResponder()
|
||||||
|
if accepted { NoteEditorRegistry.shared.focused = self }
|
||||||
|
return accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
let accepted = super.resignFirstResponder()
|
||||||
|
if accepted, NoteEditorRegistry.shared.focused === self { NoteEditorRegistry.shared.focused = nil }
|
||||||
|
return accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types dictated text at the cursor (replacing a selection) through the normal edit path, so undo works.
|
||||||
|
func insertDictation(_ text: String) {
|
||||||
|
let range = selectedRange()
|
||||||
|
let ns = string as NSString
|
||||||
|
var before: Character?
|
||||||
|
if range.location > 0, range.location <= ns.length {
|
||||||
|
before = ns.substring(with: ns.rangeOfComposedCharacterSequence(at: range.location - 1)).first
|
||||||
|
}
|
||||||
|
let prepared = DictationSpacing.prepared(text, characterBefore: before)
|
||||||
|
if shouldChangeText(in: range, replacementString: prepared) {
|
||||||
|
insertText(prepared, replacementRange: range)
|
||||||
|
}
|
||||||
|
scrollRangeToVisible(selectedRange())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NoteEditorRegistry {
|
||||||
|
static let shared = NoteEditorRegistry()
|
||||||
|
weak var focused: NoteTextView?
|
||||||
|
|
||||||
|
/// The editor, but only while it really has the cursor in the key window.
|
||||||
|
var active: NoteTextView? {
|
||||||
|
guard let view = focused, let window = view.window, window.isKeyWindow, window.firstResponder === view else { return nil }
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends dictated text into the Notitie editor.
|
||||||
|
struct EditorInserter: TextInserting {
|
||||||
|
func insert(_ text: String) async throws {
|
||||||
|
await MainActor.run { NoteEditorRegistry.shared.active?.insertDictation(text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoteEditor: NSViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
var isEditable: Bool
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scroll = NSScrollView()
|
||||||
|
scroll.drawsBackground = false
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.autohidesScrollers = true
|
||||||
|
scroll.borderType = .noBorder
|
||||||
|
|
||||||
|
let view = NoteTextView(frame: .zero)
|
||||||
|
view.minSize = .zero
|
||||||
|
view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||||
|
view.isVerticallyResizable = true
|
||||||
|
view.isHorizontallyResizable = false
|
||||||
|
view.autoresizingMask = [.width]
|
||||||
|
view.textContainer?.widthTracksTextView = true
|
||||||
|
view.textContainerInset = NSSize(width: Space.s5, height: Space.s5)
|
||||||
|
view.isRichText = false
|
||||||
|
view.allowsUndo = true
|
||||||
|
view.drawsBackground = false
|
||||||
|
view.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
view.isAutomaticDashSubstitutionEnabled = false
|
||||||
|
view.delegate = context.coordinator
|
||||||
|
view.string = text
|
||||||
|
scroll.documentView = view
|
||||||
|
context.coordinator.view = view
|
||||||
|
apply(to: view)
|
||||||
|
return scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scroll: NSScrollView, context: Context) {
|
||||||
|
context.coordinator.parent = self
|
||||||
|
guard let view = context.coordinator.view else { return }
|
||||||
|
if view.string != text, !view.hasMarkedText() {
|
||||||
|
let selection = view.selectedRange()
|
||||||
|
view.string = text
|
||||||
|
let length = (text as NSString).length
|
||||||
|
view.setSelectedRange(NSRange(location: min(selection.location, length), length: 0))
|
||||||
|
}
|
||||||
|
view.isEditable = isEditable
|
||||||
|
apply(to: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font and colours come from the design tokens; colours follow the current appearance.
|
||||||
|
private func apply(to view: NoteTextView) {
|
||||||
|
let style = Typography.reading
|
||||||
|
let font = NSFont.systemFont(ofSize: style.size, weight: .regular)
|
||||||
|
view.font = font
|
||||||
|
view.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||||
|
let ink = NSColor(Palette.inkPrimary)
|
||||||
|
view.textColor = ink
|
||||||
|
view.insertionPointColor = ink
|
||||||
|
}
|
||||||
|
let paragraph = NSMutableParagraphStyle()
|
||||||
|
paragraph.lineSpacing = max(0, style.line - style.size * 1.2)
|
||||||
|
view.defaultParagraphStyle = paragraph
|
||||||
|
view.typingAttributes = [.font: font, .foregroundColor: view.textColor ?? .labelColor, .paragraphStyle: paragraph]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
var parent: NoteEditor
|
||||||
|
weak var view: NoteTextView?
|
||||||
|
|
||||||
|
init(_ parent: NoteEditor) { self.parent = parent }
|
||||||
|
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard let view else { return }
|
||||||
|
parent.text = view.string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import AppKit
|
||||||
|
import SstData
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NoteView: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@ObservedObject var note: NoteStore
|
||||||
|
@State private var confirmingClear = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Layout.panelGap) {
|
||||||
|
header
|
||||||
|
ZStack {
|
||||||
|
PaperBackground()
|
||||||
|
if SnapshotMode.isActive {
|
||||||
|
// ImageRenderer cannot draw an NSView; the snapshot shows the text instead.
|
||||||
|
Text(note.text)
|
||||||
|
.textStyle(Typography.reading)
|
||||||
|
.foregroundStyle(Palette.inkPrimary)
|
||||||
|
.padding(Space.s5)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
} else {
|
||||||
|
NoteEditor(text: $note.text, isEditable: !note.isReadOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.confirmationDialog(Strings.clearNoteConfirm, isPresented: $confirmingClear) {
|
||||||
|
Button(Strings.clear, role: .destructive) { note.text = "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
SilkLabel(Strings.note)
|
||||||
|
Spacer()
|
||||||
|
Text(Strings.characters(note.text.count))
|
||||||
|
.textStyle(Typography.counterSmall)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
Button(Strings.copy) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(note.text, forType: .string)
|
||||||
|
}
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(note.text.isEmpty)
|
||||||
|
Button(Strings.clear) { confirmingClear = true }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(note.text.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
Text(note.isReadOnly ? Strings.noteUnreadable : Strings.noteHint(model.hotkeyBinding.displayName))
|
||||||
|
.textStyle(Typography.label)
|
||||||
|
.foregroundStyle(Palette.inkTertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on.
|
||||||
|
@MainActor
|
||||||
|
final class PermissionMonitor: ObservableObject {
|
||||||
|
enum Kind: CaseIterable, Identifiable {
|
||||||
|
case microphone, accessibility, inputMonitoring
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .microphone: Strings.microphone
|
||||||
|
case .accessibility: Strings.accessibility
|
||||||
|
case .inputMonitoring: Strings.inputMonitoring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate var pane: String {
|
||||||
|
switch self {
|
||||||
|
case .microphone: "Privacy_Microphone"
|
||||||
|
case .accessibility: "Privacy_Accessibility"
|
||||||
|
case .inputMonitoring: "Privacy_ListenEvent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var microphone = false
|
||||||
|
@Published private(set) var accessibility = false
|
||||||
|
@Published private(set) var inputMonitoring = false
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private var activation: AnyCancellable?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
refresh()
|
||||||
|
activation = NotificationCenter.default
|
||||||
|
.publisher(for: NSApplication.didBecomeActiveNotification)
|
||||||
|
.sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var allGranted: Bool { microphone && accessibility && inputMonitoring }
|
||||||
|
|
||||||
|
func isGranted(_ kind: Kind) -> Bool {
|
||||||
|
switch kind {
|
||||||
|
case .microphone: microphone
|
||||||
|
case .accessibility: accessibility
|
||||||
|
case .inputMonitoring: inputMonitoring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
accessibility = AXIsProcessTrusted()
|
||||||
|
inputMonitoring = CGPreflightListenEventAccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks macOS for every permission that is still missing (each shows its own system prompt).
|
||||||
|
func requestMissing() async {
|
||||||
|
if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) }
|
||||||
|
if !inputMonitoring { _ = CGRequestListenEventAccess() }
|
||||||
|
if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) }
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSettings(for kind: Kind) {
|
||||||
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click.
|
||||||
|
func startPolling(every interval: TimeInterval = 2) {
|
||||||
|
guard timer == nil else { return }
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPolling() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import AppKit
|
||||||
|
import SstCore
|
||||||
|
import SstData
|
||||||
|
import SstUI
|
||||||
|
import SstWhisper
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
private enum Tab: CaseIterable {
|
||||||
|
case hotkey, model, privacy
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .hotkey: Strings.tabHotkey
|
||||||
|
case .model: Strings.tabModel
|
||||||
|
case .privacy: Strings.tabPrivacy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var tab: Tab = .hotkey
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
BrushedBackground()
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
ForEach(Tab.allCases, id: \.self) { candidate in
|
||||||
|
Button(candidate.title) { tab = candidate }
|
||||||
|
.buttonStyle(KeyButtonStyle(latched: tab == candidate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
switch tab {
|
||||||
|
case .hotkey: HotkeySettingsView()
|
||||||
|
case .model: ModelSettingsView()
|
||||||
|
case .privacy: PrivacySettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.background(PanelBackground())
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
}
|
||||||
|
.frame(width: Layout.settingsWidth)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: hotkey
|
||||||
|
|
||||||
|
extension HotkeyBinding {
|
||||||
|
/// The matching flag in an AppKit event, used while capturing a new key.
|
||||||
|
fileprivate var eventFlag: NSEvent.ModifierFlags {
|
||||||
|
switch id {
|
||||||
|
case "fn": .function
|
||||||
|
case "rightOption": .option
|
||||||
|
case "rightCommand": .command
|
||||||
|
default: .control
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class HotkeyCapture: ObservableObject {
|
||||||
|
@Published var isCapturing = false
|
||||||
|
@Published var message: String?
|
||||||
|
private var monitor: Any?
|
||||||
|
|
||||||
|
func start(onCapture: @escaping (HotkeyBinding) -> Void) {
|
||||||
|
stop()
|
||||||
|
isCapturing = true
|
||||||
|
message = Strings.capturing
|
||||||
|
monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in
|
||||||
|
guard let self else { return event }
|
||||||
|
if event.type == .keyDown {
|
||||||
|
if event.keyCode == 53 { self.stop() } // Escape cancels
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let binding = HotkeyBinding.binding(keyCode: Int(event.keyCode)) else {
|
||||||
|
self.message = Strings.hotkeyRejected
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.modifierFlags.contains(binding.eventFlag) {
|
||||||
|
self.stop()
|
||||||
|
onCapture(binding)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if let monitor { NSEvent.removeMonitor(monitor) }
|
||||||
|
monitor = nil
|
||||||
|
isCapturing = false
|
||||||
|
message = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HotkeySettingsView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@StateObject private var capture = HotkeyCapture()
|
||||||
|
|
||||||
|
private var current: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel(Strings.tabHotkey, style: Typography.heading)
|
||||||
|
HStack(spacing: Layout.panelGap) {
|
||||||
|
Text(current.displayName)
|
||||||
|
.textStyle(Typography.heading)
|
||||||
|
.foregroundStyle(Palette.inkPrimary)
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.frame(minHeight: Size.controlHeight)
|
||||||
|
.background(WellBackground())
|
||||||
|
Button(Strings.captureHotkey) {
|
||||||
|
capture.start { settings.hotkeyID = $0.id }
|
||||||
|
}
|
||||||
|
.buttonStyle(KeyButtonStyle(latched: capture.isCapturing))
|
||||||
|
}
|
||||||
|
if let message = capture.message {
|
||||||
|
Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||||
|
}
|
||||||
|
SeamDivider()
|
||||||
|
Text(HotkeyBinding.allowed.map(\.displayName).joined(separator: " · "))
|
||||||
|
.textStyle(Typography.meta)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
if current == .fn {
|
||||||
|
Text(Strings.globeHint).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear { capture.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: model
|
||||||
|
|
||||||
|
private struct ModelSettingsView: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel(Strings.tabModel, style: Typography.heading)
|
||||||
|
ForEach(ModelCatalog.all) { option in
|
||||||
|
row(option)
|
||||||
|
SeamDivider()
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(model.storage.base.path)
|
||||||
|
.textStyle(Typography.code)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Button(Strings.showInFinder) {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([model.storage.base])
|
||||||
|
}
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { model.refreshInstalled() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ option: ModelOption) -> some View {
|
||||||
|
let isActive = model.activeVariant == option.id
|
||||||
|
let installed = model.installedVariants.contains(option.id)
|
||||||
|
let progress: Double? = {
|
||||||
|
if case .downloading(option.id, let fraction) = model.modelStatus { return fraction }
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
return HStack(alignment: .center, spacing: Layout.panelGap) {
|
||||||
|
LampView(isOn: isActive, tint: .amber)
|
||||||
|
VStack(alignment: .leading, spacing: Space.s1) {
|
||||||
|
Text(option.title).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary)
|
||||||
|
Text("\(option.detail) · \(option.approxSizeMB) MB")
|
||||||
|
.textStyle(Typography.meta)
|
||||||
|
.foregroundStyle(Palette.inkSecondary)
|
||||||
|
if let progress {
|
||||||
|
LEDBarView(fraction: progress)
|
||||||
|
} else if case .loading(option.id) = model.modelStatus {
|
||||||
|
SilkLabel(Strings.modelLoading(option.title), style: Typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if installed && !isActive {
|
||||||
|
Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(model.loadingVariant != nil)
|
||||||
|
Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(model.loadingVariant != nil)
|
||||||
|
} else if !installed {
|
||||||
|
Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(model.loadingVariant != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: privacy
|
||||||
|
|
||||||
|
private struct PrivacySettingsView: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@State private var confirming = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel(Strings.tabPrivacy, style: Typography.heading)
|
||||||
|
Toggle(isOn: $settings.keepHistory) {
|
||||||
|
Text(Strings.keepHistory).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary)
|
||||||
|
}
|
||||||
|
.toggleStyle(RockerToggleStyle())
|
||||||
|
SeamDivider()
|
||||||
|
Button(Strings.clearHistory) { confirming = true }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
}
|
||||||
|
.confirmationDialog(Strings.clearHistoryConfirm, isPresented: $confirming) {
|
||||||
|
Button(Strings.erase, role: .destructive) { model.clearHistory() }
|
||||||
|
Button(Strings.cancel, role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(Strings.clearHistoryDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Sidebar: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel(AppIdentity.brandCaption)
|
||||||
|
ForEach(AppModel.Section.allCases) { section in
|
||||||
|
Button {
|
||||||
|
model.section = section
|
||||||
|
} label: {
|
||||||
|
Text(title(for: section)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(KeyButtonStyle(latched: model.section == section))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
ControlsBlock()
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.frame(width: Layout.sidebarWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func title(for section: AppModel.Section) -> String {
|
||||||
|
switch section {
|
||||||
|
case .transcripts: Strings.transcripts
|
||||||
|
case .dictionary: Strings.dictionary
|
||||||
|
case .note: Strings.note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Four lamps: the three macOS permissions and the speech model.
|
||||||
|
struct ControlsBlock: View {
|
||||||
|
@EnvironmentObject private var model: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Space.s3) {
|
||||||
|
SilkLabel(Strings.controls)
|
||||||
|
SeamDivider()
|
||||||
|
ForEach(PermissionMonitor.Kind.allCases) { kind in
|
||||||
|
let granted = model.permissions.isGranted(kind)
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
LampView(isOn: granted, tint: .amber)
|
||||||
|
Text(kind.title).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||||
|
Spacer()
|
||||||
|
if !granted {
|
||||||
|
Button(Strings.open) { model.permissions.openSettings(for: kind) }
|
||||||
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
.help(Strings.openSystemSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: Space.s3) {
|
||||||
|
LampView(isOn: model.modelReady, tint: .amber)
|
||||||
|
Text(Strings.modelLamp).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { model.permissions.startPolling() }
|
||||||
|
.onDisappear { model.permissions.stopPolling() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import AppKit
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum Snapshot {
|
||||||
|
static func run(prefix: String) {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
SnapshotMode.isActive = true
|
||||||
|
let model = AppModel(preview: true)
|
||||||
|
for section in AppModel.Section.allCases {
|
||||||
|
model.section = section
|
||||||
|
let view = HStack(spacing: 0) {
|
||||||
|
MainWindow().environment(\.colorScheme, .light)
|
||||||
|
MainWindow().environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
.environmentObject(model)
|
||||||
|
.environmentObject(model.settings)
|
||||||
|
.frame(width: Layout.windowDefaultWidth * 2, height: Layout.windowDefaultHeight)
|
||||||
|
|
||||||
|
let renderer = ImageRenderer(content: view)
|
||||||
|
renderer.scale = 1
|
||||||
|
if let tiff = renderer.nsImage?.tiffRepresentation,
|
||||||
|
let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
|
||||||
|
try? png.write(to: URL(fileURLWithPath: "\(prefix)-\(section.rawValue).png"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,14 +1,30 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SstUI
|
||||||
|
|
||||||
@main
|
|
||||||
struct SstApp: App {
|
struct SstApp: App {
|
||||||
@StateObject private var model = AppModel()
|
@StateObject private var model = AppModel()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
Window(AppIdentity.displayName, id: "main") {
|
||||||
|
MainWindow()
|
||||||
|
.environmentObject(model)
|
||||||
|
.environmentObject(model.settings)
|
||||||
|
.frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight)
|
||||||
|
}
|
||||||
|
.defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight)
|
||||||
|
.commands { AppCommands(model: model) }
|
||||||
|
|
||||||
|
Settings {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(model)
|
||||||
|
.environmentObject(model.settings)
|
||||||
|
}
|
||||||
|
|
||||||
MenuBarExtra {
|
MenuBarExtra {
|
||||||
MenuContent(model: model)
|
MenuBarContent()
|
||||||
|
.environmentObject(model)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: model.iconName)
|
Image(systemName: model.menuIconName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// All user-facing text, in one place (Dutch), so translating later is a single-file job.
|
||||||
|
enum Strings {
|
||||||
|
// sections
|
||||||
|
static let transcripts = "Transcripties"
|
||||||
|
static let dictionary = "Woordenboek"
|
||||||
|
static let controls = "Controles"
|
||||||
|
|
||||||
|
// lists
|
||||||
|
static let copy = "Kopieer"
|
||||||
|
static let copyOriginal = "Kopieer origineel"
|
||||||
|
static let delete = "Verwijder"
|
||||||
|
static let original = "Origineel"
|
||||||
|
static let noTranscripts = "Nog geen transcripties. Houd de sneltoets ingedrukt en praat."
|
||||||
|
static let noResults = "Geen resultaten"
|
||||||
|
static let historyOff = "De geschiedenis staat uit (Instellingen → Privacy)."
|
||||||
|
static func corrections(_ count: Int) -> String { count == 1 ? "1 correctie" : "\(count) correcties" }
|
||||||
|
static func entries(_ count: Int) -> String { count == 1 ? "1 invoer" : "\(count) invoeren" }
|
||||||
|
|
||||||
|
// dictionary
|
||||||
|
static let heard = "Gehoord"
|
||||||
|
static let wrote = "Schrijf"
|
||||||
|
static let term = "Term"
|
||||||
|
static let correction = "Correctie"
|
||||||
|
static let add = "+"
|
||||||
|
static let openFile = "Open bestand"
|
||||||
|
static let priming = "Voorrang"
|
||||||
|
static let correcting = "Correctie"
|
||||||
|
static let undo = "Herstel"
|
||||||
|
static let save = "Bewaar"
|
||||||
|
static let editEntry = "Invoer"
|
||||||
|
static let entryGone = "Deze invoer bestaat niet meer; je wijziging is niet bewaard."
|
||||||
|
static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +."
|
||||||
|
|
||||||
|
// note
|
||||||
|
static let note = "Notitie"
|
||||||
|
static let clear = "Wis"
|
||||||
|
static func characters(_ count: Int) -> String { count == 1 ? "1 teken" : "\(count) tekens" }
|
||||||
|
static func noteHint(_ keyName: String) -> String { "Zet de cursor in het vak en houd \(keyName) ingedrukt om hier te dicteren." }
|
||||||
|
static let clearNoteConfirm = "Notitie wissen?"
|
||||||
|
static let noteUnreadable = "De notitie kon niet worden gelezen; wijzigingen worden niet bewaard."
|
||||||
|
|
||||||
|
// deck
|
||||||
|
static let record = "Rec"
|
||||||
|
static let stop = "Stop"
|
||||||
|
static let time = "Tijd"
|
||||||
|
static let level = "Niveau"
|
||||||
|
|
||||||
|
// menus
|
||||||
|
static let recordingMenu = "Opname"
|
||||||
|
static let startRecording = "Start opname"
|
||||||
|
static let stopRecording = "Stop opname"
|
||||||
|
static let search = "Zoek"
|
||||||
|
static let clearHistory = "Wis geschiedenis…"
|
||||||
|
static let openApp = "Open Sst"
|
||||||
|
static let settings = "Instellingen…"
|
||||||
|
static let quit = "Stop Sst"
|
||||||
|
|
||||||
|
// status
|
||||||
|
static let statusIdle = "Klaar"
|
||||||
|
static let statusListening = "Luistert…"
|
||||||
|
static let statusTranscribing = "Transcriberen…"
|
||||||
|
static let statusInserting = "Invoegen…"
|
||||||
|
static func modelLoading(_ title: String) -> String { "Model laden: \(title)…" }
|
||||||
|
static func modelDownloading(_ percent: Int) -> String { "Model downloaden… \(percent)%" }
|
||||||
|
static let modelFailed = "Model laden mislukt"
|
||||||
|
static func holdToDictate(_ keyName: String) -> String { "Houd \(keyName) ingedrukt om te dicteren" }
|
||||||
|
|
||||||
|
// permissions
|
||||||
|
static let microphone = "Microfoon"
|
||||||
|
static let accessibility = "Accessibility"
|
||||||
|
static let inputMonitoring = "Input Monitoring"
|
||||||
|
static let modelLamp = "Model"
|
||||||
|
static let microphoneMissing = "Microfoon-toegang ontbreekt"
|
||||||
|
static let openSystemSettings = "Open Instellingen"
|
||||||
|
static let open = "Open"
|
||||||
|
static let historyNeededForWindow = "Zet ‘Transcripties bewaren’ aan om vanuit het venster op te nemen."
|
||||||
|
static let historyUnreadable = "Het geschiedenisbestand kon niet worden gelezen; nieuwe transcripties worden niet bewaard."
|
||||||
|
static let permissionsMissing = "Geef eerst de rechten onder Controles"
|
||||||
|
|
||||||
|
// settings
|
||||||
|
static let tabHotkey = "Sneltoets"
|
||||||
|
static let tabModel = "Model"
|
||||||
|
static let tabPrivacy = "Privacy"
|
||||||
|
static let captureHotkey = "Leg nieuwe toets vast"
|
||||||
|
static let capturing = "Druk op de toets die je wilt gebruiken…"
|
||||||
|
static let hotkeyRejected = "Die toets kan niet. Kies Fn, Rechter Option, Rechter Command of Control."
|
||||||
|
static let globeHint = "Zet bij Fn in Systeeminstellingen → Toetsenbord ‘Druk op 🌐 voor’ op ‘Doe niets’."
|
||||||
|
static let useModel = "Gebruik"
|
||||||
|
static let download = "Download"
|
||||||
|
static let remove = "Verwijder"
|
||||||
|
static let showInFinder = "Toon in Finder"
|
||||||
|
static let keepHistory = "Transcripties bewaren"
|
||||||
|
static let clearHistoryConfirm = "Alle transcripties wissen?"
|
||||||
|
static let clearHistoryDetail = "Dit kan niet ongedaan worden gemaakt. Audio wordt nooit bewaard."
|
||||||
|
static let cancel = "Annuleer"
|
||||||
|
static let erase = "Wis"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// `Sst --snapshot <prefix>` renders the main window with sample data to <prefix>-transcripts.png and
|
||||||
|
// <prefix>-dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey.
|
||||||
|
if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 {
|
||||||
|
MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) }
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
SstApp.main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Where one dictation's text goes.
|
||||||
|
public enum InsertionRoute: Equatable, Sendable {
|
||||||
|
/// Pasted into whichever app is in front.
|
||||||
|
case system
|
||||||
|
/// Typed into the Notitie editor inside Sst.
|
||||||
|
case editor
|
||||||
|
/// Dropped (Sst is in front but nothing there takes text, or the recording started in the window).
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends each dictation to the system paster or the in-app editor, asked at insertion time rather than at creation.
|
||||||
|
public struct RoutingInserter: TextInserting {
|
||||||
|
private let system: TextInserting
|
||||||
|
private let editor: TextInserting
|
||||||
|
private let route: () -> InsertionRoute
|
||||||
|
|
||||||
|
public init(system: TextInserting, editor: TextInserting, route: @escaping () -> InsertionRoute) {
|
||||||
|
self.system = system
|
||||||
|
self.editor = editor
|
||||||
|
self.route = route
|
||||||
|
}
|
||||||
|
|
||||||
|
public func insert(_ text: String) async throws {
|
||||||
|
switch route() {
|
||||||
|
case .system: try await system.insert(text)
|
||||||
|
case .editor: try await editor.insert(text)
|
||||||
|
case .none: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets the app replace the speech model (for example after a model switch) without rebuilding the controller.
|
||||||
|
public final class SwappableTranscriber: Transcriber, @unchecked Sendable {
|
||||||
|
public enum SwapError: Error, LocalizedError {
|
||||||
|
case noTranscriber
|
||||||
|
|
||||||
|
public var errorDescription: String? { "Spraakmodel is nog niet geladen." }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var current: Transcriber?
|
||||||
|
|
||||||
|
public init(_ initial: Transcriber? = nil) {
|
||||||
|
current = initial
|
||||||
|
}
|
||||||
|
|
||||||
|
public func swap(to transcriber: Transcriber?) {
|
||||||
|
lock.withLock { current = transcriber }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hasTranscriber: Bool {
|
||||||
|
lock.withLock { current != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func transcribe(_ samples: [Float]) async throws -> String {
|
||||||
|
try await transcribe(samples, vocabulary: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String {
|
||||||
|
guard let transcriber = lock.withLock({ current }) else { throw SwapError.noTranscriber }
|
||||||
|
return try await transcriber.transcribe(samples, vocabulary: vocabulary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest input level, written from the audio thread and read by the meter once per frame.
|
||||||
|
public final class LevelSource: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value = AudioLevel.silenceFloorDbfs
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var dbfs: Float { lock.withLock { value } }
|
||||||
|
|
||||||
|
public func update(_ dbfs: Float) {
|
||||||
|
lock.withLock { value = dbfs }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reset() {
|
||||||
|
update(AudioLevel.silenceFloorDbfs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AudioLevel {
|
||||||
|
/// Reported for empty or silent buffers (log of zero would be -infinity).
|
||||||
|
public static let silenceFloorDbfs: Float = -120
|
||||||
|
|
||||||
|
public static func rmsDbfs(_ samples: [Float]) -> Float {
|
||||||
|
samples.withUnsafeBufferPointer { rmsDbfs($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func rmsDbfs(_ samples: UnsafeBufferPointer<Float>) -> Float {
|
||||||
|
guard !samples.isEmpty else { return silenceFloorDbfs }
|
||||||
|
var sum: Float = 0
|
||||||
|
for sample in samples { sum += sample * sample }
|
||||||
|
let rms = (sum / Float(samples.count)).squareRoot()
|
||||||
|
guard rms > 0 else { return silenceFloorDbfs }
|
||||||
|
return max(silenceFloorDbfs, 20 * log10(rms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VU reading for a dBFS level, where `referenceDbfs` is 0 VU.
|
||||||
|
public static func vu(fromDbfs dbfs: Float, referenceDbfs: Float = -20) -> Float {
|
||||||
|
dbfs - referenceDbfs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ public final class AudioRecorder: AudioRecording {
|
|||||||
private let engine = AVAudioEngine()
|
private let engine = AVAudioEngine()
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var buffer: [Float] = []
|
private var buffer: [Float] = []
|
||||||
|
|
||||||
|
/// RMS level (dBFS) of every delivered input buffer. Called on the audio thread; set before `start()`.
|
||||||
|
public var onLevel: ((Float) -> Void)?
|
||||||
|
|
||||||
private let targetFormat = AVAudioFormat(
|
private let targetFormat = AVAudioFormat(
|
||||||
commonFormat: .pcmFormatFloat32,
|
commonFormat: .pcmFormatFloat32,
|
||||||
sampleRate: 16_000,
|
sampleRate: 16_000,
|
||||||
@@ -32,8 +36,11 @@ public final class AudioRecorder: AudioRecording {
|
|||||||
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
|
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
|
||||||
|
|
||||||
input.removeTap(onBus: 0)
|
input.removeTap(onBus: 0)
|
||||||
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in
|
input.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] pcm, _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
if let onLevel = self.onLevel, let channel = pcm.floatChannelData?[0] {
|
||||||
|
onLevel(AudioLevel.rmsDbfs(UnsafeBufferPointer(start: channel, count: Int(pcm.frameLength))))
|
||||||
|
}
|
||||||
let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1
|
let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1
|
||||||
guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return }
|
guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public final class DictationController {
|
|||||||
}
|
}
|
||||||
public var onStateChange: ((DictationState) -> Void)?
|
public var onStateChange: ((DictationState) -> Void)?
|
||||||
public var onError: ((String) -> Void)?
|
public var onError: ((String) -> Void)?
|
||||||
|
/// Called with every finished transcription, just before it is inserted.
|
||||||
|
public var onResult: ((DictationResult) -> Void)?
|
||||||
|
|
||||||
private let recorder: AudioRecording
|
private let recorder: AudioRecording
|
||||||
private let transcriber: Transcriber
|
private let transcriber: Transcriber
|
||||||
@@ -14,16 +16,25 @@ public final class DictationController {
|
|||||||
private let inserter: TextInserting
|
private let inserter: TextInserting
|
||||||
private let minimumSamples: Int
|
private let minimumSamples: Int
|
||||||
private let silenceThreshold: Float
|
private let silenceThreshold: Float
|
||||||
|
private let corrector: TextCorrecting?
|
||||||
|
private let vocabularyProvider: () -> [String]
|
||||||
|
private let biasMinimumRMS: Float
|
||||||
|
|
||||||
/// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s.
|
/// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s.
|
||||||
/// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped.
|
/// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped.
|
||||||
|
/// - Parameter corrector: optional dictionary correction applied to the transcript.
|
||||||
|
/// - Parameter vocabularyProvider: words to bias the transcriber towards.
|
||||||
|
/// - Parameter biasMinimumRMS: below this RMS no vocabulary is passed (guards against invented text on quiet audio).
|
||||||
public init(
|
public init(
|
||||||
recorder: AudioRecording,
|
recorder: AudioRecording,
|
||||||
transcriber: Transcriber,
|
transcriber: Transcriber,
|
||||||
cleaner: Cleaner,
|
cleaner: Cleaner,
|
||||||
inserter: TextInserting,
|
inserter: TextInserting,
|
||||||
minimumSamples: Int = 4_800,
|
minimumSamples: Int = 4_800,
|
||||||
silenceThreshold: Float = 0.005
|
silenceThreshold: Float = 0.005,
|
||||||
|
corrector: TextCorrecting? = nil,
|
||||||
|
vocabularyProvider: @escaping () -> [String] = { [] },
|
||||||
|
biasMinimumRMS: Float = 0.015
|
||||||
) {
|
) {
|
||||||
self.recorder = recorder
|
self.recorder = recorder
|
||||||
self.transcriber = transcriber
|
self.transcriber = transcriber
|
||||||
@@ -31,6 +42,9 @@ public final class DictationController {
|
|||||||
self.inserter = inserter
|
self.inserter = inserter
|
||||||
self.minimumSamples = minimumSamples
|
self.minimumSamples = minimumSamples
|
||||||
self.silenceThreshold = silenceThreshold
|
self.silenceThreshold = silenceThreshold
|
||||||
|
self.corrector = corrector
|
||||||
|
self.vocabularyProvider = vocabularyProvider
|
||||||
|
self.biasMinimumRMS = biasMinimumRMS
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handle(_ event: HotkeyEvent) async {
|
public func handle(_ event: HotkeyEvent) async {
|
||||||
@@ -49,24 +63,35 @@ public final class DictationController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let energy = samples.reduce(0) { $0 + $1 * $1 }
|
let energy = samples.reduce(0) { $0 + $1 * $1 }
|
||||||
guard (energy / Float(samples.count)).squareRoot() >= silenceThreshold else {
|
let rms = (energy / Float(samples.count)).squareRoot()
|
||||||
|
guard rms >= silenceThreshold else {
|
||||||
state = .idle
|
state = .idle
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await process(samples)
|
await process(samples, rms: rms)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func process(_ samples: [Float]) async {
|
private func process(_ samples: [Float], rms: Float) async {
|
||||||
defer { state = .idle }
|
defer { state = .idle }
|
||||||
do {
|
do {
|
||||||
state = .transcribing
|
state = .transcribing
|
||||||
let raw = try await transcriber.transcribe(samples)
|
let vocabulary = rms >= biasMinimumRMS ? vocabularyProvider() : []
|
||||||
|
let raw = try await transcriber.transcribe(samples, vocabulary: vocabulary)
|
||||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty, !PromptEcho.isEcho(text, vocabulary: vocabulary) else { return }
|
||||||
let cleaned = try await cleaner.clean(text, context: CleaningContext())
|
let corrected = corrector?.correct(text) ?? CorrectionResult(text: text, corrections: [])
|
||||||
|
let cleaned = try await cleaner.clean(corrected.text, context: CleaningContext())
|
||||||
|
onResult?(DictationResult(
|
||||||
|
date: Date(),
|
||||||
|
duration: Double(samples.count) / 16_000,
|
||||||
|
rawText: text,
|
||||||
|
finalText: cleaned,
|
||||||
|
// Offsets only stay valid if the cleaner left the corrected text alone.
|
||||||
|
corrections: cleaned == corrected.text ? corrected.corrections : []
|
||||||
|
))
|
||||||
state = .inserting
|
state = .inserting
|
||||||
try await inserter.insert(cleaned)
|
try await inserter.insert(cleaned)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// A modifier key that can be held to dictate. Left/right keys share a flag, so the key code decides.
|
||||||
|
public struct HotkeyBinding: Hashable, Sendable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let displayName: String
|
||||||
|
public let keyCode: Int
|
||||||
|
public let flagRawValue: UInt64
|
||||||
|
|
||||||
|
public var flag: CGEventFlags { CGEventFlags(rawValue: flagRawValue) }
|
||||||
|
|
||||||
|
public static let fn = HotkeyBinding(
|
||||||
|
id: "fn", displayName: "Fn", keyCode: 63, flagRawValue: CGEventFlags.maskSecondaryFn.rawValue)
|
||||||
|
public static let rightOption = HotkeyBinding(
|
||||||
|
id: "rightOption", displayName: "Rechter Option", keyCode: 61, flagRawValue: CGEventFlags.maskAlternate.rawValue)
|
||||||
|
public static let rightCommand = HotkeyBinding(
|
||||||
|
id: "rightCommand", displayName: "Rechter Command", keyCode: 54, flagRawValue: CGEventFlags.maskCommand.rawValue)
|
||||||
|
public static let rightControl = HotkeyBinding(
|
||||||
|
id: "rightControl", displayName: "Rechter Control", keyCode: 62, flagRawValue: CGEventFlags.maskControl.rawValue)
|
||||||
|
public static let leftControl = HotkeyBinding(
|
||||||
|
id: "leftControl", displayName: "Linker Control", keyCode: 59, flagRawValue: CGEventFlags.maskControl.rawValue)
|
||||||
|
|
||||||
|
public static let allowed: [HotkeyBinding] = [.fn, .rightOption, .rightCommand, .rightControl, .leftControl]
|
||||||
|
|
||||||
|
public static func binding(id: String) -> HotkeyBinding? { allowed.first { $0.id == id } }
|
||||||
|
public static func binding(keyCode: Int) -> HotkeyBinding? { allowed.first { $0.keyCode == keyCode } }
|
||||||
|
}
|
||||||
@@ -6,13 +6,18 @@ public final class HotkeyMonitor {
|
|||||||
case tapUnavailable
|
case tapUnavailable
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
"Kan de Fn-toets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)."
|
"Kan de sneltoets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var onEvent: ((HotkeyEvent) -> Void)?
|
public var onEvent: ((HotkeyEvent) -> Void)?
|
||||||
|
|
||||||
private var tracker = FnKeyTracker()
|
/// The key that has to be held. Changing it resets the press tracking.
|
||||||
|
public var binding: HotkeyBinding = .fn {
|
||||||
|
didSet { tracker = ModifierKeyTracker() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tracker = ModifierKeyTracker()
|
||||||
private var tap: CFMachPort?
|
private var tap: CFMachPort?
|
||||||
private var source: CFRunLoopSource?
|
private var source: CFRunLoopSource?
|
||||||
|
|
||||||
@@ -56,10 +61,10 @@ public final class HotkeyMonitor {
|
|||||||
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
case .tapDisabledByTimeout, .tapDisabledByUserInput:
|
||||||
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
|
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
|
||||||
case .flagsChanged:
|
case .flagsChanged:
|
||||||
// .maskSecondaryFn is also set while arrows/F-keys are held; only react to the Fn key itself.
|
// Flags are shared between left and right keys and with arrows/F-keys: only react to the bound key itself.
|
||||||
guard event.getIntegerValueField(.keyboardEventKeycode) == 63 else { return }
|
guard event.getIntegerValueField(.keyboardEventKeycode) == Int64(binding.keyCode) else { return }
|
||||||
let fnDown = event.flags.contains(.maskSecondaryFn)
|
let isDown = event.flags.contains(binding.flag)
|
||||||
if let hotkeyEvent = tracker.process(fnDown: fnDown) {
|
if let hotkeyEvent = tracker.process(isDown: isDown) {
|
||||||
onEvent?(hotkeyEvent)
|
onEvent?(hotkeyEvent)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -24,6 +24,63 @@ public protocol AudioRecording {
|
|||||||
|
|
||||||
public protocol Transcriber {
|
public protocol Transcriber {
|
||||||
func transcribe(_ samples: [Float]) async throws -> String
|
func transcribe(_ samples: [Float]) async throws -> String
|
||||||
|
/// Biases decoding towards `vocabulary`. Engines without bias support ignore the list.
|
||||||
|
func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Transcriber {
|
||||||
|
func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String {
|
||||||
|
try await transcribe(samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One replacement made by the dictionary correction pass.
|
||||||
|
/// `start`/`length` are UTF-16 offsets into the corrected text.
|
||||||
|
public struct AppliedCorrection: Equatable, Codable, Sendable {
|
||||||
|
public var heard: String
|
||||||
|
public var wrote: String
|
||||||
|
public var rule: String
|
||||||
|
public var start: Int
|
||||||
|
public var length: Int
|
||||||
|
|
||||||
|
public init(heard: String, wrote: String, rule: String, start: Int, length: Int) {
|
||||||
|
self.heard = heard
|
||||||
|
self.wrote = wrote
|
||||||
|
self.rule = rule
|
||||||
|
self.start = start
|
||||||
|
self.length = length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CorrectionResult: Equatable, Sendable {
|
||||||
|
public var text: String
|
||||||
|
public var corrections: [AppliedCorrection]
|
||||||
|
|
||||||
|
public init(text: String, corrections: [AppliedCorrection]) {
|
||||||
|
self.text = text
|
||||||
|
self.corrections = corrections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol TextCorrecting {
|
||||||
|
func correct(_ text: String) -> CorrectionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished dictation, emitted just before the text is inserted.
|
||||||
|
public struct DictationResult: Equatable, Sendable {
|
||||||
|
public var date: Date
|
||||||
|
public var duration: TimeInterval
|
||||||
|
public var rawText: String
|
||||||
|
public var finalText: String
|
||||||
|
public var corrections: [AppliedCorrection]
|
||||||
|
|
||||||
|
public init(date: Date, duration: TimeInterval, rawText: String, finalText: String, corrections: [AppliedCorrection]) {
|
||||||
|
self.date = date
|
||||||
|
self.duration = duration
|
||||||
|
self.rawText = rawText
|
||||||
|
self.finalText = finalText
|
||||||
|
self.corrections = corrections
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol Cleaner {
|
public protocol Cleaner {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
public struct ModifierKeyTracker {
|
||||||
|
private var isDown = false
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Feed the current state of the bound modifier flag; returns an event only on a transition.
|
||||||
|
public mutating func process(isDown: Bool) -> HotkeyEvent? {
|
||||||
|
guard isDown != self.isDown else { return nil }
|
||||||
|
self.isDown = isDown
|
||||||
|
return isDown ? .pressed : .released
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Whisper sometimes answers a vocabulary prompt with the prompt itself when there is little speech.
|
||||||
|
public enum PromptEcho {
|
||||||
|
static func normalize(_ text: String) -> String {
|
||||||
|
String(text.lowercased().map { $0.isLetter || $0.isNumber ? $0 : " " })
|
||||||
|
.split(separator: " ")
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `text` is nothing but the first k ≥ 2 terms of the prompt.
|
||||||
|
/// A single term on its own is valid speech ("Anthropic").
|
||||||
|
public static func isEcho(_ text: String, vocabulary: [String]) -> Bool {
|
||||||
|
guard vocabulary.count >= 2 else { return false }
|
||||||
|
let normalized = normalize(text)
|
||||||
|
guard !normalized.isEmpty else { return false }
|
||||||
|
return (2...vocabulary.count).contains { k in
|
||||||
|
normalize(vocabulary.prefix(k).joined(separator: " ")) == normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// User preferences, persisted in UserDefaults as soon as a value changes.
|
||||||
|
public final class AppSettings: ObservableObject {
|
||||||
|
public static let defaultModelVariant = "large-v3-v20240930_turbo"
|
||||||
|
|
||||||
|
enum Key {
|
||||||
|
static let hotkey = "hotkeyID"
|
||||||
|
static let model = "modelVariant"
|
||||||
|
static let keepHistory = "keepHistory"
|
||||||
|
static let usePriming = "usePriming"
|
||||||
|
static let useCorrection = "useCorrection"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
/// `HotkeyBinding.id` of the key that has to be held.
|
||||||
|
@Published public var hotkeyID: String {
|
||||||
|
didSet { defaults.set(hotkeyID, forKey: Key.hotkey) }
|
||||||
|
}
|
||||||
|
@Published public var modelVariant: String {
|
||||||
|
didSet { defaults.set(modelVariant, forKey: Key.model) }
|
||||||
|
}
|
||||||
|
@Published public var keepHistory: Bool {
|
||||||
|
didSet { defaults.set(keepHistory, forKey: Key.keepHistory) }
|
||||||
|
}
|
||||||
|
/// Give the speech engine the dictionary words as context.
|
||||||
|
@Published public var usePriming: Bool {
|
||||||
|
didSet { defaults.set(usePriming, forKey: Key.usePriming) }
|
||||||
|
}
|
||||||
|
/// Apply the dictionary corrections after transcription.
|
||||||
|
@Published public var useCorrection: Bool {
|
||||||
|
didSet { defaults.set(useCorrection, forKey: Key.useCorrection) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
hotkeyID = defaults.string(forKey: Key.hotkey) ?? "fn"
|
||||||
|
modelVariant = defaults.string(forKey: Key.model) ?? Self.defaultModelVariant
|
||||||
|
keepHistory = defaults.object(forKey: Key.keepHistory) as? Bool ?? true
|
||||||
|
usePriming = defaults.object(forKey: Key.usePriming) as? Bool ?? true
|
||||||
|
useCorrection = defaults.object(forKey: Key.useCorrection) as? Bool ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
import SstCore
|
||||||
|
|
||||||
|
/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned.
|
||||||
|
public struct CorrectionEngine: TextCorrecting {
|
||||||
|
private struct Rule {
|
||||||
|
let source: String
|
||||||
|
let wrote: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let rules: [Rule]
|
||||||
|
private let regex: NSRegularExpression?
|
||||||
|
|
||||||
|
public init(entries: [DictionaryEntry], knowledge: WordKnowledge) {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var built: [(rule: Rule, order: Int)] = []
|
||||||
|
for (order, entry) in entries.enumerated() {
|
||||||
|
let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let key = EntryKey.key(source)
|
||||||
|
guard !key.isEmpty, !seen.contains(key) else { continue }
|
||||||
|
// A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text.
|
||||||
|
if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue }
|
||||||
|
seen.insert(key)
|
||||||
|
built.append((Rule(source: source, wrote: entry.wrote), order))
|
||||||
|
}
|
||||||
|
let ordered = built.sorted { a, b in
|
||||||
|
let la = a.rule.source.filter(\.isLetter).count
|
||||||
|
let lb = b.rule.source.filter(\.isLetter).count
|
||||||
|
return la != lb ? la > lb : a.order < b.order
|
||||||
|
}.map(\.rule)
|
||||||
|
rules = ordered
|
||||||
|
if ordered.isEmpty {
|
||||||
|
regex = nil
|
||||||
|
} else {
|
||||||
|
let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|")
|
||||||
|
regex = try? NSRegularExpression(
|
||||||
|
pattern: "(?<![\\p{L}\\p{N}])(?:\(alternatives))(?![\\p{L}\\p{N}])",
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parts joined by "zero or more spaces/hyphens", so "cloud code" also finds "cloudcode" and "Cloud-Code".
|
||||||
|
private static func pattern(_ source: String) -> String {
|
||||||
|
EntryKey.parts(source)
|
||||||
|
.map { NSRegularExpression.escapedPattern(for: $0) }
|
||||||
|
.joined(separator: "[\\s\\-‐‑–]*")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func correct(_ text: String) -> CorrectionResult {
|
||||||
|
guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) }
|
||||||
|
let ns = text as NSString
|
||||||
|
var output = ""
|
||||||
|
var outputLength = 0
|
||||||
|
var last = 0
|
||||||
|
var applied: [AppliedCorrection] = []
|
||||||
|
|
||||||
|
for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
|
||||||
|
let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
||||||
|
output += gap
|
||||||
|
outputLength += gap.utf16.count
|
||||||
|
|
||||||
|
let heard = ns.substring(with: match.range)
|
||||||
|
let group = (1..<match.numberOfRanges).first { match.range(at: $0).location != NSNotFound } ?? 1
|
||||||
|
let rule = rules[group - 1]
|
||||||
|
|
||||||
|
output += rule.wrote
|
||||||
|
if heard != rule.wrote {
|
||||||
|
applied.append(AppliedCorrection(
|
||||||
|
heard: heard, wrote: rule.wrote, rule: rule.source,
|
||||||
|
start: outputLength, length: rule.wrote.utf16.count))
|
||||||
|
}
|
||||||
|
outputLength += rule.wrote.utf16.count
|
||||||
|
last = match.range.location + match.range.length
|
||||||
|
}
|
||||||
|
output += ns.substring(from: last)
|
||||||
|
return CorrectionResult(text: output, corrections: applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DictionaryEntry: Identifiable, Equatable, Sendable {
|
||||||
|
public enum Kind: Equatable, Sendable {
|
||||||
|
case term(String)
|
||||||
|
case correction(heard: String, wrote: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public var kind: Kind
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), kind: Kind) {
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equality ignores `id`: two entries with the same content are the same entry.
|
||||||
|
public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind }
|
||||||
|
|
||||||
|
/// The text the correction pass listens for (a term listens for itself).
|
||||||
|
public var source: String {
|
||||||
|
switch kind {
|
||||||
|
case .term(let term): term
|
||||||
|
case .correction(let heard, _): heard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text that ends up in the transcript.
|
||||||
|
public var wrote: String {
|
||||||
|
switch kind {
|
||||||
|
case .term(let term): term
|
||||||
|
case .correction(_, let wrote): wrote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DictionaryLine: Equatable, Sendable {
|
||||||
|
case entry(DictionaryEntry)
|
||||||
|
case comment(String)
|
||||||
|
case blank
|
||||||
|
case invalid(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dictionary as a list of lines, so comments, blank lines and order survive an edit.
|
||||||
|
public struct DictionaryFile: Equatable, Sendable {
|
||||||
|
public var lines: [DictionaryLine]
|
||||||
|
|
||||||
|
public init(lines: [DictionaryLine] = []) {
|
||||||
|
self.lines = lines
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(parsing text: String) {
|
||||||
|
var raw = text.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
.map(String.init)
|
||||||
|
if raw.last == "" { raw.removeLast() }
|
||||||
|
lines = raw.map(Self.parseLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseLine(_ line: String) -> DictionaryLine {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { return .blank }
|
||||||
|
if trimmed.hasPrefix("#") { return .comment(line) }
|
||||||
|
if let arrow = trimmed.range(of: "=>") {
|
||||||
|
let heard = trimmed[..<arrow.lowerBound].trimmingCharacters(in: .whitespaces)
|
||||||
|
let wrote = trimmed[arrow.upperBound...].trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !heard.isEmpty, !wrote.isEmpty else { return .invalid(line) }
|
||||||
|
return .entry(DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)))
|
||||||
|
}
|
||||||
|
return .entry(DictionaryEntry(kind: .term(trimmed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reuses the ids of `old` for entries with equal content (first unused match, in order), so ids survive a reload.
|
||||||
|
public func preservingIDs(from old: DictionaryFile) -> DictionaryFile {
|
||||||
|
var available = old.entries
|
||||||
|
var result = self
|
||||||
|
result.lines = lines.map { line in
|
||||||
|
guard case .entry(let entry) = line else { return line }
|
||||||
|
if let index = available.firstIndex(where: { $0.kind == entry.kind }) {
|
||||||
|
let reused = available.remove(at: index)
|
||||||
|
return .entry(DictionaryEntry(id: reused.id, kind: entry.kind))
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func serialized() -> String {
|
||||||
|
let out = lines.map { line -> String in
|
||||||
|
switch line {
|
||||||
|
case .entry(let entry):
|
||||||
|
switch entry.kind {
|
||||||
|
case .term(let term): return term
|
||||||
|
case .correction(let heard, let wrote): return "\(heard) => \(wrote)"
|
||||||
|
}
|
||||||
|
case .comment(let text), .invalid(let text): return text
|
||||||
|
case .blank: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty ? "" : out.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
public var entries: [DictionaryEntry] {
|
||||||
|
lines.compactMap { line in
|
||||||
|
if case .entry(let entry) = line { return entry }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID {
|
||||||
|
let entry = DictionaryEntry(kind: kind)
|
||||||
|
lines.append(.entry(entry))
|
||||||
|
return entry.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func update(id: UUID, kind: DictionaryEntry.Kind) {
|
||||||
|
guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return }
|
||||||
|
lines[index] = .entry(DictionaryEntry(id: id, kind: kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func remove(id: UUID) {
|
||||||
|
lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import SstCore
|
||||||
|
|
||||||
|
/// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor.
|
||||||
|
@MainActor
|
||||||
|
public final class DictionaryStore {
|
||||||
|
public static let header = """
|
||||||
|
# Sst woordenboek
|
||||||
|
# Eén invoer per regel. Een los woord is een term (voorrang bij het model).
|
||||||
|
# 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
public let fileURL: URL
|
||||||
|
public private(set) var file = DictionaryFile()
|
||||||
|
public var onChange: (() -> Void)?
|
||||||
|
/// Fired (after `onChange`) when the file was changed by something other than this store.
|
||||||
|
public var onExternalChange: (() -> Void)?
|
||||||
|
|
||||||
|
private var lastText = ""
|
||||||
|
private var watcher: DispatchSourceFileSystemObject?
|
||||||
|
private var debounce: Task<Void, Never>?
|
||||||
|
|
||||||
|
public init(fileURL: URL) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public var entries: [DictionaryEntry] { file.entries }
|
||||||
|
|
||||||
|
/// Reads the file; creates it with a short header when it does not exist yet.
|
||||||
|
public func load() throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
try Self.header.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let text = try String(contentsOf: fileURL, encoding: .utf8)
|
||||||
|
lastText = text
|
||||||
|
file = DictionaryFile(parsing: text)
|
||||||
|
onChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an edit and writes the file straight away.
|
||||||
|
public func apply(_ mutate: (inout DictionaryFile) -> Void) throws {
|
||||||
|
var updated = file
|
||||||
|
mutate(&updated)
|
||||||
|
let text = updated.serialized()
|
||||||
|
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
file = updated
|
||||||
|
lastText = text
|
||||||
|
onChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-reads the file when its text differs from what we last wrote or read.
|
||||||
|
public func reloadIfChanged() {
|
||||||
|
guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return }
|
||||||
|
lastText = text
|
||||||
|
file = DictionaryFile(parsing: text).preservingIDs(from: file)
|
||||||
|
onChange?()
|
||||||
|
onExternalChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watches the folder (editors replace files instead of editing them in place).
|
||||||
|
public func startWatching() {
|
||||||
|
guard watcher == nil else { return }
|
||||||
|
let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY)
|
||||||
|
guard descriptor >= 0 else { return }
|
||||||
|
let source = DispatchSource.makeFileSystemObjectSource(
|
||||||
|
fileDescriptor: descriptor, eventMask: .write, queue: .main)
|
||||||
|
source.setEventHandler { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.scheduleReload() }
|
||||||
|
}
|
||||||
|
source.setCancelHandler { close(descriptor) }
|
||||||
|
source.resume()
|
||||||
|
watcher = source
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopWatching() {
|
||||||
|
watcher?.cancel()
|
||||||
|
watcher = nil
|
||||||
|
debounce?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReload() {
|
||||||
|
debounce?.cancel()
|
||||||
|
debounce = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self?.reloadIfChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes.
|
||||||
|
public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let knowledge: WordKnowledge
|
||||||
|
private var engine: CorrectionEngine
|
||||||
|
private var enabled = true
|
||||||
|
|
||||||
|
public init(knowledge: WordKnowledge) {
|
||||||
|
self.knowledge = knowledge
|
||||||
|
self.engine = CorrectionEngine(entries: [], knowledge: knowledge)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(entries: [DictionaryEntry]) {
|
||||||
|
let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge)
|
||||||
|
lock.withLock { engine = rebuilt }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setEnabled(_ value: Bool) {
|
||||||
|
lock.withLock { enabled = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func correct(_ text: String) -> CorrectionResult {
|
||||||
|
let (current, isOn) = lock.withLock { (engine, enabled) }
|
||||||
|
return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct EntryWarning: Equatable, Sendable {
|
||||||
|
public enum Level: Int, Comparable, Sendable {
|
||||||
|
case note = 0, warning, error
|
||||||
|
public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
public let level: Level
|
||||||
|
public let message: String
|
||||||
|
|
||||||
|
public init(level: Level, message: String) {
|
||||||
|
self.level = level
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flags dictionary entries that are likely to corrupt normal text.
|
||||||
|
public enum EntryWarnings {
|
||||||
|
/// - Parameter others: all entries except the one being checked.
|
||||||
|
public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] {
|
||||||
|
let source: String
|
||||||
|
let isCorrection: Bool
|
||||||
|
switch kind {
|
||||||
|
case .term(let term): (source, isCorrection) = (term, false)
|
||||||
|
case .correction(let heard, _): (source, isCorrection) = (heard, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = EntryKey.parts(source)
|
||||||
|
guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] }
|
||||||
|
|
||||||
|
var result: [EntryWarning] = []
|
||||||
|
if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) {
|
||||||
|
result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt."))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isCorrection {
|
||||||
|
if parts.count == 1, knowledge.isWord(source) {
|
||||||
|
result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel."))
|
||||||
|
}
|
||||||
|
return result.sorted { $0.level > $1.level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.count == 1, knowledge.isWord(parts[0]) {
|
||||||
|
result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen."))
|
||||||
|
}
|
||||||
|
if source.filter(\.isLetter).count < 3 {
|
||||||
|
result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen."))
|
||||||
|
}
|
||||||
|
if parts.count > 1 {
|
||||||
|
var flagged = false
|
||||||
|
if let common = parts.first(where: { knowledge.isFunctionWord($0) }) {
|
||||||
|
result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken."))
|
||||||
|
flagged = true
|
||||||
|
}
|
||||||
|
let glued = parts.joined()
|
||||||
|
if knowledge.isWord(glued) {
|
||||||
|
result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen."))
|
||||||
|
flagged = true
|
||||||
|
}
|
||||||
|
if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) {
|
||||||
|
result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sorted { $0.level > $1.level }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import SstCore
|
||||||
|
|
||||||
|
public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
public var id: UUID
|
||||||
|
public var date: Date
|
||||||
|
public var duration: TimeInterval
|
||||||
|
public var rawText: String
|
||||||
|
public var finalText: String
|
||||||
|
public var corrections: [AppliedCorrection]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = UUID(), date: Date, duration: TimeInterval,
|
||||||
|
rawText: String, finalText: String, corrections: [AppliedCorrection] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.date = date
|
||||||
|
self.duration = duration
|
||||||
|
self.rawText = rawText
|
||||||
|
self.finalText = finalText
|
||||||
|
self.corrections = corrections
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(result: DictationResult) {
|
||||||
|
self.init(
|
||||||
|
date: result.date, duration: result.duration, rawText: result.rawText,
|
||||||
|
finalText: result.finalText, corrections: result.corrections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest first, stored as one JSON file; saves are debounced.
|
||||||
|
@MainActor
|
||||||
|
public final class HistoryStore {
|
||||||
|
public enum LoadOutcome: Equatable {
|
||||||
|
case empty
|
||||||
|
case loaded
|
||||||
|
case recoveredCorrupt(backup: URL)
|
||||||
|
/// The file exists but cannot be read; it is left alone and nothing is written over it.
|
||||||
|
case unreadable(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Document: Codable {
|
||||||
|
var version: Int
|
||||||
|
var entries: [HistoryEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
public private(set) var entries: [HistoryEntry] = []
|
||||||
|
public private(set) var lastError: String?
|
||||||
|
public var onChange: (() -> Void)?
|
||||||
|
|
||||||
|
private let fileURL: URL
|
||||||
|
private var readOnly = false
|
||||||
|
private var saveTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
public init(fileURL: URL) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeEncoder() -> JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeDecoder() -> JSONDecoder {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func load() -> LoadOutcome {
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: fileURL)
|
||||||
|
} catch {
|
||||||
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
entries = []
|
||||||
|
onChange?()
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
readOnly = true
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
return .unreadable(error.localizedDescription)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
entries = try Self.makeDecoder().decode(Document.self, from: data).entries
|
||||||
|
onChange?()
|
||||||
|
return .loaded
|
||||||
|
} catch {
|
||||||
|
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
||||||
|
let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json")
|
||||||
|
try? FileManager.default.moveItem(at: fileURL, to: backup)
|
||||||
|
entries = []
|
||||||
|
onChange?()
|
||||||
|
return .recoveredCorrupt(backup: backup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func add(_ entry: HistoryEntry) {
|
||||||
|
entries.insert(entry, at: 0)
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove(id: UUID) {
|
||||||
|
entries.removeAll { $0.id == id }
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeAll() {
|
||||||
|
entries = []
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes immediately (also call this when the app quits).
|
||||||
|
public func flush() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = nil
|
||||||
|
guard !readOnly else { return }
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries))
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changed() {
|
||||||
|
onChange?()
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self?.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HistorySearch {
|
||||||
|
static func fold(_ text: String) -> String {
|
||||||
|
text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every query word must occur in the final or the original text (case- and diacritic-insensitive).
|
||||||
|
public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] {
|
||||||
|
let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
|
||||||
|
guard !words.isEmpty else { return entries }
|
||||||
|
return entries.filter { entry in
|
||||||
|
let haystack = fold(entry.finalText + " " + entry.rawText)
|
||||||
|
return words.allSatisfy { haystack.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HistoryDay: Identifiable, Equatable, Sendable {
|
||||||
|
public let day: Date
|
||||||
|
public let entries: [HistoryEntry]
|
||||||
|
public var id: Date { day }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HistoryGrouping {
|
||||||
|
/// Groups newest-first entries by calendar day, keeping their order.
|
||||||
|
public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] {
|
||||||
|
var days: [HistoryDay] = []
|
||||||
|
for entry in entries {
|
||||||
|
let start = calendar.startOfDay(for: entry.date)
|
||||||
|
if let last = days.last, last.day == start {
|
||||||
|
days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry])
|
||||||
|
} else {
|
||||||
|
days.append(HistoryDay(day: start, entries: [entry]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The text of the Notitie editor, kept in one plain file. Saves are debounced and atomic.
|
||||||
|
@MainActor
|
||||||
|
public final class NoteStore: ObservableObject {
|
||||||
|
@Published public var text: String = "" {
|
||||||
|
didSet {
|
||||||
|
guard text != oldValue, !loading else { return }
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public private(set) var lastError: String?
|
||||||
|
/// True when the file exists but could not be read: saving would overwrite the user's text, so it is switched off.
|
||||||
|
public private(set) var isReadOnly = false
|
||||||
|
|
||||||
|
private let fileURL: URL
|
||||||
|
private var saveTask: Task<Void, Never>?
|
||||||
|
private var loading = false
|
||||||
|
|
||||||
|
public init(fileURL: URL) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||||
|
loading = true
|
||||||
|
defer { loading = false }
|
||||||
|
do {
|
||||||
|
text = try String(contentsOf: fileURL, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
isReadOnly = true
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes immediately (also call this when the app quits).
|
||||||
|
public func flush() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = nil
|
||||||
|
guard !isReadOnly else { return }
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changed() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self?.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a space between dictations: the text before the cursor decides whether one is needed.
|
||||||
|
public enum DictationSpacing {
|
||||||
|
public static func prepared(_ text: String, characterBefore: Character?) -> String {
|
||||||
|
guard let before = characterBefore, !before.isWhitespace, let first = text.first else { return text }
|
||||||
|
if first.isWhitespace || ".,;:!?)]}\u{201D}\u{2019}".contains(first) { return text }
|
||||||
|
return " " + text
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SstCore
|
||||||
|
|
||||||
|
/// "mm:ss.t" for the counter.
|
||||||
|
public enum StopwatchFormat {
|
||||||
|
public static func string(_ seconds: Double) -> String {
|
||||||
|
let tenths = Int(max(0, seconds) * 10)
|
||||||
|
return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TextSegment: Equatable, Sendable {
|
||||||
|
public let text: String
|
||||||
|
/// Set when this piece was written by the dictionary correction.
|
||||||
|
public let correction: AppliedCorrection?
|
||||||
|
|
||||||
|
public init(text: String, correction: AppliedCorrection? = nil) {
|
||||||
|
self.text = text
|
||||||
|
self.correction = correction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CorrectionMarkup {
|
||||||
|
/// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped.
|
||||||
|
public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] {
|
||||||
|
let ns = finalText as NSString
|
||||||
|
guard ns.length > 0 else { return [] }
|
||||||
|
var result: [TextSegment] = []
|
||||||
|
var cursor = 0
|
||||||
|
for correction in corrections.sorted(by: { $0.start < $1.start }) {
|
||||||
|
let end = correction.start + correction.length
|
||||||
|
guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue }
|
||||||
|
if correction.start > cursor {
|
||||||
|
result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor))))
|
||||||
|
}
|
||||||
|
result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction))
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
if cursor < ns.length {
|
||||||
|
result.append(TextSegment(text: ns.substring(from: cursor)))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The searchable, day-grouped list behind the transcripts view.
|
||||||
|
@MainActor
|
||||||
|
public final class HistoryViewModel: ObservableObject {
|
||||||
|
private let store: HistoryStore
|
||||||
|
private let calendar: Calendar
|
||||||
|
|
||||||
|
@Published public var query = "" {
|
||||||
|
didSet { refresh() }
|
||||||
|
}
|
||||||
|
@Published public var selection: UUID?
|
||||||
|
@Published public private(set) var days: [HistoryDay] = []
|
||||||
|
@Published public private(set) var totalCount = 0
|
||||||
|
|
||||||
|
public init(store: HistoryStore, calendar: Calendar = .current) {
|
||||||
|
self.store = store
|
||||||
|
self.calendar = calendar
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refresh() {
|
||||||
|
let visible = HistorySearch.filter(store.entries, query: query)
|
||||||
|
days = HistoryGrouping.byDay(visible, calendar: calendar)
|
||||||
|
totalCount = store.entries.count
|
||||||
|
if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var selectedEntry: HistoryEntry? {
|
||||||
|
store.entries.first { $0.id == selection }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(id: UUID) {
|
||||||
|
store.remove(id: id)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dictionary rows with warnings, search, editing and undo.
|
||||||
|
@MainActor
|
||||||
|
public final class DictionaryViewModel: ObservableObject {
|
||||||
|
public struct Row: Identifiable, Equatable {
|
||||||
|
public let id: UUID
|
||||||
|
public let entry: DictionaryEntry
|
||||||
|
public let warnings: [EntryWarning]
|
||||||
|
|
||||||
|
public var worst: EntryWarning.Level? { warnings.map(\.level).max() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let store: DictionaryStore
|
||||||
|
private let knowledge: WordKnowledge
|
||||||
|
private var undoStack: [DictionaryFile] = []
|
||||||
|
|
||||||
|
@Published public var query = "" {
|
||||||
|
didSet { applyFilter() }
|
||||||
|
}
|
||||||
|
@Published public private(set) var rows: [Row] = []
|
||||||
|
@Published public private(set) var visibleRows: [Row] = []
|
||||||
|
@Published public private(set) var canUndo = false
|
||||||
|
@Published public var errorMessage: String?
|
||||||
|
|
||||||
|
public init(store: DictionaryStore, knowledge: WordKnowledge) {
|
||||||
|
self.store = store
|
||||||
|
self.knowledge = knowledge
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the rows from the store (call after the store changed).
|
||||||
|
public func reload() {
|
||||||
|
let entries = store.entries
|
||||||
|
rows = entries.enumerated().map { index, entry in
|
||||||
|
// Only earlier entries count as "already there", so a duplicate is flagged on the later one.
|
||||||
|
Row(id: entry.id, entry: entry,
|
||||||
|
warnings: EntryWarnings.check(entry.kind, among: Array(entries[..<index]), knowledge: knowledge))
|
||||||
|
}
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyFilter() {
|
||||||
|
let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
|
||||||
|
guard !words.isEmpty else {
|
||||||
|
visibleRows = rows
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visibleRows = rows.filter { row in
|
||||||
|
let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
|
||||||
|
return words.allSatisfy { haystack.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
|
||||||
|
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
|
||||||
|
let entries = store.entries
|
||||||
|
let others: [DictionaryEntry]
|
||||||
|
if let id, let index = entries.firstIndex(where: { $0.id == id }) {
|
||||||
|
others = Array(entries[..<index])
|
||||||
|
} else {
|
||||||
|
others = entries
|
||||||
|
}
|
||||||
|
return EntryWarnings.check(kind, among: others, knowledge: knowledge)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func add(_ kind: DictionaryEntry.Kind) -> UUID? {
|
||||||
|
var created: UUID?
|
||||||
|
mutate { created = $0.append(kind) }
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(id: UUID, to kind: DictionaryEntry.Kind) {
|
||||||
|
mutate { $0.update(id: id, kind: kind) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove(id: UUID) {
|
||||||
|
guard store.entries.contains(where: { $0.id == id }) else { return }
|
||||||
|
mutate { $0.remove(id: id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func undo() {
|
||||||
|
guard let snapshot = undoStack.last else { return }
|
||||||
|
do {
|
||||||
|
try store.apply { $0 = snapshot }
|
||||||
|
undoStack.removeLast()
|
||||||
|
canUndo = !undoStack.isEmpty
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file was edited elsewhere: older snapshots no longer describe it, so undo is dropped.
|
||||||
|
public func externalChangeDetected() {
|
||||||
|
undoStack.removeAll()
|
||||||
|
canUndo = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mutate(_ change: (inout DictionaryFile) -> Void) {
|
||||||
|
let snapshot = store.file
|
||||||
|
do {
|
||||||
|
try store.apply(change)
|
||||||
|
undoStack.append(snapshot)
|
||||||
|
canUndo = true
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The short word list handed to the speech engine as context.
|
||||||
|
public enum VocabularyBuilder {
|
||||||
|
/// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words.
|
||||||
|
public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] {
|
||||||
|
var out: [String] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
func add(_ word: String) {
|
||||||
|
let key = word.lowercased()
|
||||||
|
guard !word.isEmpty, !seen.contains(key) else { return }
|
||||||
|
seen.insert(key)
|
||||||
|
out.append(word)
|
||||||
|
}
|
||||||
|
for entry in entries { if case .term(let term) = entry.kind { add(term) } }
|
||||||
|
for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } }
|
||||||
|
return Array(out.prefix(maxTerms))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import NaturalLanguage
|
||||||
|
|
||||||
|
public protocol WordKnowledge {
|
||||||
|
/// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not.
|
||||||
|
func isWord(_ word: String) -> Bool
|
||||||
|
/// A very frequent function word ("in", "de", "the", "of").
|
||||||
|
func isFunctionWord(_ word: String) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings.
|
||||||
|
enum EntryKey {
|
||||||
|
static func parts(_ source: String) -> [String] {
|
||||||
|
source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source.
|
||||||
|
static func key(_ source: String) -> String {
|
||||||
|
parts(source).joined().lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most frequent Dutch and English function words.
|
||||||
|
enum CommonWords {
|
||||||
|
static let english: Set<String> = [
|
||||||
|
"a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but",
|
||||||
|
"by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him",
|
||||||
|
"his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not",
|
||||||
|
"now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than",
|
||||||
|
"that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very",
|
||||||
|
"was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your",
|
||||||
|
]
|
||||||
|
static let dutch: Set<String> = [
|
||||||
|
"aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door",
|
||||||
|
"dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is",
|
||||||
|
"ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu",
|
||||||
|
"of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van",
|
||||||
|
"veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei",
|
||||||
|
"zich", "zij", "zijn", "zo", "zou",
|
||||||
|
]
|
||||||
|
static let all: Set<String> = english.union(dutch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not).
|
||||||
|
/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list.
|
||||||
|
public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable {
|
||||||
|
private let englishWords = NLEmbedding.wordEmbedding(for: .english)
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func isWord(_ word: String) -> Bool {
|
||||||
|
let lowered = word.lowercased()
|
||||||
|
guard !lowered.isEmpty else { return false }
|
||||||
|
if let englishWords {
|
||||||
|
if englishWords.contains(lowered) { return true }
|
||||||
|
} else if spellChecks(lowered, language: "en") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return spellChecks(lowered, language: "nl")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isFunctionWord(_ word: String) -> Bool {
|
||||||
|
CommonWords.all.contains(word.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spellChecks(_ word: String, language: String) -> Bool {
|
||||||
|
NSSpellChecker.shared.checkSpelling(
|
||||||
|
of: word, startingAt: 0, language: language, wrap: false,
|
||||||
|
inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SstUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct GalleryApp: App {
|
||||||
|
init() {
|
||||||
|
// `SstGallery --snapshot out.png` renders both finishes to a PNG and exits (for checking without a screen grab).
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
guard let flag = args.firstIndex(of: "--snapshot"), args.count > flag + 1 else { return }
|
||||||
|
let path = args[flag + 1]
|
||||||
|
Task { @MainActor in
|
||||||
|
let view = HStack(spacing: 0) {
|
||||||
|
Finish(scheme: .light, title: "Aluminium", frozen: true)
|
||||||
|
Finish(scheme: .dark, title: "Graphite", frozen: true)
|
||||||
|
}
|
||||||
|
.frame(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight)
|
||||||
|
let renderer = ImageRenderer(content: view)
|
||||||
|
renderer.scale = 2
|
||||||
|
if let tiff = renderer.nsImage?.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
|
||||||
|
try? png.write(to: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup("Sst componenten") {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Finish(scheme: .light, title: "Aluminium")
|
||||||
|
Finish(scheme: .dark, title: "Graphite")
|
||||||
|
}
|
||||||
|
.frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight)
|
||||||
|
}
|
||||||
|
.defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fake speech level so the meter, LEDs and counter can be judged in motion.
|
||||||
|
private func simulatedDbfs(at t: Double) -> Double {
|
||||||
|
if t.truncatingRemainder(dividingBy: 9) > 6.2 { return -90 }
|
||||||
|
let syllable = floor(t * 4.2)
|
||||||
|
let r = abs(sin(syllable * 12.9898) * 43758.5453).truncatingRemainder(dividingBy: 1)
|
||||||
|
return r > 0.12 ? -38 + r * 22 : -90
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Finish: View {
|
||||||
|
let scheme: ColorScheme
|
||||||
|
let title: String
|
||||||
|
var frozen = false
|
||||||
|
@State private var recording = true
|
||||||
|
@State private var started = Date()
|
||||||
|
@State private var query = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if frozen {
|
||||||
|
content(elapsed: 12.3, dbfs: -26)
|
||||||
|
} else {
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||||
|
let elapsed = timeline.date.timeIntervalSince(started)
|
||||||
|
let dbfs = recording ? simulatedDbfs(at: elapsed) : -120
|
||||||
|
content(elapsed: recording ? elapsed : 0, dbfs: dbfs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func content(elapsed: Double, dbfs: Double) -> some View {
|
||||||
|
ZStack {
|
||||||
|
BrushedBackground()
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel("Afwerking · \(title)")
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
HStack(alignment: .top, spacing: Layout.panelGap) {
|
||||||
|
VUMeterView(levelDbfs: dbfs, isActive: recording, initialFraction: frozen ? VUScale.fraction(forDbfs: dbfs) : 0)
|
||||||
|
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||||
|
SilkLabel("Tijd", style: Typography.labelSmall)
|
||||||
|
SegmentCounterView(Self.clock(elapsed))
|
||||||
|
SilkLabel("Niveau", style: Typography.labelSmall)
|
||||||
|
LEDBarView(fraction: VUScale.fraction(forDbfs: dbfs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SeamDivider()
|
||||||
|
HStack(spacing: Layout.panelGap) {
|
||||||
|
Button { recording.toggle(); started = Date() } label: {
|
||||||
|
HStack { LampView(isOn: recording); Text("Rec") }
|
||||||
|
}
|
||||||
|
.buttonStyle(KeyButtonStyle(kind: .record, latched: recording))
|
||||||
|
Button("Stop") { recording = false }.buttonStyle(KeyButtonStyle())
|
||||||
|
Button("Kopieer") {}.buttonStyle(KeyButtonStyle())
|
||||||
|
Button("Uit") {}.buttonStyle(KeyButtonStyle()).disabled(true)
|
||||||
|
Spacer()
|
||||||
|
LampView(isOn: recording, large: true)
|
||||||
|
LampView(isOn: true, tint: .amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
.background(PanelBackground())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
row("14:02:11", "Ik wil de meeting verplaatsen naar tomorrow morning.", selected: false)
|
||||||
|
row("14:03:40", "Hij stuurt het door naar Claude Code vandaag.", selected: true)
|
||||||
|
}
|
||||||
|
.background(PaperBackground())
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
SilkLabel("Zoek")
|
||||||
|
Group {
|
||||||
|
if frozen {
|
||||||
|
Text("claude").textStyle(Typography.body).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
TextField("", text: $query).textFieldStyle(.plain).textStyle(Typography.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Space.s3).background(WellBackground())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Layout.panelPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ time: String, _ text: String, selected: Bool) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
|
||||||
|
Text(time).textStyle(Typography.counterSmall).foregroundStyle(Palette.inkSecondary)
|
||||||
|
Text(text).textStyle(Typography.reading).foregroundStyle(Palette.inkPrimary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Space.s4).padding(.vertical, Space.s3)
|
||||||
|
.frame(minHeight: Size.rowHeight)
|
||||||
|
.background(selected ? Palette.surfaceSelection : Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clock(_ seconds: Double) -> String {
|
||||||
|
let total = Int(seconds * 10)
|
||||||
|
return String(format: "%02d:%02d.%d", (total / 600) % 100, (total / 10) % 60, total % 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import AppKit
|
||||||
|
import SstUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The app icon: a brushed-aluminium plate with a VU meter and the red record lamp, drawn with the design tokens.
|
||||||
|
/// Canvas is 256 pt; the macOS icon grid puts the plate at 206 pt with a 46 pt corner radius.
|
||||||
|
struct IconView: View {
|
||||||
|
static let canvas: CGFloat = 256
|
||||||
|
private let plate: CGFloat = 206
|
||||||
|
private let corner: CGFloat = 46
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
BrushedBackground()
|
||||||
|
.frame(width: plate, height: plate)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: corner, style: .continuous)
|
||||||
|
.strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline))
|
||||||
|
.shadow(color: .black.opacity(Opacity.mark), radius: Space.s3, y: Space.s2)
|
||||||
|
|
||||||
|
VStack(spacing: Space.s5) {
|
||||||
|
VUMeterView(levelDbfs: -26, isActive: true, brand: "", initialFraction: 0.62)
|
||||||
|
.frame(width: 150)
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
LampView(isOn: true, large: true).scaleEffect(1.5)
|
||||||
|
SilkLabel("REC", style: Typography.heading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(y: Space.s2)
|
||||||
|
}
|
||||||
|
.frame(width: Self.canvas, height: Self.canvas)
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
@MainActor
|
||||||
|
struct IconMaker {
|
||||||
|
static func main() {
|
||||||
|
guard CommandLine.arguments.count > 1 else {
|
||||||
|
print("usage: SstIcon <output-folder.iconset>")
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
let folder = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
// (file name, pixel size) as iconutil expects them
|
||||||
|
let outputs: [(String, CGFloat)] = [
|
||||||
|
("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64),
|
||||||
|
("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512),
|
||||||
|
("icon_512x512", 512), ("icon_512x512@2x", 1024),
|
||||||
|
]
|
||||||
|
for (name, pixels) in outputs {
|
||||||
|
let renderer = ImageRenderer(content: IconView())
|
||||||
|
renderer.scale = pixels / IconView.canvas
|
||||||
|
guard let tiff = renderer.nsImage?.tiffRepresentation,
|
||||||
|
let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:])
|
||||||
|
else {
|
||||||
|
print("kon \(name) niet renderen")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
try? png.write(to: folder.appending(path: "\(name).png"))
|
||||||
|
}
|
||||||
|
print("iconset geschreven naar \(folder.path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,13 @@ import AVFoundation
|
|||||||
import SstCore
|
import SstCore
|
||||||
|
|
||||||
/// Records `seconds` of microphone audio; returns nil if access is denied or recording fails.
|
/// Records `seconds` of microphone audio; returns nil if access is denied or recording fails.
|
||||||
func recordSamples(seconds: Double) async -> [Float]? {
|
func recordSamples(seconds: Double, onLevel: ((Float) -> Void)? = nil) async -> [Float]? {
|
||||||
guard await AVCaptureDevice.requestAccess(for: .audio) else {
|
guard await AVCaptureDevice.requestAccess(for: .audio) else {
|
||||||
print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).")
|
print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let recorder = AudioRecorder()
|
let recorder = AudioRecorder()
|
||||||
|
recorder.onLevel = onLevel
|
||||||
do { try recorder.start() } catch {
|
do { try recorder.start() } catch {
|
||||||
print("start mislukt:", error.localizedDescription)
|
print("start mislukt:", error.localizedDescription)
|
||||||
return nil
|
return nil
|
||||||
@@ -18,7 +19,19 @@ func recordSamples(seconds: Double) async -> [Float]? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runRecordProbe() async {
|
func runRecordProbe() async {
|
||||||
guard let samples = await recordSamples(seconds: 3) else { return }
|
final class Levels: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var values: [Float] = []
|
||||||
|
func add(_ v: Float) { lock.withLock { values.append(v) } }
|
||||||
|
}
|
||||||
|
let levels = Levels()
|
||||||
|
guard let samples = await recordSamples(seconds: 3, onLevel: { levels.add($0) }) else { return }
|
||||||
let peak = samples.map { abs($0) }.max() ?? 0
|
let peak = samples.map { abs($0) }.max() ?? 0
|
||||||
print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)")
|
print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)")
|
||||||
|
let values = levels.lock.withLock { levels.values }
|
||||||
|
if let lo = values.min(), let hi = values.max() {
|
||||||
|
print(String(format: "niveau-meldingen: %d (≈ %.0f per s), bereik %.1f … %.1f dBFS", values.count, Double(values.count) / 3, lo, hi))
|
||||||
|
} else {
|
||||||
|
print("geen niveau-meldingen ontvangen")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ func runTranscribeProbe() async {
|
|||||||
}
|
}
|
||||||
print("RMS per seconde:", perSecond.joined(separator: " "))
|
print("RMS per seconde:", perSecond.joined(separator: " "))
|
||||||
do {
|
do {
|
||||||
|
let vocabulary = (ProcessInfo.processInfo.environment["SST_VOCAB"] ?? "")
|
||||||
|
.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
if !vocabulary.isEmpty { print("voorrang: \(vocabulary.joined(separator: ", "))") }
|
||||||
let started = Date.now
|
let started = Date.now
|
||||||
let text = try await transcriber.transcribe(samples)
|
let text = try await transcriber.transcribe(samples, vocabulary: vocabulary)
|
||||||
let elapsed = Date.now.timeIntervalSince(started)
|
let elapsed = Date.now.timeIntervalSince(started)
|
||||||
print(String(format: "(%.2f s) ", elapsed) + text)
|
print(String(format: "(%.2f s) ", elapsed) + text)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A physical key. `latched` keeps it looking pressed (REC while recording).
|
||||||
|
public struct KeyButtonStyle: ButtonStyle {
|
||||||
|
public enum Kind { case standard, record }
|
||||||
|
|
||||||
|
private let kind: Kind
|
||||||
|
private let latched: Bool
|
||||||
|
|
||||||
|
public init(kind: Kind = .standard, latched: Bool = false) {
|
||||||
|
self.kind = kind
|
||||||
|
self.latched = latched
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeBody(configuration: Configuration) -> some View {
|
||||||
|
KeyBody(configuration: configuration, kind: kind, latched: latched)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KeyBody: View {
|
||||||
|
let configuration: Configuration
|
||||||
|
let kind: Kind
|
||||||
|
let latched: Bool
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
private var pressed: Bool { configuration.isPressed || latched }
|
||||||
|
|
||||||
|
private var face: AnyShapeStyle {
|
||||||
|
switch (kind, pressed) {
|
||||||
|
case (.standard, false):
|
||||||
|
AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom))
|
||||||
|
case (.standard, true):
|
||||||
|
AnyShapeStyle(Palette.surfaceKeyPressed)
|
||||||
|
case (.record, false):
|
||||||
|
AnyShapeStyle(Palette.accentRecord)
|
||||||
|
case (.record, true):
|
||||||
|
AnyShapeStyle(Palette.accentRecord.opacity(Opacity.pressedRecord))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
configuration.label
|
||||||
|
.textStyle(Typography.control)
|
||||||
|
.foregroundStyle(kind == .record ? Palette.inkInverse : Palette.inkPrimary)
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.frame(
|
||||||
|
minWidth: kind == .record ? Size.keyRecWidth : Size.keyMinWidth,
|
||||||
|
minHeight: kind == .record ? Size.keyRecHeight : Size.controlHeight)
|
||||||
|
.background(PhysicalSurface(
|
||||||
|
fill: face, cornerRadius: Radius.sm,
|
||||||
|
shadows: pressed ? Shadows.keyPressed : Shadows.keyRaised))
|
||||||
|
.offset(y: pressed ? Shadows.keyPressedOffset : 0)
|
||||||
|
.opacity(isEnabled ? 1 : Opacity.disabled)
|
||||||
|
.animation(
|
||||||
|
reduceMotion ? nil : .timingCurve(
|
||||||
|
Motion.easingMechanical.0, Motion.easingMechanical.1, Motion.easingMechanical.2, Motion.easingMechanical.3,
|
||||||
|
duration: pressed ? Motion.keyPress : Motion.keyRelease),
|
||||||
|
value: pressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round indicator lamp. The record lamp has a small glow; text never does.
|
||||||
|
public struct LampView: View {
|
||||||
|
public enum Tint { case record, amber }
|
||||||
|
|
||||||
|
private let isOn: Bool
|
||||||
|
private let tint: Tint
|
||||||
|
private let large: Bool
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
public init(isOn: Bool, tint: Tint = .record, large: Bool = false) {
|
||||||
|
self.isOn = isOn
|
||||||
|
self.tint = tint
|
||||||
|
self.large = large
|
||||||
|
}
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch (tint, isOn) {
|
||||||
|
case (.record, true): Palette.accentRecord
|
||||||
|
case (.record, false): Palette.accentRecordUnlit
|
||||||
|
case (.amber, true): Palette.levelAmber
|
||||||
|
case (.amber, false): Palette.levelAmberUnlit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
let diameter = large ? Size.lampLarge : Size.lampDiameter
|
||||||
|
let glow = Shadows.lampOn[0]
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.overlay(
|
||||||
|
Circle().fill(RadialGradient(
|
||||||
|
colors: [Color.white.opacity(isOn ? Opacity.specularOn : Opacity.specularOff), .clear],
|
||||||
|
center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: diameter * 0.45)))
|
||||||
|
.overlay(Circle().strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline))
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.shadow(
|
||||||
|
color: isOn && tint == .record ? glow.color(scheme) : .clear,
|
||||||
|
radius: glow.blur / 2)
|
||||||
|
.animation(
|
||||||
|
reduceMotion ? nil : .easeOut(duration: isOn ? Motion.lampOn : Motion.lampOff),
|
||||||
|
value: isOn)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A two-position rocker switch: a key cap sliding in a recessed track.
|
||||||
|
public struct RockerToggleStyle: ToggleStyle {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func makeBody(configuration: Configuration) -> some View {
|
||||||
|
HStack(spacing: Space.s4) {
|
||||||
|
configuration.label
|
||||||
|
Button {
|
||||||
|
configuration.isOn.toggle()
|
||||||
|
} label: {
|
||||||
|
RockerTrack(isOn: configuration.isOn)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityValue(configuration.isOn ? "aan" : "uit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RockerTrack: View {
|
||||||
|
let isOn: Bool
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: isOn ? .trailing : .leading) {
|
||||||
|
WellBackground()
|
||||||
|
PhysicalSurface(
|
||||||
|
fill: AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)),
|
||||||
|
cornerRadius: Radius.sm, shadows: Shadows.keyRaised)
|
||||||
|
.frame(width: Size.switchWidth / 2, height: Size.switchHeight - Space.s2)
|
||||||
|
.padding(Space.s1)
|
||||||
|
}
|
||||||
|
.frame(width: Size.switchWidth, height: Size.switchHeight)
|
||||||
|
.animation(reduceMotion ? nil : .easeOut(duration: Motion.durationFast), value: isOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Seven-segment counter on dark glass. Digits, ":" and "." are drawn; unlit segments stay faintly visible.
|
||||||
|
public struct SegmentCounterView: View {
|
||||||
|
private let text: String
|
||||||
|
|
||||||
|
public init(_ text: String) {
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contentWidth: CGFloat {
|
||||||
|
text.reduce(0) { total, character in
|
||||||
|
total + (character.isNumber ? Segment.advance : Segment.colonWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Canvas { context, _ in
|
||||||
|
var x: CGFloat = 0
|
||||||
|
for character in text {
|
||||||
|
if character.isNumber {
|
||||||
|
var origin = context
|
||||||
|
origin.translateBy(x: x, y: 0)
|
||||||
|
let lit = SegmentGlyph.lit(for: character)
|
||||||
|
for id in SegmentID.allCases {
|
||||||
|
let polygon = SegmentGlyph.polygon(id)
|
||||||
|
var path = Path()
|
||||||
|
path.addLines(polygon)
|
||||||
|
path.closeSubpath()
|
||||||
|
origin.fill(path, with: .color(lit.contains(id) ? Palette.levelAmber : Palette.displaySegmentUnlit))
|
||||||
|
}
|
||||||
|
x += Segment.advance
|
||||||
|
} else {
|
||||||
|
let dot = Segment.thickness
|
||||||
|
let inset = (Segment.colonWidth - dot) / 2
|
||||||
|
let ys: [CGFloat] = character == ":"
|
||||||
|
? [Segment.digitHeight * 0.28, Segment.digitHeight * 0.64]
|
||||||
|
: [Segment.digitHeight - dot]
|
||||||
|
for y in ys {
|
||||||
|
context.fill(Path(CGRect(x: x + inset, y: y, width: dot, height: dot)), with: .color(Palette.levelAmber))
|
||||||
|
}
|
||||||
|
x += Segment.colonWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: contentWidth, height: Segment.digitHeight)
|
||||||
|
.padding(.horizontal, Space.s4)
|
||||||
|
.padding(.vertical, Space.s3)
|
||||||
|
.background(DisplayBackground())
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel("Tijd")
|
||||||
|
.accessibilityValue(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twelve-segment level bar: green, amber, then one red segment.
|
||||||
|
public struct LEDBarView: View {
|
||||||
|
private let fraction: Double
|
||||||
|
|
||||||
|
public init(fraction: Double) {
|
||||||
|
self.fraction = fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
let lit = LEDBar.litCount(forFraction: fraction)
|
||||||
|
HStack(spacing: MeterTokens.ledGap) {
|
||||||
|
ForEach(0..<MeterTokens.ledSegments, id: \.self) { index in
|
||||||
|
RoundedRectangle(cornerRadius: Radius.sharp, style: .circular)
|
||||||
|
.fill(color(for: index, lit: index < lit))
|
||||||
|
.frame(width: MeterTokens.ledSegmentWidth, height: MeterTokens.ledSegmentHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func color(for index: Int, lit: Bool) -> Color {
|
||||||
|
switch LEDBar.tone(forSegment: index) {
|
||||||
|
case .green: lit ? Palette.levelGreen : Palette.levelGreenUnlit
|
||||||
|
case .amber: lit ? Palette.levelAmber : Palette.levelAmberUnlit
|
||||||
|
case .red: lit ? Palette.accentRecord : Palette.accentRecordUnlit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand.
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public enum Palette {
|
||||||
|
/// Venster-achtergrond; het 'huis' van het apparaat
|
||||||
|
public static let surfaceChassis = Color.dynamic(light: 0xCBC8C0, dark: 0x1C1B19)
|
||||||
|
/// Geborsteld metaal, bovenkant van de verloop
|
||||||
|
public static let surfaceMetalHigh = Color.dynamic(light: 0xE3E1DB, dark: 0x3B3A36)
|
||||||
|
/// Geborsteld metaal, onderkant van de verloop
|
||||||
|
public static let surfaceMetalLow = Color.dynamic(light: 0xBDBAB2, dark: 0x262522)
|
||||||
|
/// Frontplaat van mat plastic; zijbalk en bedieningspanelen
|
||||||
|
public static let surfacePanel = Color.dynamic(light: 0xE2DFD5, dark: 0x2B2A27)
|
||||||
|
/// Verzonken vlak: invoervelden, display-vensters
|
||||||
|
public static let surfaceWell = Color.dynamic(light: 0xB4B1A8, dark: 0x131211)
|
||||||
|
/// Cassette-label crème: lijsten met transcripties en woordenboek
|
||||||
|
public static let surfacePaper = Color.dynamic(light: 0xEEE9D9, dark: 0x232220)
|
||||||
|
/// Toetsdop, bovenkant
|
||||||
|
public static let surfaceKeyHigh = Color.dynamic(light: 0xE6E3DA, dark: 0x44433F)
|
||||||
|
/// Toetsdop, onderkant
|
||||||
|
public static let surfaceKeyLow = Color.dynamic(light: 0xCBC8BF, dark: 0x33322F)
|
||||||
|
/// Ingedrukte toets
|
||||||
|
public static let surfaceKeyPressed = Color.dynamic(light: 0xBEBBB2, dark: 0x2A2927)
|
||||||
|
/// Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen)
|
||||||
|
public static let surfaceMeterFace = Color.dynamic(light: 0xEFE6C8, dark: 0xD8CEA6)
|
||||||
|
/// Geselecteerde rij in een lijst
|
||||||
|
public static let surfaceSelection = Color.dynamic(light: 0xD4CDB2, dark: 0x3A3828)
|
||||||
|
/// Displayglas van de teller; donker in beide afwerkingen
|
||||||
|
public static let surfaceDisplay = Color.dynamic(light: 0x1B1A18, dark: 0x0E0D0C)
|
||||||
|
/// Gedoofd segment op het displayglas
|
||||||
|
public static let displaySegmentUnlit = Color.dynamic(light: 0x3A3320, dark: 0x2A2415)
|
||||||
|
/// Hoofdtekst en wijzer
|
||||||
|
public static let inkPrimary = Color.dynamic(light: 0x1E1D1B, dark: 0xECE8DC)
|
||||||
|
/// Secundaire tekst
|
||||||
|
public static let inkSecondary = Color.dynamic(light: 0x403D38, dark: 0xC4BFB2)
|
||||||
|
/// Zeefdruk-labels en hulptekst
|
||||||
|
public static let inkTertiary = Color.dynamic(light: 0x4D4A44, dark: 0xABA79B)
|
||||||
|
/// Uitgeschakeld; nooit voor essentiële tekst
|
||||||
|
public static let inkDisabled = Color.dynamic(light: 0x8F8B82, dark: 0x6B685F)
|
||||||
|
/// Tekst op rode of donkere vulling; crème in beide afwerkingen
|
||||||
|
public static let inkInverse = Color.dynamic(light: 0xF4EFE3, dark: 0xF4EFE3)
|
||||||
|
/// Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen
|
||||||
|
public static let inkOnMeter = Color.dynamic(light: 0x1E1D1B, dark: 0x1E1D1B)
|
||||||
|
/// ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst
|
||||||
|
public static let accentRecord = Color.dynamic(light: 0xC8281E, dark: 0xCC2A20)
|
||||||
|
/// Lampje uit
|
||||||
|
public static let accentRecordUnlit = Color.dynamic(light: 0x7A2E28, dark: 0x4A1F1B)
|
||||||
|
/// Niveau-segment, brandend
|
||||||
|
public static let levelGreen = Color.dynamic(light: 0x5E9E48, dark: 0x6DB556)
|
||||||
|
/// Niveau-segment, waarschuwing, gecorrigeerd woord
|
||||||
|
public static let levelAmber = Color.dynamic(light: 0xD9A023, dark: 0xE5B032)
|
||||||
|
/// Groen segment, uit
|
||||||
|
public static let levelGreenUnlit = Color.dynamic(light: 0xA8B79C, dark: 0x26331F)
|
||||||
|
/// Amber segment, uit
|
||||||
|
public static let levelAmberUnlit = Color.dynamic(light: 0xCDBA8A, dark: 0x3D3418)
|
||||||
|
/// Naad tussen panelen, donkere helft
|
||||||
|
public static let lineSeamDark = Color.dynamic(light: 0x8C8981, dark: 0x0A0A09)
|
||||||
|
/// Naad tussen panelen, lichte helft
|
||||||
|
public static let lineSeamLight = Color.dynamic(light: 0xF3F1EB, dark: 0x45433F)
|
||||||
|
/// Fijne scheidingslijn, zeefdruk-kader
|
||||||
|
public static let lineHairline = Color.dynamic(light: 0xA9A69D, dark: 0x3A3834)
|
||||||
|
/// Toetsenbord-focus
|
||||||
|
public static let lineFocus = Color.dynamic(light: 0x1E1D1B, dark: 0xECE8DC)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Opacity {
|
||||||
|
/// Amber markering achter een gecorrigeerd woord
|
||||||
|
public static let mark: Double = 0.3
|
||||||
|
/// Uitgeschakelde bediening
|
||||||
|
public static let disabled: Double = 0.45
|
||||||
|
/// Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie
|
||||||
|
public static let unlit: Double = 1.0
|
||||||
|
/// Ingedrukte REC-toets: het rood dooft licht
|
||||||
|
public static let pressedRecord: Double = 0.85
|
||||||
|
/// Lichtvlekje op een brandend lampje
|
||||||
|
public static let specularOn: Double = 0.6
|
||||||
|
/// Lichtvlekje op een gedoofd lampje
|
||||||
|
public static let specularOff: Double = 0.3
|
||||||
|
/// Kleine opschriften op de wijzerplaat
|
||||||
|
public static let caption: Double = 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FontFamily: String {
|
||||||
|
case chrome = "Helvetica Neue"
|
||||||
|
case text = "SF Pro Text"
|
||||||
|
case mono = "SF Mono"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Typography {
|
||||||
|
/// Zeefdruk-label
|
||||||
|
public static let label = TextStyle(family: .chrome, size: 10, weight: 500, tracking: 0.09, line: 12, caps: true, tabular: false)
|
||||||
|
/// Schaalmarkeringen en eenheden
|
||||||
|
public static let labelSmall = TextStyle(family: .chrome, size: 9, weight: 500, tracking: 0.1, line: 11, caps: true, tabular: false)
|
||||||
|
/// Opschrift op toetsen
|
||||||
|
public static let control = TextStyle(family: .chrome, size: 11, weight: 500, tracking: 0.06, line: 14, caps: true, tabular: false)
|
||||||
|
/// Paneelkop
|
||||||
|
public static let heading = TextStyle(family: .chrome, size: 12, weight: 500, tracking: 0.12, line: 16, caps: true, tabular: false)
|
||||||
|
/// Standaardtekst
|
||||||
|
public static let body = TextStyle(family: .text, size: 13, weight: 400, tracking: 0, line: 18, caps: false, tabular: false)
|
||||||
|
/// Transcript
|
||||||
|
public static let reading = TextStyle(family: .text, size: 15, weight: 400, tracking: 0, line: 22, caps: false, tabular: false)
|
||||||
|
/// Bijschriften
|
||||||
|
public static let meta = TextStyle(family: .text, size: 11, weight: 400, tracking: 0, line: 14, caps: false, tabular: false)
|
||||||
|
/// Teller
|
||||||
|
public static let counter = TextStyle(family: .mono, size: 22, weight: 500, tracking: 0, line: 26, caps: false, tabular: true)
|
||||||
|
/// Tijdstip in een rij
|
||||||
|
public static let counterSmall = TextStyle(family: .mono, size: 12, weight: 500, tracking: 0, line: 16, caps: false, tabular: true)
|
||||||
|
/// Woordenboekregels
|
||||||
|
public static let code = TextStyle(family: .mono, size: 12, weight: 400, tracking: 0, line: 18, caps: false, tabular: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Segment {
|
||||||
|
/// Hoogte van een getekend segmentcijfer
|
||||||
|
public static let digitHeight: CGFloat = 36
|
||||||
|
/// Breedte van een segmentcijfer
|
||||||
|
public static let digitWidth: CGFloat = 20
|
||||||
|
/// Dikte van één segment
|
||||||
|
public static let thickness: CGFloat = 4
|
||||||
|
/// Ruimte tussen segmenten
|
||||||
|
public static let gap: CGFloat = 1
|
||||||
|
/// Schuinte in graden; 0 = rechtop
|
||||||
|
public static let slant: CGFloat = 0
|
||||||
|
/// Afstand van cijfer tot cijfer
|
||||||
|
public static let advance: CGFloat = 26
|
||||||
|
/// Breedte van een dubbele punt of punt
|
||||||
|
public static let colonWidth: CGFloat = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Space {
|
||||||
|
public static let s0: CGFloat = 0
|
||||||
|
public static let s1: CGFloat = 2
|
||||||
|
public static let s2: CGFloat = 4
|
||||||
|
public static let s3: CGFloat = 8
|
||||||
|
public static let s4: CGFloat = 12
|
||||||
|
public static let s5: CGFloat = 16
|
||||||
|
public static let s6: CGFloat = 24
|
||||||
|
public static let s7: CGFloat = 32
|
||||||
|
public static let s8: CGFloat = 48
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Layout {
|
||||||
|
/// Minimale vensterbreedte
|
||||||
|
public static let windowMinWidth: CGFloat = 800
|
||||||
|
/// Minimale vensterhoogte
|
||||||
|
public static let windowMinHeight: CGFloat = 560
|
||||||
|
/// Standaard vensterbreedte
|
||||||
|
public static let windowDefaultWidth: CGFloat = 1080
|
||||||
|
/// Standaard vensterhoogte
|
||||||
|
public static let windowDefaultHeight: CGFloat = 700
|
||||||
|
/// Breedte zijbalk
|
||||||
|
public static let sidebarWidth: CGFloat = 232
|
||||||
|
/// Breedte instellingenvenster
|
||||||
|
public static let settingsWidth: CGFloat = 560
|
||||||
|
/// Binnenmarge van een paneel (= space.5)
|
||||||
|
public static let panelPadding: CGFloat = 16
|
||||||
|
/// Afstand tussen panelen (= space.4)
|
||||||
|
public static let panelGap: CGFloat = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Size {
|
||||||
|
/// Standaardhoogte van toets of veld
|
||||||
|
public static let controlHeight: CGFloat = 28
|
||||||
|
/// Minimale breedte van een toets
|
||||||
|
public static let keyMinWidth: CGFloat = 56
|
||||||
|
/// REC-toets, breedte
|
||||||
|
public static let keyRecWidth: CGFloat = 64
|
||||||
|
/// REC-toets, hoogte
|
||||||
|
public static let keyRecHeight: CGFloat = 44
|
||||||
|
/// Standaard lampje
|
||||||
|
public static let lampDiameter: CGFloat = 10
|
||||||
|
/// Opnamelampje
|
||||||
|
public static let lampLarge: CGFloat = 14
|
||||||
|
/// Tuimelschakelaar, breedte
|
||||||
|
public static let switchWidth: CGFloat = 40
|
||||||
|
/// Tuimelschakelaar, hoogte
|
||||||
|
public static let switchHeight: CGFloat = 22
|
||||||
|
/// Rij in transcriptielijst
|
||||||
|
public static let rowHeight: CGFloat = 40
|
||||||
|
/// Klein pictogram
|
||||||
|
public static let iconSmall: CGFloat = 16
|
||||||
|
/// Normaal pictogram
|
||||||
|
public static let iconMedium: CGFloat = 20
|
||||||
|
/// Minimaal klikdoel
|
||||||
|
public static let hitMin: CGFloat = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Radius {
|
||||||
|
/// Panelen en velden: scherpe randen
|
||||||
|
public static let none: CGFloat = 0
|
||||||
|
/// Toetsranden: bijna scherp
|
||||||
|
public static let sharp: CGFloat = 1
|
||||||
|
/// Toetsen, velden
|
||||||
|
public static let sm: CGFloat = 2
|
||||||
|
/// Grote panelen
|
||||||
|
public static let md: CGFloat = 4
|
||||||
|
/// Rand van de VU-meter
|
||||||
|
public static let bezel: CGFloat = 6
|
||||||
|
/// Lampjes: rond
|
||||||
|
public static let lamp: CGFloat = 999
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Border {
|
||||||
|
/// Fijne lijn
|
||||||
|
public static let hairline: CGFloat = 1
|
||||||
|
/// Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef
|
||||||
|
public static let seam: CGFloat = 2
|
||||||
|
/// Rand om de VU-meter
|
||||||
|
public static let bezel: CGFloat = 3
|
||||||
|
/// Focusring, 2 pt offset
|
||||||
|
public static let focus: CGFloat = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Shadows {
|
||||||
|
/// Toets in rust: lichtvang boven, donkere onderrand, valschaduw
|
||||||
|
public static let keyRaised: [ShadowLayer] = [
|
||||||
|
ShadowLayer(inset: true, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.65, alphaDark: 0.1),
|
||||||
|
ShadowLayer(inset: true, x: 0, y: -1, blur: 0, spread: 0, hex: 0x000000, alphaLight: 0.25, alphaDark: 0.55),
|
||||||
|
ShadowLayer(inset: false, x: 0, y: 2, blur: 2, spread: 0, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.6),
|
||||||
|
]
|
||||||
|
/// Ingedrukte toets: dop 1 pt omlaag, geen valschaduw
|
||||||
|
public static let keyPressed: [ShadowLayer] = [
|
||||||
|
ShadowLayer(inset: true, x: 0, y: 2, blur: 4, spread: 0, hex: 0x000000, alphaLight: 0.5, alphaDark: 0.7),
|
||||||
|
ShadowLayer(inset: true, x: 0, y: 0, blur: 0, spread: 1, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.6),
|
||||||
|
]
|
||||||
|
public static let keyPressedOffset: CGFloat = 1
|
||||||
|
/// Verzonken vlak met lichtvang op de onderlip
|
||||||
|
public static let well: [ShadowLayer] = [
|
||||||
|
ShadowLayer(inset: true, x: 0, y: 1, blur: 3, spread: 0, hex: 0x000000, alphaLight: 0.4, alphaDark: 0.7),
|
||||||
|
ShadowLayer(inset: false, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.55, alphaDark: 0.07),
|
||||||
|
]
|
||||||
|
/// Frontplaat boven het chassis
|
||||||
|
public static let panel: [ShadowLayer] = [
|
||||||
|
ShadowLayer(inset: true, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.6, alphaDark: 0.08),
|
||||||
|
ShadowLayer(inset: false, x: 0, y: 3, blur: 8, spread: 0, hex: 0x000000, alphaLight: 0.22, alphaDark: 0.55),
|
||||||
|
]
|
||||||
|
/// Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst
|
||||||
|
public static let lampOn: [ShadowLayer] = [
|
||||||
|
ShadowLayer(inset: false, x: 0, y: 0, blur: 6, spread: 1, hex: 0xCC2A20, alphaLight: 0.45, alphaDark: 0.55),
|
||||||
|
ShadowLayer(inset: true, x: 0, y: -1, blur: 1, spread: 0, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.45),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Texture {
|
||||||
|
public static let brushedGrainLight: Double = 0.035
|
||||||
|
public static let brushedGrainDark: Double = 0.05
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MeterTokens {
|
||||||
|
/// 0 VU = -20 dBFS (RMS)
|
||||||
|
public static let vuReferenceDbfs: Double = -20
|
||||||
|
/// Onderkant van de schaal in VU
|
||||||
|
public static let vuMin: Double = -20
|
||||||
|
/// Bovenkant van de schaal in VU; vanaf 0 VU rood
|
||||||
|
public static let vuMax: Double = 3
|
||||||
|
/// Hoek tussen minimale en maximale stand
|
||||||
|
public static let vuArcDegrees: Double = 90
|
||||||
|
/// Breedte van de meter
|
||||||
|
public static let vuWidth: Double = 260
|
||||||
|
/// Hoogte van de meter
|
||||||
|
public static let vuHeight: Double = 150
|
||||||
|
/// Dikte van de wijzer
|
||||||
|
public static let vuNeedleWidth: Double = 1.5
|
||||||
|
/// Genummerde schaalmarkeringen
|
||||||
|
public static let vuTicks: [Int] = [-20, -10, -7, -5, -3, 0, 3]
|
||||||
|
/// Draaipunt van de wijzer, onder de onderrand van de wijzerplaat
|
||||||
|
public static let vuPivotOffset: Double = 22
|
||||||
|
/// Straal van de schaalboog vanaf het draaipunt
|
||||||
|
public static let vuScaleRadius: Double = 142
|
||||||
|
/// Lengte van de wijzer
|
||||||
|
public static let vuNeedleLength: Double = 146
|
||||||
|
/// Lengte van een genummerde streep
|
||||||
|
public static let vuTickMajor: Double = 11
|
||||||
|
/// Lengte van een gewone streep
|
||||||
|
public static let vuTickMinor: Double = 6
|
||||||
|
/// Afstand van de schaalboog tot de cijfers
|
||||||
|
public static let vuLabelInset: Double = 24
|
||||||
|
/// Dikte van de rode band vanaf 0 VU
|
||||||
|
public static let vuRedBandWidth: Double = 5
|
||||||
|
/// Aantal segmenten: 8 groen, 3 amber, 1 rood
|
||||||
|
public static let ledSegments: Int = 12
|
||||||
|
/// Segment in pt (breedte x hoogte)
|
||||||
|
public static let ledSegmentWidth: CGFloat = 6
|
||||||
|
public static let ledSegmentHeight: CGFloat = 14
|
||||||
|
/// Ruimte tussen segmenten
|
||||||
|
public static let ledGap: Double = 2
|
||||||
|
/// Piek-LED blijft zo lang branden
|
||||||
|
public static let peakHoldSeconds: Double = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Motion {
|
||||||
|
/// Geen overgang
|
||||||
|
public static let durationInstant: Double = 0.0
|
||||||
|
/// Toets in/uit
|
||||||
|
public static let durationFast: Double = 0.06
|
||||||
|
/// Standaardovergang
|
||||||
|
public static let durationBase: Double = 0.12
|
||||||
|
/// Paneel of venster
|
||||||
|
public static let durationSlow: Double = 0.22
|
||||||
|
/// Toetsen: direct, zonder veer
|
||||||
|
public static let easingMechanical: (Double, Double, Double, Double) = (0.2, 0, 0, 1)
|
||||||
|
/// Overige overgangen
|
||||||
|
public static let easingStandard: (Double, Double, Double, Double) = (0.4, 0, 0.2, 1)
|
||||||
|
/// Toets indrukken
|
||||||
|
public static let keyPress: Double = 0.05
|
||||||
|
/// Toets loslaten
|
||||||
|
public static let keyRelease: Double = 0.09
|
||||||
|
/// Lampje aan
|
||||||
|
public static let lampOn: Double = 0.03
|
||||||
|
/// Lampje uit: dooft langzaam, als een gloeidraad
|
||||||
|
public static let lampOff: Double = 0.2
|
||||||
|
/// VU-ballistiek: 300 ms om de stand te bereiken
|
||||||
|
public static let needleResponse: Double = 0.3
|
||||||
|
/// Veerdemping; geeft ongeveer 1,5 % overschot
|
||||||
|
public static let needleDamping: Double = 0.8
|
||||||
|
/// Verversing van de wijzer
|
||||||
|
public static let needleUpdateHz: Double = 60
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension NSColor {
|
||||||
|
convenience init(hex: UInt32) {
|
||||||
|
self.init(
|
||||||
|
srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(hex & 0xFF) / 255,
|
||||||
|
alpha: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// A colour that switches between the Aluminium (light) and Graphite (dark) value with the appearance.
|
||||||
|
static func dynamic(light: UInt32, dark: UInt32) -> Color {
|
||||||
|
Color(nsColor: NSColor(name: nil) { appearance in
|
||||||
|
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(hex: dark) : NSColor(hex: light)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same colour in both finishes (shadow bases).
|
||||||
|
static func fixed(hex: UInt32) -> Color {
|
||||||
|
Color(nsColor: NSColor(hex: hex))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// dBFS → VU → needle position and angle, all from the meter tokens.
|
||||||
|
public enum VUScale {
|
||||||
|
public static func vu(fromDbfs dbfs: Double) -> Double {
|
||||||
|
dbfs - MeterTokens.vuReferenceDbfs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0 = bottom of the scale, 1 = top. The needle may swing slightly past the end (up to 1.03).
|
||||||
|
public static func fraction(forVU vu: Double) -> Double {
|
||||||
|
min(1.03, max(0, (vu - MeterTokens.vuMin) / (MeterTokens.vuMax - MeterTokens.vuMin)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fraction(forDbfs dbfs: Double) -> Double {
|
||||||
|
fraction(forVU: vu(fromDbfs: dbfs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Radians from vertical; negative is left of vertical.
|
||||||
|
public static func angle(forFraction fraction: Double) -> Double {
|
||||||
|
(-MeterTokens.vuArcDegrees / 2 + MeterTokens.vuArcDegrees * fraction) * .pi / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Level as VoiceOver should say it: rounded to steps of 3 VU, clamped to the scale, "stil" for non-finite input.
|
||||||
|
public static func spokenLevel(fromDbfs dbfs: Double) -> String {
|
||||||
|
guard dbfs.isFinite else { return "stil" }
|
||||||
|
let stepped = (vu(fromDbfs: dbfs) / 3).rounded() * 3
|
||||||
|
let clamped = min(MeterTokens.vuMax, max(MeterTokens.vuMin, stepped))
|
||||||
|
return "\(Int(clamped)) VU"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Second-order needle: reaches ~99 % in `response` seconds, slight overshoot for damping < 1.
|
||||||
|
public struct NeedleSpring: Equatable {
|
||||||
|
public private(set) var position: Double
|
||||||
|
public private(set) var velocity: Double = 0
|
||||||
|
private let omega: Double
|
||||||
|
private let zeta: Double
|
||||||
|
|
||||||
|
public init(response: Double = Motion.needleResponse, damping: Double = Motion.needleDamping, position: Double = 0) {
|
||||||
|
self.zeta = damping
|
||||||
|
self.omega = 4.6 / (damping * response)
|
||||||
|
self.position = position
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the simulation by `dt` seconds in small fixed sub-steps (stable for any frame time).
|
||||||
|
@discardableResult
|
||||||
|
public mutating func step(target: Double, dt: Double) -> Double {
|
||||||
|
var remaining = max(0, dt)
|
||||||
|
let h = 1.0 / 240.0
|
||||||
|
while remaining > 1e-9 {
|
||||||
|
let s = min(h, remaining)
|
||||||
|
let acceleration = omega * omega * (target - position) - 2 * zeta * omega * velocity
|
||||||
|
velocity += acceleration * s
|
||||||
|
position += velocity * s
|
||||||
|
remaining -= s
|
||||||
|
}
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SegmentID: CaseIterable, Hashable {
|
||||||
|
case a, b, c, d, e, f, g
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which segments of a seven-segment digit are lit, and where each segment sits (from the segment tokens).
|
||||||
|
public enum SegmentGlyph {
|
||||||
|
static let map: [Character: Set<SegmentID>] = [
|
||||||
|
"0": [.a, .b, .c, .d, .e, .f], "1": [.b, .c], "2": [.a, .b, .d, .e, .g], "3": [.a, .b, .c, .d, .g],
|
||||||
|
"4": [.b, .c, .f, .g], "5": [.a, .c, .d, .f, .g], "6": [.a, .c, .d, .e, .f, .g], "7": [.a, .b, .c],
|
||||||
|
"8": Set(SegmentID.allCases), "9": [.a, .b, .c, .d, .f, .g],
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func lit(for character: Character) -> Set<SegmentID> {
|
||||||
|
map[character] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hexagonal bar for one segment, inside a `Segment.digitWidth` × `Segment.digitHeight` box.
|
||||||
|
public static func polygon(_ id: SegmentID) -> [CGPoint] {
|
||||||
|
let width = Segment.digitWidth, height = Segment.digitHeight
|
||||||
|
let h = Segment.thickness / 2, gap = Segment.gap
|
||||||
|
let left = h, right = width - h, mid = height / 2
|
||||||
|
|
||||||
|
func horizontal(_ x0: CGFloat, _ x1: CGFloat, _ cy: CGFloat) -> [CGPoint] {
|
||||||
|
[CGPoint(x: x0, y: cy), CGPoint(x: x0 + h, y: cy - h), CGPoint(x: x1 - h, y: cy - h),
|
||||||
|
CGPoint(x: x1, y: cy), CGPoint(x: x1 - h, y: cy + h), CGPoint(x: x0 + h, y: cy + h)]
|
||||||
|
}
|
||||||
|
func vertical(_ cx: CGFloat, _ y0: CGFloat, _ y1: CGFloat) -> [CGPoint] {
|
||||||
|
[CGPoint(x: cx, y: y0), CGPoint(x: cx + h, y: y0 + h), CGPoint(x: cx + h, y: y1 - h),
|
||||||
|
CGPoint(x: cx, y: y1), CGPoint(x: cx - h, y: y1 - h), CGPoint(x: cx - h, y: y0 + h)]
|
||||||
|
}
|
||||||
|
let x0 = left + h + gap, x1 = right - h - gap
|
||||||
|
switch id {
|
||||||
|
case .a: return horizontal(x0, x1, h)
|
||||||
|
case .g: return horizontal(x0, x1, mid)
|
||||||
|
case .d: return horizontal(x0, x1, height - h)
|
||||||
|
case .f: return vertical(left, h + h + gap, mid - h - gap)
|
||||||
|
case .b: return vertical(right, h + h + gap, mid - h - gap)
|
||||||
|
case .e: return vertical(left, mid + h + gap, height - h - h - gap)
|
||||||
|
case .c: return vertical(right, mid + h + gap, height - h - h - gap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LEDBar {
|
||||||
|
public enum Tone { case green, amber, red }
|
||||||
|
|
||||||
|
/// The last segment is red, the three before it amber, the rest green.
|
||||||
|
public static func tone(forSegment index: Int, segments: Int = MeterTokens.ledSegments) -> Tone {
|
||||||
|
if index >= segments - 1 { return .red }
|
||||||
|
if index >= segments - 4 { return .amber }
|
||||||
|
return .green
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func litCount(forFraction fraction: Double, segments: Int = MeterTokens.ledSegments) -> Int {
|
||||||
|
Int((min(1, max(0, fraction)) * Double(segments)).rounded())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A shape with a fill, layered drop shadows, layered inset shadows and a hairline edge: a physical part.
|
||||||
|
public struct PhysicalSurface: View {
|
||||||
|
private let fill: AnyShapeStyle
|
||||||
|
private let cornerRadius: CGFloat
|
||||||
|
private let shadows: [ShadowLayer]
|
||||||
|
private let edge: Color?
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
public init(fill: AnyShapeStyle, cornerRadius: CGFloat, shadows: [ShadowLayer], edge: Color? = Palette.lineSeamDark) {
|
||||||
|
self.fill = fill
|
||||||
|
self.cornerRadius = cornerRadius
|
||||||
|
self.shadows = shadows
|
||||||
|
self.edge = edge
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .circular)
|
||||||
|
ZStack {
|
||||||
|
shadows.filter { !$0.inset }.reduce(AnyView(shape.fill(fill))) { view, layer in
|
||||||
|
AnyView(view.shadow(color: layer.color(scheme), radius: layer.blur / 2, x: layer.x, y: layer.y))
|
||||||
|
}
|
||||||
|
ForEach(Array(shadows.filter(\.inset).enumerated()), id: \.offset) { _, layer in
|
||||||
|
InsetShadow(layer: layer, cornerRadius: cornerRadius)
|
||||||
|
}
|
||||||
|
if let edge {
|
||||||
|
shape.strokeBorder(edge, lineWidth: Border.hairline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shadow cast inwards from the edge of a rounded rectangle.
|
||||||
|
struct InsetShadow: View {
|
||||||
|
let layer: ShadowLayer
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
let rect = CGRect(origin: .zero, size: size)
|
||||||
|
context.clip(to: Path(roundedRect: rect, cornerRadius: cornerRadius))
|
||||||
|
context.drawLayer { inner in
|
||||||
|
inner.addFilter(.blur(radius: layer.blur / 2))
|
||||||
|
let hole = rect.insetBy(dx: layer.spread, dy: layer.spread).offsetBy(dx: layer.x, dy: layer.y)
|
||||||
|
var frame = Path(rect.insetBy(dx: -size.width, dy: -size.height))
|
||||||
|
frame.addPath(Path(roundedRect: hole, cornerRadius: max(0, cornerRadius - layer.spread)))
|
||||||
|
inner.fill(frame, with: .color(layer.color(scheme)), style: FillStyle(eoFill: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faceplate of matt plastic, sitting above the chassis.
|
||||||
|
public struct PanelBackground: View {
|
||||||
|
public init() {}
|
||||||
|
public var body: some View {
|
||||||
|
PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePanel), cornerRadius: Radius.none, shadows: Shadows.panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recessed area (input fields, display windows).
|
||||||
|
public struct WellBackground: View {
|
||||||
|
public init() {}
|
||||||
|
public var body: some View {
|
||||||
|
PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceWell), cornerRadius: Radius.none, shadows: Shadows.well)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dark display glass for the counter.
|
||||||
|
public struct DisplayBackground: View {
|
||||||
|
public init() {}
|
||||||
|
public var body: some View {
|
||||||
|
PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceDisplay), cornerRadius: Radius.none, shadows: Shadows.well)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cassette-label cream, for lists of transcripts and dictionary entries.
|
||||||
|
public struct PaperBackground: View {
|
||||||
|
public init() {}
|
||||||
|
public var body: some View {
|
||||||
|
PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePaper), cornerRadius: Radius.none, shadows: Shadows.well)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An engraved groove: a dark line with a light line below it.
|
||||||
|
public struct SeamDivider: View {
|
||||||
|
public init() {}
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Rectangle().fill(Palette.lineSeamDark).frame(height: Border.hairline)
|
||||||
|
Rectangle().fill(Palette.lineSeamLight).frame(height: Border.hairline)
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small capitals silkscreen label.
|
||||||
|
public struct SilkLabel: View {
|
||||||
|
private let text: String
|
||||||
|
private let style: TextStyle
|
||||||
|
|
||||||
|
public init(_ text: String, style: TextStyle = Typography.label) {
|
||||||
|
self.text = text
|
||||||
|
self.style = style
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Text(text).textStyle(style).foregroundStyle(Palette.inkTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brushed aluminium: a vertical gradient with a horizontal grain.
|
||||||
|
public struct BrushedBackground: View {
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(colors: [Palette.surfaceMetalHigh, Palette.surfaceMetalLow], startPoint: .top, endPoint: .bottom)
|
||||||
|
Image(nsImage: scheme == .dark ? BrushedTexture.dark : BrushedTexture.light)
|
||||||
|
.resizable(resizingMode: .tile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BrushedTexture {
|
||||||
|
private static let side = 256
|
||||||
|
private static let gain = 2.2 // makes the token grain value visible as streak opacity
|
||||||
|
|
||||||
|
static let light = make(grain: Texture.brushedGrainLight)
|
||||||
|
static let dark = make(grain: Texture.brushedGrainDark)
|
||||||
|
|
||||||
|
private struct SplitMix64: RandomNumberGenerator {
|
||||||
|
var state: UInt64
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E37_79B9_7F4A_7C15
|
||||||
|
var z = state
|
||||||
|
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||||
|
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||||
|
return z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A seeded grain: every row has its own brightness and pixels vary only slightly, which gives fine horizontal streaks.
|
||||||
|
private static func make(grain: Double) -> NSImage {
|
||||||
|
var rng = SplitMix64(state: 7)
|
||||||
|
let rows = (0..<side).map { _ in Double.random(in: 0...1, using: &rng) }
|
||||||
|
var pixels = [UInt8](repeating: 0, count: side * side * 4)
|
||||||
|
for y in 0..<side {
|
||||||
|
let row = rows[y]
|
||||||
|
for x in 0..<side {
|
||||||
|
let value = row * 0.9 + Double.random(in: 0...1, using: &rng) * 0.1
|
||||||
|
let alpha = min(1, abs(value - 0.5) * 2 * grain * gain)
|
||||||
|
let shade = value > 0.5 ? 1.0 : 0.0
|
||||||
|
let index = (y * side + x) * 4
|
||||||
|
let premultiplied = UInt8(shade * alpha * 255)
|
||||||
|
pixels[index] = premultiplied
|
||||||
|
pixels[index + 1] = premultiplied
|
||||||
|
pixels[index + 2] = premultiplied
|
||||||
|
pixels[index + 3] = UInt8(alpha * 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let provider = CGDataProvider(data: Data(pixels) as CFData)!
|
||||||
|
let image = CGImage(
|
||||||
|
width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||||
|
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)!
|
||||||
|
return NSImage(cgImage: image, size: NSSize(width: side, height: side))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct TextStyle: Equatable {
|
||||||
|
public let family: FontFamily
|
||||||
|
public let size: CGFloat
|
||||||
|
public let weight: Int
|
||||||
|
/// In em.
|
||||||
|
public let tracking: CGFloat
|
||||||
|
public let line: CGFloat
|
||||||
|
public let caps: Bool
|
||||||
|
public let tabular: Bool
|
||||||
|
|
||||||
|
public init(family: FontFamily, size: CGFloat, weight: Int, tracking: CGFloat, line: CGFloat, caps: Bool, tabular: Bool) {
|
||||||
|
self.family = family
|
||||||
|
self.size = size
|
||||||
|
self.weight = weight
|
||||||
|
self.tracking = tracking
|
||||||
|
self.line = line
|
||||||
|
self.caps = caps
|
||||||
|
self.tabular = tabular
|
||||||
|
}
|
||||||
|
|
||||||
|
var fontWeight: Font.Weight {
|
||||||
|
switch weight {
|
||||||
|
case ..<450: .regular
|
||||||
|
case 450..<550: .medium
|
||||||
|
case 550..<650: .semibold
|
||||||
|
default: .bold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var font: Font {
|
||||||
|
let base: Font
|
||||||
|
switch family {
|
||||||
|
case .mono: base = .system(size: size, weight: fontWeight, design: .monospaced)
|
||||||
|
case .text: base = .system(size: size, weight: fontWeight)
|
||||||
|
case .chrome: base = .custom(family.rawValue, size: size).weight(fontWeight)
|
||||||
|
}
|
||||||
|
return tabular ? base.monospacedDigit() : base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
/// Font, tracking, line spacing and capitalisation from a token text style.
|
||||||
|
func textStyle(_ style: TextStyle) -> some View {
|
||||||
|
self.font(style.font)
|
||||||
|
.tracking(style.tracking * style.size)
|
||||||
|
.lineSpacing(max(0, style.line - style.size * 1.2))
|
||||||
|
.textCase(style.caps ? .uppercase : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One layer of a layered shadow token. Inset layers are drawn inside the shape.
|
||||||
|
public struct ShadowLayer: Equatable {
|
||||||
|
public let inset: Bool
|
||||||
|
public let x: CGFloat
|
||||||
|
public let y: CGFloat
|
||||||
|
public let blur: CGFloat
|
||||||
|
public let spread: CGFloat
|
||||||
|
public let hex: UInt32
|
||||||
|
public let alphaLight: Double
|
||||||
|
public let alphaDark: Double
|
||||||
|
|
||||||
|
public init(inset: Bool, x: CGFloat, y: CGFloat, blur: CGFloat, spread: CGFloat, hex: UInt32, alphaLight: Double, alphaDark: Double) {
|
||||||
|
self.inset = inset
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.blur = blur
|
||||||
|
self.spread = spread
|
||||||
|
self.hex = hex
|
||||||
|
self.alphaLight = alphaLight
|
||||||
|
self.alphaDark = alphaDark
|
||||||
|
}
|
||||||
|
|
||||||
|
public func color(_ scheme: ColorScheme) -> Color {
|
||||||
|
Color.fixed(hex: hex).opacity(scheme == .dark ? alphaDark : alphaLight)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Analogue VU meter with a physical needle. Pass the RMS level in dBFS; 0 VU = `MeterTokens.vuReferenceDbfs`.
|
||||||
|
/// The needle only animates while `isActive` (and briefly after, to settle), so an idle meter costs nothing.
|
||||||
|
public struct VUMeterView: View {
|
||||||
|
private let levelDbfs: Double
|
||||||
|
private let isActive: Bool
|
||||||
|
private let brand: String
|
||||||
|
|
||||||
|
@State private var needle: NeedleState
|
||||||
|
@State private var awake = false
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
private final class NeedleState {
|
||||||
|
var spring: NeedleSpring
|
||||||
|
var dampingUsed = Motion.needleDamping
|
||||||
|
var last: TimeInterval?
|
||||||
|
var peakUntil: TimeInterval = 0
|
||||||
|
|
||||||
|
init(position: Double) {
|
||||||
|
spring = NeedleSpring(position: position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Parameter initialFraction: where the needle starts (0 = bottom of the scale); useful for previews and snapshots.
|
||||||
|
public init(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0) {
|
||||||
|
self.levelDbfs = levelDbfs
|
||||||
|
self.isActive = isActive
|
||||||
|
self.brand = brand
|
||||||
|
_needle = State(initialValue: NeedleState(position: initialFraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !(isActive || awake))) { timeline in
|
||||||
|
Canvas { context, size in
|
||||||
|
draw(&context, size: size, now: timeline.date.timeIntervalSinceReferenceDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aspectRatio(MeterTokens.vuWidth / MeterTokens.vuHeight, contentMode: .fit)
|
||||||
|
.background(Palette.surfaceMeterFace)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Radius.bezel, style: .circular)
|
||||||
|
.strokeBorder(Palette.lineSeamDark, lineWidth: Border.bezel))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Radius.bezel, style: .circular))
|
||||||
|
.onChange(of: isActive) { _, active in
|
||||||
|
if active {
|
||||||
|
awake = true
|
||||||
|
} else {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
awake = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel("Niveau")
|
||||||
|
.accessibilityValue(spokenValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounded to steps of 3 VU so VoiceOver is not flooded with changes.
|
||||||
|
private var spokenValue: String {
|
||||||
|
guard isActive else { return "stil" }
|
||||||
|
return VUScale.spokenLevel(fromDbfs: levelDbfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight)
|
||||||
|
|
||||||
|
private func draw(_ context: inout GraphicsContext, size: CGSize, now: TimeInterval) {
|
||||||
|
let width = MeterTokens.vuWidth, height = MeterTokens.vuHeight
|
||||||
|
let scale = min(size.width / width, size.height / height)
|
||||||
|
context.translateBy(x: (size.width - width * scale) / 2, y: (size.height - height * scale) / 2)
|
||||||
|
context.scaleBy(x: scale, y: scale)
|
||||||
|
context.clip(to: Path(CGRect(x: 0, y: 0, width: width, height: height)))
|
||||||
|
|
||||||
|
// physics
|
||||||
|
let damping = reduceMotion ? 1.0 : Motion.needleDamping
|
||||||
|
if needle.dampingUsed != damping {
|
||||||
|
needle.spring = NeedleSpring(damping: damping, position: needle.spring.position)
|
||||||
|
needle.dampingUsed = damping
|
||||||
|
}
|
||||||
|
let dt = needle.last.map { min(0.05, now - $0) } ?? 0
|
||||||
|
needle.last = now
|
||||||
|
let target = isActive ? VUScale.fraction(forDbfs: levelDbfs) : 0
|
||||||
|
let position = needle.spring.step(target: target, dt: dt)
|
||||||
|
let vuNow = MeterTokens.vuMin + (MeterTokens.vuMax - MeterTokens.vuMin) * position
|
||||||
|
if vuNow > 0 && isActive { needle.peakUntil = now + MeterTokens.peakHoldSeconds }
|
||||||
|
|
||||||
|
let pivot = CGPoint(x: width / 2, y: height + MeterTokens.vuPivotOffset)
|
||||||
|
func point(_ radius: CGFloat, _ fraction: Double) -> CGPoint {
|
||||||
|
let angle = VUScale.angle(forFraction: fraction)
|
||||||
|
return CGPoint(x: pivot.x + radius * CGFloat(sin(angle)), y: pivot.y - radius * CGFloat(cos(angle)))
|
||||||
|
}
|
||||||
|
func arc(_ radius: CGFloat, from: Double, to: Double) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
let steps = 48
|
||||||
|
for step in 0...steps {
|
||||||
|
let p = point(radius, from + (to - from) * Double(step) / Double(steps))
|
||||||
|
step == 0 ? path.move(to: p) : path.addLine(to: p)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = MeterTokens.vuScaleRadius
|
||||||
|
let redFrom = VUScale.fraction(forVU: 0)
|
||||||
|
context.stroke(arc(radius, from: 0, to: redFrom), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline)
|
||||||
|
context.stroke(arc(radius - Border.hairline, from: redFrom, to: 1), with: .color(Palette.accentRecord), lineWidth: MeterTokens.vuRedBandWidth)
|
||||||
|
|
||||||
|
for vu in Int(MeterTokens.vuMin)...Int(MeterTokens.vuMax) {
|
||||||
|
let major = MeterTokens.vuTicks.contains(vu)
|
||||||
|
let fraction = VUScale.fraction(forVU: Double(vu))
|
||||||
|
let color = vu > 0 ? Palette.accentRecord : Palette.inkOnMeter
|
||||||
|
var tick = Path()
|
||||||
|
tick.move(to: point(radius, fraction))
|
||||||
|
tick.addLine(to: point(radius - (major ? MeterTokens.vuTickMajor : MeterTokens.vuTickMinor), fraction))
|
||||||
|
context.stroke(tick, with: .color(color), lineWidth: Border.hairline)
|
||||||
|
if major {
|
||||||
|
let label = Text(vu > 0 ? "+\(vu)" : "\(vu)").font(Typography.labelSmall.font).foregroundStyle(color)
|
||||||
|
context.draw(label, at: point(radius - MeterTokens.vuLabelInset, fraction), anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(
|
||||||
|
Text("VU").font(Typography.heading.font).tracking(Typography.heading.tracking * Typography.heading.size).foregroundStyle(Palette.inkOnMeter),
|
||||||
|
at: CGPoint(x: width / 2, y: height - Space.s6), anchor: .center)
|
||||||
|
context.draw(
|
||||||
|
Text(brand).font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter.opacity(Opacity.caption)),
|
||||||
|
at: CGPoint(x: Space.s4, y: height - Space.s3), anchor: .leading)
|
||||||
|
|
||||||
|
// peak lamp
|
||||||
|
let lampCenter = CGPoint(x: width - Space.s6, y: Space.s6)
|
||||||
|
let lampRadius = Size.lampDiameter / 2
|
||||||
|
let peakLit = now < needle.peakUntil
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)),
|
||||||
|
with: .color(peakLit ? Palette.accentRecord : Palette.accentRecordUnlit))
|
||||||
|
context.stroke(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)),
|
||||||
|
with: .color(Palette.inkOnMeter), lineWidth: Border.hairline)
|
||||||
|
context.draw(
|
||||||
|
Text("PEAK").font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter),
|
||||||
|
at: CGPoint(x: lampCenter.x, y: lampCenter.y + lampRadius + Space.s3), anchor: .center)
|
||||||
|
|
||||||
|
// needle
|
||||||
|
var needlePath = Path()
|
||||||
|
needlePath.move(to: pivot)
|
||||||
|
needlePath.addLine(to: point(MeterTokens.vuNeedleLength, max(-0.02, min(1.05, position))))
|
||||||
|
context.stroke(needlePath, with: .color(Palette.inkOnMeter), lineWidth: MeterTokens.vuNeedleWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A speech model the user can pick in Settings.
|
||||||
|
public struct ModelOption: Identifiable, Equatable, Sendable {
|
||||||
|
public let id: String // WhisperKit variant name
|
||||||
|
public let title: String
|
||||||
|
public let detail: String
|
||||||
|
public let approxSizeMB: Int
|
||||||
|
|
||||||
|
public init(id: String, title: String, detail: String, approxSizeMB: Int) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
self.approxSizeMB = approxSizeMB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ModelCatalog {
|
||||||
|
public static let defaultVariant = "large-v3-v20240930_turbo"
|
||||||
|
|
||||||
|
/// Sizes measured from the Hugging Face repository `argmaxinc/whisperkit-coreml`.
|
||||||
|
public static let all: [ModelOption] = [
|
||||||
|
ModelOption(id: defaultVariant, title: "Whisper Large v3 Turbo", detail: "Aanbevolen · nauwkeurigst", approxSizeMB: 1638),
|
||||||
|
ModelOption(id: "large-v3-v20240930_turbo_632MB", title: "Whisper Large v3 Turbo (compact)", detail: "Kleiner, iets minder nauwkeurig", approxSizeMB: 646),
|
||||||
|
ModelOption(id: "small", title: "Whisper Small", detail: "Snel en licht, minder nauwkeurig", approxSizeMB: 486),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where models live on disk: `<base>/models/argmaxinc/whisperkit-coreml/openai_whisper-<variant>`.
|
||||||
|
public struct ModelStorage: Sendable {
|
||||||
|
public static let repo = "argmaxinc/whisperkit-coreml"
|
||||||
|
public static let requiredModels = ["MelSpectrogram", "AudioEncoder", "TextDecoder"]
|
||||||
|
|
||||||
|
public let base: URL
|
||||||
|
|
||||||
|
public init(base: URL = ModelStorage.defaultBase) {
|
||||||
|
self.base = base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application Support, so models are neither in ~/Documents (privacy prompt, iCloud) nor re-fetched.
|
||||||
|
public static var defaultBase: URL {
|
||||||
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appending(path: "Sst/models", directoryHint: .isDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
var repoDirectory: URL {
|
||||||
|
base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func folders(for variant: String) -> [URL] {
|
||||||
|
let names = (try? FileManager.default.contentsOfDirectory(atPath: repoDirectory.path)) ?? []
|
||||||
|
return names
|
||||||
|
.filter { $0 == variant || $0 == "openai_whisper-\(variant)" }
|
||||||
|
.map { repoDirectory.appending(path: $0, directoryHint: .isDirectory) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder of a completely downloaded variant, or nil.
|
||||||
|
public func folder(for variant: String) -> URL? {
|
||||||
|
folders(for: variant).first { folder in
|
||||||
|
Self.requiredModels.allSatisfy {
|
||||||
|
FileManager.default.fileExists(atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isInstalled(_ variant: String) -> Bool {
|
||||||
|
folder(for: variant) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(_ variant: String) throws {
|
||||||
|
for folder in folders(for: variant) { try FileManager.default.removeItem(at: folder) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,44 +10,24 @@ public final class WhisperKitTranscriber: Transcriber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let modelName: String
|
private let modelName: String
|
||||||
|
private let storage: ModelStorage
|
||||||
private var pipeline: WhisperKit?
|
private var pipeline: WhisperKit?
|
||||||
|
|
||||||
/// Download progress (0...1) during the one-time model download. May be called from any thread.
|
/// Download progress (0...1) during the one-time model download. May be called from any thread.
|
||||||
public var onProgress: ((Double) -> Void)?
|
public var onProgress: ((Double) -> Void)?
|
||||||
|
|
||||||
public init(model: String = "large-v3-v20240930_turbo") {
|
public init(model: String = ModelCatalog.defaultVariant, storage: ModelStorage = ModelStorage()) {
|
||||||
self.modelName = model
|
self.modelName = model
|
||||||
|
self.storage = storage
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hugging Face hub root; WhisperKit stores models in `<root>/models/<repo>/<variant>`.
|
public var variant: String { modelName }
|
||||||
/// Kept in Application Support so it is neither in ~/Documents (TCC prompt, iCloud) nor re-fetched.
|
|
||||||
private static var storageBase: URL {
|
|
||||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
||||||
.appending(path: "Sst/models", directoryHint: .isDirectory)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let repo = "argmaxinc/whisperkit-coreml"
|
private func loadPipeline(from folder: URL) async throws -> WhisperKit {
|
||||||
|
|
||||||
/// A previously downloaded variant folder, if it contains the compiled Core ML models.
|
|
||||||
private func localModelFolder(base: URL) -> URL? {
|
|
||||||
let repoDir = base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory)
|
|
||||||
let folders = (try? FileManager.default.contentsOfDirectory(atPath: repoDir.path)) ?? []
|
|
||||||
return folders
|
|
||||||
.filter { $0.hasSuffix(modelName) }
|
|
||||||
.map { repoDir.appending(path: $0, directoryHint: .isDirectory) }
|
|
||||||
.first { folder in
|
|
||||||
["MelSpectrogram", "AudioEncoder", "TextDecoder"].allSatisfy {
|
|
||||||
FileManager.default.fileExists(
|
|
||||||
atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPipeline(from folder: URL, base: URL) async throws -> WhisperKit {
|
|
||||||
try await WhisperKit(WhisperKitConfig(
|
try await WhisperKit(WhisperKitConfig(
|
||||||
downloadBase: base,
|
downloadBase: storage.base,
|
||||||
modelFolder: folder.path,
|
modelFolder: folder.path,
|
||||||
tokenizerFolder: base,
|
tokenizerFolder: storage.base,
|
||||||
prewarm: true,
|
prewarm: true,
|
||||||
load: true,
|
load: true,
|
||||||
download: false
|
download: false
|
||||||
@@ -58,34 +38,64 @@ public final class WhisperKitTranscriber: Transcriber {
|
|||||||
/// once downloaded, launching needs no network.
|
/// once downloaded, launching needs no network.
|
||||||
public func load() async throws {
|
public func load() async throws {
|
||||||
guard pipeline == nil else { return }
|
guard pipeline == nil else { return }
|
||||||
let base = Self.storageBase
|
try FileManager.default.createDirectory(at: storage.base, withIntermediateDirectories: true)
|
||||||
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
if let folder = localModelFolder(base: base),
|
if let folder = storage.folder(for: modelName),
|
||||||
let loaded = try? await loadPipeline(from: folder, base: base) {
|
let loaded = try? await loadPipeline(from: folder) {
|
||||||
pipeline = loaded
|
pipeline = loaded
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let folder = try await WhisperKit.download(
|
let folder = try await WhisperKit.download(
|
||||||
variant: modelName,
|
variant: modelName,
|
||||||
downloadBase: base,
|
downloadBase: storage.base,
|
||||||
from: Self.repo
|
from: ModelStorage.repo
|
||||||
) { [onProgress] progress in
|
) { [onProgress] progress in
|
||||||
onProgress?(progress.fractionCompleted)
|
onProgress?(progress.fractionCompleted)
|
||||||
}
|
}
|
||||||
pipeline = try await loadPipeline(from: folder, base: base)
|
pipeline = try await loadPipeline(from: folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transcribe(_ samples: [Float]) async throws -> String {
|
public func transcribe(_ samples: [Float]) async throws -> String {
|
||||||
|
try await transcribe(samples, vocabulary: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terms are added one by one while the encoded prompt stays within `maxTokens`.
|
||||||
|
/// Returns nil when nothing fits or the list is empty.
|
||||||
|
static func promptTokens(
|
||||||
|
vocabulary: [String],
|
||||||
|
maxTokens: Int = 64,
|
||||||
|
specialTokenBegin: Int,
|
||||||
|
encode: (String) -> [Int]
|
||||||
|
) -> [Int]? {
|
||||||
|
var kept: [String] = []
|
||||||
|
var tokens: [Int] = []
|
||||||
|
for term in vocabulary {
|
||||||
|
let candidate = kept + [term]
|
||||||
|
let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin }
|
||||||
|
if encoded.count > maxTokens { break }
|
||||||
|
kept = candidate
|
||||||
|
tokens = encoded
|
||||||
|
}
|
||||||
|
return tokens.isEmpty ? nil : tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String {
|
||||||
guard let pipeline else { throw TranscriberError.notLoaded }
|
guard let pipeline else { throw TranscriberError.notLoaded }
|
||||||
// language nil + detectLanguage: Whisper picks NL or EN per utterance.
|
// language nil + detectLanguage: Whisper picks NL or EN per utterance.
|
||||||
let options = DecodingOptions(
|
var options = DecodingOptions(
|
||||||
task: .transcribe,
|
task: .transcribe,
|
||||||
language: nil,
|
language: nil,
|
||||||
detectLanguage: true,
|
detectLanguage: true,
|
||||||
skipSpecialTokens: true,
|
skipSpecialTokens: true,
|
||||||
withoutTimestamps: true
|
withoutTimestamps: true
|
||||||
)
|
)
|
||||||
|
if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer {
|
||||||
|
options.promptTokens = Self.promptTokens(
|
||||||
|
vocabulary: vocabulary,
|
||||||
|
specialTokenBegin: tokenizer.specialTokens.specialTokenBegin,
|
||||||
|
encode: { tokenizer.encode(text: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options)
|
let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options)
|
||||||
return results
|
return results
|
||||||
.map(\.text)
|
.map(\.text)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct RoutingInserterTests {
|
||||||
|
@Test func sendsToTheSystemRoute() async throws {
|
||||||
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
|
try await RoutingInserter(system: system, editor: editor, route: { .system }).insert("hallo")
|
||||||
|
#expect(system.inserted == ["hallo"])
|
||||||
|
#expect(editor.inserted.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sendsToTheEditorRoute() async throws {
|
||||||
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
|
try await RoutingInserter(system: system, editor: editor, route: { .editor }).insert("hallo")
|
||||||
|
#expect(editor.inserted == ["hallo"])
|
||||||
|
#expect(system.inserted.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dropsTheTextOnNone() async throws {
|
||||||
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
|
try await RoutingInserter(system: system, editor: editor, route: { .none }).insert("hallo")
|
||||||
|
#expect(system.inserted.isEmpty)
|
||||||
|
#expect(editor.inserted.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func askedAtInsertionTimeNotAtCreation() async throws {
|
||||||
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
|
var current = InsertionRoute.none
|
||||||
|
let router = RoutingInserter(system: system, editor: editor, route: { current })
|
||||||
|
current = .editor
|
||||||
|
try await router.insert("nu wel")
|
||||||
|
#expect(editor.inserted == ["nu wel"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func propagatesInserterErrors() async {
|
||||||
|
struct Boom: Error {}
|
||||||
|
let system = FakeInserter()
|
||||||
|
system.error = Boom()
|
||||||
|
await #expect(throws: Boom.self) {
|
||||||
|
try await RoutingInserter(system: system, editor: FakeInserter(), route: { .system }).insert("x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SwappableTranscriberTests {
|
||||||
|
@Test func throwsWithoutATranscriber() async {
|
||||||
|
let swappable = SwappableTranscriber()
|
||||||
|
#expect(!swappable.hasTranscriber)
|
||||||
|
await #expect(throws: SwappableTranscriber.SwapError.self) {
|
||||||
|
_ = try await swappable.transcribe([0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func delegatesIncludingTheVocabulary() async throws {
|
||||||
|
let inner = FakeVocabularyTranscriber()
|
||||||
|
inner.result = "klaar"
|
||||||
|
let swappable = SwappableTranscriber(inner)
|
||||||
|
#expect(try await swappable.transcribe([0], vocabulary: ["Anthropic"]) == "klaar")
|
||||||
|
#expect(inner.vocabularies == [["Anthropic"]])
|
||||||
|
#expect(try await swappable.transcribe([0]) == "klaar")
|
||||||
|
#expect(inner.vocabularies == [["Anthropic"], []])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func swapReplacesTheTranscriber() async throws {
|
||||||
|
let first = FakeVocabularyTranscriber(), second = FakeVocabularyTranscriber()
|
||||||
|
first.result = "een"; second.result = "twee"
|
||||||
|
let swappable = SwappableTranscriber(first)
|
||||||
|
#expect(try await swappable.transcribe([0]) == "een")
|
||||||
|
swappable.swap(to: second)
|
||||||
|
#expect(try await swappable.transcribe([0]) == "twee")
|
||||||
|
swappable.swap(to: nil)
|
||||||
|
#expect(!swappable.hasTranscriber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LevelSourceTests {
|
||||||
|
@Test func startsSilentAndKeepsTheLatestValue() {
|
||||||
|
let source = LevelSource()
|
||||||
|
#expect(source.dbfs == AudioLevel.silenceFloorDbfs)
|
||||||
|
source.update(-23)
|
||||||
|
source.update(-18)
|
||||||
|
#expect(source.dbfs == -18)
|
||||||
|
source.reset()
|
||||||
|
#expect(source.dbfs == AudioLevel.silenceFloorDbfs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct AudioLevelTests {
|
||||||
|
@Test func constantTenthIsMinusTwentyDbfs() {
|
||||||
|
let level = AudioLevel.rmsDbfs([Float](repeating: 0.1, count: 1_000))
|
||||||
|
#expect(abs(level - (-20)) < 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fullScaleIsZeroDbfs() {
|
||||||
|
let level = AudioLevel.rmsDbfs([Float](repeating: 1.0, count: 100))
|
||||||
|
#expect(abs(level) < 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func negativeSamplesCountLikePositiveOnes() {
|
||||||
|
let level = AudioLevel.rmsDbfs([Float](repeating: -0.1, count: 1_000))
|
||||||
|
#expect(abs(level - (-20)) < 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyAndSilentBuffersHitTheFloor() {
|
||||||
|
#expect(AudioLevel.rmsDbfs([]) == AudioLevel.silenceFloorDbfs)
|
||||||
|
#expect(AudioLevel.rmsDbfs([Float](repeating: 0, count: 64)) == AudioLevel.silenceFloorDbfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func vuIsRelativeToTheReference() {
|
||||||
|
#expect(AudioLevel.vu(fromDbfs: -20) == 0)
|
||||||
|
#expect(AudioLevel.vu(fromDbfs: -40) == -20)
|
||||||
|
#expect(AudioLevel.vu(fromDbfs: -17) == 3)
|
||||||
|
#expect(AudioLevel.vu(fromDbfs: -30, referenceDbfs: -18) == -12)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DictationControllerHooksTests {
|
||||||
|
let recorder = FakeRecorder()
|
||||||
|
let transcriber = FakeVocabularyTranscriber()
|
||||||
|
let inserter = FakeInserter()
|
||||||
|
static let loud = [Float](repeating: 0.1, count: 16_000)
|
||||||
|
static let quiet = [Float](repeating: 0.01, count: 16_000)
|
||||||
|
|
||||||
|
func makeController(
|
||||||
|
cleaner: Cleaner = FakeCleaner(),
|
||||||
|
corrector: TextCorrecting? = nil,
|
||||||
|
vocabulary: [String] = []
|
||||||
|
) -> DictationController {
|
||||||
|
DictationController(
|
||||||
|
recorder: recorder,
|
||||||
|
transcriber: transcriber,
|
||||||
|
cleaner: cleaner,
|
||||||
|
inserter: inserter,
|
||||||
|
corrector: corrector,
|
||||||
|
vocabularyProvider: { vocabulary }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dictate(_ controller: DictationController) async {
|
||||||
|
await controller.handle(.pressed)
|
||||||
|
await controller.handle(.released)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loudRecordingPassesVocabularyToTranscriber() async {
|
||||||
|
recorder.samples = Self.loud
|
||||||
|
await dictate(makeController(vocabulary: ["Anthropic", "Vercel"]))
|
||||||
|
#expect(transcriber.vocabularies == [["Anthropic", "Vercel"]])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func quietRecordingSkipsVocabularyButStillTranscribes() async {
|
||||||
|
recorder.samples = Self.quiet // RMS 0.01: above silence (0.005), below bias minimum (0.015)
|
||||||
|
await dictate(makeController(vocabulary: ["Anthropic", "Vercel"]))
|
||||||
|
#expect(transcriber.vocabularies == [[]])
|
||||||
|
#expect(inserter.inserted == ["[hallo wereld]"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func promptEchoIsDiscarded() async {
|
||||||
|
recorder.samples = Self.loud
|
||||||
|
transcriber.result = "Anthropic, Vercel."
|
||||||
|
let controller = makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"])
|
||||||
|
var results: [DictationResult] = []
|
||||||
|
controller.onResult = { results.append($0) }
|
||||||
|
|
||||||
|
await dictate(controller)
|
||||||
|
|
||||||
|
#expect(inserter.inserted.isEmpty)
|
||||||
|
#expect(results.isEmpty)
|
||||||
|
#expect(controller.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleVocabularyTermIsKept() async {
|
||||||
|
recorder.samples = Self.loud
|
||||||
|
transcriber.result = "Anthropic"
|
||||||
|
await dictate(makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"]))
|
||||||
|
#expect(inserter.inserted == ["[Anthropic]"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func correctorRunsBeforeCleanerAndCorrectionsSurviveAPassthroughCleaner() async {
|
||||||
|
recorder.samples = Self.loud
|
||||||
|
transcriber.result = "hallo"
|
||||||
|
let controller = makeController(cleaner: PassthroughCleaner(), corrector: UppercaseCorrector())
|
||||||
|
var results: [DictationResult] = []
|
||||||
|
controller.onResult = { results.append($0) }
|
||||||
|
|
||||||
|
await dictate(controller)
|
||||||
|
|
||||||
|
#expect(inserter.inserted == ["HALLO"])
|
||||||
|
#expect(results.count == 1)
|
||||||
|
#expect(results[0].rawText == "hallo")
|
||||||
|
#expect(results[0].finalText == "HALLO")
|
||||||
|
#expect(results[0].corrections.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func correctionsAreDroppedWhenTheCleanerChangesTheText() async {
|
||||||
|
recorder.samples = Self.loud
|
||||||
|
transcriber.result = "hallo"
|
||||||
|
let controller = makeController(cleaner: FakeCleaner(), corrector: UppercaseCorrector())
|
||||||
|
var results: [DictationResult] = []
|
||||||
|
controller.onResult = { results.append($0) }
|
||||||
|
|
||||||
|
await dictate(controller)
|
||||||
|
|
||||||
|
#expect(results[0].finalText == "[HALLO]")
|
||||||
|
#expect(results[0].corrections.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resultIsReportedEvenWhenInsertionFails() async {
|
||||||
|
recorder.samples = Self.loud
|
||||||
|
inserter.error = TestError()
|
||||||
|
let controller = makeController()
|
||||||
|
var results: [DictationResult] = []
|
||||||
|
var errors: [String] = []
|
||||||
|
controller.onResult = { results.append($0) }
|
||||||
|
controller.onError = { errors.append($0) }
|
||||||
|
|
||||||
|
await dictate(controller)
|
||||||
|
|
||||||
|
#expect(results.count == 1)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resultCarriesDurationAndDate() async {
|
||||||
|
recorder.samples = Self.loud // 16 000 samples = 1 s
|
||||||
|
let controller = makeController()
|
||||||
|
var results: [DictationResult] = []
|
||||||
|
controller.onResult = { results.append($0) }
|
||||||
|
let before = Date()
|
||||||
|
|
||||||
|
await dictate(controller)
|
||||||
|
|
||||||
|
#expect(results[0].duration == 1.0)
|
||||||
|
#expect(results[0].date >= before)
|
||||||
|
#expect(results[0].corrections.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,3 +41,25 @@ final class FakeInserter: TextInserting {
|
|||||||
inserted.append(text)
|
inserted.append(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class FakeVocabularyTranscriber: Transcriber {
|
||||||
|
var result = "hallo wereld"
|
||||||
|
private(set) var vocabularies: [[String]] = []
|
||||||
|
|
||||||
|
func transcribe(_ samples: [Float]) async throws -> String { result }
|
||||||
|
|
||||||
|
func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String {
|
||||||
|
vocabularies.append(vocabulary)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UppercaseCorrector: TextCorrecting {
|
||||||
|
func correct(_ text: String) -> CorrectionResult {
|
||||||
|
let upper = text.uppercased()
|
||||||
|
return CorrectionResult(
|
||||||
|
text: upper,
|
||||||
|
corrections: [AppliedCorrection(heard: text, wrote: upper, rule: "upper", start: 0, length: upper.utf16.count)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct HotkeyBindingTests {
|
||||||
|
@Test func allowedBindingsMatchTheSpec() {
|
||||||
|
#expect(HotkeyBinding.allowed.map(\.id) == ["fn", "rightOption", "rightCommand", "rightControl", "leftControl"])
|
||||||
|
#expect(HotkeyBinding.allowed.map(\.keyCode) == [63, 61, 54, 62, 59])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func idsAndKeyCodesAreUnique() {
|
||||||
|
#expect(Set(HotkeyBinding.allowed.map(\.id)).count == HotkeyBinding.allowed.count)
|
||||||
|
#expect(Set(HotkeyBinding.allowed.map(\.keyCode)).count == HotkeyBinding.allowed.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func lookupByKeyCodeAndId() {
|
||||||
|
#expect(HotkeyBinding.binding(keyCode: 61) == .rightOption)
|
||||||
|
#expect(HotkeyBinding.binding(id: "leftControl") == .leftControl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unsupportedKeysAreRejected() {
|
||||||
|
#expect(HotkeyBinding.binding(keyCode: 56) == nil) // left shift
|
||||||
|
#expect(HotkeyBinding.binding(keyCode: 57) == nil) // caps lock
|
||||||
|
#expect(HotkeyBinding.binding(id: "nope") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func everyBindingHasAFlagAndName() {
|
||||||
|
for binding in HotkeyBinding.allowed {
|
||||||
|
#expect(binding.flagRawValue != 0)
|
||||||
|
#expect(!binding.displayName.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct ModifierKeyTrackerTests {
|
||||||
|
@Test func emitsPressedThenReleasedOnlyOnTransitions() {
|
||||||
|
var tracker = ModifierKeyTracker()
|
||||||
|
#expect(tracker.process(isDown: false) == nil)
|
||||||
|
#expect(tracker.process(isDown: true) == .pressed)
|
||||||
|
#expect(tracker.process(isDown: true) == nil)
|
||||||
|
#expect(tracker.process(isDown: false) == .released)
|
||||||
|
#expect(tracker.process(isDown: false) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func freshTrackerStartsReleased() {
|
||||||
|
var tracker = ModifierKeyTracker()
|
||||||
|
#expect(tracker.process(isDown: true) == .pressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct PromptEchoTests {
|
||||||
|
let vocabulary = ["Anthropic", "Vercel", "Supabase"]
|
||||||
|
|
||||||
|
@Test func firstTwoTermsAreAnEcho() {
|
||||||
|
#expect(PromptEcho.isEcho("Anthropic, Vercel.", vocabulary: vocabulary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wholeListIsAnEcho() {
|
||||||
|
#expect(PromptEcho.isEcho("anthropic vercel supabase", vocabulary: vocabulary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleTermIsRealSpeech() {
|
||||||
|
#expect(!PromptEcho.isEcho("Anthropic", vocabulary: vocabulary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func termsOutOfOrderAreNotAnEcho() {
|
||||||
|
#expect(!PromptEcho.isEcho("Vercel, Anthropic", vocabulary: vocabulary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sentenceContainingTermsIsNotAnEcho() {
|
||||||
|
#expect(!PromptEcho.isEcho("Ik gebruik Anthropic en Vercel", vocabulary: vocabulary))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tooShortVocabularyNeverEchoes() {
|
||||||
|
#expect(!PromptEcho.isEcho("Anthropic", vocabulary: ["Anthropic"]))
|
||||||
|
#expect(!PromptEcho.isEcho("", vocabulary: vocabulary))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
private struct PlainTranscriber: Transcriber {
|
||||||
|
func transcribe(_ samples: [Float]) async throws -> String { "plain \(samples.count)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TranscriberVocabularyTests {
|
||||||
|
@Test func vocabularyVariantFallsBackToPlainTranscribe() async throws {
|
||||||
|
let result = try await PlainTranscriber().transcribe([0, 0, 0], vocabulary: ["Anthropic"])
|
||||||
|
#expect(result == "plain 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func appliedCorrectionRoundTripsThroughJSON() throws {
|
||||||
|
let original = AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 12, length: 11)
|
||||||
|
let data = try JSONEncoder().encode(original)
|
||||||
|
let decoded = try JSONDecoder().decode(AppliedCorrection.self, from: data)
|
||||||
|
#expect(decoded == original)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct AppSettingsTests {
|
||||||
|
func freshDefaults() -> UserDefaults {
|
||||||
|
let name = "sst-tests-\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: name)!
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultsMatchTheSpec() {
|
||||||
|
let settings = AppSettings(defaults: freshDefaults())
|
||||||
|
#expect(settings.hotkeyID == "fn")
|
||||||
|
#expect(settings.modelVariant == "large-v3-v20240930_turbo")
|
||||||
|
#expect(settings.keepHistory)
|
||||||
|
#expect(settings.usePriming)
|
||||||
|
#expect(settings.useCorrection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func changesArePersisted() {
|
||||||
|
let defaults = freshDefaults()
|
||||||
|
let settings = AppSettings(defaults: defaults)
|
||||||
|
settings.hotkeyID = "rightOption"
|
||||||
|
settings.modelVariant = "small"
|
||||||
|
settings.keepHistory = false
|
||||||
|
settings.usePriming = false
|
||||||
|
settings.useCorrection = false
|
||||||
|
|
||||||
|
let reloaded = AppSettings(defaults: defaults)
|
||||||
|
#expect(reloaded.hotkeyID == "rightOption")
|
||||||
|
#expect(reloaded.modelVariant == "small")
|
||||||
|
#expect(!reloaded.keepHistory)
|
||||||
|
#expect(!reloaded.usePriming)
|
||||||
|
#expect(!reloaded.useCorrection)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SstCore
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct CorrectionEngineTests {
|
||||||
|
let knowledge = FakeKnowledge()
|
||||||
|
|
||||||
|
func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine {
|
||||||
|
CorrectionEngine(entries: entries, knowledge: knowledge)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func replacesAPairAndReportsIt() {
|
||||||
|
let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag")
|
||||||
|
#expect(result.text == "ik gebruik Claude Code vandaag")
|
||||||
|
#expect(result.corrections.count == 1)
|
||||||
|
let c = result.corrections[0]
|
||||||
|
#expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code")
|
||||||
|
#expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func findsGluedHyphenatedAndSpacedVariants() {
|
||||||
|
let e = engine([pair("cloud code", "Claude Code")])
|
||||||
|
for variant in ["CloudCode", "Cloud-Code", "CLOUD CODE", "cloud\u{2011}code", "cloud code"] {
|
||||||
|
#expect(e.correct(variant).text == "Claude Code", "variant \(variant)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func neverTouchesRealWordsThatMerelyStartTheSame() {
|
||||||
|
let e = engine([pair("cloud code", "Claude Code")])
|
||||||
|
#expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig")
|
||||||
|
#expect(e.correct("cloud codex").text == "cloud codex")
|
||||||
|
#expect(e.correct("de cloud en de code").text == "de cloud en de code")
|
||||||
|
#expect(e.correct("the cloud").corrections.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func respectsPunctuationBoundaries() {
|
||||||
|
let e = engine([pair("cloud code", "Claude Code")])
|
||||||
|
#expect(e.correct("(cloud code)").text == "(Claude Code)")
|
||||||
|
#expect(e.correct("cloud code.").text == "Claude Code.")
|
||||||
|
#expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func longestMatchWins() {
|
||||||
|
let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")])
|
||||||
|
#expect(e.correct("cloud code review nu").text == "Claude Code Review nu")
|
||||||
|
#expect(e.correct("cloud-code-review").text == "Claude Code Review")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func replacedTextIsNeverRescanned() {
|
||||||
|
let e = engine([pair("a", "b"), pair("b", "c")])
|
||||||
|
#expect(e.correct("a b").text == "b c")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func termsFixCasingAndSpacing() {
|
||||||
|
let e = engine([term("Anthropic"), term("Claude Code")])
|
||||||
|
#expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic")
|
||||||
|
#expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func termAlreadyRightIsNotReportedAsACorrection() {
|
||||||
|
let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude")
|
||||||
|
#expect(result.text == "Anthropic bouwt Claude")
|
||||||
|
#expect(result.corrections.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleWordTermThatIsARealWordGetsNoCasingRule() {
|
||||||
|
let e = engine([term("Reef"), term("Slack")])
|
||||||
|
#expect(e.correct("het reef en slack").text == "het reef en slack")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func firstEntryWinsOnDuplicateSources() {
|
||||||
|
let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")])
|
||||||
|
#expect(e.correct("cloudcode").text == "Claude Code")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func replacementIsLiteral() {
|
||||||
|
let e = engine([pair("price", "US$1 \\0 $1")])
|
||||||
|
#expect(e.correct("the price").text == "the US$1 \\0 $1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handlesDiacritics() {
|
||||||
|
let e = engine([term("Café")])
|
||||||
|
#expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func offsetsAreRelativeToTheCorrectedText() {
|
||||||
|
let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a")
|
||||||
|
#expect(result.text == "xxx y xxx")
|
||||||
|
#expect(result.corrections.map(\.start) == [0, 4, 6])
|
||||||
|
#expect(result.corrections.map(\.length) == [3, 1, 3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyDictionaryAndEmptyTextAreNoOps() {
|
||||||
|
#expect(engine([]).correct("hallo").text == "hallo")
|
||||||
|
#expect(engine([pair("a", "b")]).correct("").text == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct DictionaryFileTests {
|
||||||
|
let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n"
|
||||||
|
|
||||||
|
@Test func parsesTermsPairsCommentsAndBlanks() {
|
||||||
|
let file = DictionaryFile(parsing: sample)
|
||||||
|
#expect(file.lines.count == 4)
|
||||||
|
#expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")])
|
||||||
|
#expect(file.lines[0] == .comment("# Opmerking"))
|
||||||
|
#expect(file.lines[2] == .blank)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundTripKeepsCommentsBlankLinesAndOrder() {
|
||||||
|
#expect(DictionaryFile(parsing: sample).serialized() == sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalisesSpacingAroundTheArrow() {
|
||||||
|
#expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n")
|
||||||
|
#expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unreadableLinesAreKeptAsInvalid() {
|
||||||
|
let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n")
|
||||||
|
#expect(file.lines[0] == .invalid("oops =>"))
|
||||||
|
#expect(file.lines[1] == .invalid("=> nothing"))
|
||||||
|
#expect(file.serialized() == "oops =>\n=> nothing\nok\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handlesWindowsLineEndingsAndMissingFinalNewline() {
|
||||||
|
let file = DictionaryFile(parsing: "a\r\nb => c")
|
||||||
|
#expect(file.entries == [term("a"), pair("b", "c")])
|
||||||
|
#expect(file.serialized() == "a\nb => c\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyTextIsAValidEmptyFile() {
|
||||||
|
#expect(DictionaryFile(parsing: "").entries.isEmpty)
|
||||||
|
#expect(DictionaryFile(parsing: "").serialized() == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func appendUpdateAndRemove() {
|
||||||
|
var file = DictionaryFile(parsing: "# c\n")
|
||||||
|
let id = file.append(.term("Vercel"))
|
||||||
|
#expect(file.entries == [term("Vercel")])
|
||||||
|
file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel"))
|
||||||
|
#expect(file.entries == [pair("versel", "Vercel")])
|
||||||
|
file.remove(id: id)
|
||||||
|
#expect(file.entries.isEmpty)
|
||||||
|
#expect(file.serialized() == "# c\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservingIDsKeepsIdsOfUnchangedLines() {
|
||||||
|
let old = DictionaryFile(parsing: "a\nb => c\nd\n")
|
||||||
|
let new = DictionaryFile(parsing: "z\na\nb => c\nd2\n").preservingIDs(from: old)
|
||||||
|
let oldIDs = old.entries.map(\.id), newIDs = new.entries.map(\.id)
|
||||||
|
#expect(newIDs[1] == oldIDs[0])
|
||||||
|
#expect(newIDs[2] == oldIDs[1])
|
||||||
|
#expect(!oldIDs.contains(newIDs[0]))
|
||||||
|
#expect(!oldIDs.contains(newIDs[3]))
|
||||||
|
#expect(new.serialized() == "z\na\nb => c\nd2\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservingIDsMapsDuplicatesOneToOneInOrder() {
|
||||||
|
let old = DictionaryFile(parsing: "a\na\n")
|
||||||
|
let new = DictionaryFile(parsing: "a\na\na\n").preservingIDs(from: old)
|
||||||
|
let oldIDs = old.entries.map(\.id), newIDs = new.entries.map(\.id)
|
||||||
|
#expect(Array(newIDs[0...1]) == oldIDs)
|
||||||
|
#expect(!oldIDs.contains(newIDs[2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SstCore
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DictionaryStoreTests {
|
||||||
|
func tempFile() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loadCreatesAFileWithAHeaderWhenMissing() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = DictionaryStore(fileURL: url)
|
||||||
|
try store.load()
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
#expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func applyWritesAtomicallyAndKeepsComments() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = DictionaryStore(fileURL: url)
|
||||||
|
try store.load()
|
||||||
|
try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) }
|
||||||
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(text.hasPrefix("# Sst woordenboek"))
|
||||||
|
#expect(text.hasSuffix("cloud code => Claude Code\n"))
|
||||||
|
#expect(store.entries == [pair("cloud code", "Claude Code")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func externalEditIsPickedUp() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = DictionaryStore(fileURL: url)
|
||||||
|
try store.load()
|
||||||
|
var changes = 0
|
||||||
|
store.onChange = { changes += 1 }
|
||||||
|
try "Vercel\n".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
store.reloadIfChanged()
|
||||||
|
#expect(store.entries == [term("Vercel")])
|
||||||
|
#expect(changes == 1)
|
||||||
|
store.reloadIfChanged() // unchanged text: no second notification
|
||||||
|
#expect(changes == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func externalChangeFiresBothCallbacksAndKeepsIDs() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = DictionaryStore(fileURL: url)
|
||||||
|
try store.load()
|
||||||
|
try store.apply { $0.append(.term("Vercel")) }
|
||||||
|
let id = store.entries[0].id
|
||||||
|
var changes = 0, external = 0
|
||||||
|
store.onChange = { changes += 1 }
|
||||||
|
store.onExternalChange = { external += 1 }
|
||||||
|
try "Nieuw\nVercel\n".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
store.reloadIfChanged()
|
||||||
|
#expect(changes == 1 && external == 1)
|
||||||
|
#expect(store.entries[1].id == id)
|
||||||
|
try store.apply { $0.append(.term("Eigen")) }
|
||||||
|
#expect(changes == 2 && external == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ourOwnWriteDoesNotTriggerAReload() throws {
|
||||||
|
let store = DictionaryStore(fileURL: tempFile())
|
||||||
|
try store.load()
|
||||||
|
try store.apply { $0.append(.term("Supabase")) }
|
||||||
|
var changes = 0
|
||||||
|
store.onChange = { changes += 1 }
|
||||||
|
store.reloadIfChanged()
|
||||||
|
#expect(changes == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func watcherNoticesAnEditorSavingTheFile() async throws {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = DictionaryStore(fileURL: url)
|
||||||
|
try store.load()
|
||||||
|
var changed = false
|
||||||
|
store.onChange = { changed = true }
|
||||||
|
store.startWatching()
|
||||||
|
try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) }
|
||||||
|
store.stopWatching()
|
||||||
|
#expect(changed)
|
||||||
|
#expect(store.entries == [term("Anthropic")])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DictionaryCorrectorTests {
|
||||||
|
@Test func correctsWhenEnabledAndPassesThroughWhenDisabled() {
|
||||||
|
let corrector = DictionaryCorrector(knowledge: FakeKnowledge())
|
||||||
|
corrector.update(entries: [pair("cloud code", "Claude Code")])
|
||||||
|
#expect(corrector.correct("cloud code").text == "Claude Code")
|
||||||
|
corrector.setEnabled(false)
|
||||||
|
#expect(corrector.correct("cloud code").text == "cloud code")
|
||||||
|
corrector.setEnabled(true)
|
||||||
|
corrector.update(entries: [])
|
||||||
|
#expect(corrector.correct("cloud code").text == "cloud code")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct EntryWarningsTests {
|
||||||
|
let knowledge = FakeKnowledge()
|
||||||
|
|
||||||
|
func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] {
|
||||||
|
EntryWarnings.check(kind, among: others, knowledge: knowledge)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleCommonWordIsAWarning() {
|
||||||
|
let w = check(.correction(heard: "cloud", wrote: "Claude"))
|
||||||
|
#expect(w.contains { $0.level == .warning && $0.message.contains("cloud") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func veryShortSourceIsAWarning() {
|
||||||
|
#expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func functionWordInsideAPhraseIsAWarning() {
|
||||||
|
#expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func gluedFormThatIsARealWordIsAWarning() {
|
||||||
|
let w = check(.correction(heard: "some thing", wrote: "Something"))
|
||||||
|
#expect(w.contains { $0.level == .warning && $0.message.contains("something") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func phraseOfCommonWordsOnlyGetsANote() {
|
||||||
|
let w = check(.correction(heard: "cloud code", wrote: "Claude Code"))
|
||||||
|
#expect(w.count == 1)
|
||||||
|
#expect(w[0].level == .note)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func distinctiveEntriesGetNoWarnings() {
|
||||||
|
#expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty)
|
||||||
|
#expect(check(.term("Supabase")).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() {
|
||||||
|
let others = [pair("cloud code", "Claude Code")]
|
||||||
|
let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others)
|
||||||
|
#expect(w.first?.level == .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func termThatIsARealWordGetsANoteAboutBiasOnly() {
|
||||||
|
let w = check(.term("Reef"))
|
||||||
|
#expect(w.count == 1)
|
||||||
|
#expect(w[0].level == .note)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptySourceIsAnError() {
|
||||||
|
#expect(check(.term(" ")).first?.level == .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func warningsAreSortedMostSevereFirst() {
|
||||||
|
let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")])
|
||||||
|
#expect(w.map(\.level) == w.map(\.level).sorted(by: >))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
/// Deterministic stand-in for the macOS spell checker.
|
||||||
|
struct FakeKnowledge: WordKnowledge {
|
||||||
|
var words: Set<String> = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"]
|
||||||
|
var functionWords: Set<String> = ["in", "de", "the", "of"]
|
||||||
|
func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) }
|
||||||
|
func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) }
|
||||||
|
func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) }
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SstCore
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct HistoryStoreTests {
|
||||||
|
func tempFile() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry {
|
||||||
|
HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func newestEntryComesFirstAndSurvivesARoundTrip() {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
store.add(entry("eerste", at: 0))
|
||||||
|
store.add(HistoryEntry(
|
||||||
|
date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code",
|
||||||
|
corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)]))
|
||||||
|
store.flush()
|
||||||
|
|
||||||
|
let reloaded = HistoryStore(fileURL: url)
|
||||||
|
#expect(reloaded.load() == .loaded)
|
||||||
|
#expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"])
|
||||||
|
#expect(reloaded.entries == store.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileIsEmpty() {
|
||||||
|
let store = HistoryStore(fileURL: tempFile())
|
||||||
|
#expect(store.load() == .empty)
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removeAndRemoveAll() {
|
||||||
|
let store = HistoryStore(fileURL: tempFile())
|
||||||
|
let a = entry("a", at: 0), b = entry("b", at: 1)
|
||||||
|
store.add(a); store.add(b)
|
||||||
|
store.remove(id: a.id)
|
||||||
|
#expect(store.entries.map(\.finalText) == ["b"])
|
||||||
|
store.removeAll()
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try "dit is geen json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
guard case .recoveredCorrupt(let backup) = store.load() else {
|
||||||
|
Issue.record("expected recoveredCorrupt"); return
|
||||||
|
}
|
||||||
|
#expect(FileManager.default.fileExists(atPath: backup.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loadNotifiesOnChange() {
|
||||||
|
let url = tempFile()
|
||||||
|
let writer = HistoryStore(fileURL: url)
|
||||||
|
writer.add(entry("a", at: 0)); writer.add(entry("b", at: 1))
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
var calls = 0
|
||||||
|
store.onChange = { calls += 1 }
|
||||||
|
#expect(store.load() == .loaded)
|
||||||
|
#expect(calls == 1)
|
||||||
|
#expect(store.entries.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unreadableFileIsReportedAndNeverOverwritten() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) // history.json is a directory
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
guard case .unreadable = store.load() else {
|
||||||
|
Issue.record("expected unreadable"); return
|
||||||
|
}
|
||||||
|
store.add(entry("a", at: 0))
|
||||||
|
store.flush()
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory))
|
||||||
|
#expect(isDirectory.boolValue)
|
||||||
|
#expect(store.lastError != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func onChangeFiresForEdits() {
|
||||||
|
let store = HistoryStore(fileURL: tempFile())
|
||||||
|
var calls = 0
|
||||||
|
store.onChange = { calls += 1 }
|
||||||
|
store.add(entry("a", at: 0))
|
||||||
|
store.removeAll()
|
||||||
|
#expect(calls == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func entryFromDictationResultKeepsEverything() {
|
||||||
|
let result = DictationResult(
|
||||||
|
date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final",
|
||||||
|
corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)])
|
||||||
|
let e = HistoryEntry(result: result)
|
||||||
|
#expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistorySearchTests {
|
||||||
|
let entries = [
|
||||||
|
HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"),
|
||||||
|
HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@Test func emptyQueryReturnsEverything() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: " ").count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ignoresCaseAndDiacritics() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: "CAFE").count == 1)
|
||||||
|
#expect(HistorySearch.filter(entries, query: "café").count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func allWordsMustMatchInAnyOrder() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: "handig claude").count == 1)
|
||||||
|
#expect(HistorySearch.filter(entries, query: "handig café").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func searchesTheOriginalTextToo() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: "cloud").count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryGroupingTests {
|
||||||
|
@Test func groupsByDayKeepingOrder() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
func at(_ day: Int, _ hour: Int) -> Date {
|
||||||
|
calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))!
|
||||||
|
}
|
||||||
|
let entries = [at(2, 15), at(2, 9), at(1, 20)].map {
|
||||||
|
HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x")
|
||||||
|
}
|
||||||
|
let days = HistoryGrouping.byDay(entries, calendar: calendar)
|
||||||
|
#expect(days.count == 2)
|
||||||
|
#expect(days[0].entries.count == 2)
|
||||||
|
#expect(days[1].entries.count == 1)
|
||||||
|
#expect(days[0].day > days[1].day)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct NoteStoreTests {
|
||||||
|
private func temp() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appending(path: "note-\(UUID().uuidString)/note.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileLoadsEmpty() {
|
||||||
|
let store = NoteStore(fileURL: temp())
|
||||||
|
store.load()
|
||||||
|
#expect(store.text == "")
|
||||||
|
#expect(!store.isReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func flushWritesAndAnotherStoreReadsIt() {
|
||||||
|
let url = temp()
|
||||||
|
let store = NoteStore(fileURL: url)
|
||||||
|
store.text = "eerste regel\nté"
|
||||||
|
store.flush()
|
||||||
|
let other = NoteStore(fileURL: url)
|
||||||
|
other.load()
|
||||||
|
#expect(other.text == "eerste regel\nté")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func editsAreSavedAfterTheDebounce() async throws {
|
||||||
|
let url = temp()
|
||||||
|
let store = NoteStore(fileURL: url)
|
||||||
|
store.text = "bewaard"
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
try await Task.sleep(for: .milliseconds(900))
|
||||||
|
#expect(try String(contentsOf: url, encoding: .utf8) == "bewaard")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loadingDoesNotTriggerASave() async throws {
|
||||||
|
let url = temp()
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try "oud".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let store = NoteStore(fileURL: url)
|
||||||
|
store.load()
|
||||||
|
#expect(store.text == "oud")
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
try await Task.sleep(for: .milliseconds(900))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unreadableFileIsNeverOverwritten() throws {
|
||||||
|
let url = temp()
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data([0xFF, 0xFE, 0xFD]).write(to: url)
|
||||||
|
let store = NoteStore(fileURL: url)
|
||||||
|
store.load()
|
||||||
|
#expect(store.isReadOnly)
|
||||||
|
store.text = "nieuw"
|
||||||
|
store.flush()
|
||||||
|
#expect(try Data(contentsOf: url) == Data([0xFF, 0xFE, 0xFD]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DictationSpacingTests {
|
||||||
|
@Test func noSpaceAtTheStartOrAfterWhitespace() {
|
||||||
|
#expect(DictationSpacing.prepared("hallo", characterBefore: nil) == "hallo")
|
||||||
|
#expect(DictationSpacing.prepared("hallo", characterBefore: " ") == "hallo")
|
||||||
|
#expect(DictationSpacing.prepared("hallo", characterBefore: "\n") == "hallo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func spaceAfterAWordOrSentenceEnd() {
|
||||||
|
#expect(DictationSpacing.prepared("hallo", characterBefore: "k") == " hallo")
|
||||||
|
#expect(DictationSpacing.prepared("Nog een.", characterBefore: ".") == " Nog een.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func noSpaceBeforeClosingPunctuationOrLeadingWhitespace() {
|
||||||
|
#expect(DictationSpacing.prepared(", dus", characterBefore: "k") == ", dus")
|
||||||
|
#expect(DictationSpacing.prepared(" dus", characterBefore: "k") == " dus")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyTextStaysEmpty() {
|
||||||
|
#expect(DictationSpacing.prepared("", characterBefore: "k") == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct SystemWordKnowledgeTests {
|
||||||
|
let knowledge = SystemWordKnowledge()
|
||||||
|
|
||||||
|
@Test func recognisesEnglishAndDutchWords() {
|
||||||
|
#expect(knowledge.isWord("cloud"))
|
||||||
|
#expect(knowledge.isWord("Cloud"))
|
||||||
|
#expect(knowledge.isWord("vandaag"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func doesNotTreatProductNamesAsWords() {
|
||||||
|
#expect(!knowledge.isWord("vercel"))
|
||||||
|
#expect(!knowledge.isWord("supabase"))
|
||||||
|
#expect(!knowledge.isWord("anthropic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func functionWordsComeFromTheBuiltInList() {
|
||||||
|
#expect(knowledge.isFunctionWord("de"))
|
||||||
|
#expect(knowledge.isFunctionWord("The"))
|
||||||
|
#expect(!knowledge.isFunctionWord("vercel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SstCore
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct StopwatchFormatTests {
|
||||||
|
@Test func formatsMinutesSecondsAndTenths() {
|
||||||
|
#expect(StopwatchFormat.string(0) == "00:00.0")
|
||||||
|
#expect(StopwatchFormat.string(7.46) == "00:07.4")
|
||||||
|
#expect(StopwatchFormat.string(65.2) == "01:05.2")
|
||||||
|
#expect(StopwatchFormat.string(-3) == "00:00.0")
|
||||||
|
#expect(StopwatchFormat.string(6039.9) == "00:39.9") // minutes wrap at 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CorrectionMarkupTests {
|
||||||
|
func correction(_ start: Int, _ length: Int, heard: String = "x", wrote: String = "y") -> AppliedCorrection {
|
||||||
|
AppliedCorrection(heard: heard, wrote: wrote, rule: heard, start: start, length: length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func noCorrectionsGivesOnePlainSegment() {
|
||||||
|
#expect(CorrectionMarkup.segments(finalText: "hallo wereld", corrections: []) == [TextSegment(text: "hallo wereld")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyTextGivesNoSegments() {
|
||||||
|
#expect(CorrectionMarkup.segments(finalText: "", corrections: [correction(0, 1)]).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func splitsAroundACorrectionInTheMiddle() {
|
||||||
|
let c = correction(11, 11, heard: "cloud code", wrote: "Claude Code")
|
||||||
|
let segments = CorrectionMarkup.segments(finalText: "ik gebruik Claude Code vandaag", corrections: [c])
|
||||||
|
#expect(segments == [
|
||||||
|
TextSegment(text: "ik gebruik "),
|
||||||
|
TextSegment(text: "Claude Code", correction: c),
|
||||||
|
TextSegment(text: " vandaag"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handlesCorrectionsAtTheStartAndEnd() {
|
||||||
|
let first = correction(0, 3), last = correction(8, 3)
|
||||||
|
let segments = CorrectionMarkup.segments(finalText: "abc def ghi", corrections: [last, first])
|
||||||
|
#expect(segments.map(\.text) == ["abc", " def ", "ghi"])
|
||||||
|
#expect(segments[0].correction == first && segments[2].correction == last && segments[1].correction == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func skipsOutOfRangeAndOverlappingCorrections() {
|
||||||
|
let ok = correction(0, 3), overlapping = correction(2, 3), outside = correction(50, 4), empty = correction(4, 0)
|
||||||
|
let segments = CorrectionMarkup.segments(finalText: "abc def", corrections: [ok, overlapping, outside, empty])
|
||||||
|
#expect(segments.map(\.text) == ["abc", " def"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func offsetsAreUTF16SoEmojiDoNotShiftThem() {
|
||||||
|
// "😀 " is 3 UTF-16 units; the corrected word starts at 3.
|
||||||
|
let c = correction(3, 6, heard: "wereld", wrote: "wereld")
|
||||||
|
let segments = CorrectionMarkup.segments(finalText: "😀 wereld", corrections: [c])
|
||||||
|
#expect(segments.map(\.text) == ["😀 ", "wereld"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct HistoryViewModelTests {
|
||||||
|
func store(_ texts: [String]) -> HistoryStore {
|
||||||
|
let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json"))
|
||||||
|
for (index, text) in texts.enumerated() {
|
||||||
|
store.add(HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), duration: 1, rawText: text, finalText: text))
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func daysAreFilledAfterTheStoreLoads() {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json")
|
||||||
|
let writer = HistoryStore(fileURL: url)
|
||||||
|
writer.add(HistoryEntry(date: .now, duration: 1, rawText: "x", finalText: "x"))
|
||||||
|
writer.flush()
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
let model = HistoryViewModel(store: store)
|
||||||
|
store.onChange = { model.refresh() }
|
||||||
|
#expect(model.days.isEmpty)
|
||||||
|
store.load()
|
||||||
|
#expect(!model.days.isEmpty)
|
||||||
|
#expect(model.totalCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func listsAllEntriesNewestFirstAndFiltersByQuery() {
|
||||||
|
let model = HistoryViewModel(store: store(["eerste", "tweede zin", "derde"]))
|
||||||
|
#expect(model.totalCount == 3)
|
||||||
|
#expect(model.days.flatMap(\.entries).map(\.finalText) == ["derde", "tweede zin", "eerste"])
|
||||||
|
model.query = "tweede"
|
||||||
|
#expect(model.days.flatMap(\.entries).map(\.finalText) == ["tweede zin"])
|
||||||
|
#expect(model.totalCount == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func selectionIsClearedWhenItDisappearsFromTheFilter() {
|
||||||
|
let history = store(["alpha", "beta"])
|
||||||
|
let model = HistoryViewModel(store: history)
|
||||||
|
model.selection = history.entries.first { $0.finalText == "alpha" }?.id
|
||||||
|
#expect(model.selectedEntry?.finalText == "alpha")
|
||||||
|
model.query = "beta"
|
||||||
|
#expect(model.selection == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deleteRemovesFromStoreAndList() {
|
||||||
|
let history = store(["a", "b"])
|
||||||
|
let model = HistoryViewModel(store: history)
|
||||||
|
model.delete(id: history.entries[0].id)
|
||||||
|
#expect(history.entries.count == 1)
|
||||||
|
#expect(model.totalCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func refreshPicksUpNewEntries() {
|
||||||
|
let history = store(["a"])
|
||||||
|
let model = HistoryViewModel(store: history)
|
||||||
|
history.add(HistoryEntry(date: .now, duration: 1, rawText: "nieuw", finalText: "nieuw"))
|
||||||
|
model.refresh()
|
||||||
|
#expect(model.totalCount == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct DictionaryViewModelTests {
|
||||||
|
func makeModel(_ text: String = "") throws -> (DictionaryViewModel, DictionaryStore) {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appending(path: "sst-dict-\(UUID().uuidString)/dictionary.txt")
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let store = DictionaryStore(fileURL: url)
|
||||||
|
try store.load()
|
||||||
|
return (DictionaryViewModel(store: store, knowledge: FakeKnowledge()), store)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rowsCarryWarnings() throws {
|
||||||
|
let (model, _) = try makeModel("Supabase\ncloud => Claude\n")
|
||||||
|
#expect(model.rows.count == 2)
|
||||||
|
#expect(model.rows[0].warnings.isEmpty)
|
||||||
|
#expect(model.rows[1].worst == .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func duplicateIsFlaggedOnTheLaterEntryOnly() throws {
|
||||||
|
let (model, _) = try makeModel("cloud code => Claude Code\nCloud-Code => Other\n")
|
||||||
|
#expect(model.rows[0].worst != .error)
|
||||||
|
#expect(model.rows[1].worst == .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func addUpdateAndRemoveGoThroughTheStore() throws {
|
||||||
|
let (model, store) = try makeModel()
|
||||||
|
let id = model.add(.term("Vercel"))!
|
||||||
|
#expect(store.entries == [term("Vercel")])
|
||||||
|
model.update(id: id, to: .correction(heard: "versel", wrote: "Vercel"))
|
||||||
|
#expect(store.entries == [pair("versel", "Vercel")])
|
||||||
|
model.remove(id: id)
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
#expect(model.rows.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func undoRestoresTheLastChangeStepByStep() throws {
|
||||||
|
let (model, store) = try makeModel("Supabase\n")
|
||||||
|
#expect(!model.canUndo)
|
||||||
|
let id = model.add(.term("Vercel"))!
|
||||||
|
model.remove(id: id)
|
||||||
|
#expect(store.entries == [term("Supabase")])
|
||||||
|
model.undo()
|
||||||
|
#expect(store.entries == [term("Supabase"), term("Vercel")])
|
||||||
|
model.undo()
|
||||||
|
#expect(store.entries == [term("Supabase")])
|
||||||
|
#expect(!model.canUndo)
|
||||||
|
model.undo() // nothing left: harmless
|
||||||
|
#expect(store.entries == [term("Supabase")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func externalChangeClearsUndo() throws {
|
||||||
|
let (model, store) = try makeModel("Supabase\n")
|
||||||
|
model.add(.term("Vercel"))
|
||||||
|
#expect(model.canUndo)
|
||||||
|
model.externalChangeDetected()
|
||||||
|
#expect(!model.canUndo)
|
||||||
|
model.undo()
|
||||||
|
#expect(store.entries == [term("Supabase"), term("Vercel")])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func failedUndoKeepsTheSnapshot() throws {
|
||||||
|
let (model, store) = try makeModel("Supabase\n")
|
||||||
|
model.add(.term("Vercel"))
|
||||||
|
let folder = store.fileURL.deletingLastPathComponent()
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path)
|
||||||
|
model.undo()
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: folder.path)
|
||||||
|
#expect(model.errorMessage != nil)
|
||||||
|
#expect(model.canUndo)
|
||||||
|
model.errorMessage = nil
|
||||||
|
model.undo()
|
||||||
|
#expect(store.entries == [term("Supabase")])
|
||||||
|
#expect(!model.canUndo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func searchMatchesBothSidesIgnoringCaseAndDiacritics() throws {
|
||||||
|
let (model, _) = try makeModel("cloud code => Claude Code\nCafé\nVercel\n")
|
||||||
|
model.query = "CLAUDE"
|
||||||
|
#expect(model.visibleRows.count == 1)
|
||||||
|
model.query = "cafe"
|
||||||
|
#expect(model.visibleRows.count == 1)
|
||||||
|
model.query = ""
|
||||||
|
#expect(model.visibleRows.count == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func editorWarningsIgnoreTheEntryBeingEdited() throws {
|
||||||
|
let (model, store) = try makeModel("cloud code => Claude Code\n")
|
||||||
|
let id = store.entries[0].id
|
||||||
|
let editingItself = model.warnings(for: .correction(heard: "cloud code", wrote: "Claude Code"), editing: id)
|
||||||
|
#expect(!editingItself.contains { $0.level == .error })
|
||||||
|
let asNew = model.warnings(for: .correction(heard: "cloud code", wrote: "Other"), editing: nil)
|
||||||
|
#expect(asNew.contains { $0.level == .error })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removingAnUnknownIdChangesNothing() throws {
|
||||||
|
let (model, store) = try makeModel("Supabase\n")
|
||||||
|
let before = store.entries
|
||||||
|
model.remove(id: UUID())
|
||||||
|
#expect(store.entries == before)
|
||||||
|
#expect(model.canUndo == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func editingTheEarlierOfTwoDuplicatesShowsNoDuplicateError() throws {
|
||||||
|
let (model, store) = try makeModel("cloud code => A\nCloud-Code => B\n")
|
||||||
|
let first = store.entries[0]
|
||||||
|
let second = store.entries[1]
|
||||||
|
#expect(!model.warnings(for: first.kind, editing: first.id).contains { $0.level == .error })
|
||||||
|
#expect(model.warnings(for: second.kind, editing: second.id).contains { $0.level == .error })
|
||||||
|
#expect(model.warnings(for: first.kind, editing: nil).contains { $0.level == .error })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
struct VocabularyBuilderTests {
|
||||||
|
@Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() {
|
||||||
|
let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")]
|
||||||
|
#expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func listIsCapped() {
|
||||||
|
let entries = (1...40).map { term("Woord\($0)") }
|
||||||
|
#expect(VocabularyBuilder.build(from: entries).count == 24)
|
||||||
|
#expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyDictionaryGivesNoVocabulary() {
|
||||||
|
#expect(VocabularyBuilder.build(from: []).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Testing
|
||||||
|
@testable import SstUI
|
||||||
|
|
||||||
|
struct VUScaleTests {
|
||||||
|
@Test func zeroVUIsMinusTwentyDbfs() {
|
||||||
|
#expect(VUScale.vu(fromDbfs: -20) == 0)
|
||||||
|
#expect(VUScale.vu(fromDbfs: -40) == -20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fractionSpansTheScale() {
|
||||||
|
#expect(VUScale.fraction(forVU: -20) == 0)
|
||||||
|
#expect(abs(VUScale.fraction(forVU: 3) - 1) < 1e-9)
|
||||||
|
#expect(abs(VUScale.fraction(forVU: 0) - 20.0 / 23.0) < 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fractionIsClampedAtBothEnds() {
|
||||||
|
#expect(VUScale.fraction(forVU: -60) == 0)
|
||||||
|
#expect(VUScale.fraction(forVU: 30) == 1.03)
|
||||||
|
#expect(VUScale.fraction(forDbfs: -120) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func angleIsSymmetricAroundVertical() {
|
||||||
|
#expect(abs(VUScale.angle(forFraction: 0) + VUScale.angle(forFraction: 1)) < 1e-9)
|
||||||
|
#expect(abs(VUScale.angle(forFraction: 0.5)) < 1e-9)
|
||||||
|
#expect(abs(VUScale.angle(forFraction: 1) - 45 * .pi / 180) < 1e-9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NeedleSpringTests {
|
||||||
|
func simulate(_ spring: NeedleSpring, seconds: Double, target: Double = 1) -> (final: Double, peak: Double) {
|
||||||
|
var spring = spring
|
||||||
|
var peak = 0.0
|
||||||
|
let dt = 1.0 / 240
|
||||||
|
for _ in 0..<Int(seconds / dt) { peak = max(peak, spring.step(target: target, dt: dt)) }
|
||||||
|
return (spring.position, peak)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func reachesTheTargetWithinTheResponseTime() {
|
||||||
|
let result = simulate(NeedleSpring(), seconds: Motion.needleResponse)
|
||||||
|
#expect(abs(result.final - 1) < 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func overshootsSlightlyWithTheTokenDamping() {
|
||||||
|
let result = simulate(NeedleSpring(), seconds: 1)
|
||||||
|
#expect(result.peak > 1.003)
|
||||||
|
#expect(result.peak < 1.03)
|
||||||
|
#expect(abs(result.final - 1) < 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func criticalDampingNeverOvershoots() {
|
||||||
|
let result = simulate(NeedleSpring(damping: 1), seconds: 1)
|
||||||
|
#expect(result.peak <= 1.000001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func largeFrameStepsStayStable() {
|
||||||
|
var spring = NeedleSpring()
|
||||||
|
for _ in 0..<40 { spring.step(target: 1, dt: 0.05) }
|
||||||
|
#expect(abs(spring.position - 1) < 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func restsAtTheStartWithoutATarget() {
|
||||||
|
var spring = NeedleSpring()
|
||||||
|
for _ in 0..<60 { spring.step(target: 0, dt: 1.0 / 60) }
|
||||||
|
#expect(spring.position == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SegmentGlyphTests {
|
||||||
|
@Test func everyDigitHasASegmentPattern() {
|
||||||
|
for digit in "0123456789" { #expect(!SegmentGlyph.lit(for: digit).isEmpty) }
|
||||||
|
#expect(SegmentGlyph.lit(for: "8").count == 7)
|
||||||
|
#expect(SegmentGlyph.lit(for: "1") == [.b, .c])
|
||||||
|
#expect(SegmentGlyph.lit(for: ":").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func polygonsStayInsideTheDigitBoxAndDiffer() {
|
||||||
|
var seen: [[CGPoint]] = []
|
||||||
|
for id in SegmentID.allCases {
|
||||||
|
let polygon = SegmentGlyph.polygon(id)
|
||||||
|
#expect(polygon.count == 6)
|
||||||
|
for point in polygon {
|
||||||
|
#expect(point.x >= 0 && point.x <= Segment.digitWidth)
|
||||||
|
#expect(point.y >= 0 && point.y <= Segment.digitHeight)
|
||||||
|
}
|
||||||
|
#expect(!seen.contains(polygon))
|
||||||
|
seen.append(polygon)
|
||||||
|
}
|
||||||
|
#expect(seen.count == 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LEDBarTests {
|
||||||
|
@Test func tonesFollowTheEightThreeOneSplit() {
|
||||||
|
let tones = (0..<12).map { LEDBar.tone(forSegment: $0) }
|
||||||
|
#expect(tones.prefix(8).allSatisfy { $0 == .green })
|
||||||
|
#expect(tones[8...10].allSatisfy { $0 == .amber })
|
||||||
|
#expect(tones[11] == .red)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func litCountScalesAndClamps() {
|
||||||
|
#expect(LEDBar.litCount(forFraction: 0) == 0)
|
||||||
|
#expect(LEDBar.litCount(forFraction: 0.5) == 6)
|
||||||
|
#expect(LEDBar.litCount(forFraction: 1) == 12)
|
||||||
|
#expect(LEDBar.litCount(forFraction: 1.03) == 12)
|
||||||
|
#expect(LEDBar.litCount(forFraction: -1) == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpokenLevelTests {
|
||||||
|
@Test func nonFiniteInputReturnsStil() {
|
||||||
|
#expect(VUScale.spokenLevel(fromDbfs: -.infinity) == "stil")
|
||||||
|
#expect(VUScale.spokenLevel(fromDbfs: .nan) == "stil")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func referenceLevel() {
|
||||||
|
#expect(VUScale.spokenLevel(fromDbfs: -20) == "0 VU")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func negativeLevel() {
|
||||||
|
#expect(VUScale.spokenLevel(fromDbfs: -30) == "-9 VU")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func clampedToMinimum() {
|
||||||
|
#expect(VUScale.spokenLevel(fromDbfs: -120) == "-20 VU")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func clampedToMaximum() {
|
||||||
|
#expect(VUScale.spokenLevel(fromDbfs: 10) == "3 VU")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
/// Root of the package: Tests/SstUITests/<this file> → three levels up.
|
||||||
|
private let root = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||||
|
|
||||||
|
struct TokenIntegrityTests {
|
||||||
|
@Test func generatedSwiftMatchesTokensJSONAndContrastHolds() throws {
|
||||||
|
#expect(
|
||||||
|
FileManager.default.fileExists(atPath: root.appending(path: "Sources/SstUI/DesignTokens.swift").path),
|
||||||
|
"Sources/SstUI/DesignTokens.swift ontbreekt: run `python3 Design/build_tokens.py`.")
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||||
|
process.arguments = ["python3", "Design/build_tokens.py", "--check"]
|
||||||
|
process.currentDirectoryURL = root
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||||
|
#expect(process.terminationStatus == 0, "Run `python3 Design/build_tokens.py`.\n\(output.suffix(600))")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Views take every colour, size and time from the tokens. A line may opt out with `// token-exempt: reason`.
|
||||||
|
@Test func viewsContainNoLooseDesignValues() throws {
|
||||||
|
let forbidden: [(name: String, pattern: String)] = [
|
||||||
|
("hex colour", #"0x[0-9A-Fa-f]{6}\b"#),
|
||||||
|
("component colour", #"(NS)?Color\((red|white|hue):"#),
|
||||||
|
("literal font size", #"\.system\(size:\s*\d"#),
|
||||||
|
("literal corner radius", #"cornerRadius:\s*\d|\.cornerRadius\(\s*\d"#),
|
||||||
|
("literal padding", #"\.padding\(\s*(\.\w+\s*,\s*)?\d"#),
|
||||||
|
("literal frame size", #"(width|height|minWidth|minHeight|maxWidth|maxHeight|idealWidth|idealHeight):\s*[1-9]"#),
|
||||||
|
("literal line width", #"lineWidth:\s*\d"#),
|
||||||
|
("literal opacity", #"\.opacity\(\s*[0-9]"#),
|
||||||
|
]
|
||||||
|
let skipped: Set<String> = ["DesignTokens.swift", "DynamicColor.swift"]
|
||||||
|
var directories = [root.appending(path: "Sources/SstUI")]
|
||||||
|
let app = root.appending(path: "Sources/Sst")
|
||||||
|
if FileManager.default.fileExists(atPath: app.path) { directories.append(app) }
|
||||||
|
|
||||||
|
var problems: [String] = []
|
||||||
|
for directory in directories {
|
||||||
|
let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
|
||||||
|
.filter { $0.pathExtension == "swift" && !skipped.contains($0.lastPathComponent) }
|
||||||
|
for file in files {
|
||||||
|
let lines = try String(contentsOf: file, encoding: .utf8).components(separatedBy: "\n")
|
||||||
|
for (index, line) in lines.enumerated() where !line.contains("token-exempt") {
|
||||||
|
for rule in forbidden where line.range(of: rule.pattern, options: .regularExpression) != nil {
|
||||||
|
problems.append("\(file.lastPathComponent):\(index + 1) \(rule.name): \(line.trimmingCharacters(in: .whitespaces))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(problems.isEmpty, "Loose design values:\n\(problems.joined(separator: "\n"))")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import SstWhisper
|
||||||
|
|
||||||
|
struct ModelStorageTests {
|
||||||
|
func makeStorage() -> ModelStorage {
|
||||||
|
ModelStorage(base: FileManager.default.temporaryDirectory.appending(path: "sst-models-\(UUID().uuidString)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates `<repo>/<folder>/<model>.mlmodelc/coremldata.bin` for the given models.
|
||||||
|
func install(_ storage: ModelStorage, folder: String, models: [String] = ModelStorage.requiredModels) throws {
|
||||||
|
for model in models {
|
||||||
|
let dir = storage.repoDirectory.appending(path: "\(folder)/\(model).mlmodelc", directoryHint: .isDirectory)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
try Data([1]).write(to: dir.appending(path: "coremldata.bin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyStorageHasNothingInstalled() {
|
||||||
|
let storage = makeStorage()
|
||||||
|
#expect(storage.folder(for: "small") == nil)
|
||||||
|
#expect(!storage.isInstalled("small"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func completeInstallIsFound() throws {
|
||||||
|
let storage = makeStorage()
|
||||||
|
try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo")
|
||||||
|
#expect(storage.isInstalled("large-v3-v20240930_turbo"))
|
||||||
|
#expect(storage.folder(for: "large-v3-v20240930_turbo")?.lastPathComponent == "openai_whisper-large-v3-v20240930_turbo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func incompleteInstallDoesNotCount() throws {
|
||||||
|
let storage = makeStorage()
|
||||||
|
try install(storage, folder: "openai_whisper-small", models: ["MelSpectrogram", "AudioEncoder"])
|
||||||
|
#expect(!storage.isInstalled("small"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func variantsAreMatchedExactly() throws {
|
||||||
|
let storage = makeStorage()
|
||||||
|
try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo_632MB")
|
||||||
|
#expect(!storage.isInstalled("large-v3-v20240930_turbo"))
|
||||||
|
#expect(storage.isInstalled("large-v3-v20240930_turbo_632MB"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deleteRemovesOnlyThatVariant() throws {
|
||||||
|
let storage = makeStorage()
|
||||||
|
try install(storage, folder: "openai_whisper-small")
|
||||||
|
try install(storage, folder: "openai_whisper-base")
|
||||||
|
try storage.delete("small")
|
||||||
|
#expect(!storage.isInstalled("small"))
|
||||||
|
#expect(storage.isInstalled("base"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deletingSomethingNotInstalledIsHarmless() throws {
|
||||||
|
try makeStorage().delete("small")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func catalogStartsWithTheDefaultAndHasUniqueIds() {
|
||||||
|
#expect(ModelCatalog.all.first?.id == ModelCatalog.defaultVariant)
|
||||||
|
#expect(Set(ModelCatalog.all.map(\.id)).count == ModelCatalog.all.count)
|
||||||
|
#expect(ModelCatalog.all.allSatisfy { $0.approxSizeMB > 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstWhisper
|
||||||
|
|
||||||
|
struct PromptTokensTests {
|
||||||
|
/// Fake tokenizer: one token (id 7) per word, plus one special token (id 2000) that must be filtered out.
|
||||||
|
let encode: (String) -> [Int] = { text in
|
||||||
|
text.split(whereSeparator: { !$0.isLetter }).map { _ in 7 } + [2000]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func allTermsFitAndSpecialTokensAreFiltered() {
|
||||||
|
let tokens = WhisperKitTranscriber.promptTokens(
|
||||||
|
vocabulary: ["Anthropic", "Vercel", "Supabase"], maxTokens: 64, specialTokenBegin: 1000, encode: encode)
|
||||||
|
#expect(tokens == [7, 7, 7])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func termsThatDoNotFitAreDroppedFromTheEnd() {
|
||||||
|
let vocab = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
|
||||||
|
let tokens = WhisperKitTranscriber.promptTokens(
|
||||||
|
vocabulary: vocab, maxTokens: 4, specialTokenBegin: 1000, encode: encode)
|
||||||
|
#expect(tokens?.count == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyVocabularyGivesNoPrompt() {
|
||||||
|
#expect(WhisperKitTranscriber.promptTokens(
|
||||||
|
vocabulary: [], maxTokens: 64, specialTokenBegin: 1000, encode: encode) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func firstTermAloneTooLongGivesNoPrompt() {
|
||||||
|
#expect(WhisperKitTranscriber.promptTokens(
|
||||||
|
vocabulary: ["one two three"], maxTokens: 2, specialTokenBegin: 1000, encode: encode) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,296 @@
|
|||||||
|
# Sst Design Tokens — v0.1.0-draft
|
||||||
|
|
||||||
|
**Status:** Ter goedkeuring — nog niet gebruikt door de app
|
||||||
|
|
||||||
|
Draagbare veldrecorder / cassettedeck uit de jaren 80 (Sony TC-D5, Marantz PMD, Nakamichi, Braun). Twee afwerkingen: ALUMINIUM (licht) en GRAPHITE (donker). Eén accent: het rode opnamelampje. Amber en groen alleen voor niveau-indicatoren.
|
||||||
|
|
||||||
|
Bron: `Design/tokens.json`. Dit bestand en `tokens-preview.html` worden gegenereerd met `python3 Design/build_tokens.py`; pas nooit alleen deze bestanden aan.
|
||||||
|
|
||||||
|
## Kleur
|
||||||
|
|
||||||
|
Verwijs ernaar als `color.<naam>`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donker).
|
||||||
|
|
||||||
|
| Token | Aluminium | Graphite | Gebruik |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `color.surface.chassis` | `#CBC8C0` | `#1C1B19` | Venster-achtergrond; het 'huis' van het apparaat |
|
||||||
|
| `color.surface.metalHigh` | `#E3E1DB` | `#3B3A36` | Geborsteld metaal, bovenkant van de verloop |
|
||||||
|
| `color.surface.metalLow` | `#BDBAB2` | `#262522` | Geborsteld metaal, onderkant van de verloop |
|
||||||
|
| `color.surface.panel` | `#E2DFD5` | `#2B2A27` | Frontplaat van mat plastic; zijbalk en bedieningspanelen |
|
||||||
|
| `color.surface.well` | `#B4B1A8` | `#131211` | Verzonken vlak: invoervelden, display-vensters |
|
||||||
|
| `color.surface.paper` | `#EEE9D9` | `#232220` | Cassette-label crème: lijsten met transcripties en woordenboek |
|
||||||
|
| `color.surface.keyHigh` | `#E6E3DA` | `#44433F` | Toetsdop, bovenkant |
|
||||||
|
| `color.surface.keyLow` | `#CBC8BF` | `#33322F` | Toetsdop, onderkant |
|
||||||
|
| `color.surface.keyPressed` | `#BEBBB2` | `#2A2927` | Ingedrukte toets |
|
||||||
|
| `color.surface.meterFace` | `#EFE6C8` | `#D8CEA6` | Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen) |
|
||||||
|
| `color.surface.selection` | `#D4CDB2` | `#3A3828` | Geselecteerde rij in een lijst |
|
||||||
|
| `color.surface.display` | `#1B1A18` | `#0E0D0C` | Displayglas van de teller; donker in beide afwerkingen |
|
||||||
|
| `color.display.segmentUnlit` | `#3A3320` | `#2A2415` | Gedoofd segment op het displayglas |
|
||||||
|
| `color.ink.primary` | `#1E1D1B` | `#ECE8DC` | Hoofdtekst en wijzer |
|
||||||
|
| `color.ink.secondary` | `#403D38` | `#C4BFB2` | Secundaire tekst |
|
||||||
|
| `color.ink.tertiary` | `#4D4A44` | `#ABA79B` | Zeefdruk-labels en hulptekst |
|
||||||
|
| `color.ink.disabled` | `#8F8B82` | `#6B685F` | Uitgeschakeld; nooit voor essentiële tekst |
|
||||||
|
| `color.ink.inverse` | `#F4EFE3` | `#F4EFE3` | Tekst op rode of donkere vulling; crème in beide afwerkingen |
|
||||||
|
| `color.ink.onMeter` | `#1E1D1B` | `#1E1D1B` | Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen |
|
||||||
|
| `color.accent.record` | `#C8281E` | `#CC2A20` | ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst |
|
||||||
|
| `color.accent.recordUnlit` | `#7A2E28` | `#4A1F1B` | Lampje uit |
|
||||||
|
| `color.level.green` | `#5E9E48` | `#6DB556` | Niveau-segment, brandend |
|
||||||
|
| `color.level.amber` | `#D9A023` | `#E5B032` | Niveau-segment, waarschuwing, gecorrigeerd woord |
|
||||||
|
| `color.level.greenUnlit` | `#A8B79C` | `#26331F` | Groen segment, uit |
|
||||||
|
| `color.level.amberUnlit` | `#CDBA8A` | `#3D3418` | Amber segment, uit |
|
||||||
|
| `color.line.seamDark` | `#8C8981` | `#0A0A09` | Naad tussen panelen, donkere helft |
|
||||||
|
| `color.line.seamLight` | `#F3F1EB` | `#45433F` | Naad tussen panelen, lichte helft |
|
||||||
|
| `color.line.hairline` | `#A9A69D` | `#3A3834` | Fijne scheidingslijn, zeefdruk-kader |
|
||||||
|
| `color.line.focus` | `#1E1D1B` | `#ECE8DC` | Toetsenbord-focus |
|
||||||
|
|
||||||
|
**Regel:** `accent.record` is het enige accent en wordt nooit voor lopende tekst gebruikt. Amber en groen zijn alleen voor niveau-indicatoren en markeringen.
|
||||||
|
|
||||||
|
### Contrast (berekend)
|
||||||
|
|
||||||
|
| Tekst | Op | Afwerking | Verhouding | |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ink.primary` | `surface.chassis` | light | 10.08:1 | ok |
|
||||||
|
| `ink.primary` | `surface.chassis` | dark | 14.05:1 | ok |
|
||||||
|
| `ink.primary` | `surface.panel` | light | 12.63:1 | ok |
|
||||||
|
| `ink.primary` | `surface.panel` | dark | 11.72:1 | ok |
|
||||||
|
| `ink.primary` | `surface.paper` | light | 13.87:1 | ok |
|
||||||
|
| `ink.primary` | `surface.paper` | dark | 12.98:1 | ok |
|
||||||
|
| `ink.primary` | `surface.well` | light | 7.86:1 | ok |
|
||||||
|
| `ink.primary` | `surface.well` | dark | 15.27:1 | ok |
|
||||||
|
| `ink.primary` | `surface.selection` | light | 10.57:1 | ok |
|
||||||
|
| `ink.primary` | `surface.selection` | dark | 9.65:1 | ok |
|
||||||
|
| `ink.onMeter` | `surface.meterFace` | light | 13.49:1 | ok |
|
||||||
|
| `ink.onMeter` | `surface.meterFace` | dark | 10.67:1 | ok |
|
||||||
|
| `ink.primary` | `surface.keyHigh` | light | 13.13:1 | ok |
|
||||||
|
| `ink.primary` | `surface.keyHigh` | dark | 8.08:1 | ok |
|
||||||
|
| `ink.primary` | `surface.keyLow` | light | 10.07:1 | ok |
|
||||||
|
| `ink.primary` | `surface.keyLow` | dark | 10.47:1 | ok |
|
||||||
|
| `ink.secondary` | `surface.chassis` | light | 6.47:1 | ok |
|
||||||
|
| `ink.secondary` | `surface.chassis` | dark | 9.38:1 | ok |
|
||||||
|
| `ink.secondary` | `surface.panel` | light | 8.11:1 | ok |
|
||||||
|
| `ink.secondary` | `surface.panel` | dark | 7.82:1 | ok |
|
||||||
|
| `ink.secondary` | `surface.paper` | light | 8.90:1 | ok |
|
||||||
|
| `ink.secondary` | `surface.paper` | dark | 8.66:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.chassis` | light | 5.28:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.chassis` | dark | 7.16:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.panel` | light | 6.62:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.panel` | dark | 5.97:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.paper` | light | 7.27:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.paper` | dark | 6.61:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.metalHigh` | light | 6.75:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.metalHigh` | dark | 4.73:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.metalLow` | light | 4.55:1 | ok |
|
||||||
|
| `ink.tertiary` | `surface.metalLow` | dark | 6.37:1 | ok |
|
||||||
|
| `ink.inverse` | `accent.record` | light | 4.84:1 | ok |
|
||||||
|
| `ink.inverse` | `accent.record` | dark | 4.67:1 | ok |
|
||||||
|
| `level.amber` | `surface.display` | light | 7.46:1 | ok |
|
||||||
|
| `level.amber` | `surface.display` | dark | 9.79:1 | ok |
|
||||||
|
|
||||||
|
## Opaciteit
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `opacity.mark` | 0.3 | Amber markering achter een gecorrigeerd woord |
|
||||||
|
| `opacity.disabled` | 0.45 | Uitgeschakelde bediening |
|
||||||
|
| `opacity.unlit` | 1.0 | Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie |
|
||||||
|
| `opacity.pressedRecord` | 0.85 | Ingedrukte REC-toets: het rood dooft licht |
|
||||||
|
| `opacity.specularOn` | 0.6 | Lichtvlekje op een brandend lampje |
|
||||||
|
| `opacity.specularOff` | 0.3 | Lichtvlekje op een gedoofd lampje |
|
||||||
|
| `opacity.caption` | 0.75 | Kleine opschriften op de wijzerplaat |
|
||||||
|
|
||||||
|
## Lettertypen
|
||||||
|
|
||||||
|
| Token | Familie | Terugval | Gebruik |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `font.family.chrome` | Helvetica Neue | Helvetica, Arial | Zeefdruk, toetsen, koppen: neutrale grotesk |
|
||||||
|
| `font.family.text` | SF Pro Text | system-ui | Leesbare tekst: transcripties, woordenboek |
|
||||||
|
| `font.family.mono` | SF Mono | Menlo, monospace | Teller, tijden, woordenboekbestand |
|
||||||
|
|
||||||
|
## Tekststijlen
|
||||||
|
|
||||||
|
| Token | Familie | Grootte | Gewicht | Spatiëring | Regelhoogte | Hoofdletters | Gebruik |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `type.label` | chrome | 10 pt | 500 | 0.09 em | 12 pt | ja | Zeefdruk-label |
|
||||||
|
| `type.labelSmall` | chrome | 9 pt | 500 | 0.1 em | 11 pt | ja | Schaalmarkeringen en eenheden |
|
||||||
|
| `type.control` | chrome | 11 pt | 500 | 0.06 em | 14 pt | ja | Opschrift op toetsen |
|
||||||
|
| `type.heading` | chrome | 12 pt | 500 | 0.12 em | 16 pt | ja | Paneelkop |
|
||||||
|
| `type.body` | text | 13 pt | 400 | 0 em | 18 pt | nee | Standaardtekst |
|
||||||
|
| `type.reading` | text | 15 pt | 400 | 0 em | 22 pt | nee | Transcript |
|
||||||
|
| `type.meta` | text | 11 pt | 400 | 0 em | 14 pt | nee | Bijschriften |
|
||||||
|
| `type.counter` | mono | 22 pt | 500 | 0 em | 26 pt | nee | Teller |
|
||||||
|
| `type.counterSmall` | mono | 12 pt | 500 | 0 em | 16 pt | nee | Tijdstip in een rij |
|
||||||
|
| `type.code` | mono | 12 pt | 400 | 0 em | 18 pt | nee | Woordenboekregels |
|
||||||
|
|
||||||
|
## Segmentcijfers (getekend, geen lettertype)
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `segment.digitHeight` | 36 | Hoogte van een getekend segmentcijfer |
|
||||||
|
| `segment.digitWidth` | 20 | Breedte van een segmentcijfer |
|
||||||
|
| `segment.thickness` | 4 | Dikte van één segment |
|
||||||
|
| `segment.gap` | 1 | Ruimte tussen segmenten |
|
||||||
|
| `segment.slant` | 0 | Schuinte in graden; 0 = rechtop |
|
||||||
|
| `segment.advance` | 26 | Afstand van cijfer tot cijfer |
|
||||||
|
| `segment.colonWidth` | 10 | Breedte van een dubbele punt of punt |
|
||||||
|
|
||||||
|
## Ruimte
|
||||||
|
|
||||||
|
| Token | Waarde |
|
||||||
|
|---|---|
|
||||||
|
| `space.0` | 0 pt |
|
||||||
|
| `space.1` | 2 pt |
|
||||||
|
| `space.2` | 4 pt |
|
||||||
|
| `space.3` | 8 pt |
|
||||||
|
| `space.4` | 12 pt |
|
||||||
|
| `space.5` | 16 pt |
|
||||||
|
| `space.6` | 24 pt |
|
||||||
|
| `space.7` | 32 pt |
|
||||||
|
| `space.8` | 48 pt |
|
||||||
|
|
||||||
|
## Indeling
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `layout.window.minWidth` | 800 | Minimale vensterbreedte |
|
||||||
|
| `layout.window.minHeight` | 560 | Minimale vensterhoogte |
|
||||||
|
| `layout.window.defaultWidth` | 1080 | Standaard vensterbreedte |
|
||||||
|
| `layout.window.defaultHeight` | 700 | Standaard vensterhoogte |
|
||||||
|
| `layout.sidebar.width` | 232 | Breedte zijbalk |
|
||||||
|
| `layout.settings.width` | 560 | Breedte instellingenvenster |
|
||||||
|
| `layout.panel.padding` | 16 | Binnenmarge van een paneel (= space.5) |
|
||||||
|
| `layout.panel.gap` | 12 | Afstand tussen panelen (= space.4) |
|
||||||
|
|
||||||
|
## Afmetingen
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `size.control.height` | 28 | Standaardhoogte van toets of veld |
|
||||||
|
| `size.key.minWidth` | 56 | Minimale breedte van een toets |
|
||||||
|
| `size.key.recWidth` | 64 | REC-toets, breedte |
|
||||||
|
| `size.key.recHeight` | 44 | REC-toets, hoogte |
|
||||||
|
| `size.lamp.diameter` | 10 | Standaard lampje |
|
||||||
|
| `size.lamp.large` | 14 | Opnamelampje |
|
||||||
|
| `size.switch.width` | 40 | Tuimelschakelaar, breedte |
|
||||||
|
| `size.switch.height` | 22 | Tuimelschakelaar, hoogte |
|
||||||
|
| `size.row.height` | 40 | Rij in transcriptielijst |
|
||||||
|
| `size.icon.small` | 16 | Klein pictogram |
|
||||||
|
| `size.icon.medium` | 20 | Normaal pictogram |
|
||||||
|
| `size.hit.min` | 24 | Minimaal klikdoel |
|
||||||
|
|
||||||
|
## Hoekradius
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `radius.none` | 0 | Panelen en velden: scherpe randen |
|
||||||
|
| `radius.sharp` | 1 | Toetsranden: bijna scherp |
|
||||||
|
| `radius.sm` | 2 | Toetsen, velden |
|
||||||
|
| `radius.md` | 4 | Grote panelen |
|
||||||
|
| `radius.bezel` | 6 | Rand van de VU-meter |
|
||||||
|
| `radius.lamp` | 999 | Lampjes: rond |
|
||||||
|
|
||||||
|
## Randen
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `border.hairline` | 1 pt | Fijne lijn |
|
||||||
|
| `border.seam` | 2 pt | Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef |
|
||||||
|
| `border.bezel` | 3 pt | Rand om de VU-meter |
|
||||||
|
| `border.focus` | 2 pt | Focusring, 2 pt offset |
|
||||||
|
|
||||||
|
## Schaduw en diepte
|
||||||
|
|
||||||
|
### `shadow.key.raised`
|
||||||
|
|
||||||
|
Toets in rust: lichtvang boven, donkere onderrand, valschaduw
|
||||||
|
|
||||||
|
| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | ja | 0 | 1 | 0 | 0 | `#FFFFFF` | 0.65 | 0.1 |
|
||||||
|
| 2 | ja | 0 | -1 | 0 | 0 | `#000000` | 0.25 | 0.55 |
|
||||||
|
| 3 | nee | 0 | 2 | 2 | 0 | `#000000` | 0.35 | 0.6 |
|
||||||
|
|
||||||
|
### `shadow.key.pressed`
|
||||||
|
|
||||||
|
Ingedrukte toets: dop 1 pt omlaag, geen valschaduw Dop verschuift 1 pt omlaag.
|
||||||
|
|
||||||
|
| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | ja | 0 | 2 | 4 | 0 | `#000000` | 0.5 | 0.7 |
|
||||||
|
| 2 | ja | 0 | 0 | 0 | 1 | `#000000` | 0.35 | 0.6 |
|
||||||
|
|
||||||
|
### `shadow.well`
|
||||||
|
|
||||||
|
Verzonken vlak met lichtvang op de onderlip
|
||||||
|
|
||||||
|
| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | ja | 0 | 1 | 3 | 0 | `#000000` | 0.4 | 0.7 |
|
||||||
|
| 2 | nee | 0 | 1 | 0 | 0 | `#FFFFFF` | 0.55 | 0.07 |
|
||||||
|
|
||||||
|
### `shadow.panel`
|
||||||
|
|
||||||
|
Frontplaat boven het chassis
|
||||||
|
|
||||||
|
| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | ja | 0 | 1 | 0 | 0 | `#FFFFFF` | 0.6 | 0.08 |
|
||||||
|
| 2 | nee | 0 | 3 | 8 | 0 | `#000000` | 0.22 | 0.55 |
|
||||||
|
|
||||||
|
### `shadow.lamp.on`
|
||||||
|
|
||||||
|
Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst
|
||||||
|
|
||||||
|
| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 1 | nee | 0 | 0 | 6 | 1 | `#CC2A20` | 0.45 | 0.55 |
|
||||||
|
| 2 | ja | 0 | -1 | 1 | 0 | `#000000` | 0.35 | 0.45 |
|
||||||
|
|
||||||
|
## Textuur
|
||||||
|
|
||||||
|
**`texture.brushed`** — Geborsteld aluminium voor chassis en zijbalk. Richting horizontaal; korrel 0.035 (aluminium) / 0.05 (graphite); surface.metalHigh naar surface.metalLow, van boven naar beneden.
|
||||||
|
|
||||||
|
## Meters
|
||||||
|
|
||||||
|
| Token | Waarde | Gebruik |
|
||||||
|
|---|---|---|
|
||||||
|
| `meter.vu.referenceDbfs` | -20 | 0 VU = -20 dBFS (RMS) |
|
||||||
|
| `meter.vu.min` | -20 | Onderkant van de schaal in VU |
|
||||||
|
| `meter.vu.max` | 3 | Bovenkant van de schaal in VU; vanaf 0 VU rood |
|
||||||
|
| `meter.vu.arcDegrees` | 90 | Hoek tussen minimale en maximale stand |
|
||||||
|
| `meter.vu.width` | 260 | Breedte van de meter |
|
||||||
|
| `meter.vu.height` | 150 | Hoogte van de meter |
|
||||||
|
| `meter.vu.needleWidth` | 1.5 | Dikte van de wijzer |
|
||||||
|
| `meter.vu.ticks` | -20 -10 -7 -5 -3 0 +3 | Genummerde schaalmarkeringen |
|
||||||
|
| `meter.vu.pivotOffset` | 22 | Draaipunt van de wijzer, onder de onderrand van de wijzerplaat |
|
||||||
|
| `meter.vu.scaleRadius` | 142 | Straal van de schaalboog vanaf het draaipunt |
|
||||||
|
| `meter.vu.needleLength` | 146 | Lengte van de wijzer |
|
||||||
|
| `meter.vu.tickMajor` | 11 | Lengte van een genummerde streep |
|
||||||
|
| `meter.vu.tickMinor` | 6 | Lengte van een gewone streep |
|
||||||
|
| `meter.vu.labelInset` | 24 | Afstand van de schaalboog tot de cijfers |
|
||||||
|
| `meter.vu.redBandWidth` | 5 | Dikte van de rode band vanaf 0 VU |
|
||||||
|
| `meter.led.segments` | 12 | Aantal segmenten: 8 groen, 3 amber, 1 rood |
|
||||||
|
| `meter.led.segmentSize` | 6x14 | Segment in pt (breedte x hoogte) |
|
||||||
|
| `meter.led.gap` | 2 | Ruimte tussen segmenten |
|
||||||
|
| `meter.peak.holdMs` | 1000 | Piek-LED blijft zo lang branden |
|
||||||
|
|
||||||
|
## Beweging
|
||||||
|
|
||||||
|
| Token | Waarde | Eenheid | Gebruik |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `motion.duration.instant` | 0 | ms | Geen overgang |
|
||||||
|
| `motion.duration.fast` | 60 | ms | Toets in/uit |
|
||||||
|
| `motion.duration.base` | 120 | ms | Standaardovergang |
|
||||||
|
| `motion.duration.slow` | 220 | ms | Paneel of venster |
|
||||||
|
| `motion.easing.mechanical` | cubic-bezier(0.2, 0, 0, 1) | | Toetsen: direct, zonder veer |
|
||||||
|
| `motion.easing.standard` | cubic-bezier(0.4, 0, 0.2, 1) | | Overige overgangen |
|
||||||
|
| `motion.key.press` | 50 | ms | Toets indrukken |
|
||||||
|
| `motion.key.release` | 90 | ms | Toets loslaten |
|
||||||
|
| `motion.lamp.on` | 30 | ms | Lampje aan |
|
||||||
|
| `motion.lamp.off` | 200 | ms | Lampje uit: dooft langzaam, als een gloeidraad |
|
||||||
|
| `motion.needle.response` | 0.3 | s | VU-ballistiek: 300 ms om de stand te bereiken |
|
||||||
|
| `motion.needle.damping` | 0.8 | ratio | Veerdemping; geeft ongeveer 1,5 % overschot |
|
||||||
|
| `motion.needle.updateHz` | 60 | Hz | Verversing van de wijzer |
|
||||||
|
| `motion.reducedMotion` | Overgangen 0 ms; wijzer blijft bewegen maar met demping 1.0 (geen overschot) | | Systeeminstelling 'Verminder beweging' |
|
||||||
|
|
||||||
|
## Wat bewust ontbreekt
|
||||||
|
|
||||||
|
Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten. Lampjes mogen een kleine gloed hebben (`shadow.lamp.on`); tekst nooit.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Sst als volwaardige Mac-app — overzicht van de uitvoeringsplannen
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` · **Tokens:** `docs/design/tokens.md` (bron: `Design/tokens.json`)
|
||||||
|
|
||||||
|
De spec is opgesplitst in vijf plannen. Elk levert op zichzelf werkende, geteste software op en bouwt voort op de vorige. Voer ze **in deze volgorde** uit, op één branch (`feat/macos-app`), bij voorkeur met `superpowers:subagent-driven-development`.
|
||||||
|
|
||||||
|
| # | Plan | Levert | Tests daarna (per testtarget) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `2026-10-01-app-1-engine-hooks.md` | vier additieve raakpunten in de engine: woordenlijst voor de transcriber, niveau-uitvoer, correctie + resultaat-hook, instelbare sneltoets | SstCoreTests 41 · SstWhisperTests 4 |
|
||||||
|
| 2 | `2026-10-01-app-2-data.md` | `SstData`: woordenboekbestand, correctieronde, waarschuwingen, voorrangslijst, geschiedenis met zoeken | + SstDataTests 54 |
|
||||||
|
| 3 | `2026-10-01-app-3-ui-kit.md` | tokens als Swift (gegenereerd), componenten, galerij | + SstUITests 15 |
|
||||||
|
| 4 | `2026-10-01-app-4-shell.md` | echte Mac-app: Dock, menu's, Instellingen, menubalkitem, icoon, ondertekende bundel | SstCoreTests 48 · SstDataTests 56 · SstWhisperTests 11 |
|
||||||
|
| 5 | `2026-10-01-app-5-main-window.md` | hoofdvenster: deck, zijbalk, geschiedenis, woordenboek | SstDataTests 73 (totaal 147 tests) |
|
||||||
|
|
||||||
|
## Hoe het plan is gecontroleerd
|
||||||
|
|
||||||
|
De code in de plannen is niet geraden: elk plan is samengesteld uit bestanden die in een wegwerp-kopie van de repo zijn gecompileerd en getest (alle fasen samen: 147 tests groen, app bouwt, snapshots van het venster bekeken in beide afwerkingen, bundel gebouwd en ondertekend). Bewerkingen aan bestaande bestanden zijn scripts met asserts die bewezen dezelfde eindtoestand opleveren. Onderweg zijn zo ook fouten in de eerste versie van de plannen gevonden en hersteld (ontbrekende `import`, een te strenge "gewoon woord"-regel, een ontbrekend omgevingsobject, een niet-bestaand-map-bij-voorbeeldgegevens).
|
||||||
|
|
||||||
|
## Gedeelde namen (de interface tussen de plannen)
|
||||||
|
|
||||||
|
| Waar | Naam |
|
||||||
|
|---|---|
|
||||||
|
| `SstCore` | `Transcriber.transcribe(_:vocabulary:)`, `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`, `DictationController(corrector:vocabularyProvider:)`, `onResult`, `HotkeyBinding`, `AudioRecorder.onLevel`, `AudioLevel`, `GatedInserter`, `SwappableTranscriber`, `LevelSource` |
|
||||||
|
| `SstData` | `DictionaryFile`, `DictionaryEntry`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistoryStore`, `HistorySearch`, `DictionaryStore`, `DictionaryCorrector`, `AppSettings`, `HistoryViewModel`, `DictionaryViewModel`, `CorrectionMarkup`, `StopwatchFormat` |
|
||||||
|
| `SstWhisper` | `WhisperKitTranscriber(model:storage:)`, `ModelStorage`, `ModelCatalog`, `ModelOption` |
|
||||||
|
| `SstUI` | `Palette`, `Space`, `Radius`, `Border`, `Size`, `Layout`, `Opacity`, `Typography`, `Shadows`, `Motion`, `MeterTokens`, `Segment`; `PhysicalSurface`, `PanelBackground`, `WellBackground`, `DisplayBackground`, `PaperBackground`, `SeamDivider`, `SilkLabel`, `BrushedBackground`, `KeyButtonStyle`, `LampView`, `RockerToggleStyle`, `VUMeterView`, `SegmentCounterView`, `LEDBarView` |
|
||||||
|
| `Sst` | `AppModel`, `Strings`, `AppIdentity`, `PermissionMonitor`, scenes en views |
|
||||||
|
|
||||||
|
## Uitvoeringsadvies
|
||||||
|
|
||||||
|
- Taken met volledige code in het plan (fase 1–3, grote delen van 4 en 5) zijn transcriptie plus testen: een goedkoop model volstaat voor de implementer, een standaardmodel voor de reviewer.
|
||||||
|
- Visuele taken (fase 3 taak 6, fase 4 taak 7–8, fase 5 taak 3–6) hebben de gebruiker nodig voor het oordeel; de handmatige acceptatietaken zijn daarom bewust niet voor subagents.
|
||||||
|
- Fase 1 taak 5 stap 6 en fase 3 taak 6 vragen om een microfoon, Fn-toets of het scherm van de gebruiker.
|
||||||
|
- Na fase 4 moet het oude ad-hoc ondertekende `Sst` eenmalig zijn rechten opnieuw krijgen (`tccutil reset …`, door de gebruiker uit te voeren; staat in het plan).
|
||||||
|
|
||||||
|
## Bewust buiten scope (zie spec)
|
||||||
|
|
||||||
|
Overlay-pill, AI-cleanup, streaming, meerdere woordenboeken, import/export van de geschiedenis, notarisatie, DMG, updates, automatisch starten bij inloggen, toggle-modus, definitieve naam en merk.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
|||||||
|
# Sst als volwaardige macOS-app — ontwerp
|
||||||
|
|
||||||
|
Datum: 2026-10-01 · Status: concept ter review · Bouwt voort op `2026-10-01-push-to-talk-dictation-design.md` (de engine) en de goedgekeurde tokens in `docs/design/tokens.md` (bron: `Design/tokens.json`, v0.1.0-draft)
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Van menubalk-hulpprogramma naar een echte Mac-app: Dock-icoon, app-menu, een vrij schaalbaar hoofdvenster, een instellingenvenster (⌘,) en een dubbelklikbaar `Sst.app` dat in Programma's kan staan. Het menubalkitem blijft als secundaire status- en sneltoetsweergave. De interface volgt het goedgekeurde ontwerpsysteem: een jaren-80 veldrecorder, met uitsluitend waarden uit de tokens.
|
||||||
|
|
||||||
|
## Wat de gebruiker krijgt
|
||||||
|
|
||||||
|
- **Hoofdvenster:** doorzoekbare transcriptiegeschiedenis met een kopieerknop per rij; een live VU-meter, teller en LED-balk tijdens het opnemen; start- en stopknop; het woordenboek.
|
||||||
|
- **Instellingen (⌘,):** sneltoets en model.
|
||||||
|
- **Woordenboek:** woorden en correctieparen, bewerkbaar in de app én als gewoon tekstbestand. Werkt op twee manieren: voorrang bij het model (bias) en een gegarandeerde correctieronde achteraf.
|
||||||
|
- **Geschiedenis toont correcties:** wanneer het woordenboek iets wijzigt, zie je wat er stond en wat het werd.
|
||||||
|
|
||||||
|
## Buiten scope
|
||||||
|
|
||||||
|
- Overlay-pill tijdens het dicteren, AI-cleanup (taalmodel), streaming-transcriptie.
|
||||||
|
- Meerdere woordenboeken of profielen, per-app-instellingen, import of export van de geschiedenis, synchronisatie.
|
||||||
|
- Notarisatie, DMG, automatische updates, automatisch starten bij inloggen.
|
||||||
|
- Toggle-modus (eenmaal drukken start, nogmaals stopt). Alleen vasthouden.
|
||||||
|
- Definitieve naam en merk. "Sst" is de werktitel; de naam staat op één plek (`AppIdentity`) zodat hernoemen goedkoop blijft.
|
||||||
|
- Een app-icoon in volledige merkkwaliteit; wel een eerste icoon in het goedgekeurde ontwerp.
|
||||||
|
|
||||||
|
## Beslissingen en aannames
|
||||||
|
|
||||||
|
1. **De engine blijft in gedrag identiek.** Er komen vier kleine, additieve raakpunten bij (zie "Engine-raakpunten"). Bestaande tests blijven ongewijzigd groen.
|
||||||
|
2. **"Een kopie van elk document"** is een **kopieerknop per transcriptie** (kopieert de uiteindelijke tekst).
|
||||||
|
3. **UI-taal is Nederlands**, in één bestand met teksten (`Strings`), zodat vertalen later kan. Systeemmenu's (Hide, Quit, enz.) volgen de taal van macOS.
|
||||||
|
4. **Geschiedenis staat standaard aan** en bewaart alleen tekst, nooit audio. Een schakelaar in Instellingen zet bewaren uit en een knop wist alles.
|
||||||
|
5. **Sluit je het hoofdvenster, dan draait de app door** (Dock-icoon en menubalkitem blijven). Dicteren in andere apps blijft werken. ⌘Q sluit de app echt af. Klikken op het Dock-icoon opent het venster weer.
|
||||||
|
6. **Is Sst zelf de voorgrond-app** (of start je de opname met de knop in het venster), dan wordt de tekst **niet geplakt** maar alleen in de geschiedenis bewaard. Anders zou het dictaat in het zoekveld van Sst terechtkomen.
|
||||||
|
7. **Ondertekening:** met je Apple Development-certificaat, zodat macOS je rechten (Microfoon, Accessibility, Input Monitoring) na een herbouw niet vergeet. Het script zoekt zelf de eerste "Apple Development"-identiteit en valt anders terug op ad-hoc, met een duidelijke waarschuwing. Geen identiteit staat in de repo.
|
||||||
|
8. **Sneltoetsen:** alleen modifier-toetsen, vasthouden. Keuze: Fn (standaard), Rechter Option, Rechter Command, Rechter Control, Linker Control.
|
||||||
|
9. **Afwerking** (Aluminium of Graphite) volgt het uiterlijk van macOS.
|
||||||
|
|
||||||
|
## Architectuur
|
||||||
|
|
||||||
|
Eén Swift Package, vier lagen, steeds in één richting afhankelijk:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sst (app) → SstUI (tokens, componenten)
|
||||||
|
├────────→ SstData → SstCore (woordenboek, correcties, geschiedenis)
|
||||||
|
└────────→ SstWhisper → SstCore (de engine; dunne uitbreidingen)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Verantwoordelijkheid | Testbaar zonder UI |
|
||||||
|
|---|---|---|
|
||||||
|
| `SstCore` / `SstWhisper` | Engine (bestaat). Vier additieve raakpunten. | ja |
|
||||||
|
| `SstData` | `DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistoryStore`, `HistorySearch`, `AppSettings`. Deelt de typen `AppliedCorrection` en `TextCorrecting` met `SstCore`. | ja |
|
||||||
|
| `SstUI` | Gegenereerde `DesignTokens`, componenten (toets, lampje, VU-meter, segmentteller, LED-balk, paneel, verzonken vlak, zeefdruk-label) | gedeeltelijk |
|
||||||
|
| `Sst` | Scenes, menu's, `AppModel`, vensters, bundelscript | handmatig |
|
||||||
|
|
||||||
|
### Engine-raakpunten (allemaal additief, met standaardwaarden)
|
||||||
|
|
||||||
|
1. **Voorrang voor het model.** `Transcriber` krijgt `transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String`. De oude methode blijft bestaan als extensie met een lege lijst. `WhisperKitTranscriber` zet de woorden om in `DecodingOptions.promptTokens`: tekst `" " + woorden.joined(", ")`, via `tokenizer.encode(text:)`, gefilterd op tokens onder `specialTokens.specialTokenBegin` (zo doet de WhisperKit-CLI het ook).
|
||||||
|
2. **Niveau-uitvoer.** `AudioRecorder` krijgt `onLevel: ((Float) -> Void)?`: RMS in dBFS per buffer van 1024 frames (~21 ms bij 48 kHz). De bestaande buffer van 4096 blijft ongewijzigd voor de opname zelf; de niveau-tap rekent op dezelfde audio.
|
||||||
|
3. **Resultaat-hook en correctie.** `DictationController` krijgt een optionele `corrector: TextCorrecting?` en `onResult: ((DictationResult) -> Void)?`. `DictationResult` bevat `rawText`, `finalText`, `corrections`, `duration`, `date`. Zonder corrector en hook gedraagt de controller zich exact als nu.
|
||||||
|
4. **Instelbare toets.** `HotkeyMonitor` krijgt een `HotkeyBinding` (keycode + vlagmasker) via een nieuwe `ModifierKeyTracker`. `FnKeyTracker` blijft bestaan en wordt de Fn-variant. Het keycode-filter (nu 63) wordt de keycode van de gekozen binding.
|
||||||
|
|
||||||
|
## App-shell
|
||||||
|
|
||||||
|
### Scenes
|
||||||
|
|
||||||
|
```
|
||||||
|
Window("Sst", id: "main") — één hoofdvenster, geen documentvensters
|
||||||
|
Settings — ⌘,
|
||||||
|
MenuBarExtra — secundair
|
||||||
|
.commands { … } — app-menu en menu's
|
||||||
|
```
|
||||||
|
|
||||||
|
Zonder `LSUIElement`, `NSPrincipalClass` standaard. `applicationShouldHandleReopen` opent het hoofdvenster als alle vensters dicht zijn. Vensterafmetingen komen uit `layout.window.*`: minimaal 800×560, standaard 1080×700; macOS onthoudt de laatste grootte.
|
||||||
|
|
||||||
|
### Menu's
|
||||||
|
|
||||||
|
| Menu | Inhoud |
|
||||||
|
|---|---|
|
||||||
|
| **Sst** | Over Sst, Instellingen… (⌘,), Verberg, Verberg anderen, Toon alles, Stop Sst (⌘Q) |
|
||||||
|
| **Archief** | Sluit venster (⌘W) |
|
||||||
|
| **Bewerken** | Standaard (Kopieer, Selecteer alles, Zoek…); Verwijder transcriptie (⌘⌫); Wis geschiedenis… |
|
||||||
|
| **Opname** | Start of stop opname (⌥⌘R); Transcripties (⌘1); Woordenboek (⌘2); Zoek (⌘F) |
|
||||||
|
| **Venster** / **Help** | Standaard |
|
||||||
|
|
||||||
|
### Menubalkitem (secundair)
|
||||||
|
|
||||||
|
Statusicoon met SF Symbols (`mic`, `mic.fill`, `waveform`, `exclamationmark.triangle`), geen aangepast beeld. Menu: één statusregel, de gekozen sneltoets ("Houd RECHTER OPTION ingedrukt"), "Open Sst", "Instellingen…", "Stop Sst". Geen geschiedenis, geen meter.
|
||||||
|
|
||||||
|
### Bundel en installatie
|
||||||
|
|
||||||
|
`scripts/make-app.sh` bouwt `build/Sst.app` met: `CFBundleIdentifier` (huidige `local.sst.app` blijft, zodat bestaande rechten blijven werken), versie, `LSApplicationCategoryType` productivity, `CFBundleIconFile`, `NSMicrophoneUsageDescription` in het Nederlands, **zonder `LSUIElement`**. Het script ondertekent met de gevonden identiteit. `--install` kopieert naar `/Applications` (alleen door de gebruiker uit te voeren). Het icoon komt uit een script dat een SwiftUI-weergave met de tokens naar een `.icns` rendert (`Design/make_icon`).
|
||||||
|
|
||||||
|
### Rechten en eerste start
|
||||||
|
|
||||||
|
Een blok "CONTROLES" onderaan de zijbalk toont vier lampjes: Microfoon, Accessibility, Input Monitoring, Model. Elk heeft een knop naar de juiste Systeeminstellingen. Rechten worden **opnieuw gelezen zodra de app actief wordt** en elke 2 s zolang het blok zichtbaar is, zodat de weergave niet meer verouderd raakt. Tot alle vier branden staat de REC-toets uit met een duidelijke reden.
|
||||||
|
|
||||||
|
## Hoofdvenster
|
||||||
|
|
||||||
|
Indeling (afmetingen en kleuren uitsluitend uit tokens):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ zijbalk 232 ─┬─ deck (vast, bovenaan) ───────────────────────┐
|
||||||
|
│ TRANSCRIPTIES │ [VU-meter] [00:12.4] [LED-balk] ● REC STOP │
|
||||||
|
│ WOORDENBOEK ├────────────────────────────────────────────────┤
|
||||||
|
│ │ zoekveld │
|
||||||
|
│ CONTROLES │ lijst (papier) of woordenboek │
|
||||||
|
└───────────────┴────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deck
|
||||||
|
|
||||||
|
- **VU-meter:** 0 VU = −20 dBFS. `vu = rmsDbfs + 20`, schaal −20…+3. De wijzer volgt `onLevel` met de veer uit `motion.needle.*` (respons 0,30 s, demping 0,80; met "Verminder beweging" demping 1,0). Piek-LED 1000 ms. Getekend met `Canvas`, ververst met 60 Hz via `TimelineView` **alleen wanneer het venster zichtbaar is én er wordt opgenomen**; daarna valt de wijzer terug en de timer stopt (geen CPU in rust).
|
||||||
|
- **Teller:** segmentcijfers (`segment.*`) op `surface.display`, ghost-segmenten voor gedoofde delen. Toont de opnametijd; in rust de duur van de laatste opname.
|
||||||
|
- **LED-balk:** 12 segmenten (8 groen, 3 amber, 1 rood) op hetzelfde signaal.
|
||||||
|
- **REC / STOP:** REC start, STOP beëindigt; de REC-toets blijft ingedrukt zolang er wordt opgenomen, met brandend lampje. Een sessie vanuit het venster slaat alleen op (zie beslissing 6).
|
||||||
|
- **Fout:** een amber lampje met een korte regel; wordt gewist bij de volgende opname.
|
||||||
|
|
||||||
|
### Transcripties
|
||||||
|
|
||||||
|
- Gesorteerd nieuw → oud, gegroepeerd per dag (zeefdruk-kop). Rij: tijd (`type.counterSmall`), tekst (`type.reading`), kopieerknop, minimale hoogte `size.row.height`.
|
||||||
|
- **Zoeken** (⌘F): live, niet hoofdlettergevoelig en diakriet-ongevoelig, op zowel de uiteindelijke als de oorspronkelijke tekst; woorden hoeven niet aaneen te staan (alle zoekwoorden moeten voorkomen).
|
||||||
|
- **Correcties zichtbaar:** vervangen stukken krijgen de amber markering (`opacity.mark`); onder de rij staat "2 CORRECTIES · cloud code → Claude Code". Geselecteerd toont de rij de oorspronkelijke tekst naast de uiteindelijke.
|
||||||
|
- Kopieerknop kopieert de uiteindelijke tekst; rechtsklik: kopieer origineel, verwijder. Lege staat: korte zeefdruk-tekst, geen illustratie.
|
||||||
|
|
||||||
|
### Woordenboek
|
||||||
|
|
||||||
|
Tabel op papier-oppervlak met kolommen GEHOORD → SCHRIJF. Een regel zonder pijl is een term (alleen SCHRIJF). Toevoegen met `+`, bewerken met dubbelklik, verwijderen met ⌫, ongedaan maken met ⌘Z, zoeken met het zoekveld. Een knop "Open bestand" toont het bestand in de standaardeditor. Twee kleine schakelaars: "Voorrang" en "Correctie" (beide standaard aan), zodat je kunt zien wat elk mechanisme doet. Een amber lampje op de rij toont een waarschuwing (zie hieronder).
|
||||||
|
|
||||||
|
## Instellingen
|
||||||
|
|
||||||
|
Venster `layout.settings.width` breed, drie tabbladen als zeefdruk-toetsen.
|
||||||
|
|
||||||
|
- **Sneltoets:** de gekozen toets wordt vastgelegd door erop te drukken (de app is dan actief en luistert met een lokale event-monitor). Alleen de toegestane modifiers uit beslissing 8; andere toetsen worden afgewezen met uitleg. Bij Fn staat een hint over de Globe-instelling van macOS.
|
||||||
|
- **Model:** vaste lijst van drie varianten, namen en groottes gecontroleerd bij Hugging Face (`argmaxinc/whisperkit-coreml`): `large-v3-v20240930_turbo` (standaard, 1638 MB), `large-v3-v20240930_turbo_632MB` (646 MB) en `small` (486 MB). Per model: status (aanwezig, gekozen, niet gedownload), download- of verwijderknop en voortgang als LED-balk. Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het laden, dan blijft het vorige model actief met een duidelijke melding. Toont de opslaglocatie met "Toon in Finder".
|
||||||
|
- **Privacy:** schakelaar "Transcripties bewaren" en knop "Wis geschiedenis…" (met bevestiging).
|
||||||
|
|
||||||
|
## Woordenboek — het ontwerp in detail
|
||||||
|
|
||||||
|
### Bestand
|
||||||
|
|
||||||
|
`~/Library/Application Support/Sst/dictionary.txt`, UTF-8, één invoer per regel:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Opmerkingen beginnen met #
|
||||||
|
Anthropic
|
||||||
|
Vercel
|
||||||
|
Supabase
|
||||||
|
cloud code => Claude Code
|
||||||
|
```
|
||||||
|
|
||||||
|
- Een regel zonder `=>` is een **term**. Een regel met `=>` is een **correctiepaar** (links gehoord, rechts schrijven). Spaties rond `=>` en aan de uiteinden worden genegeerd.
|
||||||
|
- Opmerkingen, lege regels en de volgorde blijven bij opslaan **behouden**: de app leest naar een lijst van regels (invoer, opmerking, leeg) en schrijft die letterlijk terug. Een onleesbare regel blijft staan en krijgt een waarschuwing, ze wordt nooit stilletjes weggegooid.
|
||||||
|
- De app schrijft atomisch en let op wijzigingen door een externe editor (bestandsmonitor, 200 ms debounce) en herlaadt dan. Verdwijnt de invoer die je aan het bewerken was, dan sluit de editor met een melding.
|
||||||
|
|
||||||
|
### Mechanisme 1 — voorrang bij het model
|
||||||
|
|
||||||
|
`VocabularyBuilder` maakt een korte lijst: eerst de termen (bestandsvolgorde), dan de unieke rechterkanten van de correctieparen. **Maximaal 24 woorden en 64 tokens**; past er meer niet, dan vallen de laatste woorden af. Doorgegeven als `promptTokens` (zie raakpunt 1).
|
||||||
|
|
||||||
|
Tegen verzinnen op stilte:
|
||||||
|
|
||||||
|
- Is het RMS van de opname lager dan **0,015** (het stiltefilter van 0,005 blijft erboven), dan wordt **geen prompt** meegegeven.
|
||||||
|
- Bestaat het resultaat (genormaliseerd: kleine letters, zonder leestekens) uitsluitend uit de eerste *k* termen van de prompt met *k* ≥ 2, dan wordt het verworpen: dat is een echo van de prompt, geen spraak. Eén term alleen ("Anthropic") is wel geldige spraak.
|
||||||
|
- WhisperKit zet de snelle prefill-cache uit zodra een prompt is gezet; de snelheid wordt bij de bouw gemeten met en zonder prompt en in het plan vastgelegd. Doel: onder 1,5 s voor 8 s spraak.
|
||||||
|
|
||||||
|
### Mechanisme 2 — correctieronde (het gegarandeerde pad)
|
||||||
|
|
||||||
|
`CorrectionEngine` loopt na de transcriptie over de tekst:
|
||||||
|
|
||||||
|
- **Hele woorden**, niet hoofdlettergevoelig, **één doorgang** van links naar rechts. De langste overeenkomst wint (bij gelijke lengte de eerste in het bestand). Vervangen tekst wordt niet opnieuw gescand, dus A→B en B→C vormen nooit een ketting.
|
||||||
|
- **Aaneengeplakte en gekoppelde vormen:** de bronzin wordt gesplitst op spaties en koppeltekens; de delen worden verbonden met `[spatie, koppelteken]*`. "cloud code" herkent dus "cloud code", "cloudcode", "Cloud-Code" en "CLOUD CODE". Een bron van één deel (geen splitsing) herkent alleen zichzelf, hoofdletter-onafhankelijk.
|
||||||
|
- **Woordgrenzen** zijn Unicode-bewust: de overeenkomst mag niet direct aan een letter of cijfer grenzen. Daarom raakt "cloud code" nooit "Cloudflare", "cloud codex" of het losse woord "cloud".
|
||||||
|
- **Termen** (invoer zonder pijl) krijgen een normaliserende regel: dezelfde letters, maar met de gewenste schrijfwijze (hoofdletters en spatiëring). "claude code", "claudecode" en "Claude-Code" worden "Claude Code". **Uitzondering:** is de term in kleine letters een gewoon woord (bijvoorbeeld "Reef" of "Slack"), dan krijgt hij alleen voorrang bij het model en géén automatische hoofdletterregel; de rij laat dat zien.
|
||||||
|
- De vervanging is letterlijk (geen sjablonen); `$` en `\` blijven staan.
|
||||||
|
- De engine geeft per vervanging terug: gehoord (zoals het in de tekst stond), geschreven, de regel, en de plaats in de uiteindelijke tekst.
|
||||||
|
|
||||||
|
### Waarschuwingen bij invoer die veel voorkomt
|
||||||
|
|
||||||
|
`EntryWarnings` bepaalt bij toevoegen en bewerken, met een injecteerbare `WordKnowledge`. Standaard: de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden; "anthropic" en "vercel" staan er niet in, "cloud" en "reef" wel), de Nederlandse spellingcontrole van macOS, en een meegeleverde lijst van de meest voorkomende functiewoorden in beide talen (circa 170):
|
||||||
|
|
||||||
|
| Niveau | Wanneer |
|
||||||
|
|---|---|
|
||||||
|
| **Waarschuwing** | De bron is één woord dat in nl of en bestaat ("cloud" → wordt overal vervangen) |
|
||||||
|
| **Waarschuwing** | De bron is korter dan 3 letters |
|
||||||
|
| **Waarschuwing** | Een deel van een meerdelige bron is een veelvoorkomend functiewoord ("in de") |
|
||||||
|
| **Waarschuwing** | De aaneengeplakte vorm is zelf een bestaand woord ("some thing" → "something") |
|
||||||
|
| **Let op** | Alle delen zijn gewone woorden: de correctie werkt alleen op de hele combinatie (jouw voorbeeld "cloud code") |
|
||||||
|
| **Fout** | Dubbele bron (de eerste telt) of een lege kant |
|
||||||
|
|
||||||
|
Een waarschuwing blokkeert het opslaan niet, maar toont een amber lampje op de rij en de reden in de editor.
|
||||||
|
|
||||||
|
### Zichtbaar in de geschiedenis
|
||||||
|
|
||||||
|
Elke opname bewaart `rawText`, `finalText` en `corrections`. De rij toont de markeringen en de samenvatting. Is er geen correctie gedaan, dan staat er niets extra. Zo zie je direct of het woordenboek iets doet.
|
||||||
|
|
||||||
|
## Geschiedenis
|
||||||
|
|
||||||
|
`HistoryStore` bewaart `~/Library/Application Support/Sst/history.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "version": 1, "entries": [
|
||||||
|
{ "id": "UUID", "date": "ISO-8601", "duration": 8.4,
|
||||||
|
"rawText": "…", "finalText": "…",
|
||||||
|
"corrections": [ { "heard": "cloud code", "wrote": "Claude Code", "rule": "cloud code", "start": 12, "length": 11 } ] } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Atomisch schrijven, met korte debounce; geheugen-eerst. Meer dan 10 000 regels is geen doel van deze versie. Onleesbaar bestand: hernoemen naar `history.corrupt-<datum>.json` en vers beginnen, met een melding. Staat bewaren uit, dan wordt niets geschreven en toont het hoofdvenster een regel dat de geschiedenis uit staat.
|
||||||
|
|
||||||
|
## Ontwerpsysteem in code
|
||||||
|
|
||||||
|
`Design/build_tokens.py` krijgt een Swift-uitvoer (`--swift`) die `Sources/SstUI/DesignTokens.swift` genereert uit `Design/tokens.json`:
|
||||||
|
|
||||||
|
- Kleuren als dynamische `NSColor`/`Color` met aluminium- en graphite-waarden, schaduwen als lagenlijsten, afmetingen, typografie en beweging als constanten.
|
||||||
|
- Componenten gebruiken **uitsluitend** deze constanten. Een test zoekt in `Sources/SstUI` en `Sources/Sst` naar losse hex-kleuren en vaste puntafmetingen buiten de gegenereerde file en faalt als ze er zijn.
|
||||||
|
- Een test controleert dat het gegenereerde bestand overeenkomt met een verse generatie (geen drift) en dat de contrastcontrole slaagt.
|
||||||
|
- Componenten: `KeyButtonStyle` (rust, ingedrukt, REC), `LampView`, `PanelBackground`, `WellBackground`, `SeamDivider`, `SilkLabel`, `VUMeterView`, `SegmentCounterView`, `LEDBarView`, `PaperList`. De geborstelde textuur wordt één keer gegenereerd en gecachet.
|
||||||
|
|
||||||
|
## Toegankelijkheid
|
||||||
|
|
||||||
|
- Alle bediening bereikbaar met het toetsenbord; zichtbare focusring (`border.focus`, `line.focus`).
|
||||||
|
- VoiceOver: toetsen en lampjes hebben labels ("Opname starten", "Microfoon: toegestaan"); de VU-meter meldt zijn waarde ("niveau min 12 VU") met 2 Hz, niet met 60 Hz.
|
||||||
|
- "Verminder beweging" wordt gerespecteerd (zie `motion.reducedMotion`).
|
||||||
|
- Alle tekstparen halen ≥ 4,5:1 (gecontroleerd door het tokenscript).
|
||||||
|
|
||||||
|
## Foutafhandeling
|
||||||
|
|
||||||
|
| Situatie | Gedrag |
|
||||||
|
|---|---|
|
||||||
|
| Model niet gedownload / laden mislukt | Controle-lampje uit, REC uitgeschakeld, melding met knop "Opnieuw proberen"; vorige model blijft bij wisselen actief |
|
||||||
|
| Geen netwerk bij eerste download | Duidelijke melding; geen herhaalde verzoeken op de achtergrond |
|
||||||
|
| Rechten ontbreken | Lampje en knop in CONTROLES; hotkey-opnames geven een korte fout |
|
||||||
|
| Plakken mislukt | Tekst blijft op het klembord en staat wel in de geschiedenis |
|
||||||
|
| Woordenboekbestand ontbreekt | Wordt aangemaakt met een korte kop; leeg is geldig |
|
||||||
|
| Woordenboekregel onleesbaar | Regel blijft behouden, krijgt een waarschuwing |
|
||||||
|
| Geschiedenisbestand beschadigd | Hernoemen, vers beginnen, melding |
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
|
||||||
|
- **Unit (zonder UI):** `DictionaryFile` (ronde parse → schrijf met opmerkingen en volgorde), `CorrectionEngine` (spatie/koppelteken/aaneengeplakt, "Cloudflare" en "cloud codex" blijven onaangeroerd, langste eerst, geen ketens, hoofdletters, Unicode, `$`/`\` letterlijk), `EntryWarnings` met een valse `WordKnowledge`, `VocabularyBuilder` (limieten, volgorde), stilte-bias-regels, `HistoryStore` (lezen, schrijven, beschadigd), `HistorySearch` (diakrieten, meerdere woorden), VU-afbeelding (dBFS → VU → hoek), `ModifierKeyTracker` met meerdere bindingen, en de engine-raakpunten met nep-onderdelen. Het bestaande 13-testen-pakket blijft groen.
|
||||||
|
- **Tokens:** parity- en contrasttest (zie "Ontwerpsysteem in code").
|
||||||
|
- **Handmatig** (met de gebruiker, zoals bij het skeleton): Dock-icoon en app-menu; venster vrij schalen; ⌘, ; sluiten en weer openen via het Dock; dicteren in TextEdit, browser en terwijl Sst achter staat; dicteren terwijl Sst voorop staat (alleen opslaan); woordenboek: term, correctiepaar, "cloud-code"-variaties, "Cloudflare" blijft; waarschuwing bij "cloud"; bestand in een editor wijzigen en zien herladen; model wisselen; rechten intrekken en terugzien; VoiceOver kort; "Verminder beweging".
|
||||||
|
|
||||||
|
## Bouwvolgorde
|
||||||
|
|
||||||
|
Elke fase levert op zichzelf testbare software en krijgt een eigen plan:
|
||||||
|
|
||||||
|
1. **Engine-raakpunten** (SstCore/SstWhisper) met tests.
|
||||||
|
2. **SstData:** woordenboek, correcties, waarschuwingen, voorrang, geschiedenis, zoeken, met tests.
|
||||||
|
3. **SstUI:** tokens genereren in Swift, componenten, parity- en contrasttest, een klein ontwikkelvenster om componenten te bekijken.
|
||||||
|
4. **App-shell:** scenes, menu's, menubalkitem, Instellingen (sneltoets en model), bundelscript, ondertekening, icoon.
|
||||||
|
5. **Hoofdvenster:** deck met meter, geschiedenis, woordenboekweergave, bedrading met `AppModel`; handmatige rooktest.
|
||||||
|
|
||||||
|
## Open punten (graag bevestigen)
|
||||||
|
|
||||||
|
1. De vier **engine-raakpunten**, in het bijzonder dat `Transcriber` een woordenlijst accepteert.
|
||||||
|
2. **"Kopie van elk document"** als kopieerknop per transcriptie.
|
||||||
|
3. **Nederlandse UI-teksten** met macOS-systeemmenu's in de taal van het systeem.
|
||||||
|
4. De vaste lijst **toegestane sneltoetsen** en alleen vasthouden.
|
||||||
|
5. Dat Sst **niet plakt** wanneer Sst zelf voorop staat of de opname via het venster start.
|
||||||
|
6. De keuze om **geschiedenis standaard aan** te zetten.
|
||||||
+35
-8
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Builds build/Sst.app (a normal Dock app, no LSUIElement). Pass --install to copy it to /Applications.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
APP="build/Sst.app"
|
||||||
|
IDENTIFIER="local.sst.app"
|
||||||
|
VERSION="0.2.0"
|
||||||
|
|
||||||
swift build -c release --product Sst
|
swift build -c release --product Sst
|
||||||
BIN=".build/release/Sst"
|
BIN=".build/release/Sst"
|
||||||
APP="build/Sst.app"
|
|
||||||
|
|
||||||
rm -rf "$APP"
|
rm -rf "$APP"
|
||||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||||
@@ -13,26 +17,49 @@ cp "$BIN" "$APP/Contents/MacOS/Sst"
|
|||||||
for b in .build/release/*.bundle; do
|
for b in .build/release/*.bundle; do
|
||||||
[ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/"
|
[ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/"
|
||||||
done
|
done
|
||||||
|
if [ -f Design/AppIcon.icns ]; then
|
||||||
|
cp Design/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
|
||||||
|
else
|
||||||
|
echo "Let op: Design/AppIcon.icns ontbreekt; run scripts/make-icon.sh."
|
||||||
|
fi
|
||||||
|
|
||||||
cat > "$APP/Contents/Info.plist" <<'PLIST'
|
cat > "$APP/Contents/Info.plist" <<PLIST
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleIdentifier</key><string>local.sst.app</string>
|
<key>CFBundleIdentifier</key><string>$IDENTIFIER</string>
|
||||||
<key>CFBundleName</key><string>Sst</string>
|
<key>CFBundleName</key><string>Sst</string>
|
||||||
|
<key>CFBundleDisplayName</key><string>Sst</string>
|
||||||
<key>CFBundleExecutable</key><string>Sst</string>
|
<key>CFBundleExecutable</key><string>Sst</string>
|
||||||
<key>CFBundlePackageType</key><string>APPL</string>
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key><string>0.1.0</string>
|
<key>CFBundleShortVersionString</key><string>$VERSION</string>
|
||||||
<key>CFBundleVersion</key><string>1</string>
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleIconFile</key><string>AppIcon</string>
|
||||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||||
<key>LSUIElement</key><true/>
|
<key>LSApplicationCategoryType</key><string>public.app-category.productivity</string>
|
||||||
|
<key>NSHighResolutionCapable</key><true/>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Sst neemt je stem op zolang je Fn ingedrukt houdt, om die lokaal naar tekst om te zetten.</string>
|
<string>Sst neemt je stem op zolang je de sneltoets ingedrukt houdt, om die lokaal naar tekst om te zetten.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|
||||||
codesign --force --sign - --identifier local.sst.app "$APP"
|
# A stable signing identity keeps the macOS permissions (Microphone, Accessibility, Input Monitoring) across rebuilds.
|
||||||
|
IDENTITY="${SST_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/.*"\(Apple Development: [^"]*\)".*/\1/p' | head -1)}"
|
||||||
|
if [ -n "$IDENTITY" ]; then
|
||||||
|
codesign --force --sign "$IDENTITY" --identifier "$IDENTIFIER" "$APP"
|
||||||
|
echo "Ondertekend met: $IDENTITY"
|
||||||
|
else
|
||||||
|
codesign --force --sign - --identifier "$IDENTIFIER" "$APP"
|
||||||
|
echo "WAARSCHUWING: geen Apple Development-certificaat gevonden; ad-hoc ondertekend (rechten gaan na elke herbouw verloren)."
|
||||||
|
fi
|
||||||
|
codesign --verify --strict "$APP"
|
||||||
echo "Gebouwd: $APP"
|
echo "Gebouwd: $APP"
|
||||||
echo "Na een herbouw: tccutil reset Accessibility local.sst.app; tccutil reset ListenEvent local.sst.app; tccutil reset Microphone local.sst.app (en geef de rechten opnieuw)."
|
echo "Eenmalig na het wisselen van ondertekening: tccutil reset Accessibility $IDENTIFIER; tccutil reset ListenEvent $IDENTIFIER; tccutil reset Microphone $IDENTIFIER (en geef de rechten opnieuw)."
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--install" ]; then
|
||||||
|
rm -rf /Applications/Sst.app
|
||||||
|
cp -R "$APP" /Applications/Sst.app
|
||||||
|
echo "Geïnstalleerd: /Applications/Sst.app"
|
||||||
|
fi
|
||||||
|
|||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Renders the app icon from the design tokens and stores it as Design/AppIcon.icns (committed).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
swift build --product SstIcon
|
||||||
|
rm -rf build/AppIcon.iconset
|
||||||
|
.build/debug/SstIcon build/AppIcon.iconset
|
||||||
|
iconutil -c icns build/AppIcon.iconset -o Design/AppIcon.icns
|
||||||
|
echo "Icoon geschreven: Design/AppIcon.icns"
|
||||||
Reference in New Issue
Block a user