dsh-aha-tutor 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1640,11 +1640,74 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1640
1640
  if (!chunks.length) throw new MaterialError("page-no-text", "No readable text on this page");
1641
1641
  return finish("url", title, chunks, { url: url.href });
1642
1642
  }
1643
+ const BYTES_PER_SECOND = 32e3;
1644
+ const MAX_SPEECH_BYTES = 3840044;
1645
+ const MAX_TEXT = 2e3;
1646
+ const USABLE = /* @__PURE__ */ new Set(["ready", "standby"]);
1647
+ const PREPARING = /* @__PURE__ */ new Set([
1648
+ "checking",
1649
+ "downloading",
1650
+ "cancelling"
1651
+ ]);
1652
+ function wavSeconds(bytes) {
1653
+ const data = bytes instanceof Uint8Array ? bytes : null;
1654
+ if (!data || data.length < 46) return null;
1655
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1656
+ const tag = (at) => String.fromCharCode(...data.subarray(at, at + 4));
1657
+ return tag(0) === "RIFF" && tag(8) === "WAVE" && tag(12) === "fmt " && tag(36) === "data" && view.getUint32(4, true) === data.length - 8 && view.getUint32(16, true) === 16 && view.getUint16(20, true) === 1 && view.getUint16(22, true) === 1 && view.getUint32(24, true) === 16e3 && view.getUint32(28, true) === BYTES_PER_SECOND && view.getUint16(32, true) === 2 && view.getUint16(34, true) === 16 && view.getUint32(40, true) === data.length - 44 && (data.length - 44) % 2 === 0 ? (data.length - 44) / BYTES_PER_SECOND : null;
1658
+ }
1659
+ function createSpeechInput({ getSpeech, logger }) {
1660
+ function status() {
1661
+ const speech = getSpeech();
1662
+ let snapshot = null;
1663
+ try {
1664
+ snapshot = speech?.snapshot?.() ?? null;
1665
+ } catch {
1666
+ snapshot = null;
1667
+ }
1668
+ const chosen = snapshot?.selection?.providerId;
1669
+ const provider = snapshot?.providers?.find((entry) => entry.id === chosen);
1670
+ if (!provider) return {
1671
+ state: "off",
1672
+ maxSeconds: 120
1673
+ };
1674
+ const phase = provider.preparation?.phase ?? "ready";
1675
+ return {
1676
+ state: USABLE.has(phase) ? "ready" : PREPARING.has(phase) ? "preparing" : phase === "failed" ? "failed" : "unprepared",
1677
+ maxSeconds: 120
1678
+ };
1679
+ }
1680
+ async function transcribe({ audio, signal }) {
1681
+ const { state } = status();
1682
+ if (state === "off") throw new TutorError("speech-off", "Voice input is not enabled in DSH");
1683
+ if (state !== "ready") throw new TutorError("speech-unprepared", "The speech recognizer is not ready yet");
1684
+ const seconds = wavSeconds(audio);
1685
+ if (seconds === null) throw new TutorError("speech-audio", "The recording could not be read");
1686
+ if (seconds > 120) throw new TutorError("speech-too-long", `Recordings are limited to 120 seconds`);
1687
+ const speech = getSpeech();
1688
+ let result;
1689
+ try {
1690
+ result = await speech.transcribe(speech.resolve({ audio: Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength) }), signal ?? new AbortController().signal);
1691
+ } catch (error) {
1692
+ if (signal?.aborted) throw error;
1693
+ logger?.debug?.("aha-tutor speech recognition failed: %s", error?.message);
1694
+ throw new TutorError("speech-failed", "Speech recognition failed");
1695
+ }
1696
+ const text = String(typeof result === "string" ? result : result?.text ?? "").replace(/\s+/gu, " ").trim().slice(0, MAX_TEXT);
1697
+ if (!text) throw new TutorError("speech-empty", "No speech was heard");
1698
+ return { text };
1699
+ }
1700
+ return Object.freeze({
1701
+ status,
1702
+ transcribe
1703
+ });
1704
+ }
1643
1705
  //#endregion
1644
1706
  //#region src/host/http.js
1645
1707
  const UPLOAD_PATH = "/api/aha-tutor/upload";
1646
1708
  const TTS_PATH = "/api/aha-tutor/tts";
1647
1709
  const FONT_PATH = "/api/aha-tutor/font";
1710
+ const SPEECH_PATH = "/api/aha-tutor/speech";
1648
1711
  async function readCapped(stream, max, signal) {
1649
1712
  const parts = [];
1650
1713
  let size = 0;
@@ -1697,6 +1760,34 @@ function uploadRoute({ studio }) {
1697
1760
  }
1698
1761
  };
1699
1762
  }
1763
+ function speechRoute({ speechInput }) {
1764
+ return {
1765
+ path: SPEECH_PATH,
1766
+ methods: ["POST"],
1767
+ requestBody: "streaming",
1768
+ fetch: async (request) => {
1769
+ let audio;
1770
+ try {
1771
+ audio = await readCapped(request.body, MAX_SPEECH_BYTES, request.signal);
1772
+ } catch {
1773
+ return failure$1("aborted", "Upload interrupted", 400);
1774
+ }
1775
+ if (audio === null) return failure$1("speech-too-long", "The recording is too long", 413);
1776
+ try {
1777
+ return json({
1778
+ ok: true,
1779
+ value: await speechInput.transcribe({
1780
+ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength),
1781
+ signal: request.signal
1782
+ })
1783
+ });
1784
+ } catch (error) {
1785
+ const known = error?.name === "TutorError";
1786
+ return failure$1(known ? publicCode(error) : "speech-failed", known ? error.message : "Speech recognition failed", known && error.code !== "speech-failed" ? 400 : 502);
1787
+ }
1788
+ }
1789
+ };
1790
+ }
1700
1791
  function ttsRoute({ speak }) {
1701
1792
  return {
1702
1793
  path: TTS_PATH,
@@ -4215,7 +4306,7 @@ const STYLES = [
4215
4306
  "simpler",
4216
4307
  "deeper"
4217
4308
  ];
4218
- function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info }) {
4309
+ function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info, speechInput }) {
4219
4310
  return {
4220
4311
  "app/info": () => info(),
4221
4312
  "job/get": (payload) => jobs.get(need(payload, "id", "job"), { since: Math.max(0, Number(payload.since) || 0) }) ?? { status: "missing" },
@@ -4491,6 +4582,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4491
4582
  cached: result.cached === true
4492
4583
  };
4493
4584
  },
4585
+ "speech/status": () => speechInput.status(),
4586
+ "speech/transcribe": async (payload) => {
4587
+ const encoded = typeof payload.base64 === "string" ? payload.base64 : "";
4588
+ if (encoded.length > Math.ceil(3840044 / 3) * 4) throw new TutorError("speech-too-long", "The recording is too long");
4589
+ const audio = Buffer.from(encoded, "base64");
4590
+ if (!encoded || audio.toString("base64") !== encoded) throw new TutorError("speech-audio", "The recording could not be read");
4591
+ return speechInput.transcribe({ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength) });
4592
+ },
4494
4593
  "settings/get": () => settings.get(),
4495
4594
  "settings/save": (payload) => settings.save(payload.settings ?? {}),
4496
4595
  "settings/set-key": (payload) => settings.setKey({
@@ -4998,6 +5097,7 @@ const PREF_DEFAULTS = Object.freeze({
4998
5097
  voiceRate: 1,
4999
5098
  boardTheme: "auto",
5000
5099
  boardFont: "hand",
5100
+ boardView: "canvas",
5001
5101
  ttsProvider: "openai",
5002
5102
  tts: Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider, providerDefaults(provider)]))
5003
5103
  });
@@ -5028,6 +5128,7 @@ function cleanPrefs(input = {}, base = PREF_DEFAULTS) {
5028
5128
  "dark"
5029
5129
  ].includes(input.boardTheme)) next.boardTheme = input.boardTheme;
5030
5130
  if (["hand", "standard"].includes(input.boardFont)) next.boardFont = input.boardFont;
5131
+ if (["canvas", "pages"].includes(input.boardView)) next.boardView = input.boardView;
5031
5132
  if (TTS_PROVIDERS.includes(input.ttsProvider)) next.ttsProvider = input.ttsProvider;
5032
5133
  for (const provider of TTS_PROVIDERS) {
5033
5134
  const patch = input.tts?.[provider];
@@ -5802,6 +5903,10 @@ function apply(ctx) {
5802
5903
  keys,
5803
5904
  llmPort: llm
5804
5905
  });
5906
+ const speechInput = createSpeechInput({
5907
+ getSpeech: () => ctx.get?.("speechToText"),
5908
+ logger
5909
+ });
5805
5910
  async function speak({ text, provider, signal }) {
5806
5911
  await ready;
5807
5912
  const current = prefs.read();
@@ -5838,7 +5943,8 @@ function apply(ctx) {
5838
5943
  settings,
5839
5944
  tts: { speak },
5840
5945
  plugin,
5841
- info
5946
+ info,
5947
+ speechInput
5842
5948
  }),
5843
5949
  "app/info": (payload) => info(payload),
5844
5950
  "samples/list": () => samples.list(),
@@ -5853,6 +5959,7 @@ function apply(ctx) {
5853
5959
  if (typeof fetchRoutes?.register !== "function") return;
5854
5960
  connectionCtx.effect(() => fetchRoutes.register(uploadRoute({ studio })), "aha-tutor: upload route");
5855
5961
  connectionCtx.effect(() => fetchRoutes.register(ttsRoute({ speak })), "aha-tutor: tts route");
5962
+ connectionCtx.effect(() => fetchRoutes.register(speechRoute({ speechInput })), "aha-tutor: speech route");
5856
5963
  connectionCtx.effect(() => fetchRoutes.register(fontRoute()), "aha-tutor: font route");
5857
5964
  });
5858
5965
  ctx.effect(() => publishRuntime({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-aha-tutor",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "开窍 Aha Tutor: a personal AI tutor for DeepSeek Harness that builds a course around you and teaches it on a narrated handwriting whiteboard",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",